@pi-in-go/pigpen-a2a 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +26 -0
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/extensions/a2a/auth.go +120 -0
- package/extensions/a2a/auth_test.go +133 -0
- package/extensions/a2a/bench_test.go +70 -0
- package/extensions/a2a/binary_test.go +225 -0
- package/extensions/a2a/cancelqueued_test.go +142 -0
- package/extensions/a2a/card_cache_test.go +42 -0
- package/extensions/a2a/client.go +499 -0
- package/extensions/a2a/client_test.go +402 -0
- package/extensions/a2a/config.go +282 -0
- package/extensions/a2a/config_test.go +194 -0
- package/extensions/a2a/e2e_test.go +311 -0
- package/extensions/a2a/executor.go +318 -0
- package/extensions/a2a/extension.go +382 -0
- package/extensions/a2a/extension_test.go +396 -0
- package/extensions/a2a/fakehost_test.go +548 -0
- package/extensions/a2a/fakepig_test.go +212 -0
- package/extensions/a2a/gaps_test.go +59 -0
- package/extensions/a2a/go.mod +15 -0
- package/extensions/a2a/go.sum +14 -0
- package/extensions/a2a/interop_test.go +163 -0
- package/extensions/a2a/procattr_other.go +25 -0
- package/extensions/a2a/procattr_windows.go +26 -0
- package/extensions/a2a/resubscribe_test.go +113 -0
- package/extensions/a2a/review_test.go +293 -0
- package/extensions/a2a/server.go +267 -0
- package/extensions/a2a/server_test.go +792 -0
- package/extensions/a2a/survivors_test.go +302 -0
- package/extensions/a2a/worker.go +388 -0
- package/extensions/a2a/worker_test.go +257 -0
- package/package.json +41 -0
- package/port/PORT.md +126 -0
- package/port/a2a-go-LICENSE +201 -0
- package/port/golden/flag-without-auth-refused.jsonl +5 -0
- package/port/golden/listener-off.jsonl +4 -0
- package/port/golden/send-missing-message.jsonl +18 -0
- package/port/golden/send-without-remotes.jsonl +18 -0
- package/port/golden/task-unknown-action.jsonl +18 -0
- package/port/golden/tools-visible-to-model.jsonl +11 -0
- package/port/interop/kagent/main.go +73 -0
- package/port/mutation-run.txt +222 -0
- package/port/mutations.json +656 -0
- package/port/red.txt +105 -0
- package/port/scenarios/flag-without-auth-refused.json +8 -0
- package/port/scenarios/listener-off.json +7 -0
- package/port/scenarios/send-missing-message.json +11 -0
- package/port/scenarios/send-without-remotes.json +11 -0
- package/port/scenarios/task-unknown-action.json +11 -0
- package/port/scenarios/tools-visible-to-model.json +8 -0
- package/provenance.json +18 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
package a2aext
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/url"
|
|
9
|
+
"sort"
|
|
10
|
+
"strings"
|
|
11
|
+
"sync"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/a2aproject/a2a-go/v2/a2a"
|
|
15
|
+
"github.com/a2aproject/a2a-go/v2/a2aclient"
|
|
16
|
+
"github.com/a2aproject/a2a-go/v2/a2aclient/agentcard"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Remotes calls configured A2A agents over protocol 1.0 (JSON-RPC binding).
|
|
20
|
+
type Remotes struct {
|
|
21
|
+
cfg map[string]RemoteAgent
|
|
22
|
+
getenv func(string) string
|
|
23
|
+
|
|
24
|
+
mu sync.Mutex
|
|
25
|
+
clients map[string]*a2aclient.Client
|
|
26
|
+
cards map[string]*a2a.AgentCard
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// SendArgs are the inputs of Send.
|
|
30
|
+
type SendArgs struct {
|
|
31
|
+
Agent, Message, ContextID, TaskID string
|
|
32
|
+
// OnTask, if set, is called once, as soon as the remote reports the task's ids.
|
|
33
|
+
OnTask func(taskID, contextID string)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// TaskSummary is what a remote task looks like to the model.
|
|
37
|
+
type TaskSummary struct {
|
|
38
|
+
TaskID string
|
|
39
|
+
ContextID string
|
|
40
|
+
// State is lower-case with hyphens: submitted, working, input-required, completed, canceled, failed, rejected, auth-required.
|
|
41
|
+
State string
|
|
42
|
+
Text string
|
|
43
|
+
Terminal bool
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// NewRemotes builds the client set. Nothing is dialled until a call is made.
|
|
47
|
+
func NewRemotes(cfg map[string]RemoteAgent, getenv func(string) string) *Remotes {
|
|
48
|
+
if getenv == nil {
|
|
49
|
+
getenv = func(string) string { return "" }
|
|
50
|
+
}
|
|
51
|
+
withDefaults := make(map[string]RemoteAgent, len(cfg))
|
|
52
|
+
for n, ra := range cfg {
|
|
53
|
+
if ra.TimeoutSeconds <= 0 {
|
|
54
|
+
ra.TimeoutSeconds = defaultRemoteTimeout
|
|
55
|
+
}
|
|
56
|
+
withDefaults[n] = ra
|
|
57
|
+
}
|
|
58
|
+
cfg = withDefaults
|
|
59
|
+
return &Remotes{cfg: cfg, getenv: getenv, clients: map[string]*a2aclient.Client{}, cards: map[string]*a2a.AgentCard{}}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Names lists configured remote names in order.
|
|
63
|
+
func (r *Remotes) Names() []string {
|
|
64
|
+
names := make([]string, 0, len(r.cfg))
|
|
65
|
+
for n := range r.cfg {
|
|
66
|
+
names = append(names, n)
|
|
67
|
+
}
|
|
68
|
+
sort.Strings(names)
|
|
69
|
+
return names
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func (r *Remotes) remote(name string) (RemoteAgent, error) {
|
|
73
|
+
ra, ok := r.cfg[name]
|
|
74
|
+
if !ok {
|
|
75
|
+
if len(r.cfg) == 0 {
|
|
76
|
+
return RemoteAgent{}, errors.New("a2a: no remotes are configured (add a \"remotes\" object to a2a.json)")
|
|
77
|
+
}
|
|
78
|
+
return RemoteAgent{}, fmt.Errorf("a2a: unknown agent %q; configured remotes: %s", name, strings.Join(r.Names(), ", "))
|
|
79
|
+
}
|
|
80
|
+
return ra, nil
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func (ra RemoteAgent) hasCredentials() bool { return ra.BearerTokenEnv != "" || len(ra.HeaderEnv) > 0 }
|
|
84
|
+
|
|
85
|
+
func (r *Remotes) headers(name string, ra RemoteAgent) (http.Header, error) {
|
|
86
|
+
h := http.Header{}
|
|
87
|
+
if ra.BearerTokenEnv != "" {
|
|
88
|
+
v := r.getenv(ra.BearerTokenEnv)
|
|
89
|
+
if v == "" {
|
|
90
|
+
return nil, fmt.Errorf("a2a: remote %q needs the environment variable %s (bearerTokenEnv), which is not set", name, ra.BearerTokenEnv)
|
|
91
|
+
}
|
|
92
|
+
h.Set("Authorization", "Bearer "+v)
|
|
93
|
+
}
|
|
94
|
+
for header, env := range ra.HeaderEnv {
|
|
95
|
+
v := r.getenv(env)
|
|
96
|
+
if v == "" {
|
|
97
|
+
return nil, fmt.Errorf("a2a: remote %q needs the environment variable %s (headerEnv %s), which is not set", name, env, header)
|
|
98
|
+
}
|
|
99
|
+
h.Set(header, v)
|
|
100
|
+
}
|
|
101
|
+
return h, nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type headerTransport struct {
|
|
105
|
+
h http.Header
|
|
106
|
+
host string // credentials go to this host only
|
|
107
|
+
scheme string // and over this scheme only (an https remote's token never goes over http)
|
|
108
|
+
base http.RoundTripper
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func (t headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
112
|
+
req = req.Clone(req.Context())
|
|
113
|
+
if req.URL.Host != t.host {
|
|
114
|
+
return nil, fmt.Errorf("a2a: refusing to send credentials to %s (the remote is configured at %s)", req.URL.Host, t.host)
|
|
115
|
+
}
|
|
116
|
+
if req.URL.Scheme != t.scheme {
|
|
117
|
+
return nil, fmt.Errorf("a2a: refusing to send credentials over %s (the remote is configured with %s)", req.URL.Scheme, t.scheme)
|
|
118
|
+
}
|
|
119
|
+
for k, v := range t.h {
|
|
120
|
+
req.Header[k] = v
|
|
121
|
+
}
|
|
122
|
+
return t.base.RoundTrip(req)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func hostOf(raw string) string {
|
|
126
|
+
u, err := url.Parse(raw)
|
|
127
|
+
if err != nil {
|
|
128
|
+
return ""
|
|
129
|
+
}
|
|
130
|
+
return u.Host
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func schemeOf(raw string) string {
|
|
134
|
+
u, err := url.Parse(raw)
|
|
135
|
+
if err != nil {
|
|
136
|
+
return ""
|
|
137
|
+
}
|
|
138
|
+
return u.Scheme
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// originOf is scheme://host, the unit credentials are pinned to.
|
|
142
|
+
func originOf(raw string) string { return schemeOf(raw) + "://" + hostOf(raw) }
|
|
143
|
+
|
|
144
|
+
func (r *Remotes) httpClient(name string, ra RemoteAgent) (*http.Client, error) {
|
|
145
|
+
h, err := r.headers(name, ra)
|
|
146
|
+
if err != nil {
|
|
147
|
+
return nil, err
|
|
148
|
+
}
|
|
149
|
+
var rt http.RoundTripper = http.DefaultTransport
|
|
150
|
+
if len(h) > 0 {
|
|
151
|
+
rt = headerTransport{h: h, host: hostOf(ra.URL), scheme: schemeOf(ra.URL), base: http.DefaultTransport}
|
|
152
|
+
}
|
|
153
|
+
return &http.Client{Transport: rt, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, nil
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func (r *Remotes) call(ctx context.Context, ra RemoteAgent) (context.Context, context.CancelFunc) {
|
|
157
|
+
return context.WithTimeout(ctx, time.Duration(ra.TimeoutSeconds)*time.Second)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Card resolves a remote's agent card.
|
|
161
|
+
func (r *Remotes) Card(ctx context.Context, name string) (*a2a.AgentCard, error) {
|
|
162
|
+
ra, err := r.remote(name)
|
|
163
|
+
if err != nil {
|
|
164
|
+
return nil, err
|
|
165
|
+
}
|
|
166
|
+
if ra.SkipCard {
|
|
167
|
+
return nil, fmt.Errorf("a2a: remote %q is configured with skipCard; it has no card to show", name)
|
|
168
|
+
}
|
|
169
|
+
return r.card(ctx, name, ra)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func (r *Remotes) card(ctx context.Context, name string, ra RemoteAgent) (*a2a.AgentCard, error) {
|
|
173
|
+
r.mu.Lock()
|
|
174
|
+
if c := r.cards[name]; c != nil {
|
|
175
|
+
r.mu.Unlock()
|
|
176
|
+
return c, nil
|
|
177
|
+
}
|
|
178
|
+
r.mu.Unlock()
|
|
179
|
+
hc, err := r.httpClient(name, ra)
|
|
180
|
+
if err != nil {
|
|
181
|
+
return nil, err
|
|
182
|
+
}
|
|
183
|
+
ctx, cancel := r.call(ctx, ra)
|
|
184
|
+
defer cancel()
|
|
185
|
+
card, err := agentcard.NewResolver(hc).Resolve(ctx, ra.URL)
|
|
186
|
+
if err != nil {
|
|
187
|
+
return nil, fmt.Errorf("a2a: fetch the agent card of %q: %w", name, err)
|
|
188
|
+
}
|
|
189
|
+
r.mu.Lock()
|
|
190
|
+
r.cards[name] = card
|
|
191
|
+
r.mu.Unlock()
|
|
192
|
+
return card, nil
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func (r *Remotes) client(ctx context.Context, name string, ra RemoteAgent) (*a2aclient.Client, error) {
|
|
196
|
+
r.mu.Lock()
|
|
197
|
+
if c := r.clients[name]; c != nil {
|
|
198
|
+
r.mu.Unlock()
|
|
199
|
+
return c, nil
|
|
200
|
+
}
|
|
201
|
+
r.mu.Unlock()
|
|
202
|
+
hc, err := r.httpClient(name, ra)
|
|
203
|
+
if err != nil {
|
|
204
|
+
return nil, err
|
|
205
|
+
}
|
|
206
|
+
// Defaults are disabled so nothing but the pinned JSON-RPC 1.0 transport can be selected.
|
|
207
|
+
opts := []a2aclient.FactoryOption{a2aclient.WithDefaultsDisabled(), a2aclient.WithJSONRPCTransport(hc)}
|
|
208
|
+
var c *a2aclient.Client
|
|
209
|
+
if ra.SkipCard {
|
|
210
|
+
c, err = a2aclient.NewFromEndpoints(ctx, []*a2a.AgentInterface{a2a.NewAgentInterface(ra.URL, a2a.TransportProtocolJSONRPC)}, opts...)
|
|
211
|
+
} else {
|
|
212
|
+
card, cerr := r.card(ctx, name, ra)
|
|
213
|
+
if cerr != nil {
|
|
214
|
+
return nil, cerr
|
|
215
|
+
}
|
|
216
|
+
iface, ierr := pinnedInterface(name, ra, card)
|
|
217
|
+
if ierr != nil {
|
|
218
|
+
return nil, ierr
|
|
219
|
+
}
|
|
220
|
+
c, err = a2aclient.NewFromEndpoints(ctx, []*a2a.AgentInterface{iface}, opts...)
|
|
221
|
+
}
|
|
222
|
+
if err != nil {
|
|
223
|
+
return nil, fmt.Errorf("a2a: connect to %q: %w", name, err)
|
|
224
|
+
}
|
|
225
|
+
r.mu.Lock()
|
|
226
|
+
r.clients[name] = c
|
|
227
|
+
r.mu.Unlock()
|
|
228
|
+
return c, nil
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// pinnedInterface picks the card's A2A 1.0 JSON-RPC interface. Credentials only go to the
|
|
232
|
+
// configured origin (scheme and host): a card is not trusted to redirect or downgrade them.
|
|
233
|
+
func pinnedInterface(name string, ra RemoteAgent, card *a2a.AgentCard) (*a2a.AgentInterface, error) {
|
|
234
|
+
var offered []string
|
|
235
|
+
for _, i := range card.SupportedInterfaces {
|
|
236
|
+
offered = append(offered, string(i.ProtocolBinding)+" "+string(i.ProtocolVersion))
|
|
237
|
+
if i.ProtocolBinding != a2a.TransportProtocolJSONRPC || !versionOK(string(i.ProtocolVersion)) {
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
if ra.hasCredentials() && originOf(i.URL) != originOf(ra.URL) {
|
|
241
|
+
return nil, fmt.Errorf("a2a: the card of %q names %s, not the configured %s; not sending credentials there (set skipCard to use the configured URL as the endpoint)", name, originOf(i.URL), originOf(ra.URL))
|
|
242
|
+
}
|
|
243
|
+
return i, nil
|
|
244
|
+
}
|
|
245
|
+
return nil, fmt.Errorf("a2a: %q offers no A2A %s JSON-RPC interface (offers: %s); this adapter is pinned to protocol %s", name, ProtocolVersion, strings.Join(offered, ", "), ProtocolVersion)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func stateName(s a2a.TaskState) string {
|
|
249
|
+
n := strings.TrimPrefix(string(s), "TASK_STATE_")
|
|
250
|
+
return strings.ToLower(strings.ReplaceAll(n, "_", "-"))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func textOf(parts a2a.ContentParts) string {
|
|
254
|
+
var b strings.Builder
|
|
255
|
+
for _, p := range parts {
|
|
256
|
+
if t, ok := p.Content.(a2a.Text); ok {
|
|
257
|
+
b.WriteString(string(t))
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return b.String()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func messageText(m *a2a.Message) string {
|
|
264
|
+
if m == nil {
|
|
265
|
+
return ""
|
|
266
|
+
}
|
|
267
|
+
return textOf(m.Parts)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func summarize(t *a2a.Task) TaskSummary {
|
|
271
|
+
var arts []string
|
|
272
|
+
for _, a := range t.Artifacts {
|
|
273
|
+
if s := textOf(a.Parts); s != "" {
|
|
274
|
+
arts = append(arts, s)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return finish(TaskSummary{TaskID: string(t.ID), ContextID: t.ContextID, State: stateName(t.Status.State), Terminal: t.Status.State.Terminal()},
|
|
278
|
+
strings.Join(arts, "\n\n"), messageText(t.Status.Message), t.Status.State)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func finish(s TaskSummary, artifactText, statusText string, state a2a.TaskState) TaskSummary {
|
|
282
|
+
var parts []string
|
|
283
|
+
if artifactText != "" {
|
|
284
|
+
parts = append(parts, artifactText)
|
|
285
|
+
}
|
|
286
|
+
if statusText != "" && state != a2a.TaskStateCompleted {
|
|
287
|
+
parts = append(parts, statusText)
|
|
288
|
+
}
|
|
289
|
+
s.Text = strings.Join(parts, "\n\n")
|
|
290
|
+
return s
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Send sends a message and returns when the task is terminal or needs input. onProgress
|
|
294
|
+
// receives assistant text as it streams. Cancelling ctx (or the timeout) cancels the remote task.
|
|
295
|
+
func (r *Remotes) Send(ctx context.Context, args SendArgs, onProgress func(string)) (TaskSummary, error) {
|
|
296
|
+
ra, err := r.remote(args.Agent)
|
|
297
|
+
if err != nil {
|
|
298
|
+
return TaskSummary{}, err
|
|
299
|
+
}
|
|
300
|
+
if strings.TrimSpace(args.Message) == "" {
|
|
301
|
+
return TaskSummary{}, errors.New("a2a: message is empty")
|
|
302
|
+
}
|
|
303
|
+
callCtx, cancelCall := r.call(ctx, ra)
|
|
304
|
+
defer cancelCall()
|
|
305
|
+
c, err := r.client(callCtx, args.Agent, ra)
|
|
306
|
+
if err != nil {
|
|
307
|
+
return TaskSummary{}, err
|
|
308
|
+
}
|
|
309
|
+
msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart(args.Message))
|
|
310
|
+
msg.ContextID, msg.TaskID = args.ContextID, a2a.TaskID(args.TaskID)
|
|
311
|
+
req := &a2a.SendMessageRequest{Message: msg}
|
|
312
|
+
|
|
313
|
+
streaming := ra.SkipCard
|
|
314
|
+
if !ra.SkipCard {
|
|
315
|
+
if card, err := r.card(callCtx, args.Agent, ra); err == nil {
|
|
316
|
+
streaming = card.Capabilities.Streaming
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
var events func(func(a2a.Event, error) bool)
|
|
320
|
+
if streaming {
|
|
321
|
+
events = c.SendStreamingMessage(callCtx, req)
|
|
322
|
+
} else {
|
|
323
|
+
events = func(yield func(a2a.Event, error) bool) {
|
|
324
|
+
res, err := c.SendMessage(callCtx, req)
|
|
325
|
+
if err != nil {
|
|
326
|
+
yield(nil, err)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
yield(res, nil)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
var (
|
|
334
|
+
sum TaskSummary
|
|
335
|
+
artifacts []a2a.ArtifactID
|
|
336
|
+
artText = map[a2a.ArtifactID]*strings.Builder{}
|
|
337
|
+
statusText string
|
|
338
|
+
state a2a.TaskState
|
|
339
|
+
final *TaskSummary
|
|
340
|
+
)
|
|
341
|
+
assembled := func() string {
|
|
342
|
+
var parts []string
|
|
343
|
+
for _, id := range artifacts {
|
|
344
|
+
if s := artText[id].String(); s != "" {
|
|
345
|
+
parts = append(parts, s)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return strings.Join(parts, "\n\n")
|
|
349
|
+
}
|
|
350
|
+
announced := false
|
|
351
|
+
for ev, err := range events {
|
|
352
|
+
if err != nil {
|
|
353
|
+
return r.failedSend(ctx, callCtx, args.Agent, ra, sum, err)
|
|
354
|
+
}
|
|
355
|
+
switch e := ev.(type) {
|
|
356
|
+
case *a2a.Task:
|
|
357
|
+
s := summarize(e)
|
|
358
|
+
final = &s
|
|
359
|
+
sum.TaskID, sum.ContextID = s.TaskID, s.ContextID
|
|
360
|
+
state, statusText = e.Status.State, messageText(e.Status.Message)
|
|
361
|
+
for _, a := range e.Artifacts {
|
|
362
|
+
if artText[a.ID] == nil {
|
|
363
|
+
artText[a.ID] = &strings.Builder{}
|
|
364
|
+
artifacts = append(artifacts, a.ID)
|
|
365
|
+
}
|
|
366
|
+
artText[a.ID].Reset()
|
|
367
|
+
artText[a.ID].WriteString(textOf(a.Parts))
|
|
368
|
+
}
|
|
369
|
+
case *a2a.Message:
|
|
370
|
+
s := TaskSummary{TaskID: string(e.TaskID), ContextID: e.ContextID, State: "message", Text: messageText(e), Terminal: true}
|
|
371
|
+
return s, nil
|
|
372
|
+
case *a2a.TaskStatusUpdateEvent:
|
|
373
|
+
sum.TaskID, sum.ContextID = string(e.TaskID), e.ContextID
|
|
374
|
+
state, statusText = e.Status.State, messageText(e.Status.Message)
|
|
375
|
+
final = nil
|
|
376
|
+
case *a2a.TaskArtifactUpdateEvent:
|
|
377
|
+
sum.TaskID, sum.ContextID = string(e.TaskID), e.ContextID
|
|
378
|
+
id := e.Artifact.ID
|
|
379
|
+
if artText[id] == nil {
|
|
380
|
+
artText[id] = &strings.Builder{}
|
|
381
|
+
artifacts = append(artifacts, id)
|
|
382
|
+
}
|
|
383
|
+
if !e.Append {
|
|
384
|
+
artText[id].Reset()
|
|
385
|
+
}
|
|
386
|
+
chunk := textOf(e.Artifact.Parts)
|
|
387
|
+
artText[id].WriteString(chunk)
|
|
388
|
+
if chunk != "" && onProgress != nil {
|
|
389
|
+
onProgress(chunk)
|
|
390
|
+
}
|
|
391
|
+
final = nil
|
|
392
|
+
}
|
|
393
|
+
if !announced && sum.TaskID != "" {
|
|
394
|
+
announced = true
|
|
395
|
+
if args.OnTask != nil {
|
|
396
|
+
args.OnTask(sum.TaskID, sum.ContextID)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if final != nil {
|
|
401
|
+
return *final, nil
|
|
402
|
+
}
|
|
403
|
+
if state == a2a.TaskStateUnspecified {
|
|
404
|
+
return sum, fmt.Errorf("a2a: %q ended the stream without a task state", args.Agent)
|
|
405
|
+
}
|
|
406
|
+
sum.State, sum.Terminal = stateName(state), state.Terminal()
|
|
407
|
+
return finish(sum, assembled(), statusText, state), nil
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// failedSend reports a broken or cancelled call and, when a remote task was started and is
|
|
411
|
+
// still going, asks the remote to cancel it.
|
|
412
|
+
func (r *Remotes) failedSend(caller, call context.Context, name string, ra RemoteAgent, sum TaskSummary, err error) (TaskSummary, error) {
|
|
413
|
+
if sum.TaskID != "" && (caller.Err() != nil || call.Err() != nil) {
|
|
414
|
+
cctx, cancel := context.WithTimeout(context.WithoutCancel(caller), 10*time.Second)
|
|
415
|
+
defer cancel()
|
|
416
|
+
if c, cerr := r.client(cctx, name, ra); cerr == nil {
|
|
417
|
+
_, _ = c.CancelTask(cctx, &a2a.CancelTaskRequest{ID: a2a.TaskID(sum.TaskID)})
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
switch {
|
|
421
|
+
case caller.Err() != nil:
|
|
422
|
+
return sum, caller.Err()
|
|
423
|
+
case call.Err() != nil:
|
|
424
|
+
return sum, fmt.Errorf("a2a: %q did not finish within %ds: %w", name, ra.TimeoutSeconds, call.Err())
|
|
425
|
+
}
|
|
426
|
+
return sum, fmt.Errorf("a2a: %q: %w", name, describe(err))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// describe keeps the protocol error but never a credential: errors from the transport
|
|
430
|
+
// carry status lines and bodies, not request headers.
|
|
431
|
+
func describe(err error) error {
|
|
432
|
+
switch {
|
|
433
|
+
case errors.Is(err, a2a.ErrUnauthenticated):
|
|
434
|
+
return errors.New("authentication failed (the remote rejected the credentials)")
|
|
435
|
+
case strings.Contains(err.Error(), "401"):
|
|
436
|
+
return errors.New("authentication failed (HTTP 401; check the remote's bearerTokenEnv)")
|
|
437
|
+
}
|
|
438
|
+
return err
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
func (r *Remotes) list(ctx context.Context, name string) ([]TaskSummary, error) {
|
|
442
|
+
ra, err := r.remote(name)
|
|
443
|
+
if err != nil {
|
|
444
|
+
return nil, err
|
|
445
|
+
}
|
|
446
|
+
ctx, cancel := r.call(ctx, ra)
|
|
447
|
+
defer cancel()
|
|
448
|
+
c, err := r.client(ctx, name, ra)
|
|
449
|
+
if err != nil {
|
|
450
|
+
return nil, err
|
|
451
|
+
}
|
|
452
|
+
res, err := c.ListTasks(ctx, &a2a.ListTasksRequest{IncludeArtifacts: true})
|
|
453
|
+
if err != nil {
|
|
454
|
+
return nil, fmt.Errorf("a2a: %q: %w", name, describe(err))
|
|
455
|
+
}
|
|
456
|
+
out := make([]TaskSummary, 0, len(res.Tasks))
|
|
457
|
+
for _, t := range res.Tasks {
|
|
458
|
+
out = append(out, summarize(t))
|
|
459
|
+
}
|
|
460
|
+
return out, nil
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// GetTask fetches a remote task.
|
|
464
|
+
func (r *Remotes) GetTask(ctx context.Context, name, taskID string) (TaskSummary, error) {
|
|
465
|
+
ra, err := r.remote(name)
|
|
466
|
+
if err != nil {
|
|
467
|
+
return TaskSummary{}, err
|
|
468
|
+
}
|
|
469
|
+
ctx, cancel := r.call(ctx, ra)
|
|
470
|
+
defer cancel()
|
|
471
|
+
c, err := r.client(ctx, name, ra)
|
|
472
|
+
if err != nil {
|
|
473
|
+
return TaskSummary{}, err
|
|
474
|
+
}
|
|
475
|
+
t, err := c.GetTask(ctx, &a2a.GetTaskRequest{ID: a2a.TaskID(taskID)})
|
|
476
|
+
if err != nil {
|
|
477
|
+
return TaskSummary{}, fmt.Errorf("a2a: %q: %w", name, describe(err))
|
|
478
|
+
}
|
|
479
|
+
return summarize(t), nil
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// CancelTask cancels a remote task.
|
|
483
|
+
func (r *Remotes) CancelTask(ctx context.Context, name, taskID string) (TaskSummary, error) {
|
|
484
|
+
ra, err := r.remote(name)
|
|
485
|
+
if err != nil {
|
|
486
|
+
return TaskSummary{}, err
|
|
487
|
+
}
|
|
488
|
+
ctx, cancel := r.call(ctx, ra)
|
|
489
|
+
defer cancel()
|
|
490
|
+
c, err := r.client(ctx, name, ra)
|
|
491
|
+
if err != nil {
|
|
492
|
+
return TaskSummary{}, err
|
|
493
|
+
}
|
|
494
|
+
t, err := c.CancelTask(ctx, &a2a.CancelTaskRequest{ID: a2a.TaskID(taskID)})
|
|
495
|
+
if err != nil {
|
|
496
|
+
return TaskSummary{}, fmt.Errorf("a2a: %q: %w", name, describe(err))
|
|
497
|
+
}
|
|
498
|
+
return summarize(t), nil
|
|
499
|
+
}
|