Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions a2aclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ package a2aclient

import (
"context"
"fmt"
"iter"
"sync/atomic"

"github.com/a2aproject/a2a-go/a2a"
"github.com/a2aproject/a2a-go/a2aext"
"github.com/a2aproject/a2a-go/internal/utils"
)

Expand Down Expand Up @@ -270,6 +272,26 @@ func (c *Client) GetAgentCard(ctx context.Context) (*a2a.AgentCard, error) {
return resp, err
}

func Invoke[Req, Resp any](ctx context.Context, client *Client, method *a2aext.UnaryClientMethod[Req, Resp], arg *Req) (*Resp, error) {
ctx, err := client.interceptBefore(ctx, method.Name(), arg)
if err != nil {
return nil, err
}

resp, err := client.transport.Invoke(ctx, method, arg)
if errOverride := client.interceptAfter(ctx, method.Name(), resp, err); errOverride != nil {
return nil, errOverride
}

typedResp, ok := resp.(*Resp)
if !ok {
var want Resp
return nil, fmt.Errorf("unexpected extension method result type %T, want pointer to %T", resp, want)
}

return typedResp, err
}

func (c *Client) Destroy() error {
return c.transport.Destroy()
}
Expand Down
46 changes: 46 additions & 0 deletions a2aclient/jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"time"

"github.com/a2aproject/a2a-go/a2a"
"github.com/a2aproject/a2a-go/a2aext"
"github.com/a2aproject/a2a-go/internal/jsonrpc"
"github.com/a2aproject/a2a-go/internal/sse"
"github.com/a2aproject/a2a-go/log"
Expand All @@ -47,6 +48,28 @@ type jsonrpcResponse struct {
Error *jsonrpc.Error `json:"error,omitempty"`
}

type jsonrpcExtensionBinding struct {
Method string
ResponseFromBytes func(resp []byte) (any, error)
}

func NewJSONRPCExtensionBinding[Resp any](name string) a2aext.Binding {
return &jsonrpcExtensionBinding{
Method: name,
ResponseFromBytes: func(resp []byte) (any, error) {
var zero Resp
if err := json.Unmarshal(resp, &zero); err != nil {
return nil, err
}
return &zero, nil
},
}
}

func (*jsonrpcExtensionBinding) Protocol() a2a.TransportProtocol {
return a2a.TransportProtocolJSONRPC
}

// JSONRPCOption configures optional parameters for the JSONRPC transport.
// Options are applied during NewJSONRPCTransport initialization.
type JSONRPCOption func(*jsonrpcTransport)
Expand Down Expand Up @@ -351,6 +374,29 @@ func (t *jsonrpcTransport) DeleteTaskPushConfig(ctx context.Context, params *a2a
return err
}

func (t *jsonrpcTransport) Invoke(ctx context.Context, method a2aext.Method, req any) (any, error) {
binding, ok := method.Binding(a2a.TransportProtocolJSONRPC)
if !ok {
return nil, fmt.Errorf("method %s is not bound to JSON-RPC", method.Name())
}

typedBinding, ok := binding.(*jsonrpcExtensionBinding)
if !ok {
return nil, fmt.Errorf("method %s is not bound to JSON-RPC", method.Name())
}
Comment on lines +383 to +386

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error message here is identical to the one for the check above, which can be misleading. If the binding exists but has the wrong type, the error should reflect that instead of saying the method is not bound.

Suggested change
typedBinding, ok := binding.(*jsonrpcExtensionBinding)
if !ok {
return nil, fmt.Errorf("method %s is not bound to JSON-RPC", method.Name())
}
typedBinding, ok := binding.(*jsonrpcExtensionBinding)
if !ok {
return nil, fmt.Errorf("method %s has incorrect JSON-RPC binding type: got %T", method.Name(), binding)
}


result, err := t.sendRequest(ctx, typedBinding.Method, req)
if err != nil {
return nil, err
}

response, err := typedBinding.ResponseFromBytes(result)
if err != nil {
return nil, fmt.Errorf("result violates A2A spec - could not determine type: %w; data: %s", err, string(result))
}
return response, nil
}

// GetAgentCard retrieves the agent's card.
func (t *jsonrpcTransport) GetAgentCard(ctx context.Context) (*a2a.AgentCard, error) {
result, err := t.sendRequest(ctx, jsonrpc.MethodGetExtendedAgentCard, nil)
Expand Down
8 changes: 8 additions & 0 deletions a2aclient/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"iter"

"github.com/a2aproject/a2a-go/a2a"
"github.com/a2aproject/a2a-go/a2aext"
)

