e071084ebe
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
283 lines
7.6 KiB
Go
283 lines
7.6 KiB
Go
package a2a
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Client calls a remote agent that speaks the A2A protocol. It is the
|
|
// outbound counterpart to the gateway: where the gateway exposes a Go
|
|
// Micro agent over A2A, the Client lets a Go Micro agent or flow call an
|
|
// agent on any framework, by URL.
|
|
type Client struct {
|
|
url string
|
|
http *http.Client
|
|
}
|
|
|
|
// NewClient returns a Client for the agent at url (its JSON-RPC endpoint,
|
|
// i.e. the `url` field of the agent's card).
|
|
func NewClient(url string) *Client {
|
|
return &Client{url: url, http: &http.Client{Timeout: 60 * time.Second}}
|
|
}
|
|
|
|
// WithHTTPClient sets the underlying HTTP client (for timeouts, auth
|
|
// transports, etc.).
|
|
func (c *Client) WithHTTPClient(h *http.Client) *Client {
|
|
if h != nil {
|
|
c.http = h
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Card fetches the remote agent's Agent Card.
|
|
func (c *Client) Card(ctx context.Context) (*AgentCard, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url+"/.well-known/agent.json", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("agent card: status %d", resp.StatusCode)
|
|
}
|
|
var card AgentCard
|
|
if err := json.NewDecoder(resp.Body).Decode(&card); err != nil {
|
|
return nil, err
|
|
}
|
|
return &card, nil
|
|
}
|
|
|
|
// Send sends a text message to the remote agent and returns its reply.
|
|
// If the agent returns a task that isn't yet terminal, Send polls
|
|
// tasks/get until it completes or ctx is done.
|
|
func (c *Client) Send(ctx context.Context, text string) (string, error) {
|
|
task, err := c.SendMessage(ctx, Message{
|
|
Role: "user",
|
|
Kind: "message",
|
|
MessageID: uuid.New().String(),
|
|
Parts: []Part{{Kind: "text", Text: text}},
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if task.Status.State != stateCompleted {
|
|
return "", fmt.Errorf("remote task %s ended in state %q", task.ID, task.Status.State)
|
|
}
|
|
return artifactsText(task.Artifacts), nil
|
|
}
|
|
|
|
// SendMessage sends an A2A message and returns the resulting terminal task.
|
|
// To continue a multi-turn task, pass a Message with TaskID and ContextID set
|
|
// to a prior task's id and context id.
|
|
func (c *Client) SendMessage(ctx context.Context, message Message) (*Task, error) {
|
|
if message.MessageID == "" {
|
|
message.MessageID = uuid.New().String()
|
|
}
|
|
if message.Kind == "" {
|
|
message.Kind = "message"
|
|
}
|
|
if message.Role == "" {
|
|
message.Role = "user"
|
|
}
|
|
res, err := c.call(ctx, "message/send", sendParams{Message: message})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The result is a Message or a Task; the "kind" field disambiguates.
|
|
var probe struct {
|
|
Kind string `json:"kind"`
|
|
}
|
|
_ = json.Unmarshal(res, &probe)
|
|
|
|
if probe.Kind == "message" {
|
|
var m Message
|
|
if err := json.Unmarshal(res, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Task{
|
|
ID: m.TaskID,
|
|
ContextID: m.ContextID,
|
|
Kind: "task",
|
|
Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)},
|
|
Artifacts: []Artifact{textArtifact(textOf(m.Parts))},
|
|
History: []Message{m},
|
|
AP2Mandates: append([]AP2SignedMandate{}, m.AP2Mandates...),
|
|
}, nil
|
|
}
|
|
|
|
var task Task
|
|
if err := json.Unmarshal(res, &task); err != nil {
|
|
return nil, err
|
|
}
|
|
for !terminal(task.Status.State) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(300 * time.Millisecond):
|
|
}
|
|
got, err := c.call(ctx, "tasks/get", getParams{ID: task.ID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(got, &task); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &task, nil
|
|
}
|
|
|
|
// Resubscribe reconnects to a retained or active task stream and returns task
|
|
// snapshots as the remote agent emits updates. The returned channel is closed
|
|
// when the task reaches a terminal state or ctx is canceled.
|
|
func (c *Client) Resubscribe(ctx context.Context, taskID string) (<-chan Task, <-chan error) {
|
|
tasks := make(chan Task, 8)
|
|
errs := make(chan error, 1)
|
|
go func() {
|
|
defer close(tasks)
|
|
defer close(errs)
|
|
body, _ := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": uuid.New().String(),
|
|
"method": "tasks/resubscribe",
|
|
"params": getParams{ID: taskID},
|
|
})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
errs <- fmt.Errorf("tasks/resubscribe: status %d", resp.StatusCode)
|
|
return
|
|
}
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if !strings.HasPrefix(line, "data:") {
|
|
continue
|
|
}
|
|
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
if payload == "" {
|
|
continue
|
|
}
|
|
var out struct {
|
|
Result Task `json:"result"`
|
|
Error *rpcError `json:"error"`
|
|
}
|
|
if err := json.Unmarshal([]byte(payload), &out); err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
if out.Error != nil {
|
|
errs <- fmt.Errorf("a2a tasks/resubscribe: %s (%d)", out.Error.Message, out.Error.Code)
|
|
return
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
errs <- ctx.Err()
|
|
return
|
|
case tasks <- out.Result:
|
|
}
|
|
if terminal(out.Result.Status.State) {
|
|
return
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
errs <- err
|
|
}
|
|
}()
|
|
return tasks, errs
|
|
}
|
|
|
|
// SetPushNotificationConfig asks the remote agent to POST updates for taskID to cfg.URL.
|
|
func (c *Client) SetPushNotificationConfig(ctx context.Context, taskID string, cfg PushNotificationConfig) error {
|
|
_, err := c.call(ctx, "tasks/pushNotificationConfig/set", pushConfigParams{
|
|
ID: taskID,
|
|
PushNotificationConfig: cfg,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// PushNotificationConfig returns the remote push notification config for taskID.
|
|
func (c *Client) PushNotificationConfig(ctx context.Context, taskID string) (PushNotificationConfig, error) {
|
|
res, err := c.call(ctx, "tasks/pushNotificationConfig/get", getParams{ID: taskID})
|
|
if err != nil {
|
|
return PushNotificationConfig{}, err
|
|
}
|
|
var out struct {
|
|
PushNotificationConfig PushNotificationConfig `json:"pushNotificationConfig"`
|
|
}
|
|
if err := json.Unmarshal(res, &out); err != nil {
|
|
return PushNotificationConfig{}, err
|
|
}
|
|
return out.PushNotificationConfig, nil
|
|
}
|
|
|
|
// call performs one JSON-RPC request and returns the raw result.
|
|
func (c *Client) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
|
body, _ := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": uuid.New().String(),
|
|
"method": method,
|
|
"params": params,
|
|
})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var out struct {
|
|
Result json.RawMessage `json:"result"`
|
|
Error *rpcError `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return nil, err
|
|
}
|
|
if out.Error != nil {
|
|
return nil, fmt.Errorf("a2a %s: %s (%d)", method, out.Error.Message, out.Error.Code)
|
|
}
|
|
return out.Result, nil
|
|
}
|
|
|
|
func terminal(state string) bool {
|
|
switch state {
|
|
case "completed", "failed", "canceled", "rejected", "input-required":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func artifactsText(arts []Artifact) string {
|
|
var parts []Part
|
|
for _, a := range arts {
|
|
parts = append(parts, a.Parts...)
|
|
}
|
|
return textOf(parts)
|
|
}
|