From c4412cb6b99299a42ea94d884858208c55f08a84 Mon Sep 17 00:00:00 2001 From: Megumin Date: Tue, 19 Aug 2025 11:10:55 +0800 Subject: [PATCH 01/18] feat: a2a --- a2a/transport/jsonrpc/jsonrpc.go | 1 + 1 file changed, 1 insertion(+) create mode 100644 a2a/transport/jsonrpc/jsonrpc.go diff --git a/a2a/transport/jsonrpc/jsonrpc.go b/a2a/transport/jsonrpc/jsonrpc.go new file mode 100644 index 000000000..29a4d6aba --- /dev/null +++ b/a2a/transport/jsonrpc/jsonrpc.go @@ -0,0 +1 @@ +package jsonrpc From fe44408b0a0d57f42036206b04bb0846271d69f2 Mon Sep 17 00:00:00 2001 From: Scout Wang Date: Mon, 25 Aug 2025 11:18:07 +0800 Subject: [PATCH 02/18] feat: support a2a jsonrpc2 transport layer (#410) --- a2a/README.md | 0 a2a/examples/a2a.go | 21 ++ a2a/transport/jsonrpc/client/client.go | 73 ++++++ a2a/transport/jsonrpc/client/client_test.go | 43 ++++ a2a/transport/jsonrpc/client/option.go | 47 ++++ a2a/transport/jsonrpc/client/option_test.go | 49 ++++ a2a/transport/jsonrpc/core/connection.go | 191 +++++++++++++++ a2a/transport/jsonrpc/core/jsonrpc.go | 91 ++++++++ a2a/transport/jsonrpc/core/message.go | 186 +++++++++++++++ a2a/transport/jsonrpc/core/middleware.go | 56 +++++ a2a/transport/jsonrpc/core/option.go | 113 +++++++++ a2a/transport/jsonrpc/core/rpc_call.go | 191 +++++++++++++++ a2a/transport/jsonrpc/core/utils.go | 175 ++++++++++++++ a2a/transport/jsonrpc/go.mod | 32 +++ a2a/transport/jsonrpc/go.sum | 123 ++++++++++ .../jsonrpc/pkg/conninfo/conninfo.go | 74 ++++++ a2a/transport/jsonrpc/pkg/conninfo/rpcinfo.go | 48 ++++ a2a/transport/jsonrpc/pkg/conninfo/stat.go | 25 ++ a2a/transport/jsonrpc/pkg/conninfo/utils.go | 32 +++ .../jsonrpc/pkg/metadata/metadata.go | 57 +++++ a2a/transport/jsonrpc/pkg/rpcinfo/rpcinfo.go | 71 ++++++ a2a/transport/jsonrpc/pkg/rpcinfo/utils.go | 32 +++ a2a/transport/jsonrpc/pkg/tracer/tracer.go | 41 ++++ .../jsonrpc/pkg/transport/http/client.go | 219 ++++++++++++++++++ .../jsonrpc/pkg/transport/http/server.go | 195 ++++++++++++++++ .../jsonrpc/pkg/transport/http/transport.go | 43 ++++ .../jsonrpc/pkg/transport/transport.go | 41 ++++ a2a/transport/jsonrpc/pkg/utils/buffer.go | 63 +++++ a2a/transport/jsonrpc/server/option.go | 73 ++++++ a2a/transport/jsonrpc/server/server.go | 84 +++++++ a2a/transport/jsonrpc/streaming/middleware.go | 50 ++++ a2a/transport/jsonrpc/streaming/option.go | 77 ++++++ a2a/transport/jsonrpc/streaming/streaming.go | 107 +++++++++ 33 files changed, 2723 insertions(+) create mode 100644 a2a/README.md create mode 100644 a2a/examples/a2a.go create mode 100644 a2a/transport/jsonrpc/client/client.go create mode 100644 a2a/transport/jsonrpc/client/client_test.go create mode 100644 a2a/transport/jsonrpc/client/option.go create mode 100644 a2a/transport/jsonrpc/client/option_test.go create mode 100644 a2a/transport/jsonrpc/core/connection.go create mode 100644 a2a/transport/jsonrpc/core/jsonrpc.go create mode 100644 a2a/transport/jsonrpc/core/message.go create mode 100644 a2a/transport/jsonrpc/core/middleware.go create mode 100644 a2a/transport/jsonrpc/core/option.go create mode 100644 a2a/transport/jsonrpc/core/rpc_call.go create mode 100644 a2a/transport/jsonrpc/core/utils.go create mode 100644 a2a/transport/jsonrpc/go.mod create mode 100644 a2a/transport/jsonrpc/go.sum create mode 100644 a2a/transport/jsonrpc/pkg/conninfo/conninfo.go create mode 100644 a2a/transport/jsonrpc/pkg/conninfo/rpcinfo.go create mode 100644 a2a/transport/jsonrpc/pkg/conninfo/stat.go create mode 100644 a2a/transport/jsonrpc/pkg/conninfo/utils.go create mode 100644 a2a/transport/jsonrpc/pkg/metadata/metadata.go create mode 100644 a2a/transport/jsonrpc/pkg/rpcinfo/rpcinfo.go create mode 100644 a2a/transport/jsonrpc/pkg/rpcinfo/utils.go create mode 100644 a2a/transport/jsonrpc/pkg/tracer/tracer.go create mode 100644 a2a/transport/jsonrpc/pkg/transport/http/client.go create mode 100644 a2a/transport/jsonrpc/pkg/transport/http/server.go create mode 100644 a2a/transport/jsonrpc/pkg/transport/http/transport.go create mode 100644 a2a/transport/jsonrpc/pkg/transport/transport.go create mode 100644 a2a/transport/jsonrpc/pkg/utils/buffer.go create mode 100644 a2a/transport/jsonrpc/server/option.go create mode 100644 a2a/transport/jsonrpc/server/server.go create mode 100644 a2a/transport/jsonrpc/streaming/middleware.go create mode 100644 a2a/transport/jsonrpc/streaming/option.go create mode 100644 a2a/transport/jsonrpc/streaming/streaming.go diff --git a/a2a/README.md b/a2a/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/a2a/examples/a2a.go b/a2a/examples/a2a.go new file mode 100644 index 000000000..8f49cb1a3 --- /dev/null +++ b/a2a/examples/a2a.go @@ -0,0 +1,21 @@ +/* + * Copyright 2025 CloudWeGo 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 + +func main() { + +} diff --git a/a2a/transport/jsonrpc/client/client.go b/a2a/transport/jsonrpc/client/client.go new file mode 100644 index 000000000..9e92918b7 --- /dev/null +++ b/a2a/transport/jsonrpc/client/client.go @@ -0,0 +1,73 @@ +/* + * Copyright 2025 CloudWeGo 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 client + +import ( + "context" + "errors" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/conninfo" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport" +) + +var ( + errNoURL = errors.New("no url") +) + +// Client is a jsonrpc2 client that corresponding to a specific URL. +// You can create a Connection implementing jsonrpc2 semantics. +type Client struct { + remotePeer conninfo.Peer + hdl transport.ClientTransportHandler +} + +func NewClient(opts ...Option) (*Client, error) { + options := defaultOptions() + for _, opt := range opts { + opt(&options) + } + // parse url + if options.url == "" { + return nil, errNoURL + } + cli := &Client{} + cli.remotePeer = conninfo.NewPeer(conninfo.PeerTypeURL, options.url) + cli.hdl = options.hdl + return cli, nil +} + +func (cli *Client) NewConnection(ctx context.Context) (core.Connection, error) { + st, err := cli.hdl.NewTransport(ctx, cli.remotePeer) + if err != nil { + return nil, err + } + var opts []core.Option + // client calling + if cliTrans, ok := st.ClientCapability(); ok { + opts = append(opts, core.WithClientRounder(cliTrans)) + } + // server handling + if srvTrans, ok := st.ServerCapability(); ok { + opts = append(opts, core.WithServerRounder(srvTrans)) + } + ctx, conn, err := core.NewConnection(ctx, opts...) + if err != nil { + return nil, err + } + return conn, nil +} diff --git a/a2a/transport/jsonrpc/client/client_test.go b/a2a/transport/jsonrpc/client/client_test.go new file mode 100644 index 000000000..986316b16 --- /dev/null +++ b/a2a/transport/jsonrpc/client/client_test.go @@ -0,0 +1,43 @@ +/* + * Copyright 2025 CloudWeGo 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 client + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewClient(t *testing.T) { + // missing URL + cli, err := NewClient() + assert.True(t, errors.Is(err, errNoURL)) + // specify URL + testURL := "http://127.0.0.1/testing" + cli, err = NewClient(WithURL(testURL)) + assert.Nil(t, err) + assert.NotNil(t, cli) + assert.Equal(t, testURL, cli.remotePeer.Address()) + assert.NotNil(t, cli.hdl) + // specify URL and TransportHandler + testHdl := &testClientTransportHandler{} + cli, err = NewClient(WithURL(testURL), WithTransportHandler(testHdl)) + assert.Nil(t, err) + assert.NotNil(t, cli) + assert.Equal(t, testHdl, cli.hdl) +} diff --git a/a2a/transport/jsonrpc/client/option.go b/a2a/transport/jsonrpc/client/option.go new file mode 100644 index 000000000..9e1d66cf1 --- /dev/null +++ b/a2a/transport/jsonrpc/client/option.go @@ -0,0 +1,47 @@ +/* + * Copyright 2025 CloudWeGo 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 client + +import ( + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport/http" +) + +type Options struct { + url string + hdl transport.ClientTransportHandler +} + +func defaultOptions() Options { + return Options{ + hdl: http.NewClientTransportHandler(), + } +} + +type Option func(options *Options) + +func WithURL(url string) Option { + return func(options *Options) { + options.url = url + } +} + +func WithTransportHandler(hdl transport.ClientTransportHandler) Option { + return func(options *Options) { + options.hdl = hdl + } +} diff --git a/a2a/transport/jsonrpc/client/option_test.go b/a2a/transport/jsonrpc/client/option_test.go new file mode 100644 index 000000000..8c794f6f5 --- /dev/null +++ b/a2a/transport/jsonrpc/client/option_test.go @@ -0,0 +1,49 @@ +/* + * Copyright 2025 CloudWeGo 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 client + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/conninfo" +) + +type testClientTransportHandler struct{} + +func (t *testClientTransportHandler) NewTransport(ctx context.Context, peer conninfo.Peer) (core.Transport, error) { + panic("implement me") +} + +func TestWithOptions(t *testing.T) { + // default Options + defOptions := defaultOptions() + testURL := "http://127.0.0.1:8888/testing" + testHdl := &testClientTransportHandler{} + opts := []Option{ + WithURL(testURL), + WithTransportHandler(testHdl), + } + for _, opt := range opts { + opt(&defOptions) + } + assert.Equal(t, testURL, defOptions.url) + assert.Equal(t, testHdl, defOptions.hdl) +} diff --git a/a2a/transport/jsonrpc/core/connection.go b/a2a/transport/jsonrpc/core/connection.go new file mode 100644 index 000000000..4cfe8f028 --- /dev/null +++ b/a2a/transport/jsonrpc/core/connection.go @@ -0,0 +1,191 @@ +/* + * Copyright 2025 CloudWeGo 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 core + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/tracer" +) + +func NewConnection(ctx context.Context, opts ...Option) (context.Context, Connection, error) { + options := defaultOptions() + for _, opt := range opts { + opt(&options) + } + // verify + if options.cliTrans == nil && options.srvTrans == nil { + return ctx, nil, errors.New("ClientRounder and ServerRounder cannot both be nil") + } + + conn := &connection{ + tracer: options.tracer, + outbounds: make(map[string]*rpcCall), + } + + if options.cliTrans != nil { + conn.cliTrans = options.cliTrans + conn.callEp = callChain(options.callMWs...)(conn.callEndpoint) + } + + if options.srvTrans != nil { + conn.srvTrans = options.srvTrans + conn.hdls = make(map[string]requestHandlerFunc) + // PingPongHandler + for method, hdl := range options.ppHdls { + conn.hdls[method] = conn.getRequestHandlerFunc(handleChain(options.hdlMWs...)(hdl)) + } + // RequestHandler + for method, hdl := range options.reqHdls { + // todo: provide requestHandler middleware + conn.hdls[method] = requestHandlerFunc(hdl) + } + // NotificationHandler + conn.notifHdls = make(map[string]NotificationHandleEndpoint) + for method, hdl := range options.notifHdls { + conn.notifHdls[method] = hdl + } + go conn.roundLoop() + } + + return ctx, conn, nil +} + +func (conn *connection) getRequestHandlerFunc(ep HandleEndpoint) requestHandlerFunc { + return func(ctx context.Context, conn Connection, req *Request, async ServerAsync) (err error) { + defer func() { + if rawPanic := recover(); rawPanic != nil { + err = ConvertError(rawPanic.(error)) + async.Finish(ctx, err) + } + }() + resp, epErr := ep(ctx, conn, req.Params) + if epErr != nil { + err = ConvertError(epErr) + async.Finish(ctx, err) + return + } + if sErr := async.Send(ctx, resp); sErr != nil { + return sErr + } + return async.Finish(ctx, nil) + } +} + +func ConvertError(rawErr error) *Error { + // todo: using errors api + if err, ok := rawErr.(*Error); ok { + return err + } + return NewError(InternalErrorCode, rawErr.Error(), nil) +} + +type connection struct { + hdls map[string]requestHandlerFunc + notifHdls map[string]NotificationHandleEndpoint + + callEp CallEndpoint + tracer tracer.Tracer + + mu sync.Mutex + cliTrans ClientRounder + srvTrans ServerRounder + outbounds map[string]*rpcCall +} + +func (conn *connection) Call(ctx context.Context, method string, req, res interface{}, opts ...CallOption) error { + ctx = conn.tracer.Start(ctx) + defer func() { + // todo: inject error + conn.tracer.Finish(ctx) + }() + return conn.callEp(ctx, method, req, res) +} + +func (conn *connection) callEndpoint(ctx context.Context, method string, req, resp interface{}) error { + newCall, err := conn.asyncCall(ctx, method, req) + if err != nil { + return err + } + defer newCall.Close() + if err = newCall.Recv(ctx, resp); err != nil { + return err + } + return nil +} + +func (conn *connection) AsyncCall(ctx context.Context, method string, req interface{}, opts ...CallOption) (ClientAsync, error) { + return conn.asyncCall(ctx, method, req) +} + +func (conn *connection) asyncCall(ctx context.Context, method string, req interface{}) (*rpcCall, error) { + newId := allocateId() + newCall := &rpcCall{ + id: newId, + ctx: ctx, + method: method, + conn: conn, + } + msg, err := NewRequest(method, newId, req) + if err != nil { + return nil, err + } + reader, err := conn.sendRequest(ctx, msg) + if err != nil { + return nil, err + } + newCall.reader = reader + conn.mu.Lock() + conn.outbounds[newId.String()] = newCall + conn.mu.Unlock() + return newCall, nil +} + +func (conn *connection) Notify(ctx context.Context, method string, params interface{}, opts ...CallOption) error { + notif, err := NewNotification(method, params) + if err != nil { + return err + } + return conn.sendNotification(ctx, notif) +} + +func (conn *connection) handleRequest(ctx context.Context, req *Request, async ServerAsync) error { + method := req.Method + hdl, ok := conn.hdls[method] + if !ok { + return async.Finish(ctx, NewError(MethodNotFoundCode, fmt.Sprintf("Method %s not found", method), nil)) + } + return hdl(ctx, conn, req, async) +} + +func (conn *connection) handleNotification(ctx context.Context, notif *Notification) { + method := notif.Method + hdl, ok := conn.notifHdls[method] + if !ok { + // todo: log + return + } + // ignore the error now + hdl(ctx, conn, notif.Params) +} + +func (conn *connection) GetTracer() tracer.Tracer { + return conn.tracer +} diff --git a/a2a/transport/jsonrpc/core/jsonrpc.go b/a2a/transport/jsonrpc/core/jsonrpc.go new file mode 100644 index 000000000..5d284bf63 --- /dev/null +++ b/a2a/transport/jsonrpc/core/jsonrpc.go @@ -0,0 +1,91 @@ +/* + * Copyright 2025 CloudWeGo 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 core + +import ( + "context" +) + +const ( + version = "2.0" +) + +// Connection is a direct user-facing interface that implements the semantics of JSON-RPC2 +type Connection interface { + Call(ctx context.Context, method string, req, res interface{}, opts ...CallOption) error + AsyncCall(ctx context.Context, method string, req interface{}, opts ...CallOption) (ClientAsync, error) + Notify(ctx context.Context, method string, params interface{}, opts ...CallOption) error +} + +type ClientAsync interface { + // Recv returns io.EOF when finished + Recv(ctx context.Context, obj interface{}) error + Close() error +} + +type ServerAsync interface { + SendStreaming(ctx context.Context, obj interface{}) error + FinishStreaming(ctx context.Context, err error) error + Send(ctx context.Context, obj interface{}) error + Finish(ctx context.Context, err error) error +} + +type requestHandlerFunc func(ctx context.Context, conn Connection, req *Request, async ServerAsync) error + +type Transport interface { + ClientCapability() (ClientRounder, bool) + ServerCapability() (ServerRounder, bool) +} + +// ClientRounder is used to send a jsonrpc Message(Request or Notification) +// to remote side and make use of MessageReader to receive jsonrpc Message(s) (Response or Notification). +// +// Usually, there is no need to return a substantive MessageReader when sending a jsonrpc Notification. +type ClientRounder interface { + Round(ctx context.Context, msg Message) (MessageReader, error) +} + +// MessageReader can express the semantics of either Ping-Pong reader or ServerStreaming reader. +// - Ping-Pong reader: +// Read -> Message, nil +// Read -> nil, io.EOF +// - ServerStreaming reader: +// Read -> Message, nil +// ... +// Read -> nil, io.EOF +type MessageReader interface { + // Read returns io.EOF and nil Message when finished + Read(ctx context.Context) (Message, error) + // Close terminates MessageReader directly. + // Usually Read returns io.EOF or some other error, and the MessageReader's lifecycle ends naturally. + Close() error +} + +// ServerRounder is used to accept a jsonrpc Message(Request or Notification) +// from remote side and make use of MessageWriter to response jsonrpc Message(s) (Response or Notification). +// +// Usually, there is no need to return a substantive MessageWriter when receiving a jsonrpc Notification. +type ServerRounder interface { + OnRound() (context.Context, Message, MessageWriter, error) +} + +type MessageWriter interface { + WriteStreaming(ctx context.Context, msg Message) error + // Close is used to finish the series of WriteStreaming + Close() error + Write(ctx context.Context, msg Message) error +} diff --git a/a2a/transport/jsonrpc/core/message.go b/a2a/transport/jsonrpc/core/message.go new file mode 100644 index 000000000..946b1c525 --- /dev/null +++ b/a2a/transport/jsonrpc/core/message.go @@ -0,0 +1,186 @@ +/* + * Copyright 2025 CloudWeGo 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 core + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/bytedance/sonic" +) + +type ObjectType string + +const ( + ObjectTypeRequest ObjectType = "request" + ObjectTypeResponse ObjectType = "response" + ObjectTypeNotification ObjectType = "notification" +) + +var ( + ErrorParse = NewError(ParseErrorCode, "Parse error", nil) + ErrorInvalidRequest = NewError(InvalidRequestCode, "Invalid Request", nil) + ErrorMethodNotFound = NewError(MethodNotFoundCode, "Method not found", nil) + ErrorInvalidParams = NewError(InvalidParamsCode, "Invalid params", nil) + ErrorInternalError = NewError(InternalErrorCode, "Internal error", nil) +) + +const ( + ParseErrorCode = -32700 + InvalidRequestCode = -32600 + MethodNotFoundCode = -32601 + InvalidParamsCode = -32602 + InternalErrorCode = -32603 +) + +type Message interface { + Type() ObjectType +} + +type Empty struct{} + +type message struct { + objType ObjectType + + Version string `json:"jsonrpc"` + ID interface{} `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` +} + +func (msg *message) Type() ObjectType { + return msg.objType +} + +type Error struct { + // Code indicates the error type that occurred. + Code int64 `json:"code"` + // Message provides a short description of the error. + Message string `json:"message"` + // Data contains additional information about the error. + Data json.RawMessage `json:"data,omitempty"` +} + +func (err *Error) Error() string { + return err.Message +} + +type Request struct { + Version string `json:"jsonrpc"` + Method string `json:"method"` + ID ID `json:"id"` + Params json.RawMessage `json:"params"` +} + +func (req *Request) Type() ObjectType { + return ObjectTypeRequest +} + +type Notification struct { + Version string `json:"jsonrpc"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + relatedID ID +} + +func (notif *Notification) Type() ObjectType { + return ObjectTypeNotification +} + +func (notif *Notification) RelatedID() ID { + return notif.relatedID +} + +type Response struct { + Version string `json:"jsonrpc"` + ID ID `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Err *Error `json:"error,omitempty"` +} + +func (resp *Response) Type() ObjectType { + return ObjectTypeResponse +} + +func NewResponse(id ID, res interface{}) (*Response, error) { + buf, err := sonic.Marshal(res) + if err != nil { + return nil, err + } + return &Response{ + Version: version, + ID: id, + Result: buf, + }, nil +} + +type ID struct { + Str *string + Num *float64 +} + +func NewIDFromString(s string) ID { + return ID{Str: &s} +} + +func NewIDFromNumber(n float64) ID { + return ID{Num: &n} +} + +func (id ID) IsNil() bool { + return id.Str == nil && id.Num == nil +} + +func (id ID) UnmarshalJSON(data []byte) error { + if data == nil { + return nil + } + var s string + if err := json.Unmarshal(data, &s); err == nil { + id.Str = &s + return nil + } + var f float64 + if err := json.Unmarshal(data, &f); err == nil { + id.Num = &f + return nil + } + return errors.New("invalid JSON-RPC ID") +} + +func (id ID) MarshalJSON() ([]byte, error) { + if id.Str != nil { + return json.Marshal(*id.Str) + } + if id.Num != nil { + return json.Marshal(*id.Num) + } + return nil, fmt.Errorf("invalid ID: empty") +} + +func (id ID) String() string { + if id.Str != nil { + return *id.Str + } + if id.Num != nil { + return fmt.Sprintf("%v", *id.Num) + } + return "" +} diff --git a/a2a/transport/jsonrpc/core/middleware.go b/a2a/transport/jsonrpc/core/middleware.go new file mode 100644 index 000000000..56d6c9cd5 --- /dev/null +++ b/a2a/transport/jsonrpc/core/middleware.go @@ -0,0 +1,56 @@ +/* + * Copyright 2025 CloudWeGo 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 core + +import ( + "context" + "encoding/json" +) + +// client side middleware + +type CallEndpoint func(ctx context.Context, method string, req, resp interface{}) error + +type CallMiddleware func(CallEndpoint) CallEndpoint + +func callChain(mws ...CallMiddleware) CallMiddleware { + return func(endpoint CallEndpoint) CallEndpoint { + for i := len(mws) - 1; i >= 0; i-- { + endpoint = mws[i](endpoint) + } + return endpoint + } +} + +// server side middleware + +type HandleEndpoint func(ctx context.Context, conn Connection, req json.RawMessage) (interface{}, error) + +type HandleMiddleware func(HandleEndpoint) HandleEndpoint + +func handleChain(mws ...HandleMiddleware) HandleMiddleware { + return func(endpoint HandleEndpoint) HandleEndpoint { + for i := len(mws) - 1; i >= 0; i-- { + endpoint = mws[i](endpoint) + } + return endpoint + } +} + +type NotificationHandleEndpoint func(ctx context.Context, conn Connection, params json.RawMessage) error + +type RequestHandleEndpoint requestHandlerFunc diff --git a/a2a/transport/jsonrpc/core/option.go b/a2a/transport/jsonrpc/core/option.go new file mode 100644 index 000000000..dafbc5e3c --- /dev/null +++ b/a2a/transport/jsonrpc/core/option.go @@ -0,0 +1,113 @@ +/* + * Copyright 2025 CloudWeGo 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 core + +import ( + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/tracer" +) + +type CallOptions struct { + relatedId ID +} + +type CallOption func(options *CallOptions) + +func WithCallRelatedID(id ID) CallOption { + return func(options *CallOptions) { + options.relatedId = id + } +} + +type Options struct { + ppHdls map[string]HandleEndpoint + notifHdls map[string]NotificationHandleEndpoint + reqHdls map[string]RequestHandleEndpoint + + callMWs []CallMiddleware + hdlMWs []HandleMiddleware + + tracer tracer.Tracer + cliTrans ClientRounder + srvTrans ServerRounder +} + +func defaultOptions() Options { + return Options{ + tracer: tracer.NewNoopTracer(), + } +} + +type Option func(options *Options) + +type CloseCallback func(err error) + +func WithClientRounder(trans ClientRounder) Option { + return func(options *Options) { + options.cliTrans = trans + } +} + +func WithServerRounder(trans ServerRounder) Option { + return func(options *Options) { + options.srvTrans = trans + } +} + +func WithHandler(method string, hdl HandleEndpoint) Option { + return func(options *Options) { + if options.ppHdls == nil { + options.ppHdls = make(map[string]HandleEndpoint) + } + options.ppHdls[method] = hdl + } +} + +func WithNotificationHandler(method string, hdl NotificationHandleEndpoint) Option { + return func(options *Options) { + if options.notifHdls == nil { + options.notifHdls = make(map[string]NotificationHandleEndpoint) + } + options.notifHdls[method] = hdl + } +} + +func WithRequestHandler(method string, hdl RequestHandleEndpoint) Option { + return func(options *Options) { + if options.reqHdls == nil { + options.reqHdls = make(map[string]RequestHandleEndpoint) + } + options.reqHdls[method] = hdl + } +} + +func WithTracer(tracer tracer.Tracer) Option { + return func(options *Options) { + options.tracer = tracer + } +} + +func WithCallMiddleware(mw CallMiddleware) Option { + return func(options *Options) { + options.callMWs = append(options.callMWs, mw) + } +} + +func WithHandleMiddleware(mw HandleMiddleware) Option { + return func(options *Options) { + options.hdlMWs = append(options.hdlMWs, mw) + } +} diff --git a/a2a/transport/jsonrpc/core/rpc_call.go b/a2a/transport/jsonrpc/core/rpc_call.go new file mode 100644 index 000000000..101e97138 --- /dev/null +++ b/a2a/transport/jsonrpc/core/rpc_call.go @@ -0,0 +1,191 @@ +/* + * Copyright 2025 CloudWeGo 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 core + +import ( + "context" + "errors" + + "github.com/google/uuid" +) + +func (conn *connection) roundLoop() error { + for { + nCtx, msg, writer, err := conn.srvTrans.OnRound() + if err != nil { + return err + } + switch msg.Type() { + case ObjectTypeRequest: + req := msg.(*Request) + // todo: implement timeout + newCtx, cancel := context.WithCancel(nCtx) + call := &rpcCall{ + id: req.ID, + ctx: newCtx, + cancel: cancel, + req: req, + writer: writer, + conn: conn, + } + go func() { + call.handleRequest() + }() + case ObjectTypeNotification: + // there is no need to care about writer + notif := msg.(*Notification) + go func() { + conn.handleNotification(nCtx, notif) + }() + } + } +} + +func (conn *connection) sendRequest(ctx context.Context, msg Message) (MessageReader, error) { + conn.mu.Lock() + defer conn.mu.Unlock() + reader, err := conn.cliTrans.Round(ctx, msg) + if err != nil { + return nil, err + } + return reader, nil +} + +func (conn *connection) sendNotification(ctx context.Context, msg Message) error { + conn.mu.Lock() + defer conn.mu.Unlock() + // there is no need to care about reader + if _, err := conn.cliTrans.Round(ctx, msg); err != nil { + return err + } + return nil +} + +func (conn *connection) deleteOutBound(id ID) { + conn.mu.Lock() + defer conn.mu.Unlock() + delete(conn.outbounds, id.String()) +} + +func allocateId() ID { + return NewIDFromString(uuid.New().String()) +} + +var _ ClientAsync = &rpcCall{} + +type rpcCall struct { + id ID + ctx context.Context + cancel context.CancelFunc + method string + req *Request + // for client-side + reader MessageReader + // for server-side + writer MessageWriter + conn *connection +} + +func (c *rpcCall) ID() ID { + return c.id +} + +func (c *rpcCall) SendStreaming(ctx context.Context, obj interface{}) error { + msg, err := NewResponse(c.id, obj) + if err != nil { + return err + } + return c.writer.WriteStreaming(ctx, msg) +} + +func (c *rpcCall) FinishStreaming(ctx context.Context, err error) error { + if err == nil { + return c.writer.Close() + } + jErr := err.(*Error) + msg := NewFailureResponse(c.id, jErr) + if wErr := c.writer.WriteStreaming(c.ctx, msg); wErr != nil { + return wErr + } + return c.writer.Close() +} + +func (c *rpcCall) Send(ctx context.Context, obj interface{}) error { + msg, err := NewResponse(c.id, obj) + if err != nil { + return err + } + return c.writer.Write(ctx, msg) +} + +func (c *rpcCall) Finish(ctx context.Context, err error) error { + if err == nil { + return c.writer.Close() + } + jErr := err.(*Error) + msg := NewFailureResponse(c.id, jErr) + if wErr := c.writer.Write(c.ctx, msg); wErr != nil { + return wErr + } + return c.writer.Close() +} + +func (c *rpcCall) Recv(ctx context.Context, obj interface{}) error { + msg, err := c.reader.Read(ctx) + if err != nil { + return err + } + if msg.Type() != ObjectTypeResponse { + err = errors.New("not a response") + c.Close() + return err + } + resp := msg.(*Response) + if resp.Err != nil { + c.Close() + return resp.Err + } + params := resp.Result + if err = Unmarshal(params, obj); err != nil { + c.Close() + return err + } + return nil +} + +func (c *rpcCall) Close() error { + c.conn.deleteOutBound(c.id) + if c.writer != nil { + return c.writer.Close() + } + if c.reader != nil { + return c.reader.Close() + } + return nil +} + +func (c *rpcCall) handleRequest() { + var err error + defer func() { + if err != nil { + c.Close() + } + }() + if err = c.conn.handleRequest(c.ctx, c.req, c); err != nil { + return + } +} diff --git a/a2a/transport/jsonrpc/core/utils.go b/a2a/transport/jsonrpc/core/utils.go new file mode 100644 index 000000000..bb98194f1 --- /dev/null +++ b/a2a/transport/jsonrpc/core/utils.go @@ -0,0 +1,175 @@ +/* + * Copyright 2025 CloudWeGo 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 core + +import ( + "encoding/json" + "errors" + "io" + + "github.com/bytedance/sonic" + "github.com/bytedance/sonic/decoder" +) + +func DecodeMessages(buf []byte) ([]Message, bool, error) { + var rawMsgs []json.RawMessage + var msgs []Message + if err := sonic.Unmarshal(buf, &rawMsgs); err == nil { + for _, raw := range rawMsgs { + m := new(message) + if err := sonic.Unmarshal(raw, m); err != nil { + return nil, true, err + } + msg, pErr := parseMessage(m) + if pErr != nil { + return nil, true, pErr + } + msgs = append(msgs, msg) + } + return msgs, true, nil + } + m := new(message) + if err := sonic.Unmarshal(buf, m); err != nil { + return nil, false, err + } + msg, pErr := parseMessage(m) + if pErr != nil { + return nil, false, pErr + } + msgs = append(msgs, msg) + return msgs, false, nil +} + +func DecodeMessage(reader io.Reader) (Message, error) { + dec := decoder.NewStreamDecoder(reader) + msg := new(message) + if err := dec.Decode(msg); err != nil { + return nil, err + } + return parseMessage(msg) +} + +func EncodeMessage(msg Message) ([]byte, error) { + return sonic.Marshal(msg) +} + +func ReadMessage(buf []byte) (Message, error) { + msg := new(message) + if err := sonic.Unmarshal(buf, msg); err != nil { + return nil, err + } + return parseMessage(msg) +} + +func parseMessage(msg *message) (Message, error) { + id, err := ParseID(msg.ID) + if err != nil { + return nil, err + } + // request or notification + if msg.Method != "" { + // notification + if id.IsNil() { + return &Notification{ + Version: msg.Version, + Method: msg.Method, + Params: msg.Params, + }, nil + } + // request + return &Request{ + Version: msg.Version, + Method: msg.Method, + ID: id, + Params: msg.Params, + }, nil + + } + // response + return &Response{ + Version: msg.Version, + ID: id, + Result: msg.Result, + Err: msg.Error, + }, nil +} + +func Unmarshal(data []byte, v interface{}) error { + return sonic.Unmarshal(data, v) +} + +func Marshal(v interface{}) ([]byte, error) { + return sonic.Marshal(v) +} + +func NewRequest(method string, id ID, params interface{}) (*Request, error) { + buf, err := sonic.Marshal(params) + if err != nil { + return nil, err + } + return &Request{ + Version: version, + Method: method, + ID: id, + Params: buf, + }, nil +} + +func NewNotification(method string, param interface{}) (*Notification, error) { + buf, err := sonic.Marshal(param) + if err != nil { + return nil, err + } + return &Notification{ + Version: version, + Method: method, + Params: buf, + }, nil +} + +func NewFailureResponse(id ID, err *Error) *Response { + return &Response{ + Version: version, + ID: id, + Err: err, + } +} + +func NewError(code int64, message string, data interface{}) *Error { + err := &Error{ + Code: code, + Message: message, + } + if data != nil { + dataBuf, _ := Marshal(data) + // todo: ignore mErr temporarily + err.Data = dataBuf + } + return err +} + +func ParseID(raw interface{}) (ID, error) { + switch raw.(type) { + case string: + return NewIDFromString(raw.(string)), nil + case float64: + return NewIDFromNumber(raw.(float64)), nil + case nil: + return ID{}, nil + } + return ID{}, errors.New("invalid ID") +} diff --git a/a2a/transport/jsonrpc/go.mod b/a2a/transport/jsonrpc/go.mod new file mode 100644 index 000000000..98b7a9e62 --- /dev/null +++ b/a2a/transport/jsonrpc/go.mod @@ -0,0 +1,32 @@ +module github.com/cloudwego/eino-ext/a2a/transport/jsonrpc + +go 1.24.5 + +require ( + github.com/bytedance/sonic v1.14.0 + github.com/cloudwego/hertz v0.10.2 + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/bytedance/gopkg v0.1.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/gopkg v0.1.4 // indirect + github.com/cloudwego/netpoll v0.7.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/golang/protobuf v1.5.0 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/nyaruka/phonenumbers v1.0.55 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect + golang.org/x/sys v0.24.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/a2a/transport/jsonrpc/go.sum b/a2a/transport/jsonrpc/go.sum new file mode 100644 index 000000000..64cda6b09 --- /dev/null +++ b/a2a/transport/jsonrpc/go.sum @@ -0,0 +1,123 @@ +github.com/bytedance/gopkg v0.1.1 h1:3azzgSkiaw79u24a+w9arfH8OfnQQ4MHUt9lJFREEaE= +github.com/bytedance/gopkg v0.1.1/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/gopkg v0.1.4 h1:EoQiCG4sTonTPHxOGE0VlQs+sQR+Hsi2uN0qqwu8O50= +github.com/cloudwego/gopkg v0.1.4/go.mod h1:FQuXsRWRsSqJLsMVd5SYzp8/Z1y5gXKnVvRrWUOsCMI= +github.com/cloudwego/hertz v0.10.2 h1:scaVn4E/AQ/vuMAC8FXzUzsEXS/TF1ix1I+4slPhh7c= +github.com/cloudwego/hertz v0.10.2/go.mod h1:W5dUFXZPZkyfjMMo3EQrMQbofuvTsctM9IxmhbkuT18= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cloudwego/netpoll v0.7.0 h1:bDrxQaNfijRI1zyGgXHQoE/nYegL0nr+ijO1Norelc4= +github.com/cloudwego/netpoll v0.7.0/go.mod h1:PI+YrmyS7cIr0+SD4seJz3Eo3ckkXdu2ZVKBLhURLNU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/nyaruka/phonenumbers v1.0.55 h1:bj0nTO88Y68KeUQ/n3Lo2KgK7lM1hF7L9NFuwcCl3yg= +github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/a2a/transport/jsonrpc/pkg/conninfo/conninfo.go b/a2a/transport/jsonrpc/pkg/conninfo/conninfo.go new file mode 100644 index 000000000..f5ffe52a9 --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/conninfo/conninfo.go @@ -0,0 +1,74 @@ +/* + * Copyright 2025 CloudWeGo 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 conninfo + +type ConnInfo interface { + From() Peer + To() Peer +} + +type connInfo struct { + from Peer + to Peer +} + +func (c *connInfo) From() Peer { + return c.from +} + +func (c *connInfo) To() Peer { + return c.to +} + +type PeerType uint32 + +const ( + PeerTypeAddress PeerType = 1 + PeerTypeURL PeerType = 2 +) + +type Peer interface { + Type() PeerType + Address() string +} + +type peer struct { + typ PeerType + address string +} + +func (p *peer) Type() PeerType { + return p.typ +} + +func (p *peer) Address() string { + return p.address +} + +func NewPeer(typ PeerType, address string) Peer { + return &peer{ + typ: typ, + address: address, + } +} + +func NewConnInfo(from Peer, to Peer) ConnInfo { + return &connInfo{ + from: from, + to: to, + } +} diff --git a/a2a/transport/jsonrpc/pkg/conninfo/rpcinfo.go b/a2a/transport/jsonrpc/pkg/conninfo/rpcinfo.go new file mode 100644 index 000000000..37af90096 --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/conninfo/rpcinfo.go @@ -0,0 +1,48 @@ +/* + * Copyright 2025 CloudWeGo 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 conninfo + +type RPCInfo interface { + Stat() Stat + Invocation() Invocation +} + +// Invocation contains RPC related metadata +type Invocation interface { + ID() string + MethodName() string +} + +func NewInvocation(id string, methodName string) Invocation { + return &invocation{ + id: id, + methodName: methodName, + } +} + +type invocation struct { + id string + methodName string +} + +func (i *invocation) ID() string { + return i.id +} + +func (i *invocation) MethodName() string { + return i.methodName +} diff --git a/a2a/transport/jsonrpc/pkg/conninfo/stat.go b/a2a/transport/jsonrpc/pkg/conninfo/stat.go new file mode 100644 index 000000000..462e4c86d --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/conninfo/stat.go @@ -0,0 +1,25 @@ +/* + * Copyright 2025 CloudWeGo 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 conninfo + +type Stat interface { + // todo: record Event, Error, Panic + RequestSize() uint64 + ResponseSize() uint64 + LastSendNotificationSize() uint64 + LastRecvNotificationSize() uint64 +} diff --git a/a2a/transport/jsonrpc/pkg/conninfo/utils.go b/a2a/transport/jsonrpc/pkg/conninfo/utils.go new file mode 100644 index 000000000..f230fbda2 --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/conninfo/utils.go @@ -0,0 +1,32 @@ +/* + * Copyright 2025 CloudWeGo 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 conninfo + +import "context" + +type connCtxKey struct{} + +func NewCtxWithConnInfo(ctx context.Context, cc ConnInfo) context.Context { + if cc == nil { + return ctx + } + return context.WithValue(ctx, connCtxKey{}, cc) +} + +func ConnInfoFromCtx(ctx context.Context) ConnInfo { + return ctx.Value(connCtxKey{}).(ConnInfo) +} diff --git a/a2a/transport/jsonrpc/pkg/metadata/metadata.go b/a2a/transport/jsonrpc/pkg/metadata/metadata.go new file mode 100644 index 000000000..7d88a3443 --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/metadata/metadata.go @@ -0,0 +1,57 @@ +/* + * Copyright 2025 CloudWeGo 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 metadata + +import "context" + +type metadataKey struct{} + +type metadata map[string]string + +// WithValue injects key/val pair into ctx. +// If there is duplicate key, original val would be overwritten +func WithValue(ctx context.Context, key, val string) context.Context { + md, ok := ctx.Value(metadataKey{}).(metadata) + if ok { + md[key] = val + return ctx + } + newMd := metadata(make(map[string]string)) + newMd[key] = val + return context.WithValue(ctx, metadataKey{}, newMd) +} + +// GetValue extracts related val with key. +// If key does not exist, would return "", false +func GetValue(ctx context.Context, key string) (string, bool) { + md, ok := ctx.Value(metadataKey{}).(metadata) + if ok { + res, exist := md[key] + return res, exist + } + return "", false +} + +// GetAllValues extracts all key/val pairs. +// If there is no key/val pairs at all, would return nil, false +func GetAllValues(ctx context.Context) (map[string]string, bool) { + md, ok := ctx.Value(metadataKey{}).(metadata) + if ok { + return md, true + } + return nil, false +} diff --git a/a2a/transport/jsonrpc/pkg/rpcinfo/rpcinfo.go b/a2a/transport/jsonrpc/pkg/rpcinfo/rpcinfo.go new file mode 100644 index 000000000..a1b35b287 --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/rpcinfo/rpcinfo.go @@ -0,0 +1,71 @@ +/* + * Copyright 2025 CloudWeGo 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 rpcinfo + +type RPCInfo interface { + Invocation() Invocation + To() Instance +} + +type rpcInfo struct { + invocation Invocation + to Instance +} + +func (ri *rpcInfo) Invocation() Invocation { + return ri.invocation +} + +func (ri *rpcInfo) To() Instance { + return ri.to +} + +func NewRPCInfo(invocation Invocation) RPCInfo { + return &rpcInfo{ + invocation: invocation, + } +} + +// Invocation contains RPC related metadata +type Invocation interface { + ID() string + MethodName() string +} + +func NewInvocation(id string, methodName string) Invocation { + return &invocation{ + id: id, + methodName: methodName, + } +} + +type invocation struct { + id string + methodName string +} + +func (i *invocation) ID() string { + return i.id +} + +func (i *invocation) MethodName() string { + return i.methodName +} + +type Instance interface { + Address() string +} diff --git a/a2a/transport/jsonrpc/pkg/rpcinfo/utils.go b/a2a/transport/jsonrpc/pkg/rpcinfo/utils.go new file mode 100644 index 000000000..ff02bff52 --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/rpcinfo/utils.go @@ -0,0 +1,32 @@ +/* + * Copyright 2025 CloudWeGo 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 rpcinfo + +import "context" + +type rpcinfoKey struct{} + +func NewCtxWithRPCInfo(ctx context.Context, ri RPCInfo) context.Context { + if ri == nil { + return ctx + } + return context.WithValue(ctx, rpcinfoKey{}, ri) +} + +func RPCInfoFromCtx(ctx context.Context) RPCInfo { + return ctx.Value(rpcinfoKey{}).(RPCInfo) +} diff --git a/a2a/transport/jsonrpc/pkg/tracer/tracer.go b/a2a/transport/jsonrpc/pkg/tracer/tracer.go new file mode 100644 index 000000000..51a2e4db2 --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/tracer/tracer.go @@ -0,0 +1,41 @@ +/* + * Copyright 2025 CloudWeGo 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 tracer + +import "context" + +type Tracer interface { + Start(ctx context.Context) context.Context + Finish(ctx context.Context) +} + +type Getter interface { + GetTracer() Tracer +} + +type emptyTracer struct { +} + +func (t *emptyTracer) Start(ctx context.Context) context.Context { + return ctx +} + +func (t *emptyTracer) Finish(ctx context.Context) {} + +func NewNoopTracer() Tracer { + return &emptyTracer{} +} diff --git a/a2a/transport/jsonrpc/pkg/transport/http/client.go b/a2a/transport/jsonrpc/pkg/transport/http/client.go new file mode 100644 index 000000000..2d611333d --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/transport/http/client.go @@ -0,0 +1,219 @@ +/* + * Copyright 2025 CloudWeGo 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 http + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/cloudwego/hertz/pkg/app/client" + "github.com/cloudwego/hertz/pkg/protocol" + "github.com/cloudwego/hertz/pkg/protocol/consts" + "github.com/cloudwego/hertz/pkg/protocol/sse" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/conninfo" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/metadata" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/utils" +) + +type ClientTransportBuilderOptions struct { + cli *client.Client +} +type ClientTransportBuilderOption func(*ClientTransportBuilderOptions) + +func WithHertzClient(cli *client.Client) ClientTransportBuilderOption { + return func(o *ClientTransportBuilderOptions) { + o.cli = cli + } +} + +type clientTransportHandler struct { + cli *client.Client +} + +func NewClientTransportHandler(opts ...ClientTransportBuilderOption) transport.ClientTransportHandler { + o := &ClientTransportBuilderOptions{} + for _, opt := range opts { + opt(o) + } + + cli := o.cli + if cli == nil { + cli, _ = client.NewClient(client.WithDialTimeout(consts.DefaultDialTimeout)) + } + return &clientTransportHandler{ + cli: cli, + } +} + +func (c *clientTransportHandler) NewTransport(ctx context.Context, peer conninfo.Peer) (core.Transport, error) { + addr := peer.Address() + rounder := newClientRounder(addr, c.cli) + return &httpClientTransport{rounder: rounder}, nil +} + +type clientRounder struct { + cli *client.Client + addr string +} + +func newClientRounder(addr string, cli *client.Client) *clientRounder { + return &clientRounder{ + addr: addr, + cli: cli, + } +} + +func (c *clientRounder) Round(ctx context.Context, msg core.Message) (core.MessageReader, error) { + buf, err := core.EncodeMessage(msg) + if err != nil { + return nil, err + } + req := &protocol.Request{} + resp := &protocol.Response{} + req.SetMethod(consts.MethodPost) + req.SetRequestURI(c.addr) + req.SetHeader("Accept", "application/json,text/event-stream") + md, ok := metadata.GetAllValues(ctx) + if ok { + for k, v := range md { + req.SetHeader(k, v) + } + } + req.Header.SetContentTypeBytes([]byte("application/json")) + req.SetBody(buf) + if err = c.cli.Do(ctx, req, resp); err != nil { + return nil, err + } + status := resp.StatusCode() + if status != consts.StatusOK { + // return specific error based on status code + switch status { + case consts.StatusNotFound: + return nil, fmt.Errorf("url path %s not found", c.addr) + default: + return nil, fmt.Errorf("unexpected status code %d, body: %s", status, string(resp.Body())) + } + } + ct := string(resp.Header.ContentType()) + switch { + case strings.Contains(ct, "application/json"): + return &pingPongReader{ + resp: resp, + }, nil + case strings.Contains(ct, "text/event-stream"): + r, _ := sse.NewReader(resp) + sr := &sseReader{ + reader: r, + buf: utils.NewUnboundBuffer[sseData](), + } + sr.run() + return sr, nil + default: + return nil, fmt.Errorf("non-expected content-type: %s, status-code: %d", ct, status) + } +} + +type pingPongReader struct { + resp *protocol.Response + isFinish bool +} + +func (r *pingPongReader) Read(ctx context.Context) (core.Message, error) { + if r.isFinish { + return nil, io.EOF + } + // todo: think about batch + msgs, _, err := core.DecodeMessages(r.resp.Body()) + if err != nil { + return nil, err + } + return msgs[0], nil +} + +func (r *pingPongReader) Close() error { + if r.isFinish { + return nil + } + r.resp = nil + r.isFinish = true + return nil +} + +type sseReader struct { + reader *sse.Reader + buf *utils.UnboundBuffer[sseData] + err error + cancelFunc context.CancelFunc +} + +type sseData struct { + event *sse.Event + err error +} + +func (s *sseReader) run() { + ctx, cancel := context.WithCancel(context.Background()) + s.cancelFunc = cancel + go func() { + err := s.reader.ForEach(ctx, func(e *sse.Event) error { + s.buf.Push(sseData{ + event: e.Clone(), + }) + return nil + }) + if err == nil { + err = io.EOF + } + s.buf.Push(sseData{ + err: err, + }) + }() +} + +func (s *sseReader) Read(ctx context.Context) (core.Message, error) { + if s.err != nil { + return nil, s.err + } + data := <-s.buf.PopChan() + defer s.buf.Load() + if data.err != nil { + s.err = data.err + s.cancelFunc() + return nil, s.err + } + msgs, _, err := core.DecodeMessages(data.event.Data) + if err != nil { + return nil, err + } + return msgs[0], nil +} + +func (s *sseReader) Close() error { + if s.err != nil { // which means the lifecycle of sse.Reader has finished + return nil + } + s.err = io.EOF + s.cancelFunc() + // todo: judge the side effect of sse.Reader Close when downstream sends data continue + // Here it looks like the cancel-triggered forced close waits until the sse.Reader's close releases the lock. + return nil +} diff --git a/a2a/transport/jsonrpc/pkg/transport/http/server.go b/a2a/transport/jsonrpc/pkg/transport/http/server.go new file mode 100644 index 000000000..e66ca631d --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/transport/http/server.go @@ -0,0 +1,195 @@ +/* + * Copyright 2025 CloudWeGo 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 http + +import ( + "context" + "fmt" + "strconv" + "sync" + + "github.com/cloudwego/hertz/pkg/app" + hz_server "github.com/cloudwego/hertz/pkg/app/server" + "github.com/cloudwego/hertz/pkg/protocol/sse" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/metadata" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/utils" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" +) + +type ServerTransportBuilderOptions struct { + hz *hz_server.Hertz + hdl transport.ServerTransportHandler +} + +type ServerTransportBuilderOption func(*ServerTransportBuilderOptions) + +func WithHertzIns(hz *hz_server.Hertz) ServerTransportBuilderOption { + return func(o *ServerTransportBuilderOptions) { + o.hz = hz + } +} + +func WithServerTransportHandler(hdl transport.ServerTransportHandler) ServerTransportBuilderOption { + return func(o *ServerTransportBuilderOptions) { + o.hdl = hdl + } +} + +func NewServerTransportBuilder(path string, opts ...ServerTransportBuilderOption) *ServerTransportBuilder { + options := ServerTransportBuilderOptions{} + for _, opt := range opts { + opt(&options) + } + return &ServerTransportBuilder{ + path: path, + hdl: options.hdl, + rounder: newServerRounder(), + hzIns: options.hz, + } +} + +type ServerTransportBuilder struct { + path string + hdl transport.ServerTransportHandler + rounder *serverRounder + once sync.Once + hzIns *hz_server.Hertz +} + +func (s *ServerTransportBuilder) Build(ctx context.Context, hdl transport.ServerTransportHandler) (transport.ServerTransport, error) { + hz := s.hzIns + if hz == nil { + hz = hz_server.Default() + } + hz.POST(s.path, s.POST) + s.hdl = hdl + return &server{hz: hz}, nil +} + +func (s *ServerTransportBuilder) POST(c context.Context, ctx *app.RequestContext) { + msg, err := core.ReadMessage(ctx.Request.Body()) + if err != nil { + ctx.JSON(400, core.NewFailureResponse(core.ID{}, core.ErrorInvalidRequest)) + return + } + s.once.Do(func() { + s.hdl.OnTransport(c, &httpServerTransport{rounder: s.rounder}) + }) + ctx.VisitAllHeaders(func(key, val []byte) { + c = metadata.WithValue(c, string(key), string(val)) + }) + finishCh := s.rounder.newRound(c, msg, ctx) + select { + case <-c.Done(): + fmt.Println("connection closed") + case <-finishCh: + } +} + +type server struct { + hz *hz_server.Hertz +} + +func (s *server) ListenAndServe(ctx context.Context) error { + return s.hz.Run() +} + +func (s *server) Shutdown(ctx context.Context) error { + return s.hz.Shutdown(ctx) +} + +type serverRounder struct { + buf *utils.UnboundBuffer[roundMeta] +} + +func newServerRounder() *serverRounder { + return &serverRounder{buf: utils.NewUnboundBuffer[roundMeta]()} +} + +type roundMeta struct { + ctx context.Context + msg core.Message + writer *writer +} + +func (s *serverRounder) newRound(ctx context.Context, msg core.Message, hzCtx *app.RequestContext) <-chan struct{} { + w := newWriter(hzCtx) + s.buf.Push(roundMeta{ + ctx: ctx, + msg: msg, + writer: w, + }) + return w.finishCh +} + +func (s *serverRounder) OnRound() (context.Context, core.Message, core.MessageWriter, error) { + meta := <-s.buf.PopChan() + s.buf.Load() + return meta.ctx, meta.msg, meta.writer, nil +} + +type writer struct { + ctx *app.RequestContext + sseWriter *sse.Writer + id int + finishCh chan struct{} + isClosed bool +} + +func newWriter(hzCtx *app.RequestContext) *writer { + return &writer{ + ctx: hzCtx, + finishCh: make(chan struct{}), + } +} + +func (w *writer) WriteStreaming(ctx context.Context, msg core.Message) error { + if w.sseWriter == nil { + w.sseWriter = sse.NewWriter(w.ctx) + } + return w.writeSSE(msg) +} + +func (w *writer) writeSSE(msg core.Message) error { + buf, err := core.Marshal(msg) + if err != nil { + return err + } + w.id += 1 + if err = w.sseWriter.WriteEvent(strconv.Itoa(w.id), "message", buf); err != nil { + return err + } + return nil +} + +func (w *writer) Close() error { + if w.isClosed { + return nil + } + w.isClosed = true + close(w.finishCh) + return nil +} + +func (w *writer) Write(ctx context.Context, msg core.Message) error { + w.ctx.JSON(200, msg) + w.Close() + return nil +} diff --git a/a2a/transport/jsonrpc/pkg/transport/http/transport.go b/a2a/transport/jsonrpc/pkg/transport/http/transport.go new file mode 100644 index 000000000..1f3b7cb22 --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/transport/http/transport.go @@ -0,0 +1,43 @@ +/* + * Copyright 2025 CloudWeGo 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 http + +import "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + +type httpClientTransport struct { + rounder *clientRounder +} + +func (h *httpClientTransport) ClientCapability() (core.ClientRounder, bool) { + return h.rounder, true +} + +func (h *httpClientTransport) ServerCapability() (core.ServerRounder, bool) { + return nil, false +} + +type httpServerTransport struct { + rounder *serverRounder +} + +func (h *httpServerTransport) ClientCapability() (core.ClientRounder, bool) { + return nil, false +} + +func (h *httpServerTransport) ServerCapability() (core.ServerRounder, bool) { + return h.rounder, true +} diff --git a/a2a/transport/jsonrpc/pkg/transport/transport.go b/a2a/transport/jsonrpc/pkg/transport/transport.go new file mode 100644 index 000000000..031733f86 --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/transport/transport.go @@ -0,0 +1,41 @@ +/* + * Copyright 2025 CloudWeGo 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 transport + +import ( + "context" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/conninfo" +) + +type ClientTransportHandler interface { + NewTransport(ctx context.Context, peer conninfo.Peer) (core.Transport, error) +} + +type ServerTransportHandler interface { + OnTransport(ctx context.Context, trans core.Transport) error +} + +type ServerTransportBuilder interface { + Build(ctx context.Context, hdl ServerTransportHandler) (ServerTransport, error) +} + +type ServerTransport interface { + ListenAndServe(ctx context.Context) error + Shutdown(ctx context.Context) error +} diff --git a/a2a/transport/jsonrpc/pkg/utils/buffer.go b/a2a/transport/jsonrpc/pkg/utils/buffer.go new file mode 100644 index 000000000..af5f9b00e --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/utils/buffer.go @@ -0,0 +1,63 @@ +/* + * Copyright 2025 CloudWeGo 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 utils + +import ( + "sync" +) + +type UnboundBuffer[T any] struct { + mu sync.Mutex + buffer []T + ch chan T +} + +func NewUnboundBuffer[T any]() *UnboundBuffer[T] { + return &UnboundBuffer[T]{ + ch: make(chan T, 256), + } +} + +func (buf *UnboundBuffer[T]) Push(obj T) { + buf.mu.Lock() + if len(buf.buffer) == 0 { + select { + case buf.ch <- obj: + buf.mu.Unlock() + return + default: + } + } + buf.buffer = append(buf.buffer, obj) + buf.mu.Unlock() +} + +func (buf *UnboundBuffer[T]) Load() { + buf.mu.Lock() + if len(buf.buffer) > 0 { + select { + case buf.ch <- buf.buffer[0]: + buf.buffer = buf.buffer[1:] + default: + } + } + buf.mu.Unlock() +} + +func (buf *UnboundBuffer[T]) PopChan() <-chan T { + return buf.ch +} diff --git a/a2a/transport/jsonrpc/server/option.go b/a2a/transport/jsonrpc/server/option.go new file mode 100644 index 000000000..27d0f7937 --- /dev/null +++ b/a2a/transport/jsonrpc/server/option.go @@ -0,0 +1,73 @@ +/* + * Copyright 2025 CloudWeGo 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 server + +import ( + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/streaming" +) + +type Options struct { + transBuilder transport.ServerTransportBuilder + ppHdls map[string]core.HandleEndpoint + ssHdls map[string]streaming.ServerStreamingHandleEndpoint + notifHdls map[string]core.NotificationHandleEndpoint +} + +func buildOptions(opts ...Option) *Options { + options := new(Options) + for _, opt := range opts { + opt(options) + } + return options +} + +type Option func(options *Options) + +func WithTransportBuilder(transBuilder transport.ServerTransportBuilder) Option { + return func(options *Options) { + options.transBuilder = transBuilder + } +} + +func WithPingPongHandler(method string, hdl core.HandleEndpoint) Option { + return func(options *Options) { + if options.ppHdls == nil { + options.ppHdls = make(map[string]core.HandleEndpoint) + } + options.ppHdls[method] = hdl + } +} + +func WithServerStreamingHandler(method string, hdl streaming.ServerStreamingHandleEndpoint) Option { + return func(options *Options) { + if options.ssHdls == nil { + options.ssHdls = make(map[string]streaming.ServerStreamingHandleEndpoint) + } + options.ssHdls[method] = hdl + } +} + +func WithNotificationHandler(method string, hdl core.NotificationHandleEndpoint) Option { + return func(options *Options) { + if options.notifHdls == nil { + options.notifHdls = make(map[string]core.NotificationHandleEndpoint) + } + options.notifHdls[method] = hdl + } +} diff --git a/a2a/transport/jsonrpc/server/server.go b/a2a/transport/jsonrpc/server/server.go new file mode 100644 index 000000000..69fec2116 --- /dev/null +++ b/a2a/transport/jsonrpc/server/server.go @@ -0,0 +1,84 @@ +/* + * Copyright 2025 CloudWeGo 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 server + +import ( + "context" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/streaming" +) + +type Server struct { + builder transport.ServerTransportBuilder + hdl *jsonRPCHandler +} + +func NewServer(opts ...Option) *Server { + options := buildOptions(opts...) + return &Server{ + builder: options.transBuilder, + hdl: newJsonRPCHandler(options), + } +} + +func (srv *Server) Run(ctx context.Context) error { + trans, err := srv.builder.Build(ctx, srv.hdl) + if err != nil { + return err + } + return trans.ListenAndServe(ctx) +} + +type jsonRPCHandler struct { + options *Options +} + +func NewServerTransportHandler(opts ...Option) (transport.ServerTransportHandler, error) { + options := buildOptions(opts...) + return newJsonRPCHandler(options), nil +} + +func newJsonRPCHandler(options *Options) *jsonRPCHandler { + return &jsonRPCHandler{options: options} +} + +func (hdl *jsonRPCHandler) OnTransport(ctx context.Context, trans core.Transport) error { + options := hdl.options + var opts []core.Option + for method, ppHdl := range options.ppHdls { + opts = append(opts, core.WithHandler(method, ppHdl)) + } + var ssOpts []streaming.Option + for method, ssHdl := range options.ssHdls { + ssOpts = append(ssOpts, streaming.WithServerStreamingHandler(method, ssHdl)) + } + opts = append(opts, streaming.ConvertConnectionOption(ssOpts...)...) + + if cliTrans, ok := trans.ClientCapability(); ok { + opts = append(opts, core.WithClientRounder(cliTrans)) + } + if srvTrans, ok := trans.ServerCapability(); ok { + opts = append(opts, core.WithServerRounder(srvTrans)) + } + ctx, _, err := core.NewConnection(ctx, opts...) + if err != nil { + return err + } + return nil +} diff --git a/a2a/transport/jsonrpc/streaming/middleware.go b/a2a/transport/jsonrpc/streaming/middleware.go new file mode 100644 index 000000000..4a0b65e79 --- /dev/null +++ b/a2a/transport/jsonrpc/streaming/middleware.go @@ -0,0 +1,50 @@ +/* + * Copyright 2025 CloudWeGo 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 streaming + +import ( + "context" + "encoding/json" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" +) + +type ServerStreamingCallEndpoint func(ctx context.Context, method string, req interface{}) (ServerStreamingClient, error) + +type ServerStreamingCallMiddleware func(ServerStreamingCallEndpoint) ServerStreamingCallEndpoint + +func serverStreamingCallChain(mws ...ServerStreamingCallMiddleware) ServerStreamingCallMiddleware { + return func(endpoint ServerStreamingCallEndpoint) ServerStreamingCallEndpoint { + for i := len(mws) - 1; i >= 0; i-- { + endpoint = mws[i](endpoint) + } + return endpoint + } +} + +type ServerStreamingHandleEndpoint func(ctx context.Context, conn core.Connection, req json.RawMessage, srv ServerStreamingServer) error + +type ServerStreamingHandleMiddleware func(ServerStreamingHandleEndpoint) ServerStreamingHandleEndpoint + +func serverStreamingHandleChain(mws ...ServerStreamingHandleMiddleware) ServerStreamingHandleMiddleware { + return func(endpoint ServerStreamingHandleEndpoint) ServerStreamingHandleEndpoint { + for i := len(mws) - 1; i >= 0; i-- { + endpoint = mws[i](endpoint) + } + return endpoint + } +} diff --git a/a2a/transport/jsonrpc/streaming/option.go b/a2a/transport/jsonrpc/streaming/option.go new file mode 100644 index 000000000..f0a733df2 --- /dev/null +++ b/a2a/transport/jsonrpc/streaming/option.go @@ -0,0 +1,77 @@ +/* + * Copyright 2025 CloudWeGo 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 streaming + +import ( + "context" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" +) + +type Options struct { + ssHdls map[string]ServerStreamingHandleEndpoint + + ssCallMWs []ServerStreamingCallMiddleware + ssHdlMWs []ServerStreamingHandleMiddleware +} + +type Option func(options *Options) + +func WithServerStreamingHandler(method string, ep ServerStreamingHandleEndpoint) Option { + return func(options *Options) { + if options.ssHdls == nil { + options.ssHdls = make(map[string]ServerStreamingHandleEndpoint) + } + options.ssHdls[method] = ep + } +} + +func ConvertConnectionOption(opts ...Option) []core.Option { + var res []core.Option + options := new(Options) + for _, opt := range opts { + opt(options) + } + + for method, ssHdl := range options.ssHdls { + hdl := ssHdl + hdl = serverStreamingHandleChain(options.ssHdlMWs...)(hdl) + res = append(res, core.WithRequestHandler(method, func(ctx context.Context, conn core.Connection, req *core.Request, async core.ServerAsync) error { + return convertRequestHandler(hdl)(ctx, conn, req, async) + })) + } + + return res +} + +func convertRequestHandler(ep ServerStreamingHandleEndpoint) core.RequestHandleEndpoint { + return func(ctx context.Context, conn core.Connection, req *core.Request, async core.ServerAsync) (err error) { + defer func() { + if rawPanic := recover(); rawPanic != nil { + err = core.ConvertError(rawPanic.(error)) + async.FinishStreaming(ctx, err) + } + }() + if epErr := ep(ctx, conn, req.Params, &serverStreamingServer{async: async}); epErr != nil { + err = core.ConvertError(epErr) + // when endpoint throws error, we just ignore the error thrown by SendStreaming + async.FinishStreaming(ctx, err) + return + } + return async.FinishStreaming(ctx, nil) + } +} diff --git a/a2a/transport/jsonrpc/streaming/streaming.go b/a2a/transport/jsonrpc/streaming/streaming.go new file mode 100644 index 000000000..153cf79bb --- /dev/null +++ b/a2a/transport/jsonrpc/streaming/streaming.go @@ -0,0 +1,107 @@ +/* + * Copyright 2025 CloudWeGo 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 streaming + +import ( + "context" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/tracer" +) + +// Extension is a direct user-facing interface that extends the semantics of JSON-RPC2 Ping-Pong to gRPC-like Stream. +// The req/resp involved in each method call has the same ID. +// +// # ClientStreaming and BidiStreaming would be supported when there is a clear usage scenario in the future. +// +// ServerStreaming: +// +// Client => req(id=1) => Server +// Client <= resp(id=1) <= Server +// Client <= resp(id=1) <= Server +// ... +type Extension interface { + ServerStreaming(ctx context.Context, method string, req interface{}, opts ...core.CallOption) (ServerStreamingClient, error) +} + +type ServerStreamingClient interface { + Recv(ctx context.Context, obj interface{}) error + Close() error +} + +type serverStreamingClient struct { + async core.ClientAsync + tracer tracer.Tracer +} + +func (c *serverStreamingClient) Recv(ctx context.Context, obj interface{}) error { + // todo: check err and do tracing + return c.async.Recv(ctx, obj) +} + +func (c *serverStreamingClient) Close() error { + return c.async.Close() +} + +type extension struct { + conn core.Connection + ssCallEp ServerStreamingCallEndpoint + tracer tracer.Tracer +} + +func (ext *extension) ServerStreaming(ctx context.Context, method string, req interface{}, opts ...core.CallOption) (ServerStreamingClient, error) { + ctx = ext.tracer.Start(ctx) + return ext.ssCallEp(ctx, method, req) +} + +func (ext *extension) serverStreamingEndpoint(ctx context.Context, method string, req interface{}) (ServerStreamingClient, error) { + async, err := ext.conn.AsyncCall(ctx, method, req) + if err != nil { + return nil, err + } + return &serverStreamingClient{ + async: async, + }, nil +} + +func NewExtension(conn core.Connection, opts ...Option) (Extension, error) { + options := new(Options) + for _, opt := range opts { + opt(options) + } + ext := &extension{conn: conn} + ext.ssCallEp = serverStreamingCallChain(options.ssCallMWs...)(ext.serverStreamingEndpoint) + getter, ok := conn.(tracer.Getter) + if ok { + ext.tracer = getter.GetTracer() + } else { + ext.tracer = tracer.NewNoopTracer() + } + return ext, nil +} + +type ServerStreamingServer interface { + Send(ctx context.Context, obj interface{}) error +} + +type serverStreamingServer struct { + async core.ServerAsync +} + +func (s *serverStreamingServer) Send(ctx context.Context, obj interface{}) error { + return s.async.SendStreaming(ctx, obj) +} From 21c7fddbfb7ef54bf252c3eaf1e855f19b0b4109 Mon Sep 17 00:00:00 2001 From: Megumin Date: Mon, 25 Aug 2025 11:30:04 +0800 Subject: [PATCH 03/18] feat: modify go version & support buffer size --- a2a/transport/jsonrpc/go.mod | 2 +- .../jsonrpc/pkg/transport/http/client.go | 34 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/a2a/transport/jsonrpc/go.mod b/a2a/transport/jsonrpc/go.mod index 98b7a9e62..06353d343 100644 --- a/a2a/transport/jsonrpc/go.mod +++ b/a2a/transport/jsonrpc/go.mod @@ -1,6 +1,6 @@ module github.com/cloudwego/eino-ext/a2a/transport/jsonrpc -go 1.24.5 +go 1.18 require ( github.com/bytedance/sonic v1.14.0 diff --git a/a2a/transport/jsonrpc/pkg/transport/http/client.go b/a2a/transport/jsonrpc/pkg/transport/http/client.go index 2d611333d..b0e3b3274 100644 --- a/a2a/transport/jsonrpc/pkg/transport/http/client.go +++ b/a2a/transport/jsonrpc/pkg/transport/http/client.go @@ -35,7 +35,8 @@ import ( ) type ClientTransportBuilderOptions struct { - cli *client.Client + cli *client.Client + sseBufSize *int } type ClientTransportBuilderOption func(*ClientTransportBuilderOptions) @@ -45,8 +46,17 @@ func WithHertzClient(cli *client.Client) ClientTransportBuilderOption { } } +// WithSSEBufferSize specifies the maximum buffer size will be used in SSE Reader Processing. +// if size <= 0, then default maximum size (64 * 1024) would be used. +func WithSSEBufferSize(size int) ClientTransportBuilderOption { + return func(o *ClientTransportBuilderOptions) { + o.sseBufSize = &size + } +} + type clientTransportHandler struct { - cli *client.Client + cli *client.Client + sseBufSize *int } func NewClientTransportHandler(opts ...ClientTransportBuilderOption) transport.ClientTransportHandler { @@ -60,25 +70,28 @@ func NewClientTransportHandler(opts ...ClientTransportBuilderOption) transport.C cli, _ = client.NewClient(client.WithDialTimeout(consts.DefaultDialTimeout)) } return &clientTransportHandler{ - cli: cli, + cli: cli, + sseBufSize: o.sseBufSize, } } func (c *clientTransportHandler) NewTransport(ctx context.Context, peer conninfo.Peer) (core.Transport, error) { addr := peer.Address() - rounder := newClientRounder(addr, c.cli) + rounder := newClientRounder(addr, c.cli, c.sseBufSize) return &httpClientTransport{rounder: rounder}, nil } type clientRounder struct { - cli *client.Client - addr string + cli *client.Client + addr string + sseBufSize *int } -func newClientRounder(addr string, cli *client.Client) *clientRounder { +func newClientRounder(addr string, cli *client.Client, sseBufSize *int) *clientRounder { return &clientRounder{ - addr: addr, - cli: cli, + addr: addr, + cli: cli, + sseBufSize: sseBufSize, } } @@ -121,6 +134,9 @@ func (c *clientRounder) Round(ctx context.Context, msg core.Message) (core.Messa }, nil case strings.Contains(ct, "text/event-stream"): r, _ := sse.NewReader(resp) + if c.sseBufSize != nil { + r.SetMaxBufferSize(*c.sseBufSize) + } sr := &sseReader{ reader: r, buf: utils.NewUnboundBuffer[sseData](), From 3300097de208271226c9704e98709b99adc7b461 Mon Sep 17 00:00:00 2001 From: Megumin Date: Mon, 25 Aug 2025 11:46:09 +0800 Subject: [PATCH 04/18] feat: models --- a2a/go.mod | 19 +++ a2a/go.sum | 25 +++ a2a/models/artifact.go | 29 ++++ a2a/models/auth.go | 9 ++ a2a/models/card.go | 116 ++++++++++++++ a2a/models/handler.go | 24 +++ a2a/models/message.go | 115 ++++++++++++++ a2a/models/notification.go | 19 +++ a2a/models/part.go | 34 ++++ a2a/models/task.go | 231 +++++++++++++++++++++++++++ a2a/models/task_test.go | 56 +++++++ a2a/transport/jsonrpc/client.go1 | 263 +++++++++++++++++++++++++++++++ a2a/transport/jsonrpc/server.go1 | 229 +++++++++++++++++++++++++++ a2a/transport/transport.go | 23 +++ 14 files changed, 1192 insertions(+) create mode 100644 a2a/go.mod create mode 100644 a2a/go.sum create mode 100644 a2a/models/artifact.go create mode 100644 a2a/models/auth.go create mode 100644 a2a/models/card.go create mode 100644 a2a/models/handler.go create mode 100644 a2a/models/message.go create mode 100644 a2a/models/notification.go create mode 100644 a2a/models/part.go create mode 100644 a2a/models/task.go create mode 100644 a2a/models/task_test.go create mode 100644 a2a/transport/jsonrpc/client.go1 create mode 100644 a2a/transport/jsonrpc/server.go1 create mode 100644 a2a/transport/transport.go diff --git a/a2a/go.mod b/a2a/go.mod new file mode 100644 index 000000000..abec5c0a3 --- /dev/null +++ b/a2a/go.mod @@ -0,0 +1,19 @@ +module github.com/cloudwego/eino-ext/a2a + +go 1.18 + +require ( + github.com/go-openapi/spec v0.21.0 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/a2a/go.sum b/a2a/go.sum new file mode 100644 index 000000000..5b0ef044d --- /dev/null +++ b/a2a/go.sum @@ -0,0 +1,25 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/a2a/models/artifact.go b/a2a/models/artifact.go new file mode 100644 index 000000000..c5951b05a --- /dev/null +++ b/a2a/models/artifact.go @@ -0,0 +1,29 @@ +package models + +// Artifact represents an output or intermediate file from a task +type Artifact struct { + //unique identifier for the artifact generated by the agent. This identifier helps identify and assemble parts streamed by the agent + ArtifactID string `json:"artifactId"` + // Name is an optional name for the artifact + Name string `json:"name,omitempty"` + // Description is an optional description of the artifact + Description string `json:"description,omitempty"` + // Parts are the constituent parts of the artifact + Parts []Part `json:"parts"` + // Metadata is optional metadata associated with the artifact + Metadata map[string]any `json:"metadata,omitempty"` +} + +func (a *Artifact) EnsureRequiredFields() { + if a == nil { + return + } + if a.Parts == nil { + a.Parts = []Part{} + } + for i := 0; i < len(a.Parts); i++ { + if a.Parts[i].Kind == PartKindData && a.Parts[i].Data == nil { + a.Parts[i].Data = make(map[string]any) + } + } +} diff --git a/a2a/models/auth.go b/a2a/models/auth.go new file mode 100644 index 000000000..3404fd64a --- /dev/null +++ b/a2a/models/auth.go @@ -0,0 +1,9 @@ +package models + +// AuthenticationInfo defines the authentication schemes and credentials for an agent +type AuthenticationInfo struct { + // Schemes is a list of supported authentication schemes + Schemes []string `json:"schemes"` + // Credentials for authentication. Can be a string (e.g., token) or null if not required initially + Credentials string `json:"credentials,omitempty"` +} diff --git a/a2a/models/card.go b/a2a/models/card.go new file mode 100644 index 000000000..57fe78c78 --- /dev/null +++ b/a2a/models/card.go @@ -0,0 +1,116 @@ +package models + +import ( + "github.com/go-openapi/spec" +) + +type AgentProvider struct { + // Name of the organization or entity. + Organization string `json:"organization"` + // URL for the provider's organization website or relevant contact page. + URL string `json:"url"` +} + +type AgentCapabilities struct { + // If `true`, the agent supports `message/stream` and `tasks/resubscribe` for real-time + // updates via Server-Sent Events (SSE). Default: `false`. + Streaming bool `json:"streaming,omitempty"` + // If `true`, the agent supports `tasks/pushNotificationConfig/set` and `tasks/pushNotificationConfig/get` + // for asynchronous task updates via webhooks. Default: `false`. + PushNotifications bool `json:"pushNotifications,omitempty"` + // If `true`, the agent may include a detailed history of status changes + // within the `Task` object (future enhancement; specific mechanism TBD). Default: `false`. + StateTransitionHistory bool `json:"stateTransitionHistory,omitempty"` +} + +// AgentSkill defines a specific skill or capability offered by an agent +type AgentSkill struct { + // ID is the unique identifier for the skill + ID string `json:"id"` + // Name is the human-readable name of the skill + Name string `json:"name"` + // Description is an optional description of the skill + Description *string `json:"description"` + // Tags is an optional list of tags associated with the skill for categorization + Tags []string `json:"tags"` + // Examples is an optional list of example inputs or use cases for the skill + Examples []string `json:"examples,omitempty"` + // InputModes is an optional list of input modes supported by this skill + InputModes []string `json:"inputModes,omitempty"` + // OutputModes is an optional list of output modes supported by this skill + OutputModes []string `json:"outputModes,omitempty"` +} + +// AgentCard conveys key information about an A2A Server: +// - Overall identity and descriptive details. +// - Service endpoint URL. +// - Supported A2A protocol capabilities (streaming, push notifications). +// - Authentication requirements. +// - Default input/output content types (MIME types). +// - A list of specific skills the agent offers. +type AgentCard struct { + /** + * The version of the A2A protocol this agent supports. + * @default "0.2.5" + */ + ProtocolVersion string `json:"protocolVersion"` + /** + * Human readable name of the agent. + * Example: "Recipe Agent" + */ + Name string `json:"name"` + /** + * A human-readable description of the agent. Used to assist users and + * other agents in understanding what the agent can do. + * Example: "Agent that helps users with recipes and cooking." + */ + Description string `json:"description"` + /** + * A URL to the address the agent is hosted at. This represents the + * preferred endpoint as declared by the agent. + */ + URL string `json:"url"` + /** + * The transport of the preferred endpoint. If empty, defaults to JSONRPC. + */ + PreferredTransport string `json:"preferredTransport,omitempty"` + /** + * Announcement of additional supported transports. Client can use any of + * the supported transports. + */ + // AdditionalInterfaces AgentInterface[] `json:"additionalInterfaces,omitempty"` todo: support? + /** A URL to an icon for the agent. */ + IconUrl string `json:"iconUrl,omitempty"` + /** The service provider of the agent */ + Provider *AgentProvider `json:"provider,omitempty"` + /** + * The version of the agent - format is up to the provider. + * @TJS-examples ["1.0.0"] + */ + Version string `json:"version"` + /** + * A URL to the address the agent is hosted at. This represents the + * preferred endpoint as declared by the agent. + */ + DocumentationURL string `json:"documentationUrl,omitempty"` + /** Optional capabilities supported by the agent. */ + Capabilities AgentCapabilities `json:"capabilities"` + /** Security scheme details used for authenticating with this agent. */ + SecuritySchemes map[string]*spec.SecurityScheme `json:"securitySchemes,omitempty"` + /** Security requirements for contacting the agent. */ + Security map[string][]string `json:"security,omitempty"` + /** + * The set of interaction modes that the agent supports across all skills. This can be overridden per-skill. + * Supported media types for input. + */ + DefaultInputModes []string `json:"defaultInputModes"` + /** Supported media types for output. */ + DefaultOutputModes []string `json:"defaultOutputModes"` + /** Skills are a unit of capability that an agent can perform. */ + Skills []AgentSkill `json:"skills"` + /** + * true if the agent supports providing an extended agent card when the user is authenticated. + * Defaults to false if not specified. + */ + SupportsAuthenticatedExtendedCard bool `json:"supportsAuthenticatedExtendedCard,omitempty"` +} diff --git a/a2a/models/handler.go b/a2a/models/handler.go new file mode 100644 index 000000000..2aac2fa09 --- /dev/null +++ b/a2a/models/handler.go @@ -0,0 +1,24 @@ +package models + +import "context" + +type ResponseWriter interface { + Write(ctx context.Context, f *SendMessageStreamingResponseUnion) error + Close() error +} + +type ResponseReader interface { + Read() (*SendMessageStreamingResponseUnion, error) + Close() error +} + +type ServerHandlers struct { + AgentCard func(ctx context.Context) *AgentCard + SendMessage func(ctx context.Context, params *MessageSendParams) (*SendMessageResponseUnion, error) + SendMessageStreaming func(ctx context.Context, params *MessageSendParams, writer ResponseWriter) error + GetTask func(ctx context.Context, params *TaskQueryParams) (*Task, error) + CancelTask func(ctx context.Context, params *TaskIDParams) (*Task, error) + ResubscribeTask func(ctx context.Context, params *TaskIDParams, writer ResponseWriter) error + SetPushNotificationConfig func(ctx context.Context, params *TaskPushNotificationConfig) (*TaskPushNotificationConfig, error) + GetPushNotificationConfig func(ctx context.Context, params *GetTaskPushNotificationConfigParams) (*TaskPushNotificationConfig, error) +} diff --git a/a2a/models/message.go b/a2a/models/message.go new file mode 100644 index 000000000..d5e8898db --- /dev/null +++ b/a2a/models/message.go @@ -0,0 +1,115 @@ +package models + +type Role string + +const ( + RoleUser Role = "user" + RoleAgent = "agent" +) + +type Message struct { + // Indicates the sender of the message: + // "user" for messages originating from the A2A Client (acting on behalf of an end-user or system). + // "agent" for messages originating from the A2A Server (the remote agent). + Role Role `json:"role"` + // An array containing the content of the message, broken down into one or more parts. + // A message MUST contain at least one part. + // Using multiple parts allows for rich, multi-modal content (e.g., text accompanying an image). + Parts []Part `json:"parts"` + // Arbitrary key-value metadata associated with the message. + // Keys SHOULD be strings; values can be any valid JSON type. + // Useful for timestamps, source identifiers, language codes, etc. + Metadata map[string]any `json:"metadata,omitempty"` + // List of tasks referenced as contextual hint by this message. + ReferenceTaskIDs []string `json:"referenceTaskIDs,omitempty"` + // message identifier created by the message creator + MessageID string `json:"messageId"` + // task identifier the current message is related to + TaskID *string `json:"taskId,omitempty"` + // Context identifier the message is associated with + ContextID *string `json:"contextId,omitempty"` +} + +func (m *Message) EnsureRequiredFields() { + if m == nil { + return + } + if m.Parts == nil { + m.Parts = []Part{} + } + for i := 0; i < len(m.Parts); i++ { + if m.Parts[i].Kind == PartKindData && m.Parts[i].Data == nil { + m.Parts[i].Data = map[string]interface{}{} + } + } +} + +type MessageSendParams struct { + // The message to send to the agent. The `role` within this message is typically "user". + Message Message `json:"message"` + // Optional: additional configuration to send to the agent`. + Configuration *MessageSendConfiguration `json:"configuration,omitempty"` + // Arbitrary metadata for this specific `message/send` request. + Metadata map[string]any `json:"metadata,omitempty"` +} + +type MessageSendConfiguration struct { + // AcceptedOutputModes specifies accepted output modalities by the client + //AcceptedOutputModes []string `json:"acceptedOutputModes"` todo: why can client control server's output type... + // HistoryLength specifies the number of recent messages to be retrieved + //HistoryLength *int `json:"historyLength"` + // PushNotificationConfig provides the server for sending asynchronous push notifications about task updates. + PushNotificationConfig *PushNotificationConfig `json:"pushNotificationConfig"` + // Blocking specifies if the server should treat the client as a blocking request + //Blocking *bool `json:"blocking"` todo: what is the meaning of this +} + +type SendMessageResponseUnion struct { + Message *Message + Task *Task +} + +type SendMessageStreamingResponseUnion struct { + Message *Message + Task *Task + TaskStatusUpdateEvent *TaskStatusUpdateEvent + TaskArtifactUpdateEvent *TaskArtifactUpdateEvent +} + +func (s *SendMessageStreamingResponseUnion) GetTaskID() string { + if s.Message != nil && s.Message.TaskID != nil { + return *s.Message.TaskID + } else if s.Task != nil { + return s.Task.ID + } else if s.TaskStatusUpdateEvent != nil { + return s.TaskStatusUpdateEvent.TaskID + } else if s.TaskArtifactUpdateEvent != nil { + return s.TaskArtifactUpdateEvent.TaskID + } + return "" +} + +type ResponseEvent struct { + Message *Message + TaskContent *TaskContent + TaskStatusUpdateEventContent *TaskStatusUpdateEventContent + TaskArtifactUpdateEventContent *TaskArtifactUpdateEventContent +} + +func (r *ResponseEvent) EnsureRequiredFields() { + if r == nil { + return + } + if r.Message != nil { + r.Message.EnsureRequiredFields() + } + if r.TaskContent != nil { + r.TaskContent.EnsureRequiredFields() + } + if r.TaskStatusUpdateEventContent != nil { + r.TaskStatusUpdateEventContent.EnsureRequiredFields() + } + if r.TaskArtifactUpdateEventContent != nil { + r.TaskArtifactUpdateEventContent.EnsureRequiredFields() + } +} diff --git a/a2a/models/notification.go b/a2a/models/notification.go new file mode 100644 index 000000000..b688ed39d --- /dev/null +++ b/a2a/models/notification.go @@ -0,0 +1,19 @@ +package models + +type PushNotificationConfig struct { + // The absolute HTTPS webhook URL where the A2A Server should POST task updates. + // This URL MUST be HTTPS for security. + URL string `json:"url"` + // An optional, client-generated opaque token (e.g., a secret, a task-specific identifier, or a nonce). + // The A2A Server SHOULD include this token in the notification request it sends to the `url` + // (e.g., in a custom HTTP header like `X-A2A-Notification-Token` or similar). + // This allows the client's webhook receiver to validate the relevance and authenticity of the notification. + Token string `json:"token,omitempty"` + // Authentication details the A2A Server needs to use when calling the client's `url`. + // The client's webhook endpoint defines these requirements. This tells the A2A Server how to authenticate *itself* to the client's webhook. + Authentication *AuthenticationInfo `json:"authentication,omitempty"` +} + +type GetTaskPushNotificationConfigParams struct { + PushNotificationConfigID string `json:"pushNotificationConfigID,omitempty"` +} diff --git a/a2a/models/part.go b/a2a/models/part.go new file mode 100644 index 000000000..b6e22f9a5 --- /dev/null +++ b/a2a/models/part.go @@ -0,0 +1,34 @@ +package models + +type PartKind string + +const ( + PartKindText PartKind = "text" + PartKindFile PartKind = "file" + PartKindData PartKind = "data" +) + +type Part struct { + Kind PartKind `json:"kind"` + // Text is the text content for text parts + Text *string `json:"text,omitempty"` + // File is the file content for file parts + File *FileContent `json:"file,omitempty"` + // Data is the structured data content for data parts + Data map[string]any `json:"data,omitempty"` + // Metadata is optional metadata associated with this part + Metadata map[string]any `json:"metadata,omitempty"` +} + +// FileContent represents the base structure for file content +type FileContent struct { + // Name is the optional name of the file + Name string `json:"name,omitempty"` + // MimeType is the optional MIME type of the file content + MimeType string `json:"mimeType,omitempty"` + + // Bytes is the file content encoded as a Base64 string + Bytes *string `json:"bytes,omitempty"` + // URI is the URI pointing to the file content + URI *string `json:"uri,omitempty"` +} diff --git a/a2a/models/task.go b/a2a/models/task.go new file mode 100644 index 000000000..36c68c03a --- /dev/null +++ b/a2a/models/task.go @@ -0,0 +1,231 @@ +package models + +type ResponseKind string + +const ( + ResponseKindTask ResponseKind = "task" + ResponseKindMessage ResponseKind = "message" + ResponseKindArtifactUpdate ResponseKind = "artifact-update" + ResponseKindStatusUpdate ResponseKind = "status-update" +) + +// TaskState represents the state of a task within the A2A protocol +type TaskState string + +const ( + TaskStateSubmitted TaskState = "submitted" // Task received by server, acknowledged, but processing has not yet actively started. + TaskStateWorking TaskState = "working" // Task is actively being processed by the agent. + TaskStateInputRequired TaskState = "input-required" // Agent requires additional input from the client/user to proceed. (Task is paused) + TaskStateCompleted TaskState = "completed" // Task finished successfully. (Terminal state) + TaskStateCanceled TaskState = "canceled" // Task was canceled by the client or potentially by the server. (Terminal state) + TaskStateFailed TaskState = "failed" // Task terminated due to an error during processing. (Terminal state) + TaskStateRejected TaskState = "rejected" //Task has be rejected by the remote agent (Terminal state) + TaskStateAuthRequired TaskState = "auth-required" //Authentication required from client/user to proceed. (Task is paused) + TaskStateUnknown TaskState = "unknown" // The state of the task cannot be determined (e.g., task ID invalid or expired). (Effectively a terminal state from client's PoV for that ID) +) + +// TaskStatus represents the status of a task +type TaskStatus struct { + // The current lifecycle state of the task. + State TaskState `json:"state"` + // An optional message associated with the current status. + // This could be a progress update from the agent, a prompt for more input, + // a summary of the final result, or an error message. + Message *Message `json:"message,omitempty"` + // The date and time (UTC is STRONGLY recommended) when this status was recorded by the server. + // Format: ISO 8601 `date-time` string (e.g., "2023-10-27T10:00:00Z"). + Timestamp string `json:"timestamp,omitempty"` +} + +type Task struct { + // A unique identifier for the task. This ID is generated by the server. + // It should be sufficiently unique (e.g., a UUID v4). + ID string `json:"id"` + // Server-generated id for contextual alignment across interactions + // Useful for maintaining context across multiple, sequential, or related tasks. + ContextID string `json:"contextId"` // todo: how to specify related tasks... + + // The current status of the task, including its lifecycle state, an optional associated message, + // and a timestamp. + Status TaskStatus `json:"status"` + // An array of outputs (artifacts) generated by the agent for this task. + // This array can be populated incrementally, especially during streaming. + // Artifacts represent the tangible results of the task. + Artifacts []*Artifact `json:"artifacts,omitempty"` + // An optional array of recent messages exchanged within this task, + // ordered chronologically (oldest first). + // This history is included if requested by the client via the `historyLength` parameter + // in `TaskSendParams` or `TaskQueryParams`. + History []*Message `json:"history,omitempty"` // todo: what's the relation between status and history? + // Arbitrary key-value metadata associated with the task. + // Keys SHOULD be strings; values can be any valid JSON type (string, number, boolean, array, object). + // This can be used for application-specific data, tracing info, etc. + Metadata map[string]any `json:"metadata,omitempty"` + + //Kind string = "task" todo +} + +// todo: if add +//func (t *Task) TaskContent() *TaskContent { +// return &TaskContent{ +// Status: t.Status, +// Artifacts: t.Artifacts, +// History: t.History, +// Metadata: t.Metadata, +// } +//} + +func (t *Task) isSendResponse() {} +func (t *Task) isSendStreamingMessageResponse() {} + +type TaskContent struct { + // The current status of the task, including its lifecycle state, an optional associated message, + // and a timestamp. + Status TaskStatus `json:"status"` + // An array of outputs (artifacts) generated by the agent for this task. + // This array can be populated incrementally, especially during streaming. + // Artifacts represent the tangible results of the task. + Artifacts []*Artifact `json:"artifacts,omitempty"` + // An optional array of recent messages exchanged within this task, + // ordered chronologically (oldest first). + // This history is included if requested by the client via the `historyLength` parameter + // in `TaskSendParams` or `TaskQueryParams`. + History []*Message `json:"history,omitempty"` // todo: what's the relation between status and history? + // Arbitrary key-value metadata associated with the task. + // Keys SHOULD be strings; values can be any valid JSON type (string, number, boolean, array, object). + // This can be used for application-specific data, tracing info, etc. + Metadata map[string]any `json:"metadata,omitempty"` +} + +func (t *TaskContent) EnsureRequiredFields() { + if t == nil { + return + } + if t.Status.Message != nil { + t.Status.Message.EnsureRequiredFields() + } + for _, m := range t.History { + if m != nil { + m.EnsureRequiredFields() + } + } + for _, a := range t.Artifacts { + if a != nil { + a.EnsureRequiredFields() + } + } +} + +// TaskStatusUpdateEvent represents an event for task status updates +type TaskStatusUpdateEvent struct { + // The ID of the task being updated. + TaskID string `json:"taskId"` + // The context id the task is associated with + ContextID string `json:"contextId"` + + // The new status object for the task. + Status TaskStatus `json:"status"` + // If `true`, this `TaskStatusUpdateEvent` signifies the terminal status update for the current + // `message/stream` interaction cycle. This means the task has reached a terminal or paused state + // and the server does not expect to send more updates for *this specific* `stream` request. + // The server typically closes the SSE connection after sending an event with `final: true`. + // Default: `false` if omitted. + Final bool `json:"final,omitempty"` + // Arbitrary metadata for this specific status update event. + Metadata map[string]any `json:"metadata,omitempty"` +} + +func (t *TaskStatusUpdateEvent) isSendStreamingMessageResponse() {} + +type TaskStatusUpdateEventContent struct { + // The new status object for the task. + Status TaskStatus `json:"status"` + // If `true`, this `TaskStatusUpdateEvent` signifies the terminal status update for the current + // `message/stream` interaction cycle. This means the task has reached a terminal or paused state + // and the server does not expect to send more updates for *this specific* `stream` request. + // The server typically closes the SSE connection after sending an event with `final: true`. + // Default: `false` if omitted. + Final bool `json:"final,omitempty"` + // Arbitrary metadata for this specific status update event. + Metadata map[string]any `json:"metadata,omitempty"` +} + +func (t *TaskStatusUpdateEventContent) EnsureRequiredFields() { + if t == nil { + return + } + if t.Status.Message != nil { + t.Status.Message.EnsureRequiredFields() + } +} + +func (t *TaskStatusUpdateEventContent) isResponseEvent() {} + +type TaskArtifactUpdateEvent struct { + // The ID of the task associated with the generated artifact part + TaskID string `json:"taskId"` + // The context id the task is associated with + ContextID string `json:"contextId"` + + // The artifact data. This could be a complete artifact or an incremental chunk. + // The client uses `artifact.artifactId`, append, lastChunk to correctly assemble or update the artifact on its side. + Artifact Artifact `json:"artifact"` + /** Indicates if this artifact appends to a previous one. Omitted if artifact is a complete artifact. */ + Append bool `json:"append,omitempty"` + /** Indicates if this is the last chunk of the artifact. Omitted if artifact is a complete artifact. */ + LastChunk bool `json:"lastChunk,omitempty"` // todo: what's the difference between Append and LastChunk, if LastChunk means all + // Arbitrary metadata for this specific artifact update event. + Metadata map[string]any `json:"metadata,omitempty"` + // type discriminator, literal value + //Kind string = "artifact-update" todo +} + +type TaskArtifactUpdateEventContent struct { + // The artifact data. This could be a complete artifact or an incremental chunk. + // The client uses `artifact.artifactId`, append, lastChunk to correctly assemble or update the artifact on its side. + Artifact Artifact `json:"artifact"` + /** Indicates if this artifact appends to a previous one. Omitted if artifact is a complete artifact. */ + Append bool `json:"append,omitempty"` + /** Indicates if this is the last chunk of the artifact. Omitted if artifact is a complete artifact. */ + LastChunk bool `json:"lastChunk,omitempty"` // todo: what's the difference between Append and LastChunk, if LastChunk means all + // Arbitrary metadata for this specific artifact update event. + Metadata map[string]any `json:"metadata,omitempty"` +} + +func (t *TaskArtifactUpdateEventContent) EnsureRequiredFields() { + if t == nil { + return + } + (&t.Artifact).EnsureRequiredFields() +} + +type TaskQueryParams struct { + // The ID of the task to retrieve. + ID string `json:"id"` + // Optional: If a positive integer `N` is provided, the server SHOULD include the last `N` messages + // (chronologically) of the task's history in the `Task.history` field of the response. + // If `0`, or omitted, no history is explicitly requested. + HistoryLength *int `json:"historyLength,omitempty"` + // Arbitrary metadata for this specific `tasks/get` request. + Metadata map[string]any `json:"metadata,omitempty"` +} + +// TaskIDParams +// used for task/cancel and tasks/pushNotificationConfig/get and tasks/resubscribe +type TaskIDParams struct { + // The ID of the task to which the operation applies (e.g., cancel, get push notification config). + ID string `json:"id"` + // Arbitrary metadata for this specific request. + Metadata map[string]any `json:"metadata,omitempty"` +} + +// TaskPushNotificationConfig +// used for tasks/pushNotificationConfig/set and returned by tasks/pushNotificationConfig/get +type TaskPushNotificationConfig struct { + // The ID of the task for which push notification settings are being configured or retrieved. + TaskID string `json:"taskId"` + // The push notification configuration details. + // When used as params for `set`, this provides the configuration to apply. + // When used as result for `get`, this reflects the currently active configuration (server MAY omit secrets). + PushNotificationConfig PushNotificationConfig `json:"pushNotificationConfig"` +} diff --git a/a2a/models/task_test.go b/a2a/models/task_test.go new file mode 100644 index 000000000..161514e77 --- /dev/null +++ b/a2a/models/task_test.go @@ -0,0 +1,56 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEnsureRequiredFields(t *testing.T) { + r := ResponseEvent{ + Message: &Message{ + Parts: nil, + }, + TaskContent: &TaskContent{ + Status: TaskStatus{Message: &Message{Parts: []Part{ + {Kind: PartKindData, Data: nil}, + }}}, + Artifacts: []*Artifact{{Parts: []Part{ + {Kind: PartKindData, Data: nil}, + }}}, + History: []*Message{{ + Parts: []Part{{Kind: PartKindData, Data: nil}}, + }}, + }, + TaskStatusUpdateEventContent: &TaskStatusUpdateEventContent{ + Status: TaskStatus{Message: &Message{Parts: nil}}, + }, + TaskArtifactUpdateEventContent: &TaskArtifactUpdateEventContent{ + Artifact: Artifact{Parts: []Part{{Kind: PartKindData, Data: nil}}}, + }, + } + expected := ResponseEvent{ + Message: &Message{ + Parts: []Part{}, + }, + TaskContent: &TaskContent{ + Status: TaskStatus{Message: &Message{Parts: []Part{ + {Kind: PartKindData, Data: make(map[string]any)}, + }}}, + Artifacts: []*Artifact{{Parts: []Part{ + {Kind: PartKindData, Data: make(map[string]any)}, + }}}, + History: []*Message{{ + Parts: []Part{{Kind: PartKindData, Data: make(map[string]any)}}, + }}, + }, + TaskStatusUpdateEventContent: &TaskStatusUpdateEventContent{ + Status: TaskStatus{Message: &Message{Parts: make([]Part, 0)}}, + }, + TaskArtifactUpdateEventContent: &TaskArtifactUpdateEventContent{ + Artifact: Artifact{Parts: []Part{{Kind: PartKindData, Data: make(map[string]any)}}}, + }, + } + (&r).EnsureRequiredFields() + assert.Equal(t, expected, r) +} diff --git a/a2a/transport/jsonrpc/client.go1 b/a2a/transport/jsonrpc/client.go1 new file mode 100644 index 000000000..2b4dc1b17 --- /dev/null +++ b/a2a/transport/jsonrpc/client.go1 @@ -0,0 +1,263 @@ +package jsonrpc + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + std_http "net/http" + "net/url" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/client" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport/http" + hertz_client "github.com/cloudwego/hertz/pkg/app/client" + "github.com/cloudwego/hertz/pkg/protocol/consts" + + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/transport" +) + +type ClientConfig struct { + BaseURL string + HandlerPath string + AgentCardPath *string + HertzClient *hertz_client.Client + SSEBufferSize *int +} + +func NewTransport(ctx context.Context, config *ClientConfig) (transport.ClientTransport, error) { + if config == nil { + return nil, errors.New("config is required") + } + agentCardPath := ".well-known/agent-card.json" + if config.AgentCardPath != nil { + agentCardPath = *config.AgentCardPath + } + transOpts := make([]http.ClientTransportBuilderOption, 0) + if config.HertzClient != nil { + transOpts = append(transOpts, http.WithHertzClient(config.HertzClient)) + } + if config.SSEBufferSize != nil { + transOpts = append(transOpts, http.WithSSEBufferSize(*config.SSEBufferSize)) + } else { + transOpts = append(transOpts, http.WithSSEBufferSize(bufio.MaxScanTokenSize)) + } + var err error + var handlerURL string + if len(config.HandlerPath) > 0 { + handlerURL, err = url.JoinPath(config.BaseURL, config.HandlerPath) + if err != nil { + return nil, fmt.Errorf("failed to join handler url: %w", err) + } + } else { + handlerURL = config.BaseURL + } + cli, err := client.NewClient( + client.WithURL(handlerURL), + client.WithTransportHandler(http.NewClientTransportHandler(transOpts...))) + if err != nil { + return nil, fmt.Errorf("failed to create jsonrpc client: %w", err) + } + conn, err := cli.NewConnection(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create jsonrpc client connection: %w", err) + } + + hCli := config.HertzClient + if hCli == nil { + hCli, _ = hertz_client.NewClient(hertz_client.WithDialTimeout(consts.DefaultDialTimeout)) + } + + agentCardURL, err := url.JoinPath(config.BaseURL, agentCardPath) + if err != nil { + return nil, fmt.Errorf("failed to join agent card url: %w", err) + } + return &Transport{ + agentCardURL: agentCardURL, + conn: conn, + cli: hCli, + }, nil +} + +type Transport struct { + agentCardURL string + conn core.Connection + cli *hertz_client.Client +} + +func (t *Transport) AgentCard(ctx context.Context) (*models.AgentCard, error) { + code, body, err := t.cli.Get(ctx, nil, t.agentCardURL) + if err != nil { + return nil, fmt.Errorf("failed to get agent card: %w", err) + } + if code != std_http.StatusOK && code != std_http.StatusAccepted { + return nil, fmt.Errorf("failed to get agent card, code: %d, body: %s", code, string(body)) + } + + card := &models.AgentCard{} + err = json.Unmarshal(body, card) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal agent card: %w, body: %s", err, string(body)) + } + return card, nil +} + +func (t *Transport) SendMessage(ctx context.Context, params *models.MessageSendParams) (*models.SendMessageResponseUnion, error) { + var b json.RawMessage + err := t.conn.Call(ctx, "message/send", params, &b) + if err != nil { + return nil, fmt.Errorf("failed to send message: %w", err) + } + return extractSendMessageResponseUnion(b) +} + +func (t *Transport) SendMessageStreaming(ctx context.Context, params *models.MessageSendParams) (models.ResponseReader, error) { + stream, err := t.conn.AsyncCall(ctx, "message/stream", params) + if err != nil { + return nil, fmt.Errorf("failed to send message: %w", err) + } + return &frameReader{stream}, nil +} + +func (t *Transport) GetTask(ctx context.Context, params *models.TaskQueryParams) (*models.Task, error) { + ret := &models.Task{} + err := t.conn.Call(ctx, "tasks/get", params, ret) + if err != nil { + return nil, err + } + return ret, nil +} + +func (t *Transport) CancelTask(ctx context.Context, params *models.TaskIDParams) (*models.Task, error) { + ret := &models.Task{} + err := t.conn.Call(ctx, "tasks/cancel", params, ret) + if err != nil { + return nil, err + } + return ret, nil +} + +func (t *Transport) ResubscribeTask(ctx context.Context, params *models.TaskIDParams) (models.ResponseReader, error) { + ret, err := t.conn.AsyncCall(ctx, "tasks/resubscribe", params) + if err != nil { + return nil, err + } + return &frameReader{ret}, nil +} + +func (t *Transport) SetPushNotificationConfig(ctx context.Context, params *models.TaskPushNotificationConfig) (*models.TaskPushNotificationConfig, error) { + ret := &models.TaskPushNotificationConfig{} + err := t.conn.Call(ctx, "tasks/pushNotificationConfig/set", params, ret) + if err != nil { + return nil, err + } + return ret, nil +} + +func (t *Transport) GetPushNotificationConfig(ctx context.Context, params *models.GetTaskPushNotificationConfigParams) (*models.TaskPushNotificationConfig, error) { + ret := &models.TaskPushNotificationConfig{} + err := t.conn.Call(ctx, "tasks/getPushNotificationConfig/get", params, ret) + if err != nil { + return nil, err + } + return ret, nil +} + +func (t *Transport) Close() error { + return nil +} + +type frameReader struct { + a core.ClientAsync +} + +func (f *frameReader) Read() (*models.SendMessageStreamingResponseUnion, error) { + var b json.RawMessage + err := f.a.Recv(context.Background(), &b) + if err != nil { + if err == io.EOF { + return nil, io.EOF + } + return nil, fmt.Errorf("failed to read frame: %w", err) + } + return extractSendMessageStreamingResponseUnion(b) +} + +func (f *frameReader) Close() error { + return f.a.Close() +} + +func extractKind(b json.RawMessage) (models.ResponseKind, error) { + kind := struct { + Kind models.ResponseKind `json:"kind"` + }{} + err := json.Unmarshal(b, &kind) + if err != nil { + return "", fmt.Errorf("failed to extract send message response's kind: %w", err) + } + return kind.Kind, nil +} + +func extractSendMessageResponseUnion(b json.RawMessage) (*models.SendMessageResponseUnion, error) { + su, err := extractSendMessageStreamingResponseUnion(b) + if err != nil { + return nil, err + } + if su == nil { + return nil, nil + } + return &models.SendMessageResponseUnion{ + Message: su.Message, + Task: su.Task, + }, nil +} + +func extractSendMessageStreamingResponseUnion(b json.RawMessage) (*models.SendMessageStreamingResponseUnion, error) { + kind, err := extractKind(b) + if err != nil { + return nil, err + } + switch kind { + case models.ResponseKindMessage: + m := &models.Message{} + err = json.Unmarshal(b, &m) + if err != nil { + return nil, fmt.Errorf("failed to extract response's message: %w", err) + } + return &models.SendMessageStreamingResponseUnion{ + Message: m, + }, nil + case models.ResponseKindTask: + t := &models.Task{} + err = json.Unmarshal(b, &t) + if err != nil { + return nil, fmt.Errorf("failed to extract response's task: %w", err) + } + return &models.SendMessageStreamingResponseUnion{ + Task: t, + }, nil + case models.ResponseKindArtifactUpdate: + a := &models.TaskArtifactUpdateEvent{} + err = json.Unmarshal(b, &a) + if err != nil { + return nil, fmt.Errorf("failed to extract response's artifact update: %w", err) + } + return &models.SendMessageStreamingResponseUnion{ + TaskArtifactUpdateEvent: a, + }, nil + case models.ResponseKindStatusUpdate: + s := &models.TaskStatusUpdateEvent{} + err = json.Unmarshal(b, &s) + if err != nil { + return nil, fmt.Errorf("failed to extract response's status update: %w", err) + } + return &models.SendMessageStreamingResponseUnion{ + TaskStatusUpdateEvent: s, + }, nil + default: + return nil, fmt.Errorf("unsupported response's kind: %s", kind) + } +} diff --git a/a2a/transport/jsonrpc/server.go1 b/a2a/transport/jsonrpc/server.go1 new file mode 100644 index 000000000..fbb0bc77c --- /dev/null +++ b/a2a/transport/jsonrpc/server.go1 @@ -0,0 +1,229 @@ +package jsonrpc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/streaming" + "github.com/cloudwego/hertz/pkg/app" + "github.com/cloudwego/hertz/pkg/route" + + jsonrpc_http "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport/http" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/server" + + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/transport" +) + +type ServerConfig struct { + Router route.IRoutes + AgentCardPath *string + AgentCardMiddleWares []app.HandlerFunc + HandlerPath string + HandlerMiddleWares []app.HandlerFunc +} + +func NewRegistrar(ctx context.Context, config *ServerConfig) (transport.HandlerRegistrar, error) { + if config == nil { + return nil, errors.New("config is required") + } + if config.Router == nil { + return nil, errors.New("router is required") + } + path := ".well-known/agent-card.json" + if config.AgentCardPath != nil { + path = *config.AgentCardPath + } + return ®istry{ + route: config.Router, + agentCardPath: path, + agentCardMiddleWares: config.AgentCardMiddleWares, + handlerPath: config.HandlerPath, + handlerMiddleWares: config.HandlerMiddleWares, + }, nil +} + +type registry struct { + route route.IRoutes + agentCardPath string + agentCardMiddleWares []app.HandlerFunc + handlerPath string + handlerMiddleWares []app.HandlerFunc +} + +func (r *registry) Register(ctx context.Context, handlers *models.ServerHandlers) error { + a, h, err := getHertzHandlerFuncs(ctx, handlers) + if err != nil { + return err + } + r.route.GET(r.agentCardPath, append(r.agentCardMiddleWares, a)...) + r.route.POST(r.handlerPath, h) + return nil +} + +func getHertzHandlerFuncs(_ context.Context, hs *models.ServerHandlers) (agentCard, handlers app.HandlerFunc, err error) { + if hs == nil { + return nil, nil, errors.New("A2AHandlers is nil") + } + agentCard = convAgentCardHandler(hs.AgentCard) + h, err := convHandlers(hs) + if err != nil { + return nil, nil, err + } + return agentCard, h, nil +} + +func convAgentCardHandler(f func(ctx context.Context) *models.AgentCard) app.HandlerFunc { + if f == nil { + return nil + } + return func(c context.Context, ctx *app.RequestContext) { + ctx.JSON(http.StatusOK, f(c)) + return + } +} + +func convHandlers(hs *models.ServerHandlers) (app.HandlerFunc, error) { + var opts []server.Option + + if hs.SendMessage != nil { + opts = append(opts, server.WithPingPongHandler("message/send", func(ctx context.Context, _ core.Connection, req json.RawMessage) (interface{}, error) { + input := &models.MessageSendParams{} + if err := json.Unmarshal(req, input); err != nil { + return nil, fmt.Errorf("failed to unmarshal input: %w", err) + } + u, err := hs.SendMessage(ctx, input) + if err != nil { + return nil, err + } + return wrapSendMessageResponseUnion(u), nil + })) + } + if hs.SendMessageStreaming != nil { + opts = append(opts, server.WithServerStreamingHandler("message/stream", func(ctx context.Context, _ core.Connection, req json.RawMessage, srv streaming.ServerStreamingServer) error { + input := &models.MessageSendParams{} + if err := json.Unmarshal(req, input); err != nil { + return fmt.Errorf("failed to unmarshal input: %w", err) + } + return hs.SendMessageStreaming(ctx, input, &serverStreamingWrapper{srv}) + })) + } + if hs.ResubscribeTask != nil { + opts = append(opts, server.WithServerStreamingHandler("tasks/resubscribe", func(ctx context.Context, conn core.Connection, req json.RawMessage, srv streaming.ServerStreamingServer) error { + input := &models.TaskIDParams{} + if err := json.Unmarshal(req, input); err != nil { + return fmt.Errorf("failed to unmarshal input: %w", err) + } + return hs.ResubscribeTask(ctx, input, &serverStreamingWrapper{srv}) + })) + } + if hs.CancelTask != nil { + opts = append(opts, server.WithPingPongHandler("tasks/cancel", func(ctx context.Context, conn core.Connection, req json.RawMessage) (interface{}, error) { + input := &models.TaskIDParams{} + if err := json.Unmarshal(req, input); err != nil { + return nil, fmt.Errorf("failed to unmarshal input: %w", err) + } + return hs.CancelTask(ctx, input) + })) + } + if hs.GetTask != nil { + opts = append(opts, server.WithPingPongHandler("tasks/get", func(ctx context.Context, conn core.Connection, req json.RawMessage) (interface{}, error) { + input := &models.TaskQueryParams{} + if err := json.Unmarshal(req, input); err != nil { + return nil, fmt.Errorf("failed to unmarshal input: %w", err) + } + return hs.GetTask(ctx, input) + })) + } + if hs.GetPushNotificationConfig != nil { + opts = append(opts, server.WithPingPongHandler("tasks/pushNotificationConfig/get", func(ctx context.Context, conn core.Connection, req json.RawMessage) (interface{}, error) { + input := &models.GetTaskPushNotificationConfigParams{} + if err := json.Unmarshal(req, input); err != nil { + return nil, fmt.Errorf("failed to unmarshal input: %w", err) + } + return hs.GetPushNotificationConfig(ctx, input) + })) + } + if hs.SetPushNotificationConfig != nil { + opts = append(opts, server.WithPingPongHandler("tasks/pushNotificationConfig/set", func(ctx context.Context, conn core.Connection, req json.RawMessage) (interface{}, error) { + input := &models.TaskPushNotificationConfig{} + if err := json.Unmarshal(req, input); err != nil { + return nil, fmt.Errorf("failed to unmarshal input: %w", err) + } + return hs.SetPushNotificationConfig(ctx, input) + })) + } + + h, err := server.NewServerTransportHandler(opts...) + if err != nil { + return nil, err + } + + return jsonrpc_http.NewServerTransportBuilder("" /*unused*/, jsonrpc_http.WithServerTransportHandler(h)).POST, nil +} + +type serverStreamingWrapper struct { + s streaming.ServerStreamingServer +} + +func (s *serverStreamingWrapper) Write(ctx context.Context, f *models.SendMessageStreamingResponseUnion) error { + return s.s.Send(ctx, wrapSendMessageStreamingResponseUnion(f)) +} + +func (s *serverStreamingWrapper) Close() error { + return nil +} + +func wrapSendMessageResponseUnion(u *models.SendMessageResponseUnion) any { + if u == nil { + return nil + } + return wrapSendMessageStreamingResponseUnion(&models.SendMessageStreamingResponseUnion{ + Message: u.Message, + Task: u.Task, + }) +} + +func wrapSendMessageStreamingResponseUnion(u *models.SendMessageStreamingResponseUnion) any { + if u == nil { + return nil + } + if u.Message != nil { + return struct { + *models.Message + Kind models.ResponseKind `json:"kind"` + }{ + Message: u.Message, + Kind: models.ResponseKindMessage, + } + } else if u.Task != nil { + return struct { + *models.Task + Kind models.ResponseKind `json:"kind"` + }{ + Task: u.Task, + Kind: models.ResponseKindTask, + } + } else if u.TaskStatusUpdateEvent != nil { + return struct { + *models.TaskStatusUpdateEvent + Kind models.ResponseKind `json:"kind"` + }{ + TaskStatusUpdateEvent: u.TaskStatusUpdateEvent, + Kind: models.ResponseKindStatusUpdate, + } + } else if u.TaskArtifactUpdateEvent != nil { + return struct { + *models.TaskArtifactUpdateEvent + Kind models.ResponseKind `json:"kind"` + }{ + TaskArtifactUpdateEvent: u.TaskArtifactUpdateEvent, + Kind: models.ResponseKindArtifactUpdate, + } + } + return nil +} diff --git a/a2a/transport/transport.go b/a2a/transport/transport.go new file mode 100644 index 000000000..e38eb930e --- /dev/null +++ b/a2a/transport/transport.go @@ -0,0 +1,23 @@ +package transport + +import ( + "context" + + "github.com/cloudwego/eino-ext/a2a/models" +) + +type ClientTransport interface { + AgentCard(ctx context.Context) (*models.AgentCard, error) + SendMessage(ctx context.Context, params *models.MessageSendParams) (*models.SendMessageResponseUnion, error) + SendMessageStreaming(ctx context.Context, params *models.MessageSendParams) (models.ResponseReader, error) + GetTask(ctx context.Context, params *models.TaskQueryParams) (*models.Task, error) + CancelTask(ctx context.Context, params *models.TaskIDParams) (*models.Task, error) + ResubscribeTask(ctx context.Context, params *models.TaskIDParams) (models.ResponseReader, error) + SetPushNotificationConfig(ctx context.Context, params *models.TaskPushNotificationConfig) (*models.TaskPushNotificationConfig, error) + GetPushNotificationConfig(ctx context.Context, params *models.GetTaskPushNotificationConfigParams) (*models.TaskPushNotificationConfig, error) + Close() error +} + +type HandlerRegistrar interface { + Register(context.Context, *models.ServerHandlers) error +} From e546d7f05dbb4f0984c8cfb52dbb686db985573b Mon Sep 17 00:00:00 2001 From: Megumin Date: Mon, 25 Aug 2025 11:49:02 +0800 Subject: [PATCH 05/18] feat: jsonrpc transport --- a2a/transport/jsonrpc/{client.go1 => client.go} | 10 +++++----- a2a/transport/jsonrpc/go.mod | 1 + a2a/transport/jsonrpc/go.sum | 2 ++ a2a/transport/jsonrpc/jsonrpc.go | 1 - a2a/transport/jsonrpc/{server.go1 => server.go} | 9 ++++----- 5 files changed, 12 insertions(+), 11 deletions(-) rename a2a/transport/jsonrpc/{client.go1 => client.go} (100%) delete mode 100644 a2a/transport/jsonrpc/jsonrpc.go rename a2a/transport/jsonrpc/{server.go1 => server.go} (99%) diff --git a/a2a/transport/jsonrpc/client.go1 b/a2a/transport/jsonrpc/client.go similarity index 100% rename from a2a/transport/jsonrpc/client.go1 rename to a2a/transport/jsonrpc/client.go index 2b4dc1b17..8e6bc777b 100644 --- a/a2a/transport/jsonrpc/client.go1 +++ b/a2a/transport/jsonrpc/client.go @@ -10,14 +10,14 @@ import ( std_http "net/http" "net/url" - "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/client" - "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" - "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport/http" + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/transport" hertz_client "github.com/cloudwego/hertz/pkg/app/client" "github.com/cloudwego/hertz/pkg/protocol/consts" - "github.com/cloudwego/eino-ext/a2a/models" - "github.com/cloudwego/eino-ext/a2a/transport" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/client" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport/http" ) type ClientConfig struct { diff --git a/a2a/transport/jsonrpc/go.mod b/a2a/transport/jsonrpc/go.mod index 06353d343..deec154f9 100644 --- a/a2a/transport/jsonrpc/go.mod +++ b/a2a/transport/jsonrpc/go.mod @@ -13,6 +13,7 @@ require ( github.com/bytedance/gopkg v0.1.1 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/eino-ext/a2a v0.0.0-20250825034609-dbf35043464f // indirect github.com/cloudwego/gopkg v0.1.4 // indirect github.com/cloudwego/netpoll v0.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect diff --git a/a2a/transport/jsonrpc/go.sum b/a2a/transport/jsonrpc/go.sum index 64cda6b09..fcade3516 100644 --- a/a2a/transport/jsonrpc/go.sum +++ b/a2a/transport/jsonrpc/go.sum @@ -7,6 +7,8 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/eino-ext/a2a v0.0.0-20250825034609-dbf35043464f h1:SpdPFIwDjeQQG+RH+mJWOIoooZxby0NebtI+fJV4r3A= +github.com/cloudwego/eino-ext/a2a v0.0.0-20250825034609-dbf35043464f/go.mod h1:z7Dpr4Td5Uh8ePjBaciMgWcJiM9QeNWAHLYY+kRBxWo= github.com/cloudwego/gopkg v0.1.4 h1:EoQiCG4sTonTPHxOGE0VlQs+sQR+Hsi2uN0qqwu8O50= github.com/cloudwego/gopkg v0.1.4/go.mod h1:FQuXsRWRsSqJLsMVd5SYzp8/Z1y5gXKnVvRrWUOsCMI= github.com/cloudwego/hertz v0.10.2 h1:scaVn4E/AQ/vuMAC8FXzUzsEXS/TF1ix1I+4slPhh7c= diff --git a/a2a/transport/jsonrpc/jsonrpc.go b/a2a/transport/jsonrpc/jsonrpc.go deleted file mode 100644 index 29a4d6aba..000000000 --- a/a2a/transport/jsonrpc/jsonrpc.go +++ /dev/null @@ -1 +0,0 @@ -package jsonrpc diff --git a/a2a/transport/jsonrpc/server.go1 b/a2a/transport/jsonrpc/server.go similarity index 99% rename from a2a/transport/jsonrpc/server.go1 rename to a2a/transport/jsonrpc/server.go index fbb0bc77c..5807461cb 100644 --- a/a2a/transport/jsonrpc/server.go1 +++ b/a2a/transport/jsonrpc/server.go @@ -7,16 +7,15 @@ import ( "fmt" "net/http" - "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" - "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/streaming" + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/transport" "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/route" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" jsonrpc_http "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport/http" "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/server" - - "github.com/cloudwego/eino-ext/a2a/models" - "github.com/cloudwego/eino-ext/a2a/transport" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/streaming" ) type ServerConfig struct { From 10993c61148a6867a09e8befac766ca01a761b21 Mon Sep 17 00:00:00 2001 From: Megumin Date: Mon, 25 Aug 2025 16:54:29 +0800 Subject: [PATCH 06/18] feat: a2a & extension --- a2a/client/client.go | 92 +++ a2a/client/client_test.go | 1 + a2a/examples/a2a.go | 21 - a2a/examples/client/client.go | 301 ++++++++ a2a/examples/server/server.go | 182 +++++ a2a/extension/eino/client.go | 424 +++++++++++ a2a/extension/eino/examples/client/client.go | 274 +++++++ a2a/extension/eino/examples/server/server.go | 66 ++ .../eino/examples/server/subagents/agent.go | 70 ++ .../server/subagents/ask_for_clarification.go | 57 ++ .../examples/server/subagents/booksearch.go | 47 ++ a2a/extension/eino/message_extra.go | 103 +++ a2a/extension/eino/metadata.go | 24 + a2a/extension/eino/server.go | 546 ++++++++++++++ a2a/extension/eino/utils.go | 207 ++++++ a2a/go.mod | 42 +- a2a/go.sum | 215 +++++- a2a/server/eventqueue.go | 123 +++ a2a/server/eventqueue_test.go | 43 ++ a2a/server/notifier.go | 120 +++ a2a/server/server.go | 698 ++++++++++++++++++ a2a/server/server_test.go | 88 +++ a2a/server/tasklocker.go | 41 + a2a/server/taskstore.go | 34 + a2a/transport/jsonrpc/go.mod | 33 - a2a/transport/jsonrpc/go.sum | 125 ---- a2a/utils/panic.go | 24 + 27 files changed, 3820 insertions(+), 181 deletions(-) create mode 100644 a2a/client/client.go create mode 100644 a2a/client/client_test.go delete mode 100644 a2a/examples/a2a.go create mode 100644 a2a/examples/client/client.go create mode 100644 a2a/examples/server/server.go create mode 100644 a2a/extension/eino/client.go create mode 100644 a2a/extension/eino/examples/client/client.go create mode 100644 a2a/extension/eino/examples/server/server.go create mode 100644 a2a/extension/eino/examples/server/subagents/agent.go create mode 100644 a2a/extension/eino/examples/server/subagents/ask_for_clarification.go create mode 100644 a2a/extension/eino/examples/server/subagents/booksearch.go create mode 100644 a2a/extension/eino/message_extra.go create mode 100644 a2a/extension/eino/metadata.go create mode 100644 a2a/extension/eino/server.go create mode 100644 a2a/extension/eino/utils.go create mode 100644 a2a/server/eventqueue.go create mode 100644 a2a/server/eventqueue_test.go create mode 100644 a2a/server/notifier.go create mode 100644 a2a/server/server.go create mode 100644 a2a/server/server_test.go create mode 100644 a2a/server/tasklocker.go create mode 100644 a2a/server/taskstore.go delete mode 100644 a2a/transport/jsonrpc/go.mod delete mode 100644 a2a/transport/jsonrpc/go.sum create mode 100644 a2a/utils/panic.go diff --git a/a2a/client/client.go b/a2a/client/client.go new file mode 100644 index 000000000..07bc9f3bc --- /dev/null +++ b/a2a/client/client.go @@ -0,0 +1,92 @@ +package client + +import ( + "context" + "errors" + + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/transport" +) + +// A2AClient represents an A2A protocol client +type A2AClient struct { + cli transport.ClientTransport +} + +type Config struct { + Transport transport.ClientTransport +} + +// NewA2AClient creates a new A2A client +func NewA2AClient(ctx context.Context, config *Config) (*A2AClient, error) { + if config == nil { + return nil, errors.New("config is required") + } + + return &A2AClient{cli: config.Transport}, nil +} + +func (c *A2AClient) AgentCard(ctx context.Context) (*models.AgentCard, error) { + return c.cli.AgentCard(ctx) +} + +func (c *A2AClient) SendMessage(ctx context.Context, params *models.MessageSendParams) (*models.SendMessageResponseUnion, error) { + if params != nil { + (¶ms.Message).EnsureRequiredFields() + } + return c.cli.SendMessage(ctx, params) +} + +func (c *A2AClient) SendMessageStreaming(ctx context.Context, params *models.MessageSendParams) (*ServerStreamingWrapper, error) { + if params != nil { + (¶ms.Message).EnsureRequiredFields() + } + stream, err := c.cli.SendMessageStreaming(ctx, params) + if err != nil { + return nil, err + } + return &ServerStreamingWrapper{stream}, nil +} + +func (c *A2AClient) GetTask(ctx context.Context, params *models.TaskQueryParams) (*models.Task, error) { + return c.cli.GetTask(ctx, params) +} + +func (c *A2AClient) CancelTask(ctx context.Context, params *models.TaskIDParams) (*models.Task, error) { + return c.cli.CancelTask(ctx, params) +} + +func (c *A2AClient) ResubscribeTask(ctx context.Context, params *models.TaskIDParams) (*ServerStreamingWrapper, error) { + stream, err := c.cli.ResubscribeTask(ctx, params) + if err != nil { + return nil, err + } + return &ServerStreamingWrapper{stream}, nil +} + +func (c *A2AClient) SetPushNotificationConfig(ctx context.Context, params *models.TaskPushNotificationConfig) (*models.TaskPushNotificationConfig, error) { + return c.cli.SetPushNotificationConfig(ctx, params) +} + +func (c *A2AClient) GetPushNotificationConfig(ctx context.Context, params *models.GetTaskPushNotificationConfigParams) (*models.TaskPushNotificationConfig, error) { + return c.cli.GetPushNotificationConfig(ctx, params) +} + +// todo: list/delete notification config & agent/authenticatedExtendedCard + +type ServerStreamingWrapper struct { + s models.ResponseReader +} + +func (s *ServerStreamingWrapper) Recv() (resp *models.SendMessageStreamingResponseUnion, err error) { + defer func() { + if err != nil { + s.s.Close() + } + }() + return s.s.Read() +} + +func (s *ServerStreamingWrapper) Close() error { + return s.s.Close() +} diff --git a/a2a/client/client_test.go b/a2a/client/client_test.go new file mode 100644 index 000000000..da13c8ef3 --- /dev/null +++ b/a2a/client/client_test.go @@ -0,0 +1 @@ +package client diff --git a/a2a/examples/a2a.go b/a2a/examples/a2a.go deleted file mode 100644 index 8f49cb1a3..000000000 --- a/a2a/examples/a2a.go +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2025 CloudWeGo 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 - -func main() { - -} diff --git a/a2a/examples/client/client.go b/a2a/examples/client/client.go new file mode 100644 index 000000000..1a2c29509 --- /dev/null +++ b/a2a/examples/client/client.go @@ -0,0 +1,301 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sync" + "time" + + "github.com/cloudwego/eino-ext/a2a/client" + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc" +) + +func main() { + ctx := context.Background() + transport, err := jsonrpc.NewTransport(ctx, &jsonrpc.ClientConfig{ + BaseURL: "http://localhost:8888", + HandlerPath: "/test", + AgentCardPath: nil, + HertzClient: nil, + }) + if err != nil { + log.Fatal(err) + } + cli, err := client.NewA2AClient(ctx, &client.Config{ + Transport: transport, + }) + if err != nil { + log.Fatal(err) + } + + card, err := cli.AgentCard(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Printf("AgentCard: %+v", card) + + fmt.Printf("\n\n>>>>>>>>>>base chat<<<<<<<<<<\n\n") + baseChat(ctx, cli) + fmt.Printf("\n\n>>>>>>>>>stream chat<<<<<<<<<\n\n") + streamChat(ctx, cli) + fmt.Printf("\n\n>>>>>>>>>resubscribe<<<<<<<<<\n\n") + resubscribeChat(ctx, cli) + fmt.Printf("\n\n>>>>>>>>>>>notify<<<<<<<<<<<<\n\n") + notifyChat(ctx, cli) +} + +func baseChat(ctx context.Context, cli *client.A2AClient) { + result, err := cli.SendMessage(ctx, &models.MessageSendParams{ + Message: models.Message{ + Role: models.RoleUser, + Parts: []models.Part{ + {Kind: models.PartKindText, Text: ptrOf("hello")}, + }, + }, + Configuration: nil, + Metadata: nil, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println("first chat:") + fmt.Println(printTask(result.Task)) + taskID := result.Task.ID + result, err = cli.SendMessage(ctx, &models.MessageSendParams{ + Message: models.Message{ + TaskID: &taskID, + Role: models.RoleUser, + Parts: []models.Part{ + {Kind: models.PartKindText, Text: ptrOf("hello2")}, + }, + }, + Configuration: nil, + Metadata: nil, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println("second chat:") + fmt.Println(printTask(result.Task)) + + queryResult, err := cli.GetTask(ctx, &models.TaskQueryParams{ + ID: taskID, + }) + if err != nil { + log.Fatal(err) + } + fmt.Println("query result:") + fmt.Println(printTask(queryResult)) +} + +func streamChat(ctx context.Context, cli *client.A2AClient) { + stream, err := cli.SendMessageStreaming(ctx, &models.MessageSendParams{ + Message: models.Message{ + Role: models.RoleUser, + Parts: []models.Part{ + {Kind: models.PartKindText, Text: ptrOf("hello")}, + }, + }, + Configuration: nil, + Metadata: nil, + }) + if err != nil { + log.Fatal(err) + } + defer stream.Close() + + i := 0 + for { + result, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + log.Fatal(err) + } + b, err := json.Marshal(result) + if err != nil { + log.Fatal(err) + } + fmt.Printf("chunk[%d]: %s\n", i, string(b)) + i++ + } + + // error + stream, err = cli.SendMessageStreaming(ctx, &models.MessageSendParams{ + Message: models.Message{ + Role: models.RoleUser, + Parts: []models.Part{ + {Kind: models.PartKindText, Text: ptrOf("trigger error")}, + }, + }, + Configuration: nil, + Metadata: nil, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Println("\n\nerror stream chat:") + i = 0 + for { + result, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + fmt.Println(err) + return + } + b, err := json.Marshal(result) + if err != nil { + log.Fatal(err) + } + fmt.Printf("chunk[%d]: %s\n", i, string(b)) + i++ + } +} + +func resubscribeChat(ctx context.Context, cli *client.A2AClient) { + stream, err := cli.SendMessageStreaming(ctx, &models.MessageSendParams{ + Message: models.Message{ + Role: models.RoleUser, + Parts: []models.Part{ + {Kind: models.PartKindText, Text: ptrOf("hello")}, + }, + }, + Configuration: nil, + Metadata: nil, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Println("first:") + var taskID string + i := 0 + for { + if i == 2 { + err = stream.Close() + if err != nil { + log.Fatal(err) + } + break + } + result, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + log.Fatal(err) + } + taskID = result.Task.ID + + b, err := json.Marshal(result) + if err != nil { + log.Fatal(err) + } + fmt.Printf("chunk[%d]: %s\n", i, string(b)) + i++ + } + + stream, err = cli.ResubscribeTask(ctx, &models.TaskIDParams{ + ID: taskID, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Println("resubscribe:") + for { + result, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + log.Fatal(err) + } + b, err := json.Marshal(result) + if err != nil { + log.Fatal(err) + } + fmt.Printf("chunk[%d]: %s\n", i, string(b)) + i++ + } +} + +func notifyChat(ctx context.Context, cli *client.A2AClient) { + wg := &sync.WaitGroup{} + // start local server to receive + wg.Add(1) + go func() { + startNotificationServer(wg) + }() + + _, err := cli.SendMessageStreaming(ctx, &models.MessageSendParams{ + Message: models.Message{ + Role: models.RoleUser, + Parts: []models.Part{ + {Kind: models.PartKindText, Text: ptrOf("hello")}, + }, + }, + Configuration: &models.MessageSendConfiguration{PushNotificationConfig: &models.PushNotificationConfig{ + URL: "http://localhost:12345/test", + Token: "", + Authentication: nil, + }}, + Metadata: nil, + }) + if err != nil { + log.Fatal(err) + } + time.Sleep(time.Second * 10) + wg.Done() +} + +func printTask(t *models.Task) string { + b, err := json.MarshalIndent(t, "", "\t") + if err != nil { + log.Fatal(err) + } + return string(b) +} + +func ptrOf[T any](v T) *T { + return &v +} + +func startNotificationServer(wg *sync.WaitGroup) { + // 注册处理函数到 /test 路径 + http.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 读取整个请求体 + body, err := io.ReadAll(r.Body) + defer r.Body.Close() + + if err != nil { + http.Error(w, "Error reading request body", http.StatusBadRequest) + return + } + + fmt.Printf("Received POST request body:\n%s\n", string(body)) + + w.WriteHeader(http.StatusOK) + w.Write([]byte("Request body received")) + }) + + fmt.Println("Server starting on :12345...") + go func() { + _ = http.ListenAndServe(":12345", nil) + }() + wg.Wait() +} diff --git a/a2a/examples/server/server.go b/a2a/examples/server/server.go new file mode 100644 index 000000000..f37f065fd --- /dev/null +++ b/a2a/examples/server/server.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + hertz_server "github.com/cloudwego/hertz/pkg/app/server" + + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/server" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc" +) + +func main() { + ctx := context.Background() + hz := hertz_server.Default(hertz_server.WithSenseClientDisconnection(true)) + r, err := jsonrpc.NewRegistrar(ctx, &jsonrpc.ServerConfig{ + Router: hz, + HandlerPath: "/test", + AgentCardPath: nil, + }) + if err != nil { + log.Fatal(err) + } + err = server.RegisterHandlers(ctx, r, &server.Config{ + AgentCardConfig: server.AgentCardConfig{ + Name: "test agent", + Description: "a agent used for testing", + URL: "https://127.0.0.1:8080", + Version: "1", + DocumentationURL: "xxx", + Provider: &models.AgentProvider{ + Organization: "megumin", + URL: "yyy", + }, + SecuritySchemes: nil, + Security: nil, + DefaultInputModes: nil, + DefaultOutputModes: nil, + Skills: nil, + }, + MessageHandler: func(ctx context.Context, params *server.InputParams) (*models.TaskContent, error) { + return &models.TaskContent{ + Status: models.TaskStatus{ + State: models.TaskStateCompleted, + Message: &models.Message{ + Role: models.RoleAgent, + Parts: []models.Part{ + { + Kind: models.PartKindText, + Text: ptrOf("hello world"), + }, + }, + }, + }, + History: params.Task.History, + Artifacts: params.Task.Artifacts, + Metadata: params.Task.Metadata, + }, nil + }, + MessageStreamingHandler: func(ctx context.Context, params *server.InputParams, writer server.ResponseEventWriter) error { + for i := 0; i < 3; i++ { + time.Sleep(time.Second) + err := writer.Write( + models.ResponseEvent{ + TaskContent: &models.TaskContent{ + Status: models.TaskStatus{ + State: models.TaskStateWorking, + Message: &models.Message{ + Role: models.RoleAgent, + Parts: []models.Part{ + { + Kind: models.PartKindText, + Text: ptrOf(fmt.Sprintf("task messaage %d", i)), + }, + }, + }, + }, + }, + }) + if err != nil { + return err + } + } + if strings.Contains(*params.Input.Parts[0].Text, "trigger error") { + return fmt.Errorf("error has been triggered") + } + for i := 0; i < 3; i++ { + time.Sleep(time.Second) + err := writer.Write( + models.ResponseEvent{ + TaskStatusUpdateEventContent: &models.TaskStatusUpdateEventContent{ + Status: models.TaskStatus{ + State: models.TaskStateCompleted, + Message: &models.Message{ + Role: models.RoleAgent, + Parts: []models.Part{ + { + Kind: models.PartKindText, + Text: ptrOf(fmt.Sprintf("status update messaage %d", i)), + }, + }, + }, + }, + Final: false, + Metadata: nil, + }, + }, + ) + if err != nil { + return err + } + } + return nil + }, + CancelTaskHandler: func(ctx context.Context, params *server.InputParams) (*models.TaskContent, error) { + return &models.TaskContent{ + Status: models.TaskStatus{ + State: models.TaskStateCanceled, + }, + History: params.Task.History, + Artifacts: params.Task.Artifacts, + Metadata: params.Task.Metadata, + }, nil + }, + TaskEventsConsolidator: myTaskModifier, + Logger: func(ctx context.Context, format string, v ...any) { + log.Printf(format, v...) + }, + TaskStore: nil, + TaskLocker: nil, + Queue: nil, + PushNotifier: server.NewInMemoryPushNotifier(), + }) + if err != nil { + log.Fatal(err) + } + + hz.Run() +} + +func myTaskModifier(ctx context.Context, t *models.Task, events []models.ResponseEvent) *models.TaskContent { + result := &models.TaskContent{ + Status: t.Status, + Artifacts: make([]*models.Artifact, len(t.Artifacts)), + History: make([]*models.Message, len(t.History)), + Metadata: t.Metadata, + } + copy(result.Artifacts, t.Artifacts) + copy(result.History, t.History) + + for _, e := range events { + if e.TaskContent != nil { + if result.Status.Message != nil { + result.History = append(result.History, result.Status.Message) + } + result.Status = e.TaskContent.Status + result.Metadata = e.TaskContent.Metadata + result.Artifacts = append(result.Artifacts, e.TaskContent.Artifacts...) + result.History = append(result.History, e.TaskContent.History...) + } else if e.TaskStatusUpdateEventContent != nil { + if result.Status.Message != nil { + result.History = append(result.History, result.Status.Message) + } + result.Status = e.TaskStatusUpdateEventContent.Status + } else if e.TaskArtifactUpdateEventContent != nil { + if e.TaskArtifactUpdateEventContent.Append { + result.Artifacts[len(result.Artifacts)-1].Parts = append(result.Artifacts[len(result.Artifacts)-1].Parts, e.TaskArtifactUpdateEventContent.Artifact.Parts...) + } else { + result.Artifacts = append(result.Artifacts, &e.TaskArtifactUpdateEventContent.Artifact) + } + } + } + return result +} + +func ptrOf[T any](v T) *T { + return &v +} diff --git a/a2a/extension/eino/client.go b/a2a/extension/eino/client.go new file mode 100644 index 000000000..a33bfde31 --- /dev/null +++ b/a2a/extension/eino/client.go @@ -0,0 +1,424 @@ +package eino + +import ( + "context" + "errors" + "fmt" + "io" + "runtime/debug" + "sync" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/schema" + + "github.com/cloudwego/eino-ext/a2a/client" + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/transport" + "github.com/cloudwego/eino-ext/a2a/utils" +) + +type AgentConfig struct { + Transport transport.ClientTransport + + // optional, from AgentCard by default + Name *string + Description *string + Streaming *bool // use streaming first if have not set this field and agent support + + // InputMessageConvertor allows users to convert adk messages to a2a message + // Optional. + InputMessageConvertor func(ctx context.Context, messages []*schema.Message) (models.Message, error) + + OutputConvertor func(ctx context.Context, receiver *ResponseUnionReceiver, sender *AgentEventSender) + // todo: support notification? +} + +func NewAgent(ctx context.Context, cfg AgentConfig) (adk.Agent, error) { + cli, err := client.NewA2AClient(ctx, &client.Config{ + Transport: cfg.Transport, + }) + if err != nil { + return nil, fmt.Errorf("failed to create a2a client: %w", err) + } + var name, desc string + var streaming bool + if cfg.Name == nil || cfg.Description == nil || cfg.Streaming == nil { + card, err := cli.AgentCard(ctx) + if err != nil { + return nil, err + } + name = card.Name + desc = card.Description + streaming = card.Capabilities.Streaming + } + if cfg.Name != nil { + name = *cfg.Name + } + if cfg.Description != nil { + desc = *cfg.Description + } + if cfg.Streaming != nil { + streaming = *cfg.Streaming + } + + a := &a2aAgent{ + name: name, + description: desc, + streaming: streaming, + inputMessageConvertor: cfg.InputMessageConvertor, + outputConvertor: cfg.OutputConvertor, + cli: cli, + } + if a.inputMessageConvertor == nil { + a.inputMessageConvertor = func(ctx context.Context, messages []*schema.Message) (models.Message, error) { + p, err := messages2Parts(ctx, messages) + if err != nil { + return models.Message{}, err + } + return models.Message{ + Role: models.RoleUser, + Parts: p, + }, nil + } + } + if a.outputConvertor == nil { + a.outputConvertor = defaultOutputConvertor + } + return a, nil +} + +type InterruptInfo struct { + TaskID string + InterruptMessage adk.Message +} + +type options struct { + metadata map[string]any + resumeMessages []*schema.Message +} + +func WithResumeMessages(msgs []*schema.Message) adk.AgentRunOption { + return adk.WrapImplSpecificOptFn(func(o *options) { + o.resumeMessages = msgs + }) +} + +func WithMetadata(metadata map[string]any) adk.AgentRunOption { + return adk.WrapImplSpecificOptFn(func(o *options) { + o.metadata = metadata + }) +} + +type a2aAgent struct { + name string + description string + streaming bool + + inputMessageConvertor func(ctx context.Context, messages []*schema.Message) (models.Message, error) + + outputConvertor func(ctx context.Context, message *ResponseUnionReceiver, sender *AgentEventSender) + + cli *client.A2AClient +} + +func (a *a2aAgent) Name(ctx context.Context) string { + return a.name +} + +func (a *a2aAgent) Description(ctx context.Context) string { + return a.description +} + +func (a *a2aAgent) Run(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] { + o := adk.GetImplSpecificOptions(&options{}, opts...) + m, err := a.inputMessageConvertor(ctx, input.Messages) + if err != nil { + iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() + gen.Send(&adk.AgentEvent{Err: fmt.Errorf("failed to convert adk messages to a2a message: %w", err)}) + gen.Close() + return iter + } + + return a.run(ctx, m, input.EnableStreaming, o.metadata) +} + +func (a *a2aAgent) Resume(ctx context.Context, info *adk.ResumeInfo, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] { + if info == nil { + // unreachable + iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() + gen.Send(&adk.AgentEvent{Err: fmt.Errorf("empty resume info")}) + gen.Close() + return iter + } + ii, ok := info.InterruptInfo.Data.(*InterruptInfo) + if !ok { + // unreachable + iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() + gen.Send(&adk.AgentEvent{Err: fmt.Errorf("resume info's data type[%T] is unexpected", info.InterruptInfo.Data)}) + gen.Close() + return iter + } + + o := adk.GetImplSpecificOptions(&options{}, opts...) + + msg, err := a.inputMessageConvertor(ctx, o.resumeMessages) + if err != nil { + iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() + gen.Send(&adk.AgentEvent{Err: fmt.Errorf("failed to convert adk messages to a2a message: %w", err)}) + gen.Close() + return iter + } + msg.TaskID = &ii.TaskID + return a.run(ctx, msg, info.EnableStreaming, o.metadata) +} + +func (a *a2aAgent) run(ctx context.Context, msg models.Message, streaming bool, metadata map[string]any) *adk.AsyncIterator[*adk.AgentEvent] { + iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() + + if streaming { + if metadata == nil { + metadata = make(map[string]any) + } + setEnableStreaming(metadata) + } + + var receiver *ResponseUnionReceiver + if a.streaming { + stream, err := a.cli.SendMessageStreaming(ctx, &models.MessageSendParams{ + Message: msg, + Metadata: metadata, + }) + if err != nil { + gen.Send(&adk.AgentEvent{ + AgentName: a.Name(ctx), + Err: err, + }) + gen.Close() + return iter + } + receiver = &ResponseUnionReceiver{stream} + } else { + result, err := a.cli.SendMessage(ctx, &models.MessageSendParams{ + Message: msg, + Metadata: metadata, + }) + if err != nil { + gen.Send(&adk.AgentEvent{ + AgentName: a.Name(ctx), + Err: err, + }) + return iter + } + + var union *models.SendMessageStreamingResponseUnion + if result != nil { + union = &models.SendMessageStreamingResponseUnion{ + Message: result.Message, + Task: result.Task, + } + } + + receiver = &ResponseUnionReceiver{&localResponseUnionReceiver{ + mu: sync.Mutex{}, + union: union, + final: false, + }} + } + go func() { + defer func() { + e := recover() + if e != nil { + gen.Send(&adk.AgentEvent{Err: utils.NewPanicErr(e, debug.Stack())}) + } + gen.Close() + }() + a.outputConvertor(ctx, receiver, &AgentEventSender{gen: gen}) + }() + + return iter +} + +type AgentEventSender struct { + gen *adk.AsyncGenerator[*adk.AgentEvent] +} + +func (a *AgentEventSender) Send(event *adk.AgentEvent) { + a.gen.Send(event) +} + +type responseUnionReceiver interface { + Recv() (resp *models.SendMessageStreamingResponseUnion, err error) + Close() error +} + +type localResponseUnionReceiver struct { + mu sync.Mutex + union *models.SendMessageStreamingResponseUnion + final bool +} + +func (l *localResponseUnionReceiver) Recv() (resp *models.SendMessageStreamingResponseUnion, err error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.final { + return resp, io.EOF + } + l.final = true + return l.union, nil +} + +func (l *localResponseUnionReceiver) Close() error { + return nil +} + +type ResponseUnionReceiver struct { + responseUnionReceiver +} + +func defaultOutputConvertor(ctx context.Context, stream *ResponseUnionReceiver, sender *AgentEventSender) { + artifactMap := make(map[string] /*artifact id*/ *schema.StreamWriter[*schema.Message]) + defer func() { + for _, sw := range artifactMap { + sw.Close() + } + }() + + for { + event, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + sender.Send(&adk.AgentEvent{Err: err}) + } + if event.Message != nil { + m := toADKMessage(event.Message) + sender.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{ + MessageOutput: &adk.MessageVariant{ + Message: m, + Role: schema.Assistant, + }, + }, + }) + } else if event.Task != nil { + var m adk.Message + if event.Task.Status.Message != nil { + m = toADKMessage(event.Task.Status.Message) + } else { + m = schema.AssistantMessage(string(event.Task.Status.State), nil) + } + // check interrupt + if event.Task.Status.State == models.TaskStateInputRequired { + sender.Send(&adk.AgentEvent{ + Action: &adk.AgentAction{ + Interrupted: &adk.InterruptInfo{Data: &InterruptInfo{ + TaskID: event.Task.ID, + InterruptMessage: m, + }}, + }, + }) + return + } else { + sender.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{ + MessageOutput: &adk.MessageVariant{ + Message: m, + Role: schema.Assistant, + }, + }, + }) + } + } else if event.TaskStatusUpdateEvent != nil { + statusUpdateEvent := event.TaskStatusUpdateEvent + var m adk.Message + if statusUpdateEvent.Status.Message != nil { + m = toADKMessage(statusUpdateEvent.Status.Message) + } else { + m = schema.AssistantMessage(string(statusUpdateEvent.Status.State), nil) + } + + if statusUpdateEvent.Status.State == models.TaskStateInputRequired { + // handle interrupted + sender.Send(&adk.AgentEvent{ + Action: &adk.AgentAction{ + Interrupted: &adk.InterruptInfo{Data: &InterruptInfo{ + TaskID: statusUpdateEvent.TaskID, + InterruptMessage: m, + }}, + }, + }) + } else { + // handler common status update + sender.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{ + MessageOutput: &adk.MessageVariant{ + Message: m, + }, + }, + }) + } + + if statusUpdateEvent.Final { + return + } + } else if event.TaskArtifactUpdateEvent != nil { + m := artifact2ADKMessage(&event.TaskArtifactUpdateEvent.Artifact) + handleNewMessage(event.TaskArtifactUpdateEvent.Artifact.ArtifactID, artifactMap, m, event.TaskArtifactUpdateEvent.LastChunk, sender) + } + } +} + +func handleNewMessage(id string, idMap map[string]*schema.StreamWriter[*schema.Message], msg *schema.Message, final bool, sender *AgentEventSender) { + // 1. check if the messageID has been recorded + // if not, + // if Final == true, report directly. + // else record it. + // 2. write new message to stream writer. + // 3. if Final == true, close the stream writer and delete it from the map. + sw, ok := idMap[id] + if !ok { + if final { + sender.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{ + MessageOutput: &adk.MessageVariant{Message: msg}, + }, + }) + return + } + var sr *schema.StreamReader[*schema.Message] + sr, sw = schema.Pipe[*schema.Message](100) // todo: buffer size + idMap[id] = sw + sender.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{MessageOutput: &adk.MessageVariant{ + IsStreaming: true, + MessageStream: sr, + }}, + }) + } + closed := sw.Send(msg, nil) // todo: blocking? + if closed || final { + sw.Close() + delete(idMap, id) + } +} + +func convInputMessages(ctx context.Context, messages []adk.Message, inputMessageConvertor func(ctx context.Context, messages []*schema.Message) ([]models.Part, error)) (models.Message, error) { + ret := models.Message{ + Role: models.RoleUser, + } + + if inputMessageConvertor != nil { + parts, err := inputMessageConvertor(ctx, messages) + if err != nil { + return ret, err + } + ret.Parts = parts + return ret, nil + } + + for _, m := range messages { + ret.Parts = append(ret.Parts, message2Parts(m)...) + } + return ret, nil +} diff --git a/a2a/extension/eino/examples/client/client.go b/a2a/extension/eino/examples/client/client.go new file mode 100644 index 000000000..f99f329be --- /dev/null +++ b/a2a/extension/eino/examples/client/client.go @@ -0,0 +1,274 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "strings" + "sync" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/schema" + + "github.com/cloudwego/eino-ext/a2a/extension/eino" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc" +) + +func main() { + ctx := context.Background() + + fmt.Println(">>>>>>>>>>>>>>>>>>>non-stream chat<<<<<<<<<<<<<<<<<<<<<") + nonStreamChat(ctx) + fmt.Println(">>>>>>>>>>>>>>>>>>>>>stream chat<<<<<<<<<<<<<<<<<<<<<<<") + streamChat(ctx) + fmt.Println(">>>>>>>>>>>>>>>>>>human-in-the-loop<<<<<<<<<<<<<<<<<<<<") + humanInTheLoop(ctx) + fmt.Println(">>>>>>>>>>>>>>>stream-human-in-the-loop<<<<<<<<<<<<<<<<") + streamHumanInTheLoop(ctx) +} + +func nonStreamChat(ctx context.Context) { + streaming := false + t, err := jsonrpc.NewTransport(ctx, &jsonrpc.ClientConfig{ + BaseURL: "http://127.0.0.1:8888", + HandlerPath: "/test", + }) + a, err := eino.NewAgent(ctx, eino.AgentConfig{ + Transport: t, + Name: nil, + Description: nil, + Streaming: &streaming, + }) + if err != nil { + log.Fatal(err) + } + + r := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: a, + }) + iter := r.Run(ctx, []adk.Message{schema.UserMessage("recommend a fiction book to me")}) + for { + event, ok := iter.Next() + if !ok { + break + } + printEvent(event) + } +} + +func streamChat(ctx context.Context) { + streaming := true + t, err := jsonrpc.NewTransport(ctx, &jsonrpc.ClientConfig{ + BaseURL: "http://127.0.0.1:8888", + HandlerPath: "/test", + }) + a, err := eino.NewAgent(ctx, eino.AgentConfig{ + Transport: t, + Streaming: &streaming, + }) + if err != nil { + log.Fatal(err) + } + + r := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: a, + EnableStreaming: true, + }) + iter := r.Run(ctx, []adk.Message{schema.UserMessage("recommend a fiction book to me")}) + for { + event, ok := iter.Next() + if !ok { + break + } + printEvent(event) + } +} + +func humanInTheLoop(ctx context.Context) { + t, err := jsonrpc.NewTransport(ctx, &jsonrpc.ClientConfig{ + BaseURL: "http://127.0.0.1:8888", + HandlerPath: "/test", + }) + a, err := eino.NewAgent(ctx, eino.AgentConfig{ + Transport: t, + }) + if err != nil { + log.Fatal(err) + } + + runHumanInTheLoop(ctx, a) +} + +func streamHumanInTheLoop(ctx context.Context) { + streaming := true + t, err := jsonrpc.NewTransport(ctx, &jsonrpc.ClientConfig{ + BaseURL: "http://127.0.0.1:8888", + HandlerPath: "/test", + }) + a, err := eino.NewAgent(ctx, eino.AgentConfig{ + Transport: t, + Streaming: &streaming, + }) + if err != nil { + log.Fatal(err) + } + + runHumanInTheLoop(ctx, a) +} + +func runHumanInTheLoop(ctx context.Context, a adk.Agent) { + var err error + r := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: a, + CheckPointStore: &inMemoryStore{}, + }) + checkPointID := "1" + iter := r.Run(ctx, []adk.Message{schema.UserMessage("recommend a book to me")}, adk.WithCheckPointID(checkPointID)) + for { + event, ok := iter.Next() + if !ok { + break + } + printEvent(event) + } + + newInput := "recommend a fiction book to me" + fmt.Printf("provide new input: %s\n\n", newInput) + + iter, err = r.Resume(ctx, checkPointID, eino.WithResumeMessages([]adk.Message{schema.UserMessage(newInput)})) + if err != nil { + log.Fatal(err) + } + for { + event, ok := iter.Next() + if !ok { + break + } + printEvent(event) + } +} + +type inMemoryStore struct { + m sync.Map +} + +func (i *inMemoryStore) Get(ctx context.Context, checkPointID string) ([]byte, bool, error) { + v, ok := i.m.Load(checkPointID) + if !ok { + return nil, false, nil + } + return v.([]byte), true, nil +} + +func (i *inMemoryStore) Set(ctx context.Context, checkPointID string, checkPoint []byte) error { + i.m.Store(checkPointID, checkPoint) + return nil +} + +func printEvent(event *adk.AgentEvent) { + fmt.Printf("name: %s\npath: %s", event.AgentName, event.RunPath) + if event.Output != nil && event.Output.MessageOutput != nil { + if m := event.Output.MessageOutput.Message; m != nil { + if len(m.Content) > 0 { + if m.Role == schema.Tool { + fmt.Printf("\ntool response: %s", m.Content) + } else { + fmt.Printf("\nanswer: %s", m.Content) + } + } + if len(m.ToolCalls) > 0 { + for _, tc := range m.ToolCalls { + fmt.Printf("\ntool name: %s", tc.Function.Name) + fmt.Printf("\narguments: %s", tc.Function.Arguments) + } + } + } else if s := event.Output.MessageOutput.MessageStream; s != nil { + toolMap := map[int][]*schema.Message{} + var contentStart bool + charNumOfOneRow := 0 + maxCharNumOfOneRow := 120 + for { + chunk, err := s.Recv() + if err != nil { + if err == io.EOF { + break + } + fmt.Printf("error: %v", err) + return + } + if chunk.Content != "" { + if !contentStart { + contentStart = true + if chunk.Role == schema.Tool { + fmt.Printf("\ntool response: ") + } else { + fmt.Printf("\nanswer: ") + } + } + + charNumOfOneRow += len(chunk.Content) + if strings.Contains(chunk.Content, "\n") { + charNumOfOneRow = 0 + } else if charNumOfOneRow >= maxCharNumOfOneRow { + fmt.Printf("\n") + charNumOfOneRow = 0 + } + fmt.Printf(chunk.Content) + } + + if len(chunk.ToolCalls) > 0 { + for _, tc := range chunk.ToolCalls { + index := tc.Index + if index == nil { + log.Fatalf("index is nil") + } + toolMap[*index] = append(toolMap[*index], &schema.Message{ + Role: chunk.Role, + ToolCalls: []schema.ToolCall{ + { + ID: tc.ID, + Type: tc.Type, + Index: tc.Index, + Function: schema.FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }, + }, + }) + } + } + } + + for _, msgs := range toolMap { + m, err := schema.ConcatMessages(msgs) + if err != nil { + log.Fatalf("ConcatMessage failed: %v", err) + return + } + fmt.Printf("\ntool name: %s", m.ToolCalls[0].Function.Name) + fmt.Printf("\narguments: %s", m.ToolCalls[0].Function.Arguments) + } + } + } + if event.Action != nil { + if event.Action.TransferToAgent != nil { + fmt.Printf("\naction: transfer to %v", event.Action.TransferToAgent.DestAgentName) + } + if event.Action.Interrupted != nil { + ii, _ := json.MarshalIndent(event.Action.Interrupted.Data, " ", " ") + fmt.Printf("\naction: interrupted") + fmt.Printf("\ninterrupt snapshot: %v", string(ii)) + } + if event.Action.Exit { + fmt.Printf("\naction: exit") + } + } + if event.Err != nil { + fmt.Printf("\nerror: %v", event.Err) + } + fmt.Println() + fmt.Println() +} diff --git a/a2a/extension/eino/examples/server/server.go b/a2a/extension/eino/examples/server/server.go new file mode 100644 index 000000000..01dde1f88 --- /dev/null +++ b/a2a/extension/eino/examples/server/server.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "log" + "sync" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/components/tool" + hertz_server "github.com/cloudwego/hertz/pkg/app/server" + + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc" + + "github.com/cloudwego/eino-ext/a2a/extension/eino" + "github.com/cloudwego/eino-ext/a2a/extension/eino/examples/server/subagents" +) + +func main() { + ctx := context.Background() + a := subagents.NewBookRecommendAgent() + + h := hertz_server.Default() + r, err := jsonrpc.NewRegistrar(ctx, &jsonrpc.ServerConfig{ + Router: h, + HandlerPath: "/test", + }) + if err != nil { + log.Fatal(err) + } + err = eino.RegisterServerHandlers(ctx, a, &eino.ServerConfig{ + Registrar: r, + ResumeConvertor: func(ctx context.Context, t *models.Task, input *models.Message, metadata map[string]any) ([]adk.AgentRunOption, error) { + text := "" + for _, p := range input.Parts { + if p.Kind == models.PartKindText && p.Text != nil { + text += *p.Text + } + } + return []adk.AgentRunOption{adk.WithToolOptions([]tool.Option{subagents.WithNewInput(text)})}, nil + }, + CheckPointStore: &inMemoryStore{}, + }) + if err != nil { + log.Fatal(err) + } + + _ = h.Run() +} + +type inMemoryStore struct { + m sync.Map +} + +func (i *inMemoryStore) Get(ctx context.Context, checkPointID string) ([]byte, bool, error) { + v, ok := i.m.Load(checkPointID) + if !ok { + return nil, false, nil + } + return v.([]byte), true, nil +} + +func (i *inMemoryStore) Set(ctx context.Context, checkPointID string, checkPoint []byte) error { + i.m.Store(checkPointID, checkPoint) + return nil +} diff --git a/a2a/extension/eino/examples/server/subagents/agent.go b/a2a/extension/eino/examples/server/subagents/agent.go new file mode 100644 index 000000000..79275a436 --- /dev/null +++ b/a2a/extension/eino/examples/server/subagents/agent.go @@ -0,0 +1,70 @@ +/* + * Copyright 2025 CloudWeGo 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 subagents + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/cloudwego/eino-ext/components/model/openai" + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" +) + +func newChatModel() model.ToolCallingChatModel { + cm, err := openai.NewChatModel(context.Background(), &openai.ChatModelConfig{ + APIKey: os.Getenv("OPENAI_API_KEY"), + Model: os.Getenv("OPENAI_MODEL"), + BaseURL: os.Getenv("OPENAI_BASE_URL"), + ByAzure: func() bool { + if os.Getenv("OPENAI_BY_AZURE") == "true" { + return true + } + return false + }(), + }) + if err != nil { + log.Fatalf("openai.NewChatModel failed: %v", err) + } + return cm +} + +func NewBookRecommendAgent() adk.Agent { + ctx := context.Background() + + a, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: "BookRecommender", + Description: "An agent that can recommend books", + Instruction: `You are an expert book recommender. +Based on the user's request, use the "search_book" tool to find relevant books. Finally, present the results to the user.`, + Model: newChatModel(), + ToolsConfig: adk.ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: []tool.BaseTool{NewBookRecommender(), NewAskForClarificationTool()}, + }, + }, + }) + if err != nil { + log.Fatal(fmt.Errorf("failed to create chatmodel: %w", err)) + } + + return a +} diff --git a/a2a/extension/eino/examples/server/subagents/ask_for_clarification.go b/a2a/extension/eino/examples/server/subagents/ask_for_clarification.go new file mode 100644 index 000000000..d717bdbcc --- /dev/null +++ b/a2a/extension/eino/examples/server/subagents/ask_for_clarification.go @@ -0,0 +1,57 @@ +/* + * Copyright 2025 CloudWeGo 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 subagents + +import ( + "context" + "log" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/components/tool/utils" + "github.com/cloudwego/eino/compose" +) + +type askForClarificationOptions struct { + NewInput *string +} + +func WithNewInput(input string) tool.Option { + return tool.WrapImplSpecificOptFn(func(t *askForClarificationOptions) { + t.NewInput = &input + }) +} + +type AskForClarificationInput struct { + Question string `json:"question" jsonschema:"description=The specific question you want to ask the user to get the missing information"` +} + +func NewAskForClarificationTool() tool.InvokableTool { + t, err := utils.InferOptionableTool( + "ask_for_clarification", + "Call this tool when the user's request is ambiguous or lacks the necessary information to proceed. Use it to ask a follow-up question to get the details you need, such as the book's genre, before you can use other tools effectively.", + func(ctx context.Context, input *AskForClarificationInput, opts ...tool.Option) (output string, err error) { + o := tool.GetImplSpecificOptions[askForClarificationOptions](nil, opts...) + if o.NewInput == nil { + return "", compose.NewInterruptAndRerunErr(input.Question) + } + return *o.NewInput, nil + }) + if err != nil { + log.Fatal(err) + } + return t +} diff --git a/a2a/extension/eino/examples/server/subagents/booksearch.go b/a2a/extension/eino/examples/server/subagents/booksearch.go new file mode 100644 index 000000000..ab66c236f --- /dev/null +++ b/a2a/extension/eino/examples/server/subagents/booksearch.go @@ -0,0 +1,47 @@ +/* + * Copyright 2025 CloudWeGo 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 subagents + +import ( + "context" + "log" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/components/tool/utils" +) + +type BookSearchInput struct { + Genre string `json:"genre" jsonschema:"description=Preferred book genre,enum=fiction,enum=sci-fi,enum=mystery,enum=biography,enum=business"` +} + +type BookSearchOutput struct { + Books []string +} + +func NewBookRecommender() tool.InvokableTool { + bookSearchTool, err := utils.InferTool("search_book", "Search books based on user preferences", + func(ctx context.Context, input *BookSearchInput) (output *BookSearchOutput, err error) { + // search code + // ... + return &BookSearchOutput{Books: []string{"God's blessing on this wonderful world!"}}, nil + }, + ) + if err != nil { + log.Fatalf("failed to create search book tool: %v", err) + } + return bookSearchTool +} diff --git a/a2a/extension/eino/message_extra.go b/a2a/extension/eino/message_extra.go new file mode 100644 index 000000000..84ddb0a26 --- /dev/null +++ b/a2a/extension/eino/message_extra.go @@ -0,0 +1,103 @@ +package eino + +import ( + "github.com/cloudwego/eino/schema" +) + +const ( + extraKeyOfReferenceTaskIDs = "_a2a_eino_adk_reference_task_ids" + extraKeyOfMessageID = "_a2a_eino_adk_message_id" + extraKeyOfTaskID = "_a2a_eino_adk_task_id" + extraKeyOfContextID = "_a2a_eino_adk_context_id" + extraKeyOfArtifactID = "_a2a_eino_adk_artifact_id" +) + +func GetReferenceTaskIDs(msg *schema.Message) ([]string, bool) { + if msg == nil { + return nil, false + } + ids, ok := msg.Extra[extraKeyOfReferenceTaskIDs].([]string) + return ids, ok +} + +func SetReferenceTaskIDs(msg *schema.Message, ids []string) { + if msg == nil { + return + } + if msg.Extra == nil { + msg.Extra = make(map[string]interface{}) + } + msg.Extra[extraKeyOfReferenceTaskIDs] = ids +} + +func GetMessageID(msg *schema.Message) (string, bool) { + if msg == nil { + return "", false + } + id, ok := msg.Extra[extraKeyOfMessageID].(string) + return id, ok +} + +func SetMessageID(msg *schema.Message, id string) { + if msg == nil { + return + } + if msg.Extra == nil { + msg.Extra = make(map[string]interface{}) + } + msg.Extra[extraKeyOfMessageID] = id +} + +func GetTaskID(msg *schema.Message) (string, bool) { + if msg == nil { + return "", false + } + id, ok := msg.Extra[extraKeyOfTaskID].(string) + return id, ok +} + +func SetTaskID(msg *schema.Message, id string) { + if msg == nil { + return + } + if msg.Extra == nil { + msg.Extra = make(map[string]interface{}) + } + msg.Extra[extraKeyOfTaskID] = id +} + +func GetContextID(msg *schema.Message) (string, bool) { + if msg == nil { + return "", false + } + id, ok := msg.Extra[extraKeyOfContextID].(string) + return id, ok +} + +func SetContextID(msg *schema.Message, id string) { + if msg == nil { + return + } + if msg.Extra == nil { + msg.Extra = make(map[string]interface{}) + } + msg.Extra[extraKeyOfContextID] = id +} + +func GetArtifactID(msg *schema.Message) (string, bool) { + if msg == nil { + return "", false + } + id, ok := msg.Extra[extraKeyOfArtifactID].(string) + return id, ok +} + +func SetArtifactID(msg *schema.Message, id string) { + if msg == nil { + return + } + if msg.Extra == nil { + msg.Extra = make(map[string]interface{}) + } + msg.Extra[extraKeyOfArtifactID] = id +} diff --git a/a2a/extension/eino/metadata.go b/a2a/extension/eino/metadata.go new file mode 100644 index 000000000..716dabbbe --- /dev/null +++ b/a2a/extension/eino/metadata.go @@ -0,0 +1,24 @@ +package eino + +const ( + metadataKeyOfEnableStreaming = "_a2a_eino_adk_enable_streaming" + metadataKeyOfInterrupted = "_a2a_eino_adk_interrupted" +) + +func setEnableStreaming(metadata map[string]any) { + metadata[metadataKeyOfEnableStreaming] = true +} + +func getEnableStreaming(metadata map[string]any) bool { + b, ok := metadata[metadataKeyOfEnableStreaming] + return ok && b == true +} + +func setInterrupted(metadata map[string]any) { + metadata[metadataKeyOfInterrupted] = true +} + +func getInterrupted(metadata map[string]any) bool { + b, ok := metadata[metadataKeyOfInterrupted] + return ok && b == true +} diff --git a/a2a/extension/eino/server.go b/a2a/extension/eino/server.go new file mode 100644 index 000000000..941b17683 --- /dev/null +++ b/a2a/extension/eino/server.go @@ -0,0 +1,546 @@ +package eino + +import ( + "context" + "encoding/gob" + "fmt" + "time" + + "github.com/bytedance/sonic" + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/compose" + "github.com/go-openapi/spec" + "github.com/google/uuid" + + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/server" + "github.com/cloudwego/eino-ext/a2a/transport" +) + +func init() { + gob.RegisterName("_eino_a2a_adk_interrupt_info", &InterruptInfo{}) +} + +type ServerConfig struct { + Registrar transport.HandlerRegistrar + + // convert user input to agent run option + AgentRunOptionConvertor func(ctx context.Context, t *models.Task, input *models.Message, metadata map[string]any) ([]adk.AgentRunOption, error) + // optional, should be set if your agent will interrupt and resume + // save agent interrupt status for resuming + CheckPointStore compose.CheckPointStore + // optional, convert a2a history to adk input messages + HistoryMessageConvertor func(ctx context.Context, messages []*models.Message) ([]adk.Message, error) + // convert user input to agent run option when resume + ResumeConvertor func(ctx context.Context, t *models.Task, input *models.Message, metadata map[string]any) ([]adk.AgentRunOption, error) + + // optional. customized convertor to convert adk.AgentEvent to a2a models.ResponseEvent + EventConvertor func(ctx context.Context, stream *adk.AsyncIterator[*adk.AgentEvent], writer func(p models.ResponseEvent) error) (err error) + + // the following 4 fields are used by default event convertor, if you customized event convertor, it's not necessary to configure these. + + // when interrupts have happened convert interrupt info to text for sending to client. + InterruptInfoConvertor func(ctx context.Context, info *adk.InterruptInfo) (string, error) + // optional, convert adk output message to a2a parts + OutputMessageConvertor func(ctx context.Context, messages []adk.Message) ([]models.Part, error) + // optional, uuid by default + MessageIDGenerator func(ctx context.Context) (string, error) + ArtifactIDGenerator func(ctx context.Context) (string, error) + + // a2a server config + + // agent card + URL string + Version string + DocumentationURL string + Provider *models.AgentProvider + SecuritySchemes map[string]*spec.SecurityScheme + Security map[string][]string + DefaultInputModes []string + DefaultOutputModes []string + Skills []models.AgentSkill + // optional, uuid by default + TaskIDGenerator func(ctx context.Context) (string, error) + // optional, log.Printf by default + Logger server.Logger + // optional, in-memory by default + TaskStore server.TaskStore + // optional, in-memory by default + TaskLocker server.TaskLocker + // optional, in-memory by default + Queue server.EventQueue + // optional, in-memory by default + PushNotifier server.PushNotifier +} + +func RegisterServerHandlers(ctx context.Context, a adk.Agent, cfg *ServerConfig) error { + if cfg == nil { + cfg = &ServerConfig{} + } + + builder := &a2aHandlersBuilder{ + agent: a, + cp: cfg.CheckPointStore, + runOptionConv: cfg.AgentRunOptionConvertor, + resumeConv: cfg.ResumeConvertor, + inputMessageConv: cfg.HistoryMessageConvertor, + eventConvertor: cfg.EventConvertor, + } + if builder.eventConvertor == nil { + d := &defaultEventConvertor{ + messageIDGen: cfg.MessageIDGenerator, + artifactIDGen: cfg.ArtifactIDGenerator, + interruptInfoConv: cfg.InterruptInfoConvertor, + outputMessageConv: cfg.OutputMessageConvertor, + } + if d.messageIDGen == nil { + d.messageIDGen = func(ctx context.Context) (string, error) { + return uuid.New().String(), nil + } + } + if d.artifactIDGen == nil { + d.artifactIDGen = func(ctx context.Context) (string, error) { + return uuid.New().String(), nil + } + } + if d.interruptInfoConv == nil { + d.interruptInfoConv = func(ctx context.Context, info *adk.InterruptInfo) (string, error) { + return sonic.MarshalString(info) + } + } + if d.outputMessageConv == nil { + d.outputMessageConv = messages2Parts + } + builder.eventConvertor = d.handlerEventIter + } + + if builder.inputMessageConv == nil { + builder.inputMessageConv = func(_ context.Context, messages []*models.Message) ([]adk.Message, error) { + return toADKMessages(messages), nil + } + } + + return server.RegisterHandlers(ctx, cfg.Registrar, &server.Config{ + AgentCardConfig: server.AgentCardConfig{ + Name: a.Name(ctx), + Description: a.Description(ctx), + URL: cfg.URL, + Version: cfg.Version, + DocumentationURL: cfg.DocumentationURL, + Provider: cfg.Provider, + SecuritySchemes: cfg.SecuritySchemes, + Security: cfg.Security, + DefaultInputModes: cfg.DefaultInputModes, + DefaultOutputModes: cfg.DefaultOutputModes, + Skills: cfg.Skills, + }, + MessageHandler: builder.buildHandler(), + MessageStreamingHandler: builder.buildStreamHandler(), + TaskIDGenerator: cfg.TaskIDGenerator, + CancelTaskHandler: builder.buildTaskCanceler(), + TaskEventsConsolidator: builder.einoResponseEventConcatenator, + Logger: cfg.Logger, + TaskStore: cfg.TaskStore, + TaskLocker: cfg.TaskLocker, + Queue: cfg.Queue, + PushNotifier: cfg.PushNotifier, + }) +} + +type a2aHandlersBuilder struct { + agent adk.Agent + cp compose.CheckPointStore + runOptionConv, resumeConv func(ctx context.Context, t *models.Task, input *models.Message, metadata map[string]any) ([]adk.AgentRunOption, error) + inputMessageConv func(ctx context.Context, messages []*models.Message) ([]adk.Message, error) + + eventConvertor func(ctx context.Context, event *adk.AsyncIterator[*adk.AgentEvent], writer func(p models.ResponseEvent) error) (err error) +} + +func (a *a2aHandlersBuilder) buildHandler() server.MessageHandler { + return func(ctx context.Context, params *server.InputParams) (*models.TaskContent, error) { + iter, err := a.genIter(ctx, params.Task, params.Input, params.Metadata) + if err != nil { + return nil, err + } + + var responseEvents []models.ResponseEvent + err = a.eventConvertor( + ctx, + iter, + func(p models.ResponseEvent) error { + responseEvents = append(responseEvents, p) + return nil + }) + + return a.einoResponseEventConcatenator(ctx, params.Task, responseEvents), nil + } +} + +func (a *a2aHandlersBuilder) buildStreamHandler() server.MessageStreamingHandler { + return func(ctx context.Context, params *server.InputParams, writer server.ResponseEventWriter) error { + iter, err := a.genIter(ctx, params.Task, params.Input, params.Metadata) + if err != nil { + return err + } + + err = a.eventConvertor(ctx, iter, writer.Write) + if err != nil { + return err + } + return nil + } +} + +func (a *a2aHandlersBuilder) genIter( + ctx context.Context, + t *models.Task, + input *models.Message, + metadata map[string]any, +) (iter *adk.AsyncIterator[*adk.AgentEvent], err error) { + runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: a.agent, + CheckPointStore: a.cp, + EnableStreaming: getEnableStreaming(metadata), + }) + var opts []adk.AgentRunOption + if a.runOptionConv != nil { + opts, err = a.runOptionConv(ctx, t, input, metadata) + if err != nil { + return nil, fmt.Errorf("failed to convert user input to agent run options: %w", err) + } + } + if getInterrupted(t.Metadata) { + if a.resumeConv != nil { + var rOpts []adk.AgentRunOption + rOpts, err = a.resumeConv(ctx, t, input, metadata) + if err != nil { + return nil, fmt.Errorf("failed to convert user input to resume agent run options: %w", err) + } + opts = append(opts, rOpts...) + } + iter, err = runner.Resume(ctx, t.ID, opts...) + if err != nil { + return nil, err + } + } else { + in, err := a.inputMessageConv(ctx, append(t.History, input)) + if err != nil { + return nil, fmt.Errorf("failed to convert a2a history to adk input: %w", err) + } + iter = runner.Run(ctx, in, append(opts, adk.WithCheckPointID(t.ID))...) + } + return iter, nil +} + +func (a *a2aHandlersBuilder) buildTaskCanceler() server.CancelTaskHandler { + return func(ctx context.Context, params *server.InputParams) (*models.TaskContent, error) { + ret := &models.TaskContent{ + Status: params.Task.Status, + History: params.Task.History, + Artifacts: params.Task.Artifacts, + Metadata: params.Metadata, + } + return ret, nil // todo: how to cancel + } +} + +type defaultEventConvertor struct { + messageIDGen, artifactIDGen func(ctx context.Context) (string, error) + interruptInfoConv func(ctx context.Context, info *adk.InterruptInfo) (string, error) + outputMessageConv func(ctx context.Context, messages []adk.Message) ([]models.Part, error) +} + +func (d *defaultEventConvertor) handlerEventIter( + ctx context.Context, + iter *adk.AsyncIterator[*adk.AgentEvent], + writer func(p models.ResponseEvent) error, +) error { + for { + ret, ok := iter.Next() + if !ok { + // send final status update + err := writer(models.ResponseEvent{ + TaskStatusUpdateEventContent: &models.TaskStatusUpdateEventContent{ + Status: models.TaskStatus{ + State: models.TaskStateCompleted, + Timestamp: time.Now().Format(time.RFC3339), + }, + Final: true, + }, + }) + if err != nil { + return err + } + break + } + + if ret.Err != nil { + return fmt.Errorf("failed to execute agent: %w", ret.Err) + } + + interrupted, err := d.convAgentEvent(ctx, ret, writer) + if err != nil { + return err + } + if interrupted { + break + } + } + return nil +} + +func (d *defaultEventConvertor) convAgentEvent( + ctx context.Context, + event *adk.AgentEvent, + writer func(p models.ResponseEvent) error, +) (interrupted bool, err error) { + if event == nil { + return false, nil + } + // 1. tool output(success transfer to xxx agent) + transfer action -> status + // 2. interrupt -> status update + // 3. iter closed -> final statue update + // 3. llm output -> artifact + // 4. tool output -> artifact + // todo: customized output and action + + // transfer + if event.Action != nil && event.Action.TransferToAgent != nil { + text := fmt.Sprintf("transfer from agent[%s] to agent[%s]", event.AgentName, event.Action.TransferToAgent.DestAgentName) // todo: how + messageID, err := d.messageIDGen(ctx) + if err != nil { + return false, fmt.Errorf("failed to generate message ID: %w", err) + } + return false, writer( + models.ResponseEvent{ + TaskStatusUpdateEventContent: &models.TaskStatusUpdateEventContent{ + Status: models.TaskStatus{ + State: models.TaskStateWorking, + Message: &models.Message{ + Role: models.RoleAgent, + Parts: []models.Part{{Kind: models.PartKindText, Text: &text}}, + MessageID: messageID, + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + }, + ) + } + + // interrupt + if event.Action != nil && event.Action.Interrupted != nil { + data, err := d.interruptInfoConv(ctx, event.Action.Interrupted) + if err != nil { + return false, fmt.Errorf("failed to marshal interrupted info: %w", err) + } + metadata := map[string]any{} + setInterrupted(metadata) + messageID, err := d.messageIDGen(ctx) + if err != nil { + return false, fmt.Errorf("failed to generate message ID: %w", err) + } + return true, writer(models.ResponseEvent{ + TaskStatusUpdateEventContent: &models.TaskStatusUpdateEventContent{ + Status: models.TaskStatus{ + State: models.TaskStateInputRequired, + Message: &models.Message{ + MessageID: messageID, + Role: models.RoleAgent, + Parts: []models.Part{ + { + Kind: models.PartKindText, + Text: &data, + }, + }, + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + Metadata: metadata, + }, + }) + } + + // llm&tool output + if event.Output != nil && event.Output.MessageOutput != nil { + return false, d.messageVar2Status(ctx, event.AgentName, event.Output.MessageOutput, writer) + } + + // empty agent event + return false, nil +} + +func (d *defaultEventConvertor) messageVar2Status(ctx context.Context, agentName string, messageVar *adk.MessageVariant, writer func(p models.ResponseEvent) (err error)) error { + artifactID, err := d.artifactIDGen(ctx) + if err != nil { + return fmt.Errorf("failed to generate message ID: %w", err) + } + m, err := messageVar.GetMessage() + if err != nil { + return fmt.Errorf("failed to get message: %w", err) + } + p, convErr := d.outputMessageConv(ctx, []adk.Message{m}) + if convErr != nil { + return convErr + } + if p != nil { + return writer(models.ResponseEvent{ + TaskArtifactUpdateEventContent: &models.TaskArtifactUpdateEventContent{ + Artifact: models.Artifact{ + ArtifactID: artifactID, + Name: agentName, + Parts: p, + }, + LastChunk: true, + }, + }) + } + return nil +} + +func (a *a2aHandlersBuilder) einoResponseEventConcatenator(ctx context.Context, t *models.Task, events []models.ResponseEvent) *models.TaskContent { + tc := &models.TaskContent{ + Status: t.Status, + Artifacts: t.Artifacts, + History: t.History, + Metadata: t.Metadata, + } + if tc.Metadata == nil { + tc.Metadata = make(map[string]any) + } + + artifacts := make(map[string][]*models.Artifact) + var lastMessage *models.Message + for _, event := range events { + if event.Message != nil { + tc.History = append(tc.History, event.Message) + + lastMessage = event.Message + } else if event.TaskContent != nil { + if tc.Status.Message != nil { + tc.History = append(tc.History, tc.Status.Message) + } + tc.History = append(t.History, event.TaskContent.History...) + tc.Artifacts = append(t.Artifacts, event.TaskContent.Artifacts...) + tc.Status = event.TaskContent.Status + + if tc.Status.Message != nil { + lastMessage = tc.Status.Message + } + } else if event.TaskStatusUpdateEventContent != nil { + // save last status message to history + if tc.Status.Message != nil { + tc.History = append(tc.History, tc.Status.Message) + } + + // set new status + tc.Status = event.TaskStatusUpdateEventContent.Status + + for k, v := range event.TaskStatusUpdateEventContent.Metadata { + // save interrupt + tc.Metadata[k] = v + } + + if tc.Status.Message != nil { + lastMessage = tc.Status.Message + } + } else if event.TaskArtifactUpdateEventContent != nil { + if _, ok := artifacts[event.TaskArtifactUpdateEventContent.Artifact.ArtifactID]; !ok { + artifacts[event.TaskArtifactUpdateEventContent.Artifact.ArtifactID] = []*models.Artifact{} + } + artifacts[event.TaskArtifactUpdateEventContent.Artifact.ArtifactID] = append(artifacts[event.TaskArtifactUpdateEventContent.Artifact.ArtifactID], &event.TaskArtifactUpdateEventContent.Artifact) + + if event.TaskArtifactUpdateEventContent.LastChunk { + newA := concatArtifacts(artifacts[event.TaskArtifactUpdateEventContent.Artifact.ArtifactID]) + artifacts[event.TaskArtifactUpdateEventContent.Artifact.ArtifactID] = []*models.Artifact{} + + tc.Artifacts = append(tc.Artifacts, newA) + + if newA != nil { + lastMessage = &models.Message{ + Role: models.RoleAgent, + Parts: newA.Parts, + Metadata: newA.Metadata, + MessageID: newA.ArtifactID, + } + } + } + } + } + + if lastMessage != nil { + tc.Status.Message = lastMessage + } + + return tc +} + +func concatMessages(messages []*models.Message) *models.Message { + if len(messages) == 0 { + return nil + } + ret := &models.Message{} + for _, m := range messages { + if m == nil { + continue + } + if len(m.Role) > 0 { + ret.Role = m.Role + } + if len(m.MessageID) > 0 { + ret.MessageID = m.MessageID + } + if m.TaskID != nil { + ret.TaskID = m.TaskID + } + if len(m.MessageID) > 0 { + ret.MessageID = m.MessageID + } + if m.ContextID != nil { + ret.ContextID = m.ContextID + } + if len(m.ReferenceTaskIDs) > 0 { + ret.ReferenceTaskIDs = m.ReferenceTaskIDs + } + for k, v := range m.Metadata { + ret.Metadata[k] = v + } + + ret.Parts = concatParts(ret.Parts, m.Parts) + } + return ret +} + +func concatArtifacts(artifacts []*models.Artifact) *models.Artifact { + if len(artifacts) == 0 { + return nil + } + if len(artifacts) == 1 { + return artifacts[0] + } + ret := &models.Artifact{} + for _, artifact := range artifacts { + if artifact == nil { + continue + } + if len(artifact.ArtifactID) > 0 { + ret.ArtifactID = artifact.ArtifactID + } + if len(artifact.Name) > 0 { + ret.Name = artifact.Name + } + if len(artifact.Description) > 0 { + ret.Description = artifact.Description + } + for k, v := range artifact.Metadata { + ret.Metadata[k] = v + } + + ret.Parts = concatParts(ret.Parts, artifact.Parts) + } + return ret +} + +func concatParts(old, new []models.Part) []models.Part { + return append(old, new...) // todo: how to concat parts +} diff --git a/a2a/extension/eino/utils.go b/a2a/extension/eino/utils.go new file mode 100644 index 000000000..68b64085f --- /dev/null +++ b/a2a/extension/eino/utils.go @@ -0,0 +1,207 @@ +package eino + +import ( + "context" + "strings" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/schema" + + "github.com/cloudwego/eino-ext/a2a/models" +) + +func messages2Parts(_ context.Context, messages []adk.Message) ([]models.Part, error) { + var ret []models.Part + for _, m := range messages { + parts := message2Parts(m) + if len(parts) > 0 { + ret = append(ret, parts...) + } + } + return ret, nil +} + +func message2Parts(m adk.Message) []models.Part { + if m == nil { + return nil + } + // ignore reasoning_content + // ignore tool_call + if len(m.Content) > 0 { + text := m.Content + return []models.Part{{Kind: models.PartKindText, Text: &text}} + } + + if len(m.MultiContent) > 0 { + ret := make([]models.Part, 0, len(m.MultiContent)) + for _, content := range m.MultiContent { + switch content.Type { + case schema.ChatMessagePartTypeText: + text := content.Text + ret = append(ret, models.Part{Kind: models.PartKindText, Text: &text}) + case schema.ChatMessagePartTypeImageURL: + if content.ImageURL != nil { + // todo: how to specify bytes or uri? + ret = append(ret, toFileParts(content.ImageURL.MIMEType, content.ImageURL.URL)) + } + case schema.ChatMessagePartTypeAudioURL: + if content.AudioURL != nil { + // todo: how to specify bytes or uri? + ret = append(ret, toFileParts(content.AudioURL.MIMEType, content.AudioURL.URL)) + } + case schema.ChatMessagePartTypeVideoURL: + if content.VideoURL != nil { + // todo: how to specify bytes or uri? + ret = append(ret, toFileParts(content.VideoURL.MIMEType, content.VideoURL.URL)) + } + case schema.ChatMessagePartTypeFileURL: + if content.FileURL != nil { + // todo: how to specify bytes or uri? + ret = append(ret, toFileParts(content.FileURL.MIMEType, content.FileURL.URL)) + } + default: + } + } + + return ret + } + + return nil +} + +func toFileParts(mimeType, uri string) models.Part { + p := models.Part{Kind: models.PartKindFile, File: &models.FileContent{MimeType: mimeType}} + if strings.HasPrefix(uri, "http") || + strings.HasPrefix(uri, "ftp") { + p.File.URI = &uri + } else { + p.File.Bytes = &uri + } + return p +} + +func toADKMessages(ms []*models.Message) []adk.Message { + ret := make([]adk.Message, 0, len(ms)) + for _, m := range ms { + ret = append(ret, toADKMessage(m)) + } + return ret +} + +func toADKMessage(m *models.Message) adk.Message { + if m == nil { + return nil + } + ret := &schema.Message{} + switch m.Role { + case models.RoleAgent: + ret.Role = schema.Assistant + case models.RoleUser: + ret.Role = schema.User + } + + ret.Content, ret.MultiContent = parts2Content(m.Parts) + + SetMessageID(ret, m.MessageID) + if m.ContextID != nil { + SetContextID(ret, *m.ContextID) + } + if m.TaskID != nil { + SetTaskID(ret, *m.TaskID) + } + for k, v := range m.Metadata { + ret.Extra[k] = v + } + return ret +} + +func artifact2ADKMessage(a *models.Artifact) *schema.Message { + if a == nil { + return nil + } + + ret := &schema.Message{ + Role: schema.Assistant, + } + + ret.Content, ret.MultiContent = parts2Content(a.Parts) + + SetArtifactID(ret, a.ArtifactID) + for k, v := range a.Metadata { + ret.Extra[k] = v + } + return ret +} + +func parts2Content(parts []models.Part) (string, []schema.ChatMessagePart) { + mc := make([]schema.ChatMessagePart, 0, len(parts)) + allText := true + for _, part := range parts { + switch part.Kind { + case models.PartKindText: + if part.Text != nil { + mc = append(mc, schema.ChatMessagePart{ + Type: schema.ChatMessagePartTypeText, + Text: *part.Text, + }) + } + case models.PartKindFile: + allText = false + if part.File != nil { + var url string + if part.File.URI != nil { + url = *part.File.URI + } + if part.File.Bytes != nil { + url = *part.File.Bytes + } + + if strings.HasPrefix(part.File.MimeType, "image/") { + mc = append(mc, schema.ChatMessagePart{ + Type: schema.ChatMessagePartTypeImageURL, + ImageURL: &schema.ChatMessageImageURL{ + URL: url, + MIMEType: part.File.MimeType, + }, + }) + + } else if strings.HasPrefix(part.File.MimeType, "audio/") { + mc = append(mc, schema.ChatMessagePart{ + Type: schema.ChatMessagePartTypeAudioURL, + AudioURL: &schema.ChatMessageAudioURL{ + URL: url, + MIMEType: part.File.MimeType, + }, + }) + + } else if strings.HasPrefix(part.File.MimeType, "video/") { + mc = append(mc, schema.ChatMessagePart{ + Type: schema.ChatMessagePartTypeVideoURL, + VideoURL: &schema.ChatMessageVideoURL{ + URL: url, + MIMEType: part.File.MimeType, + }, + }) + } else { + mc = append(mc, schema.ChatMessagePart{ + Type: schema.ChatMessagePartTypeFileURL, + FileURL: &schema.ChatMessageFileURL{ + URL: url, + MIMEType: part.File.MimeType, + }, + }) + } + } + default: + // todo: how to handle PartKindData + } + } + if allText { + content := "" + for _, c := range mc { + content += c.Text + } + return content, nil + } + return "", mc +} diff --git a/a2a/go.mod b/a2a/go.mod index abec5c0a3..ff255913e 100644 --- a/a2a/go.mod +++ b/a2a/go.mod @@ -3,17 +3,57 @@ module github.com/cloudwego/eino-ext/a2a go 1.18 require ( + github.com/bytedance/sonic v1.14.0 + github.com/cloudwego/eino v0.5.0-alpha.7 + github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250822083409-f8d432eea60f + github.com/cloudwego/hertz v0.10.2 github.com/go-openapi/spec v0.21.0 + github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.10.0 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250821122458-ae35393076b3 // indirect + github.com/cloudwego/gopkg v0.1.4 // indirect + github.com/cloudwego/netpoll v0.7.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/evanphx/json-patch v0.5.2 // indirect + github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/getkin/kin-openapi v0.118.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/golang/protobuf v1.5.0 // indirect + github.com/goph/emperror v0.17.2 // indirect + github.com/invopop/yaml v0.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/meguminnnnnnnnn/go-openai v0.0.0-20250821095446-07791bea23a0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/nikolalohinski/gonja v1.5.3 // indirect + github.com/nyaruka/phonenumbers v1.0.55 // indirect + github.com/pelletier/go-toml/v2 v2.0.9 // indirect + github.com/perimeterx/marshmallow v1.1.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/yargevad/filepathx v1.0.0 // indirect + golang.org/x/arch v0.11.0 // indirect + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect + golang.org/x/sys v0.28.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/a2a/go.sum b/a2a/go.sum index 5b0ef044d..a9f0427b6 100644 --- a/a2a/go.sum +++ b/a2a/go.sum @@ -1,25 +1,238 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/bytedance/gopkg v0.1.1 h1:3azzgSkiaw79u24a+w9arfH8OfnQQ4MHUt9lJFREEaE= +github.com/bytedance/gopkg v0.1.1/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/mockey v1.2.14 h1:KZaFgPdiUwW+jOWFieo3Lr7INM1P+6adO3hxZhDswY8= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/eino v0.5.0-alpha.7 h1:5AN1pFk/K9YTM484qfgJInO/ACoi+THBvYHDq3bkBxM= +github.com/cloudwego/eino v0.5.0-alpha.7/go.mod h1:6Ew/DpfTukgPCUEyrxl2vgsty678+o0nBcwIVndeM5U= +github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250822083409-f8d432eea60f h1:ZWuD8oPJOFX9bWDOn2hZx9Ya0++yaoZ7kBlZBJbx00Y= +github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250822083409-f8d432eea60f/go.mod h1:CWcxcrqTmOfR9H08urLNUrPl9Q5Rd4ExgsTMnByeKjI= +github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250821122458-ae35393076b3 h1:zv49eVRDsK6WmhRJv8AcrmfO+Io5g+dJaoSGo/NqoNY= +github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250821122458-ae35393076b3/go.mod h1:nfqN4yOoMc4/EDRizyq14BRtmvOQNcs95aEMyho5RRI= +github.com/cloudwego/gopkg v0.1.4 h1:EoQiCG4sTonTPHxOGE0VlQs+sQR+Hsi2uN0qqwu8O50= +github.com/cloudwego/gopkg v0.1.4/go.mod h1:FQuXsRWRsSqJLsMVd5SYzp8/Z1y5gXKnVvRrWUOsCMI= +github.com/cloudwego/hertz v0.10.2 h1:scaVn4E/AQ/vuMAC8FXzUzsEXS/TF1ix1I+4slPhh7c= +github.com/cloudwego/hertz v0.10.2/go.mod h1:W5dUFXZPZkyfjMMo3EQrMQbofuvTsctM9IxmhbkuT18= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cloudwego/netpoll v0.7.0 h1:bDrxQaNfijRI1zyGgXHQoE/nYegL0nr+ijO1Norelc4= +github.com/cloudwego/netpoll v0.7.0/go.mod h1:PI+YrmyS7cIr0+SD4seJz3Eo3ckkXdu2ZVKBLhURLNU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/getkin/kin-openapi v0.118.0 h1:z43njxPmJ7TaPpMSCQb7PN0dEYno4tyBPQcrFdHoLuM= +github.com/getkin/kin-openapi v0.118.0/go.mod h1:l5e9PaFUo9fyLJCPGQeXI2ML8c3P8BHOEV2VaAVf/pc= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= +github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc= +github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/meguminnnnnnnnn/go-openai v0.0.0-20250821095446-07791bea23a0 h1:nIohpHs1ViKR0SVgW/cbBstHjmnqFZDM9RqgX9m9Xu8= +github.com/meguminnnnnnnnn/go-openai v0.0.0-20250821095446-07791bea23a0/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= +github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= +github.com/nyaruka/phonenumbers v1.0.55 h1:bj0nTO88Y68KeUQ/n3Lo2KgK7lM1hF7L9NFuwcCl3yg= +github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= +github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/perimeterx/marshmallow v1.1.4 h1:pZLDH9RjlLGGorbXhcaQLhfuV0pFMNfPO55FuFkxqLw= +github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= +github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= +github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= +golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/a2a/server/eventqueue.go b/a2a/server/eventqueue.go new file mode 100644 index 000000000..4a949ce59 --- /dev/null +++ b/a2a/server/eventqueue.go @@ -0,0 +1,123 @@ +package server + +import ( + "context" + "fmt" + "sync" + + "github.com/cloudwego/eino-ext/a2a/models" +) + +type EventQueue interface { + Push(ctx context.Context, taskID string, event *models.SendMessageStreamingResponseUnion, taskErr error) error + Pop(ctx context.Context, taskID string) (event *models.SendMessageStreamingResponseUnion, taskErr error, closed bool, err error) + Close(ctx context.Context, taskID string) error + Reset(ctx context.Context, taskID string) error +} + +func newInMemoryEventQueue() EventQueue { + return &inMemoryEventQueue{} +} + +type inMemoryEventQueue struct { + chanMap sync.Map +} + +type inMemoryEventQueuePair struct { + taskErr error + union *models.SendMessageStreamingResponseUnion +} + +func (i *inMemoryEventQueue) Push(ctx context.Context, taskID string, event *models.SendMessageStreamingResponseUnion, taskErr error) error { + v, ok := i.chanMap.Load(taskID) + if !ok { + return fmt.Errorf("failed to push queue: cannot find the queue of task[%s]", taskID) + } + ch := v.(*unboundedChan[*inMemoryEventQueuePair]) + ch.Send(&inMemoryEventQueuePair{ + taskErr: taskErr, + union: event, + }) + return nil +} + +func (i *inMemoryEventQueue) Pop(ctx context.Context, taskID string) (event *models.SendMessageStreamingResponseUnion, taskErr error, closed bool, err error) { + v, ok := i.chanMap.Load(taskID) + if !ok { + return nil, nil, false, fmt.Errorf("failed to pop from queue: cannot find the queue of task[%s]", taskID) + } + ch := v.(*unboundedChan[*inMemoryEventQueuePair]) + resp, success := ch.Receive() + if success { + return resp.union, resp.taskErr, false, nil + } + return nil, nil, true, nil +} + +func (i *inMemoryEventQueue) Close(ctx context.Context, taskID string) error { + v, ok := i.chanMap.Load(taskID) + if !ok { + return fmt.Errorf("failed to close queue: cannot find the queue of task[%s]", taskID) + } + v.(*unboundedChan[*inMemoryEventQueuePair]).Close() + return nil +} + +func (i *inMemoryEventQueue) Reset(ctx context.Context, taskID string) error { + i.chanMap.Store(taskID, newUnboundedChan[*inMemoryEventQueuePair]()) + return nil +} + +type unboundedChan[T any] struct { + buffer []T // Internal buffer to store data + mutex sync.Mutex // Mutex to protect buffer access + notEmpty *sync.Cond // Condition variable to wait for data + closed bool // Indicates if the channel has been closed +} + +func newUnboundedChan[T any]() *unboundedChan[T] { + ch := &unboundedChan[T]{} + ch.notEmpty = sync.NewCond(&ch.mutex) + return ch +} + +func (ch *unboundedChan[T]) Send(value T) { + ch.mutex.Lock() + defer ch.mutex.Unlock() + + if ch.closed { + panic("send on closed channel") + } + + ch.buffer = append(ch.buffer, value) + ch.notEmpty.Signal() // Wake up one goroutine waiting to receive +} + +func (ch *unboundedChan[T]) Receive() (T, bool) { + ch.mutex.Lock() + defer ch.mutex.Unlock() + + for len(ch.buffer) == 0 && !ch.closed { + ch.notEmpty.Wait() // Wait until data is available + } + + if len(ch.buffer) == 0 { + // Channel is closed and empty + var zero T + return zero, false + } + + val := ch.buffer[0] + ch.buffer = ch.buffer[1:] + return val, true +} + +func (ch *unboundedChan[T]) Close() { + ch.mutex.Lock() + defer ch.mutex.Unlock() + + if !ch.closed { + ch.closed = true + ch.notEmpty.Broadcast() // Wake up all waiting goroutines + } +} diff --git a/a2a/server/eventqueue_test.go b/a2a/server/eventqueue_test.go new file mode 100644 index 000000000..116a18e6a --- /dev/null +++ b/a2a/server/eventqueue_test.go @@ -0,0 +1,43 @@ +package server + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/cloudwego/eino-ext/a2a/models" +) + +func TestInMemoryEventQueue(t *testing.T) { + eq := newInMemoryEventQueue() + ctx := context.Background() + assert.NoError(t, eq.Reset(ctx, "1")) + assert.NoError(t, eq.Push(ctx, "1", &models.SendMessageStreamingResponseUnion{Message: &models.Message{Role: "1"}}, nil)) + assert.NoError(t, eq.Push(ctx, "1", &models.SendMessageStreamingResponseUnion{Message: &models.Message{Role: "2"}}, nil)) + assert.NoError(t, eq.Push(ctx, "1", nil, fmt.Errorf("test error"))) + assert.NoError(t, eq.Push(ctx, "1", &models.SendMessageStreamingResponseUnion{Message: &models.Message{Role: "3"}}, nil)) + assert.NoError(t, eq.Close(ctx, "1")) + + e, taskErr, closed, err := eq.Pop(ctx, "1") + assert.NoError(t, err) + assert.False(t, closed) + assert.Nil(t, taskErr) + assert.Equal(t, models.Role("1"), e.Message.Role) + e, taskErr, closed, err = eq.Pop(ctx, "1") + assert.NoError(t, err) + assert.False(t, closed) + assert.Nil(t, taskErr) + assert.Equal(t, models.Role("2"), e.Message.Role) + e, taskErr, closed, err = eq.Pop(ctx, "1") + assert.NoError(t, err) + assert.False(t, closed) + assert.ErrorContains(t, taskErr, "test error") + assert.Nil(t, e) + e, taskErr, closed, err = eq.Pop(ctx, "1") + assert.NoError(t, err) + assert.False(t, closed) + assert.Nil(t, taskErr) + assert.Equal(t, models.Role("3"), e.Message.Role) +} diff --git a/a2a/server/notifier.go b/a2a/server/notifier.go new file mode 100644 index 000000000..a1198f4ea --- /dev/null +++ b/a2a/server/notifier.go @@ -0,0 +1,120 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + + "github.com/cloudwego/eino-ext/a2a/models" +) + +type PushNotifier interface { + Set(ctx context.Context, config *models.TaskPushNotificationConfig) error + Get(ctx context.Context, taskID string) (models.PushNotificationConfig, bool, error) + Delete(ctx context.Context, taskID string) error + SendNotification(ctx context.Context, event *models.SendMessageStreamingResponseUnion) error +} + +func NewInMemoryPushNotifier() PushNotifier { + return &inMemoryPushNotifier{} +} + +type inMemoryPushNotifier struct { + infoMap sync.Map +} + +func (i *inMemoryPushNotifier) Set(ctx context.Context, config *models.TaskPushNotificationConfig) error { + if config == nil { + return nil + } + i.infoMap.Store(config.TaskID, config.PushNotificationConfig) + return nil +} + +func (i *inMemoryPushNotifier) Get(ctx context.Context, taskID string) (models.PushNotificationConfig, bool, error) { + result, ok := i.infoMap.Load(taskID) + if !ok { + return models.PushNotificationConfig{}, false, nil + } + return result.(models.PushNotificationConfig), true, nil +} + +func (i *inMemoryPushNotifier) Delete(ctx context.Context, taskID string) error { + i.infoMap.Delete(taskID) + return nil +} + +func (i *inMemoryPushNotifier) SendNotification(ctx context.Context, event *models.SendMessageStreamingResponseUnion) error { + if event == nil { + return nil + } + config, existed, err := i.Get(ctx, event.GetTaskID()) + if err != nil { + return err + } + if !existed { + return nil + } + + var body []byte + body, err = json.Marshal(wrapUnion(event)) + if err != nil { + return fmt.Errorf("failed to marshal event when send notification of task[%s]: %w", event.GetTaskID(), err) + } + req, err := http.NewRequest(http.MethodPost, config.URL, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("failed to create http request when send notification of task[%s]: %w", event.GetTaskID(), err) + } + req = req.WithContext(ctx) + + // todo: how to set auth + + _, err = http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send notification of task[%s]: %w", event.GetTaskID(), err) + } + return nil +} + +func wrapUnion(u *models.SendMessageStreamingResponseUnion) any { + if u == nil { + return nil + } + if u.Message != nil { + return struct { + *models.Message + Kind models.ResponseKind `json:"kind"` + }{ + Message: u.Message, + Kind: models.ResponseKindMessage, + } + } else if u.Task != nil { + return struct { + *models.Task + Kind models.ResponseKind `json:"kind"` + }{ + Task: u.Task, + Kind: models.ResponseKindTask, + } + } else if u.TaskStatusUpdateEvent != nil { + return struct { + *models.TaskStatusUpdateEvent + Kind models.ResponseKind `json:"kind"` + }{ + TaskStatusUpdateEvent: u.TaskStatusUpdateEvent, + Kind: models.ResponseKindStatusUpdate, + } + } else if u.TaskArtifactUpdateEvent != nil { + return struct { + *models.TaskArtifactUpdateEvent + Kind models.ResponseKind `json:"kind"` + }{ + TaskArtifactUpdateEvent: u.TaskArtifactUpdateEvent, + Kind: models.ResponseKindArtifactUpdate, + } + } + return nil +} diff --git a/a2a/server/server.go b/a2a/server/server.go new file mode 100644 index 000000000..621861434 --- /dev/null +++ b/a2a/server/server.go @@ -0,0 +1,698 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log" + "runtime/debug" + "sync" + "time" + + "github.com/go-openapi/spec" + "github.com/google/uuid" + + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/transport" + "github.com/cloudwego/eino-ext/a2a/utils" +) + +type InputParams struct { + Task *models.Task + Input *models.Message + Metadata map[string]any +} + +type MessageHandler func(ctx context.Context, params *InputParams) (*models.TaskContent, error) +type MessageStreamingHandler func(ctx context.Context, params *InputParams, writer ResponseEventWriter) error +type CancelTaskHandler func(ctx context.Context, params *InputParams) (*models.TaskContent, error) +type TaskEventsConsolidator func(ctx context.Context, t *models.Task, events []models.ResponseEvent) *models.TaskContent +type Logger func(ctx context.Context, format string, v ...any) + +type ResponseEventWriter interface { + Write(event models.ResponseEvent) error +} + +type Config struct { + AgentCardConfig + + // optional + MessageHandler MessageHandler + // optional + MessageStreamingHandler MessageStreamingHandler + + // optional, uuid by default + TaskIDGenerator func(ctx context.Context) (string, error) + // required + CancelTaskHandler CancelTaskHandler + // required + TaskEventsConsolidator TaskEventsConsolidator + // optional, log.Printf by default + Logger Logger + // optional, in-memory by default + TaskStore TaskStore + // optional, in-memory by default + TaskLocker TaskLocker + // optional, in-memory by default + Queue EventQueue + // optional, in-memory by default + PushNotifier PushNotifier +} + +type AgentCardConfig struct { + Name string + Description string + URL string + Version string + DocumentationURL string + + Provider *models.AgentProvider + + SecuritySchemes map[string]*spec.SecurityScheme + Security map[string][]string + DefaultInputModes []string + DefaultOutputModes []string + Skills []models.AgentSkill +} + +func RegisterHandlers(ctx context.Context, registrar transport.HandlerRegistrar, config *Config) error { + if registrar == nil { + return fmt.Errorf("HandlerRegistrar is required") + } + if config == nil { + return fmt.Errorf("config is required") + } + ret := &models.ServerHandlers{} + + s := initA2AServer(config) + + if s.logger == nil { + s.logger = func(_ context.Context, format string, v ...any) { log.Printf(format, v...) } + } + if s.taskIDGenerator == nil { + s.taskIDGenerator = func(_ context.Context) (string, error) { + return uuid.NewString(), nil + } + } + if s.taskStore == nil { + s.taskStore = newInMemoryTaskStore() + } + if s.taskLocker == nil { + s.taskLocker = newInMemoryTaskLocker() + } + if s.queue == nil { + s.queue = newInMemoryEventQueue() + } + + ret.AgentCard = func(ctx context.Context) *models.AgentCard { + return s.agentCard + } + ret.GetTask = func(ctx context.Context, params *models.TaskQueryParams) (*models.Task, error) { + return s.getTask(ctx, params) + } + + if s.messageHandler == nil && s.messageStreamingHandler == nil { + return errors.New("handler is required") + } + if s.messageHandler != nil { + ret.SendMessage = s.sendMessage + } + if s.messageStreamingHandler != nil { + if s.taskEventsConsolidator == nil { + return errors.New("task modifier is required if message stream handler has been set") + } + s.agentCard.Capabilities.Streaming = true + + ret.SendMessageStreaming = s.sendMessageStreaming + ret.ResubscribeTask = s.resubscribeTask + if s.messageHandler == nil { + s.messageHandler = buildMessageHandlerByStream(s.messageStreamingHandler, s.taskEventsConsolidator) + ret.SendMessage = s.sendMessage + } + } + if s.cancelTaskHandler == nil { + ret.CancelTask = func(ctx context.Context, params *models.TaskIDParams) (*models.Task, error) { + return nil, fmt.Errorf("task cancel haven't been implemented") + } + } else { + ret.CancelTask = s.cancelTask + } + if s.pushNotifier != nil { + s.agentCard.Capabilities.PushNotifications = true + ret.GetPushNotificationConfig = s.getTasksPushNotificationConfig + ret.SetPushNotificationConfig = s.setTasksPushNotificationConfig + } + + return registrar.Register(ctx, ret) +} + +func initA2AServer(config *Config) *A2AServer { + return &A2AServer{ + agentCard: initAgentCard(config), + messageHandler: config.MessageHandler, + messageStreamingHandler: config.MessageStreamingHandler, + cancelTaskHandler: config.CancelTaskHandler, + taskEventsConsolidator: config.TaskEventsConsolidator, + logger: config.Logger, + taskIDGenerator: config.TaskIDGenerator, + taskStore: config.TaskStore, + taskLocker: config.TaskLocker, + queue: config.Queue, + pushNotifier: config.PushNotifier, + } +} + +func initAgentCard(config *Config) *models.AgentCard { + return &models.AgentCard{ + ProtocolVersion: "0.2.5", + Name: config.Name, + Description: config.Description, + URL: config.URL, + Provider: config.Provider, + Version: config.Version, + DocumentationURL: config.DocumentationURL, + SecuritySchemes: config.SecuritySchemes, + Security: config.Security, + DefaultInputModes: config.DefaultInputModes, + DefaultOutputModes: config.DefaultOutputModes, + Skills: config.Skills, + } +} + +// A2AServer represents an A2A server instance +type A2AServer struct { + agentCard *models.AgentCard + agentCartPath string + + messageHandler MessageHandler + messageStreamingHandler MessageStreamingHandler + cancelTaskHandler CancelTaskHandler + + taskEventsConsolidator TaskEventsConsolidator + + logger Logger + + taskIDGenerator func(ctx context.Context) (string, error) + taskStore TaskStore + taskLocker TaskLocker + queue EventQueue + pushNotifier PushNotifier +} + +func (s *A2AServer) getTask(ctx context.Context, input *models.TaskQueryParams) (*models.Task, error) { + err := s.taskLocker.Lock(ctx, input.ID) + if err != nil { + return nil, fmt.Errorf("failed to acquire lock for new task[%s]: %w", input.ID, err) + } + defer func() { + unlockErr := s.taskLocker.Unlock(ctx, input.ID) + if unlockErr != nil { + s.logger(ctx, "failed to release lock for task[%s]: %s", input.ID, unlockErr.Error()) + } + }() + + t, ok, err := s.taskStore.Get(ctx, input.ID) + if err != nil { + return nil, fmt.Errorf("failed to get task[%s]: %w", input.ID, err) + } + if !ok { + return nil, fmt.Errorf("task[%s] not found", input.ID) + } + if input.HistoryLength != nil && len(t.History) > *input.HistoryLength { + t.History = t.History[:*input.HistoryLength] + } + return t, nil +} + +func (s *A2AServer) cancelTask(ctx context.Context, input *models.TaskIDParams) (*models.Task, error) { + var err error + err = s.taskLocker.Lock(ctx, input.ID) + if err != nil { + return nil, fmt.Errorf("failed to acquire lock for new task[%s]: %w", input.ID, err) + } + defer func() { + unlockErr := s.taskLocker.Unlock(ctx, input.ID) + if unlockErr != nil { + s.logger(ctx, "failed to release lock for task[%s]: %s", input.ID, unlockErr.Error()) + } + }() + + t, ok, err := s.taskStore.Get(ctx, input.ID) + if err != nil { + return nil, fmt.Errorf("failed to get task[%s]: %w", input.ID, err) + } + if !ok { + return nil, fmt.Errorf("task[%s] not found", input.ID) + } + if t == nil { + return nil, fmt.Errorf("task[%s] is nil", input.ID) + } + + resp, err := s.cancelTaskHandler(ctx, &InputParams{ + Task: t, + Metadata: input.Metadata, + }) + if err != nil { + return nil, fmt.Errorf("failed to cancel task[%s]: %w", input.ID, err) + } + + t = loadTaskContext(t, resp) + + err = s.taskStore.Save(ctx, t) + if err != nil { + return nil, fmt.Errorf("failed to save canceled task: %w", err) + } + + return t, nil +} + +func (s *A2AServer) sendMessage(ctx context.Context, input *models.MessageSendParams) (*models.SendMessageResponseUnion, error) { + var err error + var t *models.Task + if input.Message.TaskID != nil { + err = s.taskLocker.Lock(ctx, *input.Message.TaskID) + if err != nil { + return nil, fmt.Errorf("failed to acquire lock for task[%s]: %s", *input.Message.TaskID, err) + } + defer func() { + unLockErr := s.taskLocker.Unlock(ctx, *input.Message.TaskID) + if unLockErr != nil { + s.logger(ctx, "failed to release lock for task[%s]: %s", *input.Message.TaskID, unLockErr.Error()) + } + }() + + var ok bool + t, ok, err = s.taskStore.Get(ctx, *input.Message.TaskID) + if err != nil { + return nil, fmt.Errorf("failed to get task[%s]: %s", *input.Message.TaskID, err) + } + if !ok { + return nil, fmt.Errorf("task[%s] not found", *input.Message.TaskID) + } + if t == nil { + return nil, fmt.Errorf("task[%s] is nil", *input.Message.TaskID) + } + } else { + taskID, err := s.taskIDGenerator(ctx) + if err != nil { + return nil, fmt.Errorf("failed to generate task id: %w", err) + } + t = s.initTask(ctx, taskID) + + err = s.taskLocker.Lock(ctx, t.ID) + if err != nil { + return nil, fmt.Errorf("failed to acquire lock for new task[%s]: %s", t.ID, err) + } + defer func() { + err = s.taskLocker.Unlock(ctx, t.ID) + if err != nil { + s.logger(ctx, "failed to release lock for task[%s]: %s", t.ID, err.Error()) + } + }() + } + + // register notification + if input.Configuration != nil && input.Configuration.PushNotificationConfig != nil && s.pushNotifier != nil { + err = s.pushNotifier.Set(ctx, &models.TaskPushNotificationConfig{ + TaskID: t.ID, + PushNotificationConfig: *input.Configuration.PushNotificationConfig, + }) + if err != nil { + return nil, fmt.Errorf("failed to set push notification config for task[%s]: %s", t.ID, err) + } + } + + resp, err := s.messageHandler(ctx, &InputParams{ + Task: t, + Input: &input.Message, + Metadata: input.Metadata, + }) + if err != nil { + return nil, err + } + resp.EnsureRequiredFields() + + t = loadTaskContext(t, resp) + err = s.taskStore.Save(ctx, t) + if err != nil { + return nil, fmt.Errorf("failed to save task: %w", err) + } + + frame := wrapSendMessageStreamingResponseUnion(models.ResponseEvent{TaskContent: resp}, t.ID, t.ContextID) + if s.pushNotifier != nil { + go func() { + defer func() { + e := recover() + if e != nil { + s.logger(ctx, "panic when send notification of task[%s]: %v", t.ID, utils.NewPanicErr(e, debug.Stack())) + } + }() + err = s.pushNotifier.SendNotification(ctx, frame) + if err != nil { + s.logger(ctx, "failed to push notification to notifier: %s", err) + } + }() + } + + return &models.SendMessageResponseUnion{ + Message: frame.Message, + Task: frame.Task, + }, nil +} + +func (s *A2AServer) sendMessageStreaming(ctx context.Context, input *models.MessageSendParams, writer models.ResponseWriter) error { + var err error + var t *models.Task + + needReleaseLock := false // need release lock after acquire and before start async execute + defer func() { + if needReleaseLock { + var tid string + if input.Message.TaskID != nil { + tid = *input.Message.TaskID + } else { + tid = t.ID + } + unlockErr := s.taskLocker.Unlock(ctx, tid) + if unlockErr != nil { + s.logger(ctx, "failed to release lock for task[%s]: %s", tid, unlockErr.Error()) + } + } + writer.Close() + }() + + if input.Message.TaskID != nil { + err = s.taskLocker.Lock(ctx, *input.Message.TaskID) + if err != nil { + return fmt.Errorf("failed to acquire lock for task[%s]: %w", *input.Message.TaskID, err) + } + needReleaseLock = true + + var ok bool + t, ok, err = s.taskStore.Get(ctx, *input.Message.TaskID) + if err != nil { + return fmt.Errorf("failed to get task[%s]: %w", *input.Message.TaskID, err) + } + if !ok { + return fmt.Errorf("task[%s] not found", *input.Message.TaskID) + } + if t == nil { + return fmt.Errorf("task[%s] is nil", *input.Message.TaskID) + } + } else { + taskID, err := s.taskIDGenerator(ctx) + if err != nil { + return fmt.Errorf("failed to generate task id: %w", err) + } + t = s.initTask(ctx, taskID) + + err = s.taskLocker.Lock(ctx, t.ID) + if err != nil { + return fmt.Errorf("failed to acquire lock for new task[%s]: %s", t.ID, err) + } + needReleaseLock = true + } + + err = s.queue.Reset(ctx, t.ID) + if err != nil { + return fmt.Errorf("failed to reset queue for new task[%s]: %w", t.ID, err) + } + + // register notification + if input.Configuration != nil && input.Configuration.PushNotificationConfig != nil && s.pushNotifier != nil { + err = s.pushNotifier.Set(ctx, &models.TaskPushNotificationConfig{ + TaskID: t.ID, + PushNotificationConfig: *input.Configuration.PushNotificationConfig, + }) + if err != nil { + return fmt.Errorf("failed to set push notification config for task[%s]: %w", t.ID, err) + } + } + + needReleaseLock = false // will release after execution + + go func() { + defer func() { + e := recover() + if e != nil { + panicErr := utils.NewPanicErr(e, debug.Stack()) + err = s.queue.Push(ctx, t.ID, nil, panicErr) + if err != nil { + s.logger(ctx, "failed to push panic event, task: %s, push error: %v, panic: %v", t.ID, err, panicErr) + } + } + + err = s.queue.Close(ctx, t.ID) + if err != nil { + s.logger(ctx, "failed to close queue for task[%s]: %s", t.ID, err.Error()) + } + err = s.taskLocker.Unlock(ctx, t.ID) + if err != nil { + s.logger(ctx, "failed to release lock for task[%s]: %s", t.ID, err.Error()) + } + }() + sr := &streamResponseWriter{ + taskID: t.ID, + contextID: t.ContextID, + ctx: ctx, + s: s, + } + err = s.messageStreamingHandler(ctx, &InputParams{ + Task: t, + Input: &input.Message, + Metadata: input.Metadata, + }, sr) + if err != nil { + pushErr := s.queue.Push(ctx, t.ID, nil, err) + if err != nil { + s.logger(ctx, "failed to push task[%s] error[%v] to queue: %v", t.ID, err, pushErr) + } + return + } + + tc := s.taskEventsConsolidator(ctx, t, sr.events) + t = loadTaskContext(t, tc) + + err = s.taskStore.Save(ctx, t) + if err != nil { + pushErr := s.queue.Push(ctx, t.ID, nil, err) + if err != nil { + s.logger(ctx, "failed to save task: %v, and failed to push task[%s] to queue: %v", err, t.ID, pushErr) + } + return + } + }() + + for { + resp, taskErr, closed, err := s.queue.Pop(ctx, t.ID) + if err != nil { + return fmt.Errorf("failed to pop task[%s]: %w", t.ID, err) + } + if closed { + return nil + } + if taskErr != nil { + return taskErr // agent execute error + } + err = writer.Write(ctx, resp) + if err != nil { + return fmt.Errorf("failed to send response of task[%s]: %w", t.ID, err) + } + } +} + +func buildMessageHandlerByStream(sh MessageStreamingHandler, tm TaskEventsConsolidator) MessageHandler { + return func(ctx context.Context, params *InputParams) (*models.TaskContent, error) { + localWriter := &localResponseEventWriter{ + mu: sync.Mutex{}, + events: make([]models.ResponseEvent, 0), + } + err := sh(ctx, params, localWriter) + if err != nil { + return nil, err + } + return tm(ctx, params.Task, localWriter.events), nil + } +} + +type localResponseEventWriter struct { + mu sync.Mutex + events []models.ResponseEvent +} + +func (w *localResponseEventWriter) Write(event models.ResponseEvent) error { + w.mu.Lock() + defer w.mu.Unlock() + w.events = append(w.events, event) + return nil +} + +type streamResponseWriter struct { + taskID string + contextID string + + ctx context.Context + s *A2AServer + events []models.ResponseEvent + mu sync.Mutex +} + +func (r *streamResponseWriter) Write(p models.ResponseEvent) (err error) { + r.mu.Lock() + defer func() { + r.mu.Unlock() + if err == nil { + r.events = append(r.events, p) + } + }() + (&p).EnsureRequiredFields() + chunk := wrapSendMessageStreamingResponseUnion(p, r.taskID, r.contextID) + + err = r.s.queue.Push(r.ctx, r.taskID, chunk, nil) + if err != nil { + return fmt.Errorf("failed to push chunk to queue: %s", err) + } + + if r.s.pushNotifier != nil { + go func() { + defer func() { + e := recover() + if e != nil { + r.s.logger(r.ctx, "panic when send notification of task[%s]: %v", r.taskID, utils.NewPanicErr(e, debug.Stack())) + } + }() + err = r.s.pushNotifier.SendNotification(r.ctx, chunk) + if err != nil { + r.s.logger(r.ctx, "failed to push notification to notifier: %s", err) + } + }() + } + + return nil +} + +func (s *A2AServer) resubscribeTask(ctx context.Context, input *models.TaskIDParams, writer models.ResponseWriter) error { + // events in queue can only be read once, so if multi clients subscribe one task, the events clients received will be incompleted. + defer writer.Close() + for { + resp, taskErr, closed, err := s.queue.Pop(ctx, input.ID) + if err != nil { + return fmt.Errorf("failed to pop task[%s]: %w", input.ID, err) + } + if closed { + return nil + } + if taskErr != nil { + return taskErr + } + err = writer.Write(ctx, resp) + if err != nil { + return fmt.Errorf("failed to send response: %w", err) + } + } +} + +func (s *A2AServer) setTasksPushNotificationConfig(ctx context.Context, input *models.TaskPushNotificationConfig) (*models.TaskPushNotificationConfig, error) { + return input, s.pushNotifier.Set(ctx, input) +} + +func (s *A2AServer) getTasksPushNotificationConfig(ctx context.Context, input *models.GetTaskPushNotificationConfigParams) (*models.TaskPushNotificationConfig, error) { + conf, existed, err := s.pushNotifier.Get(ctx, input.PushNotificationConfigID) + if err != nil { + return nil, fmt.Errorf("failed to get push notification config for task[%s]: %w", input.PushNotificationConfigID, err) + } + if !existed { + return nil, fmt.Errorf("task[%s] not found", input.PushNotificationConfigID) + } + return &models.TaskPushNotificationConfig{ + TaskID: input.PushNotificationConfigID, + PushNotificationConfig: conf, + }, nil +} + +func (s *A2AServer) initTask(_ context.Context, taskID string) *models.Task { + return &models.Task{ + ID: taskID, + Status: models.TaskStatus{ + State: models.TaskStateSubmitted, + Timestamp: time.Now().Format(time.RFC3339), + }, + History: []*models.Message{}, + } +} + +func wrapSendMessageResponseUnion(sr models.ResponseEvent, taskID, contextID string) *models.SendMessageResponseUnion { + u := wrapSendMessageStreamingResponseUnion(sr, taskID, contextID) + if u == nil { + return nil + } + return &models.SendMessageResponseUnion{ + Message: u.Message, + Task: u.Task, + } +} + +func wrapSendMessageStreamingResponseUnion(sr models.ResponseEvent, taskID, contextID string) *models.SendMessageStreamingResponseUnion { + if sr.Message != nil { + return &models.SendMessageStreamingResponseUnion{ + Message: &models.Message{ + TaskID: &taskID, + ContextID: &contextID, + MessageID: sr.Message.MessageID, + Role: sr.Message.Role, + Parts: sr.Message.Parts, + ReferenceTaskIDs: sr.Message.ReferenceTaskIDs, + Metadata: sr.Message.Metadata, + }} + } else if sr.TaskContent != nil { + return &models.SendMessageStreamingResponseUnion{ + Task: &models.Task{ + ID: taskID, + ContextID: contextID, + Status: sr.TaskContent.Status, + Artifacts: sr.TaskContent.Artifacts, + History: sr.TaskContent.History, + Metadata: sr.TaskContent.Metadata, + }, + } + } else if sr.TaskStatusUpdateEventContent != nil { + return &models.SendMessageStreamingResponseUnion{ + TaskStatusUpdateEvent: &models.TaskStatusUpdateEvent{ + TaskID: taskID, + ContextID: contextID, + Status: sr.TaskStatusUpdateEventContent.Status, + Final: sr.TaskStatusUpdateEventContent.Final, + Metadata: sr.TaskStatusUpdateEventContent.Metadata, + }, + } + } else if sr.TaskArtifactUpdateEventContent != nil { + return &models.SendMessageStreamingResponseUnion{ + TaskArtifactUpdateEvent: &models.TaskArtifactUpdateEvent{ + TaskID: taskID, + ContextID: contextID, + Artifact: sr.TaskArtifactUpdateEventContent.Artifact, + Append: sr.TaskArtifactUpdateEventContent.Append, + LastChunk: sr.TaskArtifactUpdateEventContent.LastChunk, + Metadata: sr.TaskArtifactUpdateEventContent.Metadata, + }, + } + } + return nil +} + +func loadTaskContext(t *models.Task, tc *models.TaskContent) *models.Task { + if t == nil { + return nil + } + if tc == nil { + return t + } + return &models.Task{ + ID: t.ID, + ContextID: t.ContextID, + Status: tc.Status, + Artifacts: tc.Artifacts, + History: tc.History, + Metadata: tc.Metadata, + } +} diff --git a/a2a/server/server_test.go b/a2a/server/server_test.go new file mode 100644 index 000000000..b6256d435 --- /dev/null +++ b/a2a/server/server_test.go @@ -0,0 +1,88 @@ +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/cloudwego/eino-ext/a2a/models" +) + +func TestMessageHandler(t *testing.T) { + ctx := context.Background() + r := &mockHandlerRegistrar{} + taskStore := newInMemoryTaskStore() + taskLocker := newInMemoryTaskLocker() + + text := "hello world" + inputMessage := &models.Message{ + Role: models.RoleUser, + Parts: []models.Part{{Kind: models.PartKindText, Text: &text}}, + } + inputMetadata := map[string]any{"1": "2"} + assert.NoError(t, RegisterHandlers(ctx, r, &Config{ + AgentCardConfig: AgentCardConfig{}, + MessageHandler: func(ctx context.Context, params *InputParams) (*models.TaskContent, error) { + assert.Equal(t, models.TaskStateSubmitted, params.Task.Status.State) + assert.Equal(t, inputMessage, params.Input) + assert.Equal(t, inputMetadata, params.Metadata) + return &models.TaskContent{ + Status: models.TaskStatus{State: models.TaskStateCompleted}, + History: []*models.Message{nil}, + Artifacts: []*models.Artifact{nil}, + }, nil + }, + TaskStore: taskStore, + TaskLocker: taskLocker, + })) + result, err := r.h.SendMessage(ctx, &models.MessageSendParams{ + Message: *inputMessage, + Metadata: inputMetadata, + }) + assert.NoError(t, err) + assert.Equal(t, models.TaskStateCompleted, result.Task.Status.State) + assert.Equal(t, 1, len(result.Task.History)) + assert.Equal(t, 1, len(result.Task.Artifacts)) + + task, existed, err := taskStore.Get(ctx, result.Task.ID) + assert.NoError(t, err) + assert.True(t, existed) + assert.Equal(t, models.TaskStateCompleted, task.Status.State) + assert.Equal(t, 1, len(task.History)) + assert.Equal(t, 1, len(task.Artifacts)) +} + +func TestWrapSendMessageStreamingResponseUnion(t *testing.T) { + taskID := "TaskID" + contextID := "ContextID" + union := wrapSendMessageStreamingResponseUnion(models.ResponseEvent{ + Message: &models.Message{}, + }, taskID, contextID) + assert.Equal(t, taskID, *union.Message.TaskID) + assert.Equal(t, contextID, *union.Message.ContextID) + union = wrapSendMessageStreamingResponseUnion(models.ResponseEvent{ + TaskContent: &models.TaskContent{}, + }, taskID, contextID) + assert.Equal(t, taskID, union.Task.ID) + assert.Equal(t, contextID, union.Task.ContextID) + union = wrapSendMessageStreamingResponseUnion(models.ResponseEvent{ + TaskStatusUpdateEventContent: &models.TaskStatusUpdateEventContent{}, + }, taskID, contextID) + assert.Equal(t, taskID, union.TaskStatusUpdateEvent.TaskID) + assert.Equal(t, contextID, union.TaskStatusUpdateEvent.ContextID) + union = wrapSendMessageStreamingResponseUnion(models.ResponseEvent{ + TaskArtifactUpdateEventContent: &models.TaskArtifactUpdateEventContent{}, + }, taskID, contextID) + assert.Equal(t, taskID, union.TaskArtifactUpdateEvent.TaskID) + assert.Equal(t, contextID, union.TaskArtifactUpdateEvent.ContextID) +} + +type mockHandlerRegistrar struct { + h *models.ServerHandlers +} + +func (m *mockHandlerRegistrar) Register(ctx context.Context, handlers *models.ServerHandlers) error { + m.h = handlers + return nil +} diff --git a/a2a/server/tasklocker.go b/a2a/server/tasklocker.go new file mode 100644 index 000000000..53519f616 --- /dev/null +++ b/a2a/server/tasklocker.go @@ -0,0 +1,41 @@ +package server + +import ( + "context" + "fmt" + "sync" +) + +type TaskLocker interface { + Lock(ctx context.Context, id string) error + Unlock(ctx context.Context, id string) error +} + +func newInMemoryTaskLocker() TaskLocker { + return &inMemoryTaskLocker{} +} + +type inMemoryTaskLocker struct { + mutexMap sync.Map +} + +func (i *inMemoryTaskLocker) Lock(ctx context.Context, id string) error { + mu, _ := i.mutexMap.LoadOrStore(id, &sync.Mutex{}) + mutex := mu.(*sync.Mutex) + + mutex.Lock() + + return nil +} + +func (i *inMemoryTaskLocker) Unlock(ctx context.Context, id string) error { + mu, ok := i.mutexMap.Load(id) + if !ok { + return fmt.Errorf("no lock found for task with id %s", id) + } + + mutex := mu.(*sync.Mutex) + mutex.Unlock() + + return nil +} diff --git a/a2a/server/taskstore.go b/a2a/server/taskstore.go new file mode 100644 index 000000000..5b3bc7553 --- /dev/null +++ b/a2a/server/taskstore.go @@ -0,0 +1,34 @@ +package server + +import ( + "context" + "sync" + + "github.com/cloudwego/eino-ext/a2a/models" +) + +type TaskStore interface { + Get(ctx context.Context, id string) (*models.Task, bool, error) + Save(ctx context.Context, task *models.Task) error +} + +func newInMemoryTaskStore() TaskStore { + return &inMemoryTaskStore{} +} + +type inMemoryTaskStore struct { + taskMap sync.Map +} + +func (i *inMemoryTaskStore) Get(ctx context.Context, id string) (*models.Task, bool, error) { + t, ok := i.taskMap.Load(id) + if !ok { + return nil, false, nil + } + return t.(*models.Task), true, nil +} + +func (i *inMemoryTaskStore) Save(ctx context.Context, task *models.Task) error { + i.taskMap.Store(task.ID, task) + return nil +} diff --git a/a2a/transport/jsonrpc/go.mod b/a2a/transport/jsonrpc/go.mod deleted file mode 100644 index deec154f9..000000000 --- a/a2a/transport/jsonrpc/go.mod +++ /dev/null @@ -1,33 +0,0 @@ -module github.com/cloudwego/eino-ext/a2a/transport/jsonrpc - -go 1.18 - -require ( - github.com/bytedance/sonic v1.14.0 - github.com/cloudwego/hertz v0.10.2 - github.com/google/uuid v1.6.0 - github.com/stretchr/testify v1.10.0 -) - -require ( - github.com/bytedance/gopkg v0.1.1 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect - github.com/cloudwego/base64x v0.1.5 // indirect - github.com/cloudwego/eino-ext/a2a v0.0.0-20250825034609-dbf35043464f // indirect - github.com/cloudwego/gopkg v0.1.4 // indirect - github.com/cloudwego/netpoll v0.7.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fsnotify/fsnotify v1.5.4 // indirect - github.com/golang/protobuf v1.5.0 // indirect - github.com/klauspost/cpuid/v2 v2.0.9 // indirect - github.com/nyaruka/phonenumbers v1.0.55 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/tidwall/gjson v1.14.4 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect - github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/sys v0.24.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/a2a/transport/jsonrpc/go.sum b/a2a/transport/jsonrpc/go.sum deleted file mode 100644 index fcade3516..000000000 --- a/a2a/transport/jsonrpc/go.sum +++ /dev/null @@ -1,125 +0,0 @@ -github.com/bytedance/gopkg v0.1.1 h1:3azzgSkiaw79u24a+w9arfH8OfnQQ4MHUt9lJFREEaE= -github.com/bytedance/gopkg v0.1.1/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= -github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/eino-ext/a2a v0.0.0-20250825034609-dbf35043464f h1:SpdPFIwDjeQQG+RH+mJWOIoooZxby0NebtI+fJV4r3A= -github.com/cloudwego/eino-ext/a2a v0.0.0-20250825034609-dbf35043464f/go.mod h1:z7Dpr4Td5Uh8ePjBaciMgWcJiM9QeNWAHLYY+kRBxWo= -github.com/cloudwego/gopkg v0.1.4 h1:EoQiCG4sTonTPHxOGE0VlQs+sQR+Hsi2uN0qqwu8O50= -github.com/cloudwego/gopkg v0.1.4/go.mod h1:FQuXsRWRsSqJLsMVd5SYzp8/Z1y5gXKnVvRrWUOsCMI= -github.com/cloudwego/hertz v0.10.2 h1:scaVn4E/AQ/vuMAC8FXzUzsEXS/TF1ix1I+4slPhh7c= -github.com/cloudwego/hertz v0.10.2/go.mod h1:W5dUFXZPZkyfjMMo3EQrMQbofuvTsctM9IxmhbkuT18= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= -github.com/cloudwego/netpoll v0.7.0 h1:bDrxQaNfijRI1zyGgXHQoE/nYegL0nr+ijO1Norelc4= -github.com/cloudwego/netpoll v0.7.0/go.mod h1:PI+YrmyS7cIr0+SD4seJz3Eo3ckkXdu2ZVKBLhURLNU= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= -github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/nyaruka/phonenumbers v1.0.55 h1:bj0nTO88Y68KeUQ/n3Lo2KgK7lM1hF7L9NFuwcCl3yg= -github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= -github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= -github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/a2a/utils/panic.go b/a2a/utils/panic.go new file mode 100644 index 000000000..0994d48dd --- /dev/null +++ b/a2a/utils/panic.go @@ -0,0 +1,24 @@ +package utils + +import ( + "fmt" +) + +type panicErr struct { + info any + stack []byte +} + +func (p *panicErr) Error() string { + return fmt.Sprintf("panic error: %v, \nstack: %s", p.info, string(p.stack)) +} + +// NewPanicErr creates a new panic error. +// panicErr is a wrapper of panic info and stack trace. +// it implements the error interface, can print error message of info and stack trace. +func NewPanicErr(info any, stack []byte) error { + return &panicErr{ + info: info, + stack: stack, + } +} From 396d031ff044771aee25c4794ad9cb4968c99041 Mon Sep 17 00:00:00 2001 From: Megumin Date: Mon, 25 Aug 2025 17:28:10 +0800 Subject: [PATCH 07/18] feat: add license header --- a2a/client/client.go | 16 ++++++++++++++++ a2a/client/client_test.go | 16 ++++++++++++++++ a2a/examples/client/client.go | 16 ++++++++++++++++ a2a/examples/server/server.go | 16 ++++++++++++++++ a2a/extension/eino/client.go | 16 ++++++++++++++++ a2a/extension/eino/examples/client/client.go | 16 ++++++++++++++++ a2a/extension/eino/examples/server/server.go | 16 ++++++++++++++++ a2a/extension/eino/message_extra.go | 16 ++++++++++++++++ a2a/extension/eino/metadata.go | 16 ++++++++++++++++ a2a/extension/eino/server.go | 16 ++++++++++++++++ a2a/extension/eino/utils.go | 16 ++++++++++++++++ a2a/models/artifact.go | 16 ++++++++++++++++ a2a/models/auth.go | 16 ++++++++++++++++ a2a/models/card.go | 16 ++++++++++++++++ a2a/models/handler.go | 16 ++++++++++++++++ a2a/models/message.go | 16 ++++++++++++++++ a2a/models/notification.go | 16 ++++++++++++++++ a2a/models/part.go | 16 ++++++++++++++++ a2a/models/task.go | 16 ++++++++++++++++ a2a/models/task_test.go | 16 ++++++++++++++++ a2a/server/eventqueue.go | 16 ++++++++++++++++ a2a/server/eventqueue_test.go | 16 ++++++++++++++++ a2a/server/notifier.go | 16 ++++++++++++++++ a2a/server/server.go | 16 ++++++++++++++++ a2a/server/server_test.go | 16 ++++++++++++++++ a2a/server/tasklocker.go | 16 ++++++++++++++++ a2a/server/taskstore.go | 16 ++++++++++++++++ a2a/transport/jsonrpc/client.go | 16 ++++++++++++++++ a2a/transport/jsonrpc/server.go | 16 ++++++++++++++++ a2a/transport/transport.go | 16 ++++++++++++++++ a2a/utils/panic.go | 16 ++++++++++++++++ 31 files changed, 496 insertions(+) diff --git a/a2a/client/client.go b/a2a/client/client.go index 07bc9f3bc..1b149825f 100644 --- a/a2a/client/client.go +++ b/a2a/client/client.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 client import ( diff --git a/a2a/client/client_test.go b/a2a/client/client_test.go index da13c8ef3..a7ac214d2 100644 --- a/a2a/client/client_test.go +++ b/a2a/client/client_test.go @@ -1 +1,17 @@ +/* + * Copyright 2025 CloudWeGo 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 client diff --git a/a2a/examples/client/client.go b/a2a/examples/client/client.go index 1a2c29509..c8d25e99c 100644 --- a/a2a/examples/client/client.go +++ b/a2a/examples/client/client.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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/a2a/examples/server/server.go b/a2a/examples/server/server.go index f37f065fd..49d8ecbf0 100644 --- a/a2a/examples/server/server.go +++ b/a2a/examples/server/server.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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/a2a/extension/eino/client.go b/a2a/extension/eino/client.go index a33bfde31..99fa321b3 100644 --- a/a2a/extension/eino/client.go +++ b/a2a/extension/eino/client.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 eino import ( diff --git a/a2a/extension/eino/examples/client/client.go b/a2a/extension/eino/examples/client/client.go index f99f329be..ebe470d9b 100644 --- a/a2a/extension/eino/examples/client/client.go +++ b/a2a/extension/eino/examples/client/client.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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/a2a/extension/eino/examples/server/server.go b/a2a/extension/eino/examples/server/server.go index 01dde1f88..39a376870 100644 --- a/a2a/extension/eino/examples/server/server.go +++ b/a2a/extension/eino/examples/server/server.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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/a2a/extension/eino/message_extra.go b/a2a/extension/eino/message_extra.go index 84ddb0a26..0382fddf5 100644 --- a/a2a/extension/eino/message_extra.go +++ b/a2a/extension/eino/message_extra.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 eino import ( diff --git a/a2a/extension/eino/metadata.go b/a2a/extension/eino/metadata.go index 716dabbbe..a96f7b8da 100644 --- a/a2a/extension/eino/metadata.go +++ b/a2a/extension/eino/metadata.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 eino const ( diff --git a/a2a/extension/eino/server.go b/a2a/extension/eino/server.go index 941b17683..bd10cc657 100644 --- a/a2a/extension/eino/server.go +++ b/a2a/extension/eino/server.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 eino import ( diff --git a/a2a/extension/eino/utils.go b/a2a/extension/eino/utils.go index 68b64085f..f073febfc 100644 --- a/a2a/extension/eino/utils.go +++ b/a2a/extension/eino/utils.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 eino import ( diff --git a/a2a/models/artifact.go b/a2a/models/artifact.go index c5951b05a..3aa3cfb9e 100644 --- a/a2a/models/artifact.go +++ b/a2a/models/artifact.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 models // Artifact represents an output or intermediate file from a task diff --git a/a2a/models/auth.go b/a2a/models/auth.go index 3404fd64a..f657fe3ca 100644 --- a/a2a/models/auth.go +++ b/a2a/models/auth.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 models // AuthenticationInfo defines the authentication schemes and credentials for an agent diff --git a/a2a/models/card.go b/a2a/models/card.go index 57fe78c78..fae691350 100644 --- a/a2a/models/card.go +++ b/a2a/models/card.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 models import ( diff --git a/a2a/models/handler.go b/a2a/models/handler.go index 2aac2fa09..ba24d79cf 100644 --- a/a2a/models/handler.go +++ b/a2a/models/handler.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 models import "context" diff --git a/a2a/models/message.go b/a2a/models/message.go index d5e8898db..4f2a8485a 100644 --- a/a2a/models/message.go +++ b/a2a/models/message.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 models type Role string diff --git a/a2a/models/notification.go b/a2a/models/notification.go index b688ed39d..3f302cd2f 100644 --- a/a2a/models/notification.go +++ b/a2a/models/notification.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 models type PushNotificationConfig struct { diff --git a/a2a/models/part.go b/a2a/models/part.go index b6e22f9a5..10fa8dee7 100644 --- a/a2a/models/part.go +++ b/a2a/models/part.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 models type PartKind string diff --git a/a2a/models/task.go b/a2a/models/task.go index 36c68c03a..1f29a1480 100644 --- a/a2a/models/task.go +++ b/a2a/models/task.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 models type ResponseKind string diff --git a/a2a/models/task_test.go b/a2a/models/task_test.go index 161514e77..e88abd212 100644 --- a/a2a/models/task_test.go +++ b/a2a/models/task_test.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 models import ( diff --git a/a2a/server/eventqueue.go b/a2a/server/eventqueue.go index 4a949ce59..08f454326 100644 --- a/a2a/server/eventqueue.go +++ b/a2a/server/eventqueue.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 server import ( diff --git a/a2a/server/eventqueue_test.go b/a2a/server/eventqueue_test.go index 116a18e6a..eda394748 100644 --- a/a2a/server/eventqueue_test.go +++ b/a2a/server/eventqueue_test.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 server import ( diff --git a/a2a/server/notifier.go b/a2a/server/notifier.go index a1198f4ea..5b8cfc2b4 100644 --- a/a2a/server/notifier.go +++ b/a2a/server/notifier.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 server import ( diff --git a/a2a/server/server.go b/a2a/server/server.go index 621861434..9f07e804a 100644 --- a/a2a/server/server.go +++ b/a2a/server/server.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 server import ( diff --git a/a2a/server/server_test.go b/a2a/server/server_test.go index b6256d435..3ba369abd 100644 --- a/a2a/server/server_test.go +++ b/a2a/server/server_test.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 server import ( diff --git a/a2a/server/tasklocker.go b/a2a/server/tasklocker.go index 53519f616..79e76b398 100644 --- a/a2a/server/tasklocker.go +++ b/a2a/server/tasklocker.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 server import ( diff --git a/a2a/server/taskstore.go b/a2a/server/taskstore.go index 5b3bc7553..8b5a80c49 100644 --- a/a2a/server/taskstore.go +++ b/a2a/server/taskstore.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 server import ( diff --git a/a2a/transport/jsonrpc/client.go b/a2a/transport/jsonrpc/client.go index 8e6bc777b..bb5f9dde9 100644 --- a/a2a/transport/jsonrpc/client.go +++ b/a2a/transport/jsonrpc/client.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 jsonrpc import ( diff --git a/a2a/transport/jsonrpc/server.go b/a2a/transport/jsonrpc/server.go index 5807461cb..c87ee6c6e 100644 --- a/a2a/transport/jsonrpc/server.go +++ b/a2a/transport/jsonrpc/server.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 jsonrpc import ( diff --git a/a2a/transport/transport.go b/a2a/transport/transport.go index e38eb930e..399c95d78 100644 --- a/a2a/transport/transport.go +++ b/a2a/transport/transport.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 transport import ( diff --git a/a2a/utils/panic.go b/a2a/utils/panic.go index 0994d48dd..b6b466a7c 100644 --- a/a2a/utils/panic.go +++ b/a2a/utils/panic.go @@ -1,3 +1,19 @@ +/* + * Copyright 2025 CloudWeGo 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 utils import ( From c2538f961cfd024e2e85cf2b7c7cdf9ee884ef7b Mon Sep 17 00:00:00 2001 From: Megumin Date: Mon, 25 Aug 2025 17:29:50 +0800 Subject: [PATCH 08/18] typo --- a2a/examples/server/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/a2a/examples/server/server.go b/a2a/examples/server/server.go index 49d8ecbf0..33ebce1df 100644 --- a/a2a/examples/server/server.go +++ b/a2a/examples/server/server.go @@ -90,7 +90,7 @@ func main() { Parts: []models.Part{ { Kind: models.PartKindText, - Text: ptrOf(fmt.Sprintf("task messaage %d", i)), + Text: ptrOf(fmt.Sprintf("task message %d", i)), }, }, }, @@ -116,7 +116,7 @@ func main() { Parts: []models.Part{ { Kind: models.PartKindText, - Text: ptrOf(fmt.Sprintf("status update messaage %d", i)), + Text: ptrOf(fmt.Sprintf("status update message %d", i)), }, }, }, From 000751f29acacee314e95b3005d2b7ca7306c78c Mon Sep 17 00:00:00 2001 From: Megumin Date: Mon, 25 Aug 2025 17:40:22 +0800 Subject: [PATCH 09/18] fix: log entries created from user input --- a2a/examples/client/client.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/a2a/examples/client/client.go b/a2a/examples/client/client.go index c8d25e99c..f1ddb9183 100644 --- a/a2a/examples/client/client.go +++ b/a2a/examples/client/client.go @@ -23,6 +23,7 @@ import ( "io" "log" "net/http" + "strings" "sync" "time" @@ -303,7 +304,9 @@ func startNotificationServer(wg *sync.WaitGroup) { return } - fmt.Printf("Received POST request body:\n%s\n", string(body)) + safeBody := strings.ReplaceAll(string(body), "\n", "") + safeBody = strings.ReplaceAll(safeBody, "\r", "") + fmt.Printf("Received POST request body:\n%s\n", safeBody) w.WriteHeader(http.StatusOK) w.Write([]byte("Request body received")) From ce83a04f46c2d98cd7067f402938f106169fd04e Mon Sep 17 00:00:00 2001 From: Megumin Date: Mon, 25 Aug 2025 19:58:35 +0800 Subject: [PATCH 10/18] ut --- a2a/server/server.go | 11 --- a2a/server/server_test.go | 118 ++++++++++++++++++++++++++++++++ a2a/transport/jsonrpc/server.go | 4 +- 3 files changed, 120 insertions(+), 13 deletions(-) diff --git a/a2a/server/server.go b/a2a/server/server.go index 9f07e804a..b42b052f6 100644 --- a/a2a/server/server.go +++ b/a2a/server/server.go @@ -637,17 +637,6 @@ func (s *A2AServer) initTask(_ context.Context, taskID string) *models.Task { } } -func wrapSendMessageResponseUnion(sr models.ResponseEvent, taskID, contextID string) *models.SendMessageResponseUnion { - u := wrapSendMessageStreamingResponseUnion(sr, taskID, contextID) - if u == nil { - return nil - } - return &models.SendMessageResponseUnion{ - Message: u.Message, - Task: u.Task, - } -} - func wrapSendMessageStreamingResponseUnion(sr models.ResponseEvent, taskID, contextID string) *models.SendMessageStreamingResponseUnion { if sr.Message != nil { return &models.SendMessageStreamingResponseUnion{ diff --git a/a2a/server/server_test.go b/a2a/server/server_test.go index 3ba369abd..e67e5e9a1 100644 --- a/a2a/server/server_test.go +++ b/a2a/server/server_test.go @@ -69,6 +69,111 @@ func TestMessageHandler(t *testing.T) { assert.Equal(t, 1, len(task.Artifacts)) } +func TestStreamingMessageHandler(t *testing.T) { + ctx := context.Background() + r := &mockHandlerRegistrar{} + taskStore := newInMemoryTaskStore() + taskLocker := newInMemoryTaskLocker() + + text := "hello world" + inputMessage := &models.Message{ + Role: models.RoleUser, + Parts: []models.Part{{Kind: models.PartKindText, Text: &text}}, + } + inputMetadata := map[string]any{"1": "2"} + assert.NoError(t, RegisterHandlers(ctx, r, &Config{ + AgentCardConfig: AgentCardConfig{}, + MessageStreamingHandler: func(ctx context.Context, params *InputParams, writer ResponseEventWriter) error { + assert.Equal(t, models.TaskStateSubmitted, params.Task.Status.State) + assert.Equal(t, inputMessage, params.Input) + assert.Equal(t, inputMetadata, params.Metadata) + if err := writer.Write(models.ResponseEvent{ + TaskContent: &models.TaskContent{Status: models.TaskStatus{State: models.TaskStateWorking}}, + }); err != nil { + return err + } + if err := writer.Write(models.ResponseEvent{ + Message: &models.Message{ + MessageID: "test message id", + }, + }); err != nil { + return err + } + if err := writer.Write(models.ResponseEvent{ + TaskStatusUpdateEventContent: &models.TaskStatusUpdateEventContent{ + Status: models.TaskStatus{State: models.TaskStateCompleted}, + }, + }); err != nil { + return err + } + if err := writer.Write(models.ResponseEvent{ + TaskArtifactUpdateEventContent: &models.TaskArtifactUpdateEventContent{ + Artifact: models.Artifact{ + ArtifactID: "test artifact id", + }, + }, + }); err != nil { + return err + } + return nil + }, + TaskEventsConsolidator: func(ctx context.Context, t *models.Task, events []models.ResponseEvent) *models.TaskContent { + tc := &models.TaskContent{ + Status: t.Status, + Artifacts: t.Artifacts, + History: t.History, + Metadata: t.Metadata, + } + for _, event := range events { + if event.Message != nil { + tc.History = append(tc.History, event.Message) + } else if event.TaskContent != nil { + tc.Status = event.TaskContent.Status + tc.Artifacts = event.TaskContent.Artifacts + tc.History = event.TaskContent.History + tc.Metadata = event.TaskContent.Metadata + } else if event.TaskStatusUpdateEventContent != nil { + tc.Status = event.TaskStatusUpdateEventContent.Status + } else if event.TaskArtifactUpdateEventContent != nil { + tc.Artifacts = append(tc.Artifacts, &event.TaskArtifactUpdateEventContent.Artifact) + } + } + return tc + }, + TaskStore: taskStore, + TaskLocker: taskLocker, + })) + + // build message handler by streaming + result, err := r.h.SendMessage(ctx, &models.MessageSendParams{ + Message: *inputMessage, + Metadata: inputMetadata, + }) + assert.NoError(t, err) + assert.Equal(t, models.TaskStateCompleted, result.Task.Status.State) + assert.Equal(t, 1, len(result.Task.History)) + assert.Equal(t, 1, len(result.Task.Artifacts)) + + task, existed, err := taskStore.Get(ctx, result.Task.ID) + assert.NoError(t, err) + assert.True(t, existed) + assert.Equal(t, models.TaskStateCompleted, task.Status.State) + assert.Equal(t, 1, len(task.History)) + assert.Equal(t, 1, len(task.Artifacts)) + + // streaming + writer := &mockWriter{} + err = r.h.SendMessageStreaming(ctx, &models.MessageSendParams{Message: *inputMessage, Metadata: inputMetadata}, writer) + assert.NoError(t, err) + assert.Equal(t, 4, len(writer.unions)) + task, existed, err = taskStore.Get(ctx, writer.unions[0].GetTaskID()) + assert.NoError(t, err) + assert.True(t, existed) + assert.Equal(t, models.TaskStateCompleted, task.Status.State) + assert.Equal(t, 1, len(task.History)) + assert.Equal(t, 1, len(task.Artifacts)) +} + func TestWrapSendMessageStreamingResponseUnion(t *testing.T) { taskID := "TaskID" contextID := "ContextID" @@ -102,3 +207,16 @@ func (m *mockHandlerRegistrar) Register(ctx context.Context, handlers *models.Se m.h = handlers return nil } + +type mockWriter struct { + unions []*models.SendMessageStreamingResponseUnion +} + +func (m *mockWriter) Write(ctx context.Context, f *models.SendMessageStreamingResponseUnion) error { + m.unions = append(m.unions, f) + return nil +} + +func (m *mockWriter) Close() error { + return nil +} diff --git a/a2a/transport/jsonrpc/server.go b/a2a/transport/jsonrpc/server.go index c87ee6c6e..0059ad927 100644 --- a/a2a/transport/jsonrpc/server.go +++ b/a2a/transport/jsonrpc/server.go @@ -23,11 +23,11 @@ import ( "fmt" "net/http" - "github.com/cloudwego/eino-ext/a2a/models" - "github.com/cloudwego/eino-ext/a2a/transport" "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/route" + "github.com/cloudwego/eino-ext/a2a/models" + "github.com/cloudwego/eino-ext/a2a/transport" "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" jsonrpc_http "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/transport/http" "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/server" From 1a2be03111d951ee51ec53af2b1e58ca7f15ae09 Mon Sep 17 00:00:00 2001 From: Megumin Date: Tue, 26 Aug 2025 16:15:59 +0800 Subject: [PATCH 11/18] feat: support context id generator --- a2a/server/server.go | 45 +++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/a2a/server/server.go b/a2a/server/server.go index b42b052f6..f602ff72b 100644 --- a/a2a/server/server.go +++ b/a2a/server/server.go @@ -58,7 +58,8 @@ type Config struct { MessageStreamingHandler MessageStreamingHandler // optional, uuid by default - TaskIDGenerator func(ctx context.Context) (string, error) + TaskIDGenerator func(ctx context.Context) (string, error) + ContextIDGenerator func(ctx context.Context) (string, error) // required CancelTaskHandler CancelTaskHandler // required @@ -110,6 +111,11 @@ func RegisterHandlers(ctx context.Context, registrar transport.HandlerRegistrar, return uuid.NewString(), nil } } + if s.contextIDGenerator == nil { + s.contextIDGenerator = func(_ context.Context) (string, error) { + return uuid.NewString(), nil + } + } if s.taskStore == nil { s.taskStore = newInMemoryTaskStore() } @@ -171,6 +177,7 @@ func initA2AServer(config *Config) *A2AServer { taskEventsConsolidator: config.TaskEventsConsolidator, logger: config.Logger, taskIDGenerator: config.TaskIDGenerator, + contextIDGenerator: config.ContextIDGenerator, taskStore: config.TaskStore, taskLocker: config.TaskLocker, queue: config.Queue, @@ -208,11 +215,12 @@ type A2AServer struct { logger Logger - taskIDGenerator func(ctx context.Context) (string, error) - taskStore TaskStore - taskLocker TaskLocker - queue EventQueue - pushNotifier PushNotifier + taskIDGenerator func(ctx context.Context) (string, error) + contextIDGenerator func(ctx context.Context) (string, error) + taskStore TaskStore + taskLocker TaskLocker + queue EventQueue + pushNotifier PushNotifier } func (s *A2AServer) getTask(ctx context.Context, input *models.TaskQueryParams) (*models.Task, error) { @@ -309,11 +317,10 @@ func (s *A2AServer) sendMessage(ctx context.Context, input *models.MessageSendPa return nil, fmt.Errorf("task[%s] is nil", *input.Message.TaskID) } } else { - taskID, err := s.taskIDGenerator(ctx) + t, err = s.initTask(ctx) if err != nil { - return nil, fmt.Errorf("failed to generate task id: %w", err) + return nil, err } - t = s.initTask(ctx, taskID) err = s.taskLocker.Lock(ctx, t.ID) if err != nil { @@ -416,11 +423,10 @@ func (s *A2AServer) sendMessageStreaming(ctx context.Context, input *models.Mess return fmt.Errorf("task[%s] is nil", *input.Message.TaskID) } } else { - taskID, err := s.taskIDGenerator(ctx) + t, err = s.initTask(ctx) if err != nil { - return fmt.Errorf("failed to generate task id: %w", err) + return err } - t = s.initTask(ctx, taskID) err = s.taskLocker.Lock(ctx, t.ID) if err != nil { @@ -626,15 +632,24 @@ func (s *A2AServer) getTasksPushNotificationConfig(ctx context.Context, input *m }, nil } -func (s *A2AServer) initTask(_ context.Context, taskID string) *models.Task { +func (s *A2AServer) initTask(ctx context.Context) (*models.Task, error) { + taskID, err := s.taskIDGenerator(ctx) + if err != nil { + return nil, fmt.Errorf("failed to generate task id: %w", err) + } + contextID, err := s.contextIDGenerator(ctx) + if err != nil { + return nil, fmt.Errorf("failed to generate context id: %w", err) + } return &models.Task{ - ID: taskID, + ID: taskID, + ContextID: contextID, Status: models.TaskStatus{ State: models.TaskStateSubmitted, Timestamp: time.Now().Format(time.RFC3339), }, History: []*models.Message{}, - } + }, nil } func wrapSendMessageStreamingResponseUnion(sr models.ResponseEvent, taskID, contextID string) *models.SendMessageStreamingResponseUnion { From 2ea2a2ce8d4557f71af1b056dc4872cd44e47049 Mon Sep 17 00:00:00 2001 From: Megumin Date: Tue, 26 Aug 2025 20:58:31 +0800 Subject: [PATCH 12/18] fix(jsonrpc): sse receive cancel (#424) --- a2a/transport/jsonrpc/pkg/transport/http/client.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/a2a/transport/jsonrpc/pkg/transport/http/client.go b/a2a/transport/jsonrpc/pkg/transport/http/client.go index b0e3b3274..0c2ecba08 100644 --- a/a2a/transport/jsonrpc/pkg/transport/http/client.go +++ b/a2a/transport/jsonrpc/pkg/transport/http/client.go @@ -138,6 +138,7 @@ func (c *clientRounder) Round(ctx context.Context, msg core.Message) (core.Messa r.SetMaxBufferSize(*c.sseBufSize) } sr := &sseReader{ + ctx: ctx, reader: r, buf: utils.NewUnboundBuffer[sseData](), } @@ -175,6 +176,7 @@ func (r *pingPongReader) Close() error { } type sseReader struct { + ctx context.Context reader *sse.Reader buf *utils.UnboundBuffer[sseData] err error @@ -187,7 +189,7 @@ type sseData struct { } func (s *sseReader) run() { - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(s.ctx) s.cancelFunc = cancel go func() { err := s.reader.ForEach(ctx, func(e *sse.Event) error { From 4054694c1ab46cbcce759eb5d949d8818cbd6d58 Mon Sep 17 00:00:00 2001 From: Megumin Date: Wed, 27 Aug 2025 21:18:04 +0800 Subject: [PATCH 13/18] feat(a2a): consolidator support handle error (#427) --- a2a/examples/server/server.go | 2 +- a2a/extension/eino/server.go | 23 +---------------------- a2a/server/server.go | 10 +++------- 3 files changed, 5 insertions(+), 30 deletions(-) diff --git a/a2a/examples/server/server.go b/a2a/examples/server/server.go index 33ebce1df..bed2b31b8 100644 --- a/a2a/examples/server/server.go +++ b/a2a/examples/server/server.go @@ -158,7 +158,7 @@ func main() { hz.Run() } -func myTaskModifier(ctx context.Context, t *models.Task, events []models.ResponseEvent) *models.TaskContent { +func myTaskModifier(ctx context.Context, t *models.Task, events []models.ResponseEvent, _ error) *models.TaskContent { result := &models.TaskContent{ Status: t.Status, Artifacts: make([]*models.Artifact, len(t.Artifacts)), diff --git a/a2a/extension/eino/server.go b/a2a/extension/eino/server.go index bd10cc657..01beb85eb 100644 --- a/a2a/extension/eino/server.go +++ b/a2a/extension/eino/server.go @@ -150,7 +150,6 @@ func RegisterServerHandlers(ctx context.Context, a adk.Agent, cfg *ServerConfig) DefaultOutputModes: cfg.DefaultOutputModes, Skills: cfg.Skills, }, - MessageHandler: builder.buildHandler(), MessageStreamingHandler: builder.buildStreamHandler(), TaskIDGenerator: cfg.TaskIDGenerator, CancelTaskHandler: builder.buildTaskCanceler(), @@ -172,26 +171,6 @@ type a2aHandlersBuilder struct { eventConvertor func(ctx context.Context, event *adk.AsyncIterator[*adk.AgentEvent], writer func(p models.ResponseEvent) error) (err error) } -func (a *a2aHandlersBuilder) buildHandler() server.MessageHandler { - return func(ctx context.Context, params *server.InputParams) (*models.TaskContent, error) { - iter, err := a.genIter(ctx, params.Task, params.Input, params.Metadata) - if err != nil { - return nil, err - } - - var responseEvents []models.ResponseEvent - err = a.eventConvertor( - ctx, - iter, - func(p models.ResponseEvent) error { - responseEvents = append(responseEvents, p) - return nil - }) - - return a.einoResponseEventConcatenator(ctx, params.Task, responseEvents), nil - } -} - func (a *a2aHandlersBuilder) buildStreamHandler() server.MessageStreamingHandler { return func(ctx context.Context, params *server.InputParams, writer server.ResponseEventWriter) error { iter, err := a.genIter(ctx, params.Task, params.Input, params.Metadata) @@ -414,7 +393,7 @@ func (d *defaultEventConvertor) messageVar2Status(ctx context.Context, agentName return nil } -func (a *a2aHandlersBuilder) einoResponseEventConcatenator(ctx context.Context, t *models.Task, events []models.ResponseEvent) *models.TaskContent { +func (a *a2aHandlersBuilder) einoResponseEventConcatenator(ctx context.Context, t *models.Task, events []models.ResponseEvent, _ error) *models.TaskContent { tc := &models.TaskContent{ Status: t.Status, Artifacts: t.Artifacts, diff --git a/a2a/server/server.go b/a2a/server/server.go index f602ff72b..9c39da3de 100644 --- a/a2a/server/server.go +++ b/a2a/server/server.go @@ -42,7 +42,7 @@ type InputParams struct { type MessageHandler func(ctx context.Context, params *InputParams) (*models.TaskContent, error) type MessageStreamingHandler func(ctx context.Context, params *InputParams, writer ResponseEventWriter) error type CancelTaskHandler func(ctx context.Context, params *InputParams) (*models.TaskContent, error) -type TaskEventsConsolidator func(ctx context.Context, t *models.Task, events []models.ResponseEvent) *models.TaskContent +type TaskEventsConsolidator func(ctx context.Context, t *models.Task, events []models.ResponseEvent, handleErr error) *models.TaskContent type Logger func(ctx context.Context, format string, v ...any) type ResponseEventWriter interface { @@ -489,10 +489,9 @@ func (s *A2AServer) sendMessageStreaming(ctx context.Context, input *models.Mess if err != nil { s.logger(ctx, "failed to push task[%s] error[%v] to queue: %v", t.ID, err, pushErr) } - return } - tc := s.taskEventsConsolidator(ctx, t, sr.events) + tc := s.taskEventsConsolidator(ctx, t, sr.events, err) t = loadTaskContext(t, tc) err = s.taskStore.Save(ctx, t) @@ -530,10 +529,7 @@ func buildMessageHandlerByStream(sh MessageStreamingHandler, tm TaskEventsConsol events: make([]models.ResponseEvent, 0), } err := sh(ctx, params, localWriter) - if err != nil { - return nil, err - } - return tm(ctx, params.Task, localWriter.events), nil + return tm(ctx, params.Task, localWriter.events, err), nil } } From d928e5238ff9ef1f9379c66b7c3b1b767fe23508 Mon Sep 17 00:00:00 2001 From: Megumin Date: Thu, 28 Aug 2025 21:33:33 +0800 Subject: [PATCH 14/18] fix: set final field omit (#430) --- a2a/models/task.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/a2a/models/task.go b/a2a/models/task.go index 1f29a1480..cf0533bce 100644 --- a/a2a/models/task.go +++ b/a2a/models/task.go @@ -146,7 +146,7 @@ type TaskStatusUpdateEvent struct { // and the server does not expect to send more updates for *this specific* `stream` request. // The server typically closes the SSE connection after sending an event with `final: true`. // Default: `false` if omitted. - Final bool `json:"final,omitempty"` + Final bool `json:"final"` // Arbitrary metadata for this specific status update event. Metadata map[string]any `json:"metadata,omitempty"` } @@ -161,7 +161,7 @@ type TaskStatusUpdateEventContent struct { // and the server does not expect to send more updates for *this specific* `stream` request. // The server typically closes the SSE connection after sending an event with `final: true`. // Default: `false` if omitted. - Final bool `json:"final,omitempty"` + Final bool `json:"final"` // Arbitrary metadata for this specific status update event. Metadata map[string]any `json:"metadata,omitempty"` } From 5a0a98dae2473c9d0ff94c51575783c12536b04a Mon Sep 17 00:00:00 2001 From: Scout Wang Date: Mon, 1 Sep 2025 10:37:57 +0800 Subject: [PATCH 15/18] fix(a2a): do not listen on Hertz handler ctx.Done to avoid panic (#432) --- .../jsonrpc/pkg/transport/http/server.go | 12 +- .../pkg/transport/http/transport_test.go | 132 ++++++++++++++++++ 2 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 a2a/transport/jsonrpc/pkg/transport/http/transport_test.go diff --git a/a2a/transport/jsonrpc/pkg/transport/http/server.go b/a2a/transport/jsonrpc/pkg/transport/http/server.go index e66ca631d..99b8c86cb 100644 --- a/a2a/transport/jsonrpc/pkg/transport/http/server.go +++ b/a2a/transport/jsonrpc/pkg/transport/http/server.go @@ -18,7 +18,6 @@ package http import ( "context" - "fmt" "strconv" "sync" @@ -76,7 +75,8 @@ type ServerTransportBuilder struct { func (s *ServerTransportBuilder) Build(ctx context.Context, hdl transport.ServerTransportHandler) (transport.ServerTransport, error) { hz := s.hzIns if hz == nil { - hz = hz_server.Default() + // sense the disconnection by default + hz = hz_server.Default(hz_server.WithSenseClientDisconnection(true)) } hz.POST(s.path, s.POST) s.hdl = hdl @@ -95,12 +95,8 @@ func (s *ServerTransportBuilder) POST(c context.Context, ctx *app.RequestContext ctx.VisitAllHeaders(func(key, val []byte) { c = metadata.WithValue(c, string(key), string(val)) }) - finishCh := s.rounder.newRound(c, msg, ctx) - select { - case <-c.Done(): - fmt.Println("connection closed") - case <-finishCh: - } + // wait for handler return + <-s.rounder.newRound(c, msg, ctx) } type server struct { diff --git a/a2a/transport/jsonrpc/pkg/transport/http/transport_test.go b/a2a/transport/jsonrpc/pkg/transport/http/transport_test.go new file mode 100644 index 000000000..9940a2d62 --- /dev/null +++ b/a2a/transport/jsonrpc/pkg/transport/http/transport_test.go @@ -0,0 +1,132 @@ +/* + * Copyright 2025 CloudWeGo 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 http + +import ( + "context" + "sync" + "testing" + + "github.com/bytedance/sonic" + "github.com/stretchr/testify/assert" + + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/core" + "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc/pkg/conninfo" +) + +type mockRequest struct { + Msg string `json:"msg"` +} + +type mockResponse struct { + Msg string `json:"msg"` +} + +type mockServerTransportHandler struct { + mu sync.Mutex + t *testing.T + testRoundFunc func(t *testing.T, ctx context.Context, msg core.Message, msgWriter core.MessageWriter) +} + +func (hdl *mockServerTransportHandler) OnTransport(ctx context.Context, trans core.Transport) error { + hdl.mu.Lock() + defer hdl.mu.Unlock() + t := hdl.t + rounder, ok := trans.ServerCapability() + assert.True(t, ok) + go func() { + for { + rCtx, msg, msgWriter, err := rounder.OnRound() + assert.Nil(t, err) + hdl.testRoundFunc(t, rCtx, msg, msgWriter) + } + }() + + return nil +} + +func (hdl *mockServerTransportHandler) SetT(t *testing.T) { + hdl.mu.Lock() + defer hdl.mu.Unlock() + hdl.t = t +} + +func (hdl *mockServerTransportHandler) SetTestRoundFunc(f func(t *testing.T, ctx context.Context, msg core.Message, msgWriter core.MessageWriter)) { + hdl.mu.Lock() + defer hdl.mu.Unlock() + hdl.testRoundFunc = f +} + +func Test_SSE(t *testing.T) { + srvHdl := &mockServerTransportHandler{} + builder := NewServerTransportBuilder("/sse") + ctx := context.Background() + srvTrans, err := builder.Build(ctx, srvHdl) + assert.Nil(t, err) + go func() { + srvTrans.ListenAndServe(ctx) + }() + defer srvTrans.Shutdown(ctx) + t.Run("upstream Close", func(t *testing.T) { + finished := make(chan struct{}) + srvHdl.SetT(t) + srvHdl.SetTestRoundFunc(func(t *testing.T, ctx context.Context, msg core.Message, msgWriter core.MessageWriter) { + defer func() { + close(finished) + }() + jReq, ok := msg.(*core.Request) + assert.True(t, ok) + assert.Equal(t, "test_sse", jReq.Method) + assert.Equal(t, "1", jReq.ID.String()) + mockReq := mockRequest{} + assert.Nil(t, sonic.Unmarshal(jReq.Params, &mockReq)) + assert.Equal(t, "test_sse", mockReq.Msg) + jResp, err := core.NewResponse(jReq.ID, &mockResponse{mockReq.Msg}) + assert.Nil(t, err) + err = msgWriter.WriteStreaming(ctx, jResp) + assert.Nil(t, err) + // wait for connection closed + <-ctx.Done() + err = msgWriter.WriteStreaming(ctx, jResp) + assert.NotNil(t, err) + t.Log(err) + }) + cliHdl := NewClientTransportHandler() + trans, err := cliHdl.NewTransport(context.Background(), conninfo.NewPeer(conninfo.PeerTypeURL, "http://127.0.0.1:8888/sse")) + assert.Nil(t, err) + rounder, ok := trans.ClientCapability() + assert.True(t, ok) + req, err := core.NewRequest("test_sse", core.NewIDFromString("1"), &mockRequest{Msg: "test_sse"}) + assert.Nil(t, err) + reader, err := rounder.Round(context.Background(), req) + assert.Nil(t, err) + msg, err := reader.Read(ctx) + assert.Nil(t, err) + jResp, ok := msg.(*core.Response) + assert.True(t, ok) + mockResp := mockResponse{} + err = sonic.Unmarshal(jResp.Result, &mockResp) + assert.Nil(t, err) + assert.Equal(t, "test_sse", mockResp.Msg) + reader.Close() + _, err = reader.Read(ctx) + assert.NotNil(t, err) + t.Log(err) + + <-finished + }) +} From 72455ccf0e694e82bb01e5e8e7ba92991cf28f67 Mon Sep 17 00:00:00 2001 From: Megumin Date: Mon, 1 Sep 2025 10:40:52 +0800 Subject: [PATCH 16/18] fix: ut --- a2a/server/server_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/a2a/server/server_test.go b/a2a/server/server_test.go index e67e5e9a1..2fc508d0d 100644 --- a/a2a/server/server_test.go +++ b/a2a/server/server_test.go @@ -117,7 +117,7 @@ func TestStreamingMessageHandler(t *testing.T) { } return nil }, - TaskEventsConsolidator: func(ctx context.Context, t *models.Task, events []models.ResponseEvent) *models.TaskContent { + TaskEventsConsolidator: func(ctx context.Context, t *models.Task, events []models.ResponseEvent, handlerErr error) *models.TaskContent { tc := &models.TaskContent{ Status: t.Status, Artifacts: t.Artifacts, From 60bdf5d8d25aa851c4adbd37d7506275fdfcbb6f Mon Sep 17 00:00:00 2001 From: Megumin Date: Tue, 9 Sep 2025 21:58:15 +0800 Subject: [PATCH 17/18] feat(a2a): support message/send non-blocking (#447) --- a2a/models/message.go | 6 ++-- a2a/server/server.go | 77 +++++++++++++++++++++++++------------------ 2 files changed, 48 insertions(+), 35 deletions(-) diff --git a/a2a/models/message.go b/a2a/models/message.go index 4f2a8485a..d1278ec2f 100644 --- a/a2a/models/message.go +++ b/a2a/models/message.go @@ -75,9 +75,9 @@ type MessageSendConfiguration struct { // HistoryLength specifies the number of recent messages to be retrieved //HistoryLength *int `json:"historyLength"` // PushNotificationConfig provides the server for sending asynchronous push notifications about task updates. - PushNotificationConfig *PushNotificationConfig `json:"pushNotificationConfig"` - // Blocking specifies if the server should treat the client as a blocking request - //Blocking *bool `json:"blocking"` todo: what is the meaning of this + PushNotificationConfig *PushNotificationConfig `json:"pushNotificationConfig,omitempty"` + // Blocking specifies if the server should treat the client as a blocking request, true by default. + Blocking *bool `json:"blocking,omitempty"` } type SendMessageResponseUnion struct { diff --git a/a2a/server/server.go b/a2a/server/server.go index 9c39da3de..088d15864 100644 --- a/a2a/server/server.go +++ b/a2a/server/server.go @@ -224,17 +224,6 @@ type A2AServer struct { } func (s *A2AServer) getTask(ctx context.Context, input *models.TaskQueryParams) (*models.Task, error) { - err := s.taskLocker.Lock(ctx, input.ID) - if err != nil { - return nil, fmt.Errorf("failed to acquire lock for new task[%s]: %w", input.ID, err) - } - defer func() { - unlockErr := s.taskLocker.Unlock(ctx, input.ID) - if unlockErr != nil { - s.logger(ctx, "failed to release lock for task[%s]: %s", input.ID, unlockErr.Error()) - } - }() - t, ok, err := s.taskStore.Get(ctx, input.ID) if err != nil { return nil, fmt.Errorf("failed to get task[%s]: %w", input.ID, err) @@ -298,12 +287,6 @@ func (s *A2AServer) sendMessage(ctx context.Context, input *models.MessageSendPa if err != nil { return nil, fmt.Errorf("failed to acquire lock for task[%s]: %s", *input.Message.TaskID, err) } - defer func() { - unLockErr := s.taskLocker.Unlock(ctx, *input.Message.TaskID) - if unLockErr != nil { - s.logger(ctx, "failed to release lock for task[%s]: %s", *input.Message.TaskID, unLockErr.Error()) - } - }() var ok bool t, ok, err = s.taskStore.Get(ctx, *input.Message.TaskID) @@ -326,12 +309,6 @@ func (s *A2AServer) sendMessage(ctx context.Context, input *models.MessageSendPa if err != nil { return nil, fmt.Errorf("failed to acquire lock for new task[%s]: %s", t.ID, err) } - defer func() { - err = s.taskLocker.Unlock(ctx, t.ID) - if err != nil { - s.logger(ctx, "failed to release lock for task[%s]: %s", t.ID, err.Error()) - } - }() } // register notification @@ -345,6 +322,46 @@ func (s *A2AServer) sendMessage(ctx context.Context, input *models.MessageSendPa } } + if input.Configuration == nil || input.Configuration.Blocking == nil || *input.Configuration.Blocking { + // blocking + defer func() { + unLockErr := s.taskLocker.Unlock(ctx, t.ID) + if unLockErr != nil { + s.logger(ctx, "failed to release lock for task[%s]: %s", *input.Message.TaskID, unLockErr.Error()) + } + }() + frame, err := s.executeHandler(ctx, t, input) + if err != nil { + return nil, err + } + return &models.SendMessageResponseUnion{ + Message: frame.Message, + Task: frame.Task, + }, nil + } + + // non-blocking + go func() { + defer func() { + if e := recover(); e != nil { + s.logger(ctx, "recovered panic while sending message: %s", utils.NewPanicErr(e, debug.Stack())) + } + unLockErr := s.taskLocker.Unlock(ctx, t.ID) + if unLockErr != nil { + s.logger(ctx, "failed to release lock for task[%s]: %s", *input.Message.TaskID, unLockErr.Error()) + } + }() + _, err = s.executeHandler(ctx, t, input) + if err != nil { + s.logger(ctx, err.Error()) + } + }() + return &models.SendMessageResponseUnion{ + Task: t, + }, nil +} + +func (s *A2AServer) executeHandler(ctx context.Context, t *models.Task, input *models.MessageSendParams) (*models.SendMessageStreamingResponseUnion, error) { resp, err := s.messageHandler(ctx, &InputParams{ Task: t, Input: &input.Message, @@ -376,11 +393,7 @@ func (s *A2AServer) sendMessage(ctx context.Context, input *models.MessageSendPa } }() } - - return &models.SendMessageResponseUnion{ - Message: frame.Message, - Task: frame.Task, - }, nil + return frame, nil } func (s *A2AServer) sendMessageStreaming(ctx context.Context, input *models.MessageSendParams, writer models.ResponseWriter) error { @@ -492,13 +505,13 @@ func (s *A2AServer) sendMessageStreaming(ctx context.Context, input *models.Mess } tc := s.taskEventsConsolidator(ctx, t, sr.events, err) - t = loadTaskContext(t, tc) + nt := loadTaskContext(t, tc) - err = s.taskStore.Save(ctx, t) + err = s.taskStore.Save(ctx, nt) if err != nil { - pushErr := s.queue.Push(ctx, t.ID, nil, err) + pushErr := s.queue.Push(ctx, nt.ID, nil, err) if err != nil { - s.logger(ctx, "failed to save task: %v, and failed to push task[%s] to queue: %v", err, t.ID, pushErr) + s.logger(ctx, "failed to save task: %v, and failed to push task[%s] to queue: %v", err, nt.ID, pushErr) } return } From cf022ffe6e4c02577ed1702b42eea306b66400a7 Mon Sep 17 00:00:00 2001 From: Megumin Date: Thu, 25 Sep 2025 17:45:24 +0800 Subject: [PATCH 18/18] feat: modify eino agent config, from Transport to A2AClient (#463) --- a2a/extension/eino/client.go | 14 +++----- a2a/extension/eino/examples/client/client.go | 37 +++++++++++++++++--- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/a2a/extension/eino/client.go b/a2a/extension/eino/client.go index 99fa321b3..d7e1b6f11 100644 --- a/a2a/extension/eino/client.go +++ b/a2a/extension/eino/client.go @@ -29,12 +29,11 @@ import ( "github.com/cloudwego/eino-ext/a2a/client" "github.com/cloudwego/eino-ext/a2a/models" - "github.com/cloudwego/eino-ext/a2a/transport" "github.com/cloudwego/eino-ext/a2a/utils" ) type AgentConfig struct { - Transport transport.ClientTransport + Client *client.A2AClient // optional, from AgentCard by default Name *string @@ -50,16 +49,13 @@ type AgentConfig struct { } func NewAgent(ctx context.Context, cfg AgentConfig) (adk.Agent, error) { - cli, err := client.NewA2AClient(ctx, &client.Config{ - Transport: cfg.Transport, - }) - if err != nil { - return nil, fmt.Errorf("failed to create a2a client: %w", err) + if cfg.Client == nil { + return nil, errors.New("Client is required") } var name, desc string var streaming bool if cfg.Name == nil || cfg.Description == nil || cfg.Streaming == nil { - card, err := cli.AgentCard(ctx) + card, err := cfg.Client.AgentCard(ctx) if err != nil { return nil, err } @@ -83,7 +79,7 @@ func NewAgent(ctx context.Context, cfg AgentConfig) (adk.Agent, error) { streaming: streaming, inputMessageConvertor: cfg.InputMessageConvertor, outputConvertor: cfg.OutputConvertor, - cli: cli, + cli: cfg.Client, } if a.inputMessageConvertor == nil { a.inputMessageConvertor = func(ctx context.Context, messages []*schema.Message) (models.Message, error) { diff --git a/a2a/extension/eino/examples/client/client.go b/a2a/extension/eino/examples/client/client.go index ebe470d9b..9ebba9d72 100644 --- a/a2a/extension/eino/examples/client/client.go +++ b/a2a/extension/eino/examples/client/client.go @@ -28,6 +28,7 @@ import ( "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/schema" + "github.com/cloudwego/eino-ext/a2a/client" "github.com/cloudwego/eino-ext/a2a/extension/eino" "github.com/cloudwego/eino-ext/a2a/transport/jsonrpc" ) @@ -51,8 +52,15 @@ func nonStreamChat(ctx context.Context) { BaseURL: "http://127.0.0.1:8888", HandlerPath: "/test", }) + if err != nil { + log.Fatal(err) + } + cli, err := client.NewA2AClient(ctx, &client.Config{Transport: t}) + if err != nil { + log.Fatal(err) + } a, err := eino.NewAgent(ctx, eino.AgentConfig{ - Transport: t, + Client: cli, Name: nil, Description: nil, Streaming: &streaming, @@ -80,8 +88,15 @@ func streamChat(ctx context.Context) { BaseURL: "http://127.0.0.1:8888", HandlerPath: "/test", }) + if err != nil { + log.Fatal(err) + } + cli, err := client.NewA2AClient(ctx, &client.Config{Transport: t}) + if err != nil { + log.Fatal(err) + } a, err := eino.NewAgent(ctx, eino.AgentConfig{ - Transport: t, + Client: cli, Streaming: &streaming, }) if err != nil { @@ -107,8 +122,15 @@ func humanInTheLoop(ctx context.Context) { BaseURL: "http://127.0.0.1:8888", HandlerPath: "/test", }) + if err != nil { + log.Fatal(err) + } + cli, err := client.NewA2AClient(ctx, &client.Config{Transport: t}) + if err != nil { + log.Fatal(err) + } a, err := eino.NewAgent(ctx, eino.AgentConfig{ - Transport: t, + Client: cli, }) if err != nil { log.Fatal(err) @@ -123,8 +145,15 @@ func streamHumanInTheLoop(ctx context.Context) { BaseURL: "http://127.0.0.1:8888", HandlerPath: "/test", }) + if err != nil { + log.Fatal(err) + } + cli, err := client.NewA2AClient(ctx, &client.Config{Transport: t}) + if err != nil { + log.Fatal(err) + } a, err := eino.NewAgent(ctx, eino.AgentConfig{ - Transport: t, + Client: cli, Streaming: &streaming, }) if err != nil {