// A2AClient defines a transport-agnostic interface for making A2A requests.
Expand Down Expand Up @@ -56,6 +57,9 @@ type Transport interface {
// If extended card is supported calls the 'agent/getAuthenticatedExtendedCard' protocol method.
GetAgentCard(ctx context.Context) (*a2a.AgentCard, error)

// Invoke calls the provided extension method.
Invoke(ctx context.Context, client a2aext.Method, arg any) (any, error)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For clarity and consistency, consider renaming the client parameter to method. The parameter is of type a2aext.Method, which represents a method definition, not a client instance. This change would align with its usage in implementations and call sites.

Suggested change
Invoke(ctx context.Context, client a2aext.Method, arg any) (any, error)
Invoke(ctx context.Context, method a2aext.Method, arg any) (any, error)


// Clean up resources associated with the transport (eg. close a gRPC channel).
Destroy() error
}
Expand Down Expand Up @@ -120,6 +124,10 @@ func (unimplementedTransport) GetAgentCard(ctx context.Context) (*a2a.AgentCard,
return nil, errNotImplemented
}

func (unimplementedTransport) Invoke(ctx context.Context, client a2aext.Method, arg any) (any, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with the Transport interface, please rename the client parameter to method here as well.

Suggested change
func (unimplementedTransport) Invoke(ctx context.Context, client a2aext.Method, arg any) (any, error) {
func (unimplementedTransport) Invoke(ctx context.Context, method a2aext.Method, arg any) (any, error) {

return nil, errNotImplemented
}

func (unimplementedTransport) Destroy() error {
return nil
}
92 changes: 92 additions & 0 deletions a2aext/method.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package a2aext

import (
"context"
"errors"
"iter"

"github.com/a2aproject/a2a-go/a2a"
)

type Binding interface {
Protocol() a2a.TransportProtocol
}

type Method interface {
Name() string
Binding(a2a.TransportProtocol) (Binding, bool)
Streaming() bool
}

type ServerMethod interface {
Method

InvokeUnary(ctx context.Context, arg any) (any, error)
InvokeStreaming(ctx context.Context, arg any) iter.Seq2[any, error]
}

type methodDescriptor struct {
name string
bindings map[a2a.TransportProtocol]Binding
streaming bool
}

func (d *methodDescriptor) Name() string { return d.name }

func (d *methodDescriptor) Binding(protocol a2a.TransportProtocol) (Binding, bool) {
b, ok := d.bindings[protocol]
return b, ok
}

func (d *methodDescriptor) Streaming() bool { return d.streaming }

type UnaryClientMethod[Req, Resp any] struct {
methodDescriptor
}

var _ Method = (*UnaryClientMethod[any, any])(nil)

// NewUnaryClientMethod creates a new unary extension method definition.
func NewUnaryClientMethod[Arg, Res any](
name string,
bindings ...Binding,
) *UnaryClientMethod[Arg, Res] {
bmap := map[a2a.TransportProtocol]Binding{}
for _, b := range bindings {
bmap[b.Protocol()] = b
}
return &UnaryClientMethod[Arg, Res]{
methodDescriptor: methodDescriptor{name: name, bindings: bmap, streaming: false},
}
}

type unaryServerMethod[Arg, Res any] struct {
methodDescriptor
call func(context.Context, *Arg) (*Res, error)
}

// NewUnaryMethod creates a new unary extension method definition.
func NewUnaryServerMethod[Arg, Res any](
name string,
call func(context.Context, *Arg) (*Res, error),
bindings ...Binding,
) ServerMethod {
bmap := map[a2a.TransportProtocol]Binding{}
for _, b := range bindings {
bmap[b.Protocol()] = b
}
return &unaryServerMethod[Arg, Res]{
methodDescriptor: methodDescriptor{name: name, bindings: bmap, streaming: false},
call: call,
}
}

func (m *unaryServerMethod[Arg, Res]) InvokeUnary(ctx context.Context, arg any) (any, error) {
return m.call(ctx, arg.(*Arg))
}
Comment on lines +84 to +86

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The direct type assertion arg.(*Arg) will cause a panic if arg is not of the expected type *Arg. This could be triggered by a malformed request or a bug in a transport implementation, leading to a server crash. It's much safer to use the "comma ok" idiom to check the type and return an error if it's incorrect.

func (m *unaryServerMethod[Arg, Res]) InvokeUnary(ctx context.Context, arg any) (any, error) {
	typedArg, ok := arg.(*Arg)
	if !ok {
		return nil, errors.New("internal error: unexpected argument type")
	}
	return m.call(ctx, typedArg)
}


func (m *unaryServerMethod[Arg, Res]) InvokeStreaming(ctx context.Context, arg any) iter.Seq2[any, error] {
return func(yield func(any, error) bool) {
yield(nil, errors.New("unary method invoked as streaming"))
}
}
38 changes: 35 additions & 3 deletions a2asrv/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"log/slog"

"github.com/a2aproject/a2a-go/a2a"
"github.com/a2aproject/a2a-go/a2aext"
"github.com/a2aproject/a2a-go/a2asrv/eventqueue"
"github.com/a2aproject/a2a-go/a2asrv/limiter"
"github.com/a2aproject/a2a-go/a2asrv/push"
Expand Down Expand Up @@ -59,6 +60,15 @@ type RequestHandler interface {

// GetAgentCard returns an extended a2a.AgentCard if configured.
OnGetExtendedAgentCard(ctx context.Context) (*a2a.AgentCard, error)

// GetAgentCard returns an extended a2a.AgentCard if configured.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This comment appears to be a copy-paste error from OnGetExtendedAgentCard. It should be updated to describe what GetExtensionMethods does.

Suggested change
// GetAgentCard returns an extended a2a.AgentCard if configured.
// GetExtensionMethods returns all registered extension methods.

GetExtensionMethods() []a2aext.ServerMethod

// Invoke calls a registered extension method.
Invoke(ctx context.Context, method a2aext.ServerMethod, arg any) (any, error)

// InvokeStreaming calls a registered streaming extension method.
InvokeStreaming(ctx context.Context, method a2aext.ServerMethod, arg any) iter.Seq2[any, error]
}

// Implements a2asrv.RequestHandler.
Expand All @@ -75,6 +85,8 @@ type defaultRequestHandler struct {
reqContextInterceptors []RequestContextInterceptor

authenticatedCardProducer AgentCardProducer

extensionMethods []a2aext.ServerMethod
}

// RequestHandlerOption can be used to customize the default [RequestHandler] implementation behavior.
Expand Down Expand Up @@ -104,12 +116,20 @@ func WithConcurrencyConfig(config limiter.ConcurrencyConfig) RequestHandlerOptio
}
}

// WithConcurrencyConfig allows to set limits on the number of concurrent executions.
func WithExtensionMethod(method a2aext.ServerMethod) RequestHandlerOption {
return func(ih *InterceptedHandler, h *defaultRequestHandler) {
h.extensionMethods = append(h.extensionMethods, method)
}
}

// NewHandler creates a new request handler.
func NewHandler(executor AgentExecutor, options ...RequestHandlerOption) RequestHandler {
h := &defaultRequestHandler{
agentExecutor: executor,
queueManager: eventqueue.NewInMemoryManager(),
taskStore: taskstore.NewMem(),
agentExecutor: executor,
extensionMethods: []a2aext.ServerMethod{},
queueManager: eventqueue.NewInMemoryManager(),
taskStore: taskstore.NewMem(),
// push notifications are not supported by default
}
ih := &InterceptedHandler{Handler: h, Logger: slog.Default()}
Expand Down Expand Up @@ -307,6 +327,18 @@ func (h *defaultRequestHandler) OnGetExtendedAgentCard(ctx context.Context) (*a2
return h.authenticatedCardProducer.Card(ctx)
}

func (h *defaultRequestHandler) GetExtensionMethods() []a2aext.ServerMethod {
return h.extensionMethods
}

func (h *defaultRequestHandler) Invoke(ctx context.Context, method a2aext.ServerMethod, arg any) (any, error) {
return method.InvokeUnary(ctx, arg)
}

func (h *defaultRequestHandler) InvokeStreaming(ctx context.Context, method a2aext.ServerMethod, arg any) iter.Seq2[any, error] {
return method.InvokeStreaming(ctx, arg)
}

func shouldInterruptNonStreaming(params *a2a.MessageSendParams, event a2a.Event) (a2a.TaskID, bool) {
// Non-blocking clients receive a result on the first task event, default Blocking to TRUE
if params.Config != nil && params.Config.Blocking != nil && !(*params.Config.Blocking) {
Expand Down
40 changes: 40 additions & 0 deletions a2asrv/intercepted_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/google/uuid"

"github.com/a2aproject/a2a-go/a2a"
"github.com/a2aproject/a2a-go/a2aext"
"github.com/a2aproject/a2a-go/log"
)

Expand Down Expand Up @@ -225,6 +226,45 @@ func (h *InterceptedHandler) OnGetExtendedAgentCard(ctx context.Context) (*a2a.A
return response, err
}

func (h *InterceptedHandler) GetExtensionMethods() []a2aext.ServerMethod {
return h.Handler.GetExtensionMethods()
}

func (h *InterceptedHandler) Invoke(ctx context.Context, method a2aext.ServerMethod, arg any) (any, error) {
ctx, callCtx := withMethodCallContext(ctx, method.Name())
ctx = h.withLoggerContext(ctx)
ctx, err := h.interceptBefore(ctx, callCtx, arg)
if err != nil {
return nil, err
}
response, err := h.Handler.Invoke(ctx, method, arg)
if errOverride := h.interceptAfter(ctx, callCtx, response, err); errOverride != nil {
return nil, errOverride
}
return response, err
}

func (h *InterceptedHandler) InvokeStreaming(ctx context.Context, method a2aext.ServerMethod, arg any) iter.Seq2[any, error] {
return func(yield func(any, error) bool) {
ctx, callCtx := withMethodCallContext(ctx, method.Name())
ctx = h.withLoggerContext(ctx)
ctx, err := h.interceptBefore(ctx, callCtx, arg)
if err != nil {
yield(nil, err)
return
}
for event, err := range h.Handler.InvokeStreaming(ctx, method, arg) {
if errOverride := h.interceptAfter(ctx, callCtx, event, err); errOverride != nil {
yield(nil, errOverride)
return
}
if !yield(event, err) {
return
}
}
}
}

func (h *InterceptedHandler) interceptBefore(ctx context.Context, callCtx *CallContext, payload any) (context.Context, error) {
request := &Request{Payload: payload}

Expand Down
Loading
Loading