@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,318 @@
|
|
|
1
|
+
package a2aext
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"iter"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync"
|
|
10
|
+
"sync/atomic"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/a2aproject/a2a-go/v2/a2a"
|
|
14
|
+
"github.com/a2aproject/a2a-go/v2/a2asrv"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const maxPromptBytes = 256 << 10
|
|
18
|
+
|
|
19
|
+
type stopReason int32
|
|
20
|
+
|
|
21
|
+
const (
|
|
22
|
+
stopNone stopReason = iota
|
|
23
|
+
stopCanceled
|
|
24
|
+
stopTimeout
|
|
25
|
+
stopShutdown
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// running is one task between Execute's registration and its worker's exit.
|
|
29
|
+
type running struct {
|
|
30
|
+
cancel context.CancelFunc
|
|
31
|
+
reason atomic.Int32
|
|
32
|
+
done chan struct{}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// executor is the a2asrv.AgentExecutor that runs A2A tasks as PiG turns.
|
|
36
|
+
type executor struct {
|
|
37
|
+
worker Worker
|
|
38
|
+
timeout time.Duration
|
|
39
|
+
base context.Context
|
|
40
|
+
sem chan struct{}
|
|
41
|
+
logf func(format string, args ...any)
|
|
42
|
+
|
|
43
|
+
mu sync.Mutex
|
|
44
|
+
tasks map[a2a.TaskID]*running
|
|
45
|
+
ctxLocks map[string]*ctxLock
|
|
46
|
+
active atomic.Int32
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type ctxLock struct {
|
|
50
|
+
ch chan struct{}
|
|
51
|
+
refs int
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func newExecutor(base context.Context, w Worker, maxConcurrent int, timeout time.Duration, logf func(string, ...any)) *executor {
|
|
55
|
+
return &executor{worker: w, timeout: timeout, base: base, sem: make(chan struct{}, maxConcurrent), logf: logf,
|
|
56
|
+
tasks: map[a2a.TaskID]*running{}, ctxLocks: map[string]*ctxLock{}}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func principalOf(ec *a2asrv.ExecutorContext) (Principal, error) {
|
|
60
|
+
if ec.User == nil || !ec.User.Authenticated {
|
|
61
|
+
return Principal{}, a2a.ErrUnauthenticated
|
|
62
|
+
}
|
|
63
|
+
name, _ := ec.User.Attributes["name"].(string)
|
|
64
|
+
tenant, _ := ec.User.Attributes["tenant"].(string)
|
|
65
|
+
p := Principal{Name: name, Tenant: tenant}
|
|
66
|
+
if p.Key() != ec.User.Name {
|
|
67
|
+
return Principal{}, a2a.ErrUnauthenticated
|
|
68
|
+
}
|
|
69
|
+
return p, nil
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// promptOf accepts text parts only. A part it cannot represent is refused, never dropped.
|
|
73
|
+
func promptOf(m *a2a.Message) (string, error) {
|
|
74
|
+
if m == nil {
|
|
75
|
+
return "", fmt.Errorf("message is required: %w", a2a.ErrInvalidParams)
|
|
76
|
+
}
|
|
77
|
+
var texts []string
|
|
78
|
+
for _, p := range m.Parts {
|
|
79
|
+
if p == nil {
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if _, ok := p.Content.(a2a.Text); !ok {
|
|
83
|
+
return "", fmt.Errorf("only text parts are supported: %w", a2a.ErrUnsupportedContentType)
|
|
84
|
+
}
|
|
85
|
+
texts = append(texts, p.Text())
|
|
86
|
+
}
|
|
87
|
+
prompt := strings.Join(texts, "\n")
|
|
88
|
+
if strings.TrimSpace(prompt) == "" {
|
|
89
|
+
return "", fmt.Errorf("the message has no text: %w", a2a.ErrInvalidParams)
|
|
90
|
+
}
|
|
91
|
+
if len(prompt) > maxPromptBytes {
|
|
92
|
+
return "", fmt.Errorf("the message exceeds %d bytes: %w", maxPromptBytes, a2a.ErrInvalidParams)
|
|
93
|
+
}
|
|
94
|
+
return prompt, nil
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func (e *executor) lockContext(ctx context.Context, key string) (func(), error) {
|
|
98
|
+
e.mu.Lock()
|
|
99
|
+
l := e.ctxLocks[key]
|
|
100
|
+
if l == nil {
|
|
101
|
+
l = &ctxLock{ch: make(chan struct{}, 1)}
|
|
102
|
+
e.ctxLocks[key] = l
|
|
103
|
+
}
|
|
104
|
+
l.refs++
|
|
105
|
+
e.mu.Unlock()
|
|
106
|
+
release := func() {
|
|
107
|
+
e.mu.Lock()
|
|
108
|
+
l.refs--
|
|
109
|
+
if l.refs == 0 {
|
|
110
|
+
delete(e.ctxLocks, key)
|
|
111
|
+
}
|
|
112
|
+
e.mu.Unlock()
|
|
113
|
+
}
|
|
114
|
+
select {
|
|
115
|
+
case l.ch <- struct{}{}:
|
|
116
|
+
return func() { <-l.ch; release() }, nil
|
|
117
|
+
case <-ctx.Done():
|
|
118
|
+
release()
|
|
119
|
+
return nil, ctx.Err()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func (e *executor) status(ec *a2asrv.ExecutorContext, state a2a.TaskState, text string) *a2a.TaskStatusUpdateEvent {
|
|
124
|
+
var msg *a2a.Message
|
|
125
|
+
if text != "" {
|
|
126
|
+
msg = a2a.NewMessageForTask(a2a.MessageRoleAgent, ec, a2a.NewTextPart(text))
|
|
127
|
+
}
|
|
128
|
+
return a2a.NewStatusUpdateEvent(ec, state, msg)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type runEvent struct {
|
|
132
|
+
update Update
|
|
133
|
+
done bool
|
|
134
|
+
result Result
|
|
135
|
+
err error
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Execute implements a2asrv.AgentExecutor.
|
|
139
|
+
func (e *executor) Execute(ctx context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] {
|
|
140
|
+
return func(yield func(a2a.Event, error) bool) {
|
|
141
|
+
principal, err := principalOf(ec)
|
|
142
|
+
if err != nil {
|
|
143
|
+
yield(nil, err)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
prompt, err := promptOf(ec.Message)
|
|
147
|
+
if err != nil {
|
|
148
|
+
yield(nil, err)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if ec.StoredTask == nil && !yield(a2a.NewSubmittedTask(ec, ec.Message), nil) {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
runCtx, cancelRun := context.WithCancel(e.base)
|
|
156
|
+
defer cancelRun()
|
|
157
|
+
stop := context.AfterFunc(ctx, cancelRun)
|
|
158
|
+
defer stop()
|
|
159
|
+
r := &running{cancel: cancelRun, done: make(chan struct{})}
|
|
160
|
+
e.mu.Lock()
|
|
161
|
+
e.tasks[ec.TaskID] = r
|
|
162
|
+
e.mu.Unlock()
|
|
163
|
+
e.active.Add(1)
|
|
164
|
+
defer func() {
|
|
165
|
+
e.mu.Lock()
|
|
166
|
+
if e.tasks[ec.TaskID] == r {
|
|
167
|
+
delete(e.tasks, ec.TaskID)
|
|
168
|
+
}
|
|
169
|
+
e.mu.Unlock()
|
|
170
|
+
e.active.Add(-1)
|
|
171
|
+
close(r.done)
|
|
172
|
+
}()
|
|
173
|
+
|
|
174
|
+
// Wait for a slot, then for the context's session file. The task stays submitted meanwhile.
|
|
175
|
+
select {
|
|
176
|
+
case e.sem <- struct{}{}:
|
|
177
|
+
defer func() { <-e.sem }()
|
|
178
|
+
case <-runCtx.Done():
|
|
179
|
+
e.finishStopped(ec, r, yield, runCtx)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
unlock, err := e.lockContext(runCtx, principal.Key()+"\x00"+ec.ContextID)
|
|
183
|
+
if err != nil {
|
|
184
|
+
e.finishStopped(ec, r, yield, runCtx)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
defer unlock()
|
|
188
|
+
if runCtx.Err() != nil {
|
|
189
|
+
e.finishStopped(ec, r, yield, runCtx)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if !yield(e.status(ec, a2a.TaskStateWorking, ""), nil) {
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
timeoutTimer := time.AfterFunc(e.timeout, func() {
|
|
197
|
+
r.reason.CompareAndSwap(int32(stopNone), int32(stopTimeout))
|
|
198
|
+
cancelRun()
|
|
199
|
+
})
|
|
200
|
+
defer timeoutTimer.Stop()
|
|
201
|
+
|
|
202
|
+
events := make(chan runEvent, 128)
|
|
203
|
+
go func() {
|
|
204
|
+
res, err := e.worker.Run(runCtx, Turn{Principal: principal, ContextID: ec.ContextID, TaskID: string(ec.TaskID), Prompt: prompt},
|
|
205
|
+
func(u Update) {
|
|
206
|
+
select {
|
|
207
|
+
case events <- runEvent{update: u}:
|
|
208
|
+
case <-runCtx.Done():
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
events <- runEvent{done: true, result: res, err: err}
|
|
212
|
+
}()
|
|
213
|
+
|
|
214
|
+
var artifactID a2a.ArtifactID
|
|
215
|
+
for ev := range events {
|
|
216
|
+
if !ev.done {
|
|
217
|
+
switch {
|
|
218
|
+
case ev.update.Text != "":
|
|
219
|
+
var out *a2a.TaskArtifactUpdateEvent
|
|
220
|
+
if artifactID == "" {
|
|
221
|
+
out = a2a.NewArtifactEvent(ec, a2a.NewTextPart(ev.update.Text))
|
|
222
|
+
artifactID = out.Artifact.ID
|
|
223
|
+
} else {
|
|
224
|
+
out = a2a.NewArtifactUpdateEvent(ec, artifactID, a2a.NewTextPart(ev.update.Text))
|
|
225
|
+
}
|
|
226
|
+
if !yield(out, nil) {
|
|
227
|
+
cancelRun()
|
|
228
|
+
drain(events)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
case ev.update.Tool != "":
|
|
232
|
+
if !yield(e.status(ec, a2a.TaskStateWorking, "using tool "+ev.update.Tool), nil) {
|
|
233
|
+
cancelRun()
|
|
234
|
+
drain(events)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
switch {
|
|
241
|
+
case ev.err != nil && runCtx.Err() != nil:
|
|
242
|
+
e.finishStopped(ec, r, yield, runCtx)
|
|
243
|
+
case ev.err != nil:
|
|
244
|
+
e.logf("task %s failed: %v", ec.TaskID, ev.err)
|
|
245
|
+
yield(e.status(ec, a2a.TaskStateFailed, "PiG could not complete the task."), nil)
|
|
246
|
+
case ev.result.Failure != "":
|
|
247
|
+
yield(e.status(ec, a2a.TaskStateFailed, "PiG could not complete the task: "+ev.result.Failure), nil)
|
|
248
|
+
default:
|
|
249
|
+
yield(e.status(ec, a2a.TaskStateCompleted, ""), nil)
|
|
250
|
+
}
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func drain(ch <-chan runEvent) {
|
|
257
|
+
go func() {
|
|
258
|
+
for range ch {
|
|
259
|
+
}
|
|
260
|
+
}()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// finishStopped reports a task that was stopped before or during its run.
|
|
264
|
+
func (e *executor) finishStopped(ec *a2asrv.ExecutorContext, r *running, yield func(a2a.Event, error) bool, runCtx context.Context) {
|
|
265
|
+
switch stopReason(r.reason.Load()) {
|
|
266
|
+
case stopTimeout:
|
|
267
|
+
yield(e.status(ec, a2a.TaskStateFailed, fmt.Sprintf("The task exceeded its %s time limit.", e.timeout)), nil)
|
|
268
|
+
case stopCanceled:
|
|
269
|
+
// Cancel emits the canceled status itself; a second terminal event would race it.
|
|
270
|
+
default:
|
|
271
|
+
yield(e.status(ec, a2a.TaskStateCanceled, ""), nil)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Cancel implements a2asrv.AgentExecutor: stop the worker, wait for it, then report canceled.
|
|
276
|
+
func (e *executor) Cancel(ctx context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] {
|
|
277
|
+
return func(yield func(a2a.Event, error) bool) {
|
|
278
|
+
e.mu.Lock()
|
|
279
|
+
r := e.tasks[ec.TaskID]
|
|
280
|
+
e.mu.Unlock()
|
|
281
|
+
if r != nil {
|
|
282
|
+
r.reason.CompareAndSwap(int32(stopNone), int32(stopCanceled))
|
|
283
|
+
r.cancel()
|
|
284
|
+
select {
|
|
285
|
+
case <-r.done:
|
|
286
|
+
case <-ctx.Done():
|
|
287
|
+
yield(nil, ctx.Err())
|
|
288
|
+
return
|
|
289
|
+
case <-time.After(30 * time.Second):
|
|
290
|
+
yield(nil, errors.New("the worker did not stop"))
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
yield(e.status(ec, a2a.TaskStateCanceled, ""), nil)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// shutdown stops every running task and waits for them.
|
|
299
|
+
func (e *executor) shutdown(ctx context.Context) error {
|
|
300
|
+
e.mu.Lock()
|
|
301
|
+
all := make([]*running, 0, len(e.tasks))
|
|
302
|
+
for _, r := range e.tasks {
|
|
303
|
+
all = append(all, r)
|
|
304
|
+
}
|
|
305
|
+
e.mu.Unlock()
|
|
306
|
+
for _, r := range all {
|
|
307
|
+
r.reason.CompareAndSwap(int32(stopNone), int32(stopShutdown))
|
|
308
|
+
r.cancel()
|
|
309
|
+
}
|
|
310
|
+
for _, r := range all {
|
|
311
|
+
select {
|
|
312
|
+
case <-r.done:
|
|
313
|
+
case <-ctx.Done():
|
|
314
|
+
return ctx.Err()
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return nil
|
|
318
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// Package a2aext is PiG's A2A (Agent2Agent) adapter: it serves PiG tasks to A2A
|
|
2
|
+
// agents and lets PiG call remote A2A agents. It is built on the upstream Go SDK
|
|
3
|
+
// github.com/a2aproject/a2a-go/v2 (Apache-2.0) and is pinned to A2A protocol 1.0.
|
|
4
|
+
//
|
|
5
|
+
// The listener is off unless configured (a2a.json, PIG_A2A_LISTEN or --a2a-listen).
|
|
6
|
+
// See the Package README for configuration, authentication and tenant boundaries.
|
|
7
|
+
package a2aext
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"context"
|
|
11
|
+
"errors"
|
|
12
|
+
"fmt"
|
|
13
|
+
"os"
|
|
14
|
+
"path/filepath"
|
|
15
|
+
"strings"
|
|
16
|
+
"sync"
|
|
17
|
+
"time"
|
|
18
|
+
|
|
19
|
+
sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// Options replaces the extension's dependencies (tests).
|
|
23
|
+
type Options struct {
|
|
24
|
+
// Getenv reads the environment (default os.Getenv).
|
|
25
|
+
Getenv func(string) string
|
|
26
|
+
// Worker runs served tasks (default: a `pig --mode rpc` child process per task).
|
|
27
|
+
Worker Worker
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Extension returns the a2a extension.
|
|
31
|
+
func Extension() *sdk.Extension { return ExtensionWith(Options{}) }
|
|
32
|
+
|
|
33
|
+
type manager struct {
|
|
34
|
+
getenv func(string) string
|
|
35
|
+
worker Worker
|
|
36
|
+
|
|
37
|
+
mu sync.Mutex
|
|
38
|
+
server *Server
|
|
39
|
+
cfg Config
|
|
40
|
+
cfgErr error
|
|
41
|
+
remotes *Remotes
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ExtensionWith returns the extension with explicit dependencies.
|
|
45
|
+
func ExtensionWith(o Options) *sdk.Extension {
|
|
46
|
+
if o.Getenv == nil {
|
|
47
|
+
o.Getenv = os.Getenv
|
|
48
|
+
}
|
|
49
|
+
m := &manager{getenv: o.Getenv, worker: o.Worker}
|
|
50
|
+
e := sdk.New("a2a")
|
|
51
|
+
|
|
52
|
+
e.Flag("a2a-listen", sdk.FlagOptions{
|
|
53
|
+
Description: "Serve PiG tasks over A2A on host:port (needs tokens in a2a.json unless the address is loopback with insecureNoAuth)",
|
|
54
|
+
Type: sdk.FlagString,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
e.OnSessionStart(func(ctx sdk.Context, data map[string]any) (any, error) {
|
|
58
|
+
m.start(ctx)
|
|
59
|
+
return nil, nil
|
|
60
|
+
})
|
|
61
|
+
e.OnSessionShutdown(func(ctx sdk.Context, data map[string]any) (any, error) {
|
|
62
|
+
reason, _ := data["reason"].(string)
|
|
63
|
+
switch reason {
|
|
64
|
+
case "new", "resume", "fork":
|
|
65
|
+
return nil, nil // a session switch inside one process keeps serving
|
|
66
|
+
}
|
|
67
|
+
m.stop()
|
|
68
|
+
return nil, nil
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
e.Command("a2a", "Show the A2A listener and remote agents", func(ctx sdk.Context, args string) error {
|
|
72
|
+
ctx.Notify(m.status(ctx), "info")
|
|
73
|
+
return nil
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
e.ToolWithGuidelines("a2a_agents", "List the remote A2A (Agent2Agent) agents configured for this PiG, with each agent's card: name, description and skills.",
|
|
77
|
+
sdk.Schema{"type": "object", "properties": map[string]any{}, "additionalProperties": false},
|
|
78
|
+
[]string{"Use a2a_agents to discover which remote agents exist before calling a2a_send."},
|
|
79
|
+
func(ctx sdk.Context, params map[string]any) (any, error) { return m.toolAgents(ctx) })
|
|
80
|
+
|
|
81
|
+
e.ToolWithGuidelines("a2a_send", "Send a task to a remote A2A agent and wait for its answer. Returns the task state, ids and the agent's reply. Reuse contextId to continue the same conversation.",
|
|
82
|
+
sdk.Schema{
|
|
83
|
+
"type": "object",
|
|
84
|
+
"properties": map[string]any{
|
|
85
|
+
"agent": map[string]any{"type": "string", "description": "Name of a configured remote (see a2a_agents)."},
|
|
86
|
+
"message": map[string]any{"type": "string", "description": "The task or question, as plain text."},
|
|
87
|
+
"contextId": map[string]any{"type": "string", "description": "Continue this conversation (from an earlier a2a_send)."},
|
|
88
|
+
"taskId": map[string]any{"type": "string", "description": "Continue this task, when it is waiting for input."},
|
|
89
|
+
},
|
|
90
|
+
"required": []string{"agent", "message"}, "additionalProperties": false,
|
|
91
|
+
},
|
|
92
|
+
[]string{"The remote agent is another agent, not a tool: send it a self-contained request. Data you send leaves this machine."},
|
|
93
|
+
func(ctx sdk.Context, params map[string]any) (any, error) { return m.toolSend(ctx, params) })
|
|
94
|
+
|
|
95
|
+
e.ToolWithGuidelines("a2a_task", "Get, list or cancel tasks on a remote A2A agent.",
|
|
96
|
+
sdk.Schema{
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": map[string]any{
|
|
99
|
+
"agent": map[string]any{"type": "string", "description": "Name of a configured remote."},
|
|
100
|
+
"action": map[string]any{"type": "string", "enum": []string{"get", "cancel", "list"}},
|
|
101
|
+
"taskId": map[string]any{"type": "string", "description": "Required for get and cancel."},
|
|
102
|
+
},
|
|
103
|
+
"required": []string{"agent", "action"}, "additionalProperties": false,
|
|
104
|
+
},
|
|
105
|
+
nil,
|
|
106
|
+
func(ctx sdk.Context, params map[string]any) (any, error) { return m.toolTask(ctx, params) })
|
|
107
|
+
return e
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func (m *manager) agentDir(ctx sdk.Context) string {
|
|
111
|
+
if d := m.getenv("PIG_CODING_AGENT_DIR"); d != "" {
|
|
112
|
+
return d
|
|
113
|
+
}
|
|
114
|
+
return filepath.Join(ctx.ConfigHome(), "agent")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// load reads the configuration and replaces the remote client set.
|
|
118
|
+
func (m *manager) load(ctx sdk.Context) (Config, error) {
|
|
119
|
+
flag := ""
|
|
120
|
+
if v, err := ctx.GetFlag("a2a-listen"); err == nil {
|
|
121
|
+
flag, _ = v.(string)
|
|
122
|
+
}
|
|
123
|
+
cfg, err := LoadConfig(LoadOptions{ConfigHome: m.agentDir(ctx), Getenv: m.getenv, FlagListen: flag})
|
|
124
|
+
m.mu.Lock()
|
|
125
|
+
defer m.mu.Unlock()
|
|
126
|
+
m.cfg, m.cfgErr = cfg, err
|
|
127
|
+
if err == nil {
|
|
128
|
+
m.remotes = NewRemotes(cfg.Remotes, m.getenv)
|
|
129
|
+
}
|
|
130
|
+
return cfg, err
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// current returns the loaded configuration, loading it on first use.
|
|
134
|
+
func (m *manager) current(ctx sdk.Context) (Config, *Remotes, error) {
|
|
135
|
+
m.mu.Lock()
|
|
136
|
+
loaded := m.remotes != nil || m.cfgErr != nil
|
|
137
|
+
m.mu.Unlock()
|
|
138
|
+
if !loaded {
|
|
139
|
+
if _, err := m.load(ctx); err != nil {
|
|
140
|
+
return Config{}, nil, err
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
m.mu.Lock()
|
|
144
|
+
defer m.mu.Unlock()
|
|
145
|
+
return m.cfg, m.remotes, m.cfgErr
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func (m *manager) start(ctx sdk.Context) {
|
|
149
|
+
m.mu.Lock()
|
|
150
|
+
running := m.server != nil
|
|
151
|
+
m.mu.Unlock()
|
|
152
|
+
if running {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
cfg, err := m.load(ctx)
|
|
156
|
+
if err != nil {
|
|
157
|
+
ctx.Notify(err.Error(), "error")
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if !cfg.Enabled() {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
worker := m.worker
|
|
164
|
+
if worker == nil {
|
|
165
|
+
worker, err = NewProcessWorker(cfg.Worker, cfg.StateDir, m.getenv)
|
|
166
|
+
if err != nil {
|
|
167
|
+
ctx.Notify(err.Error(), "error")
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
srv, err := NewServer(cfg, worker, m.getenv)
|
|
172
|
+
if err == nil {
|
|
173
|
+
err = srv.Start()
|
|
174
|
+
}
|
|
175
|
+
if err != nil {
|
|
176
|
+
ctx.Notify(err.Error(), "error")
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
m.mu.Lock()
|
|
180
|
+
m.server = srv
|
|
181
|
+
m.mu.Unlock()
|
|
182
|
+
ctx.Notify(fmt.Sprintf("a2a: listening on %s (A2A %s, %d token(s), %d concurrent task(s))", srv.Addr(), ProtocolVersion, len(cfg.Tokens), cfg.MaxConcurrentTasks), "info")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func (m *manager) stop() {
|
|
186
|
+
m.mu.Lock()
|
|
187
|
+
srv := m.server
|
|
188
|
+
m.server = nil
|
|
189
|
+
m.remotes, m.cfgErr = nil, nil // the next use reloads the configuration
|
|
190
|
+
m.mu.Unlock()
|
|
191
|
+
if srv == nil {
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
195
|
+
defer cancel()
|
|
196
|
+
_ = srv.Shutdown(ctx)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func (m *manager) status(ctx sdk.Context) string {
|
|
200
|
+
m.mu.Lock()
|
|
201
|
+
srv := m.server
|
|
202
|
+
m.mu.Unlock()
|
|
203
|
+
cfg, remotes, err := m.current(ctx)
|
|
204
|
+
var b strings.Builder
|
|
205
|
+
switch {
|
|
206
|
+
case srv != nil:
|
|
207
|
+
fmt.Fprintf(&b, "a2a: listening on %s (A2A %s), %d active task(s)", srv.Addr(), ProtocolVersion, srv.ActiveTasks())
|
|
208
|
+
if logs := srv.RecentLogs(); len(logs) > 0 {
|
|
209
|
+
fmt.Fprintf(&b, "\nlast: %s", logs[len(logs)-1])
|
|
210
|
+
}
|
|
211
|
+
case err != nil:
|
|
212
|
+
fmt.Fprintf(&b, "a2a: listener off (%v)", err)
|
|
213
|
+
default:
|
|
214
|
+
b.WriteString("a2a: listener off (set listen in a2a.json, PIG_A2A_LISTEN or --a2a-listen to serve tasks)")
|
|
215
|
+
}
|
|
216
|
+
if remotes != nil {
|
|
217
|
+
if names := remotes.Names(); len(names) > 0 {
|
|
218
|
+
fmt.Fprintf(&b, "\nremotes: %s", strings.Join(names, ", "))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
_ = cfg
|
|
222
|
+
return b.String()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// callContext adapts the tool's request context to a context.Context that is cancelled with it.
|
|
226
|
+
func callContext(c sdk.Context) (context.Context, func()) {
|
|
227
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
228
|
+
go func() {
|
|
229
|
+
select {
|
|
230
|
+
case <-c.Done():
|
|
231
|
+
cancel()
|
|
232
|
+
case <-ctx.Done():
|
|
233
|
+
}
|
|
234
|
+
}()
|
|
235
|
+
return ctx, cancel
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func (m *manager) remotesFor(ctx sdk.Context) (*Remotes, error) {
|
|
239
|
+
_, r, err := m.current(ctx)
|
|
240
|
+
if err != nil {
|
|
241
|
+
return nil, sdk.NewToolError(err.Error())
|
|
242
|
+
}
|
|
243
|
+
if r == nil || len(r.Names()) == 0 {
|
|
244
|
+
return nil, sdk.NewToolError("a2a: no remotes are configured; add a \"remotes\" object to a2a.json")
|
|
245
|
+
}
|
|
246
|
+
return r, nil
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func stringParam(params map[string]any, name string, required bool) (string, error) {
|
|
250
|
+
v, ok := params[name]
|
|
251
|
+
if !ok || v == nil {
|
|
252
|
+
if required {
|
|
253
|
+
return "", sdk.NewToolError(fmt.Sprintf("a2a: %s is required", name))
|
|
254
|
+
}
|
|
255
|
+
return "", nil
|
|
256
|
+
}
|
|
257
|
+
s, ok := v.(string)
|
|
258
|
+
if !ok {
|
|
259
|
+
return "", sdk.NewToolError(fmt.Sprintf("a2a: %s must be a string", name))
|
|
260
|
+
}
|
|
261
|
+
if required && strings.TrimSpace(s) == "" {
|
|
262
|
+
return "", sdk.NewToolError(fmt.Sprintf("a2a: %s must not be empty", name))
|
|
263
|
+
}
|
|
264
|
+
return s, nil
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
func formatSummary(s TaskSummary) string {
|
|
268
|
+
var b strings.Builder
|
|
269
|
+
fmt.Fprintf(&b, "state: %s\ntaskId: %s\ncontextId: %s", s.State, s.TaskID, s.ContextID)
|
|
270
|
+
if !s.Terminal {
|
|
271
|
+
b.WriteString("\n(the task is not finished; use a2a_task to get it, or send another message with this taskId and contextId)")
|
|
272
|
+
}
|
|
273
|
+
if s.Text != "" {
|
|
274
|
+
b.WriteString("\n\n" + s.Text)
|
|
275
|
+
}
|
|
276
|
+
return b.String()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func (m *manager) toolAgents(ctx sdk.Context) (any, error) {
|
|
280
|
+
r, err := m.remotesFor(ctx)
|
|
281
|
+
if err != nil {
|
|
282
|
+
return nil, err
|
|
283
|
+
}
|
|
284
|
+
cctx, cancel := callContext(ctx)
|
|
285
|
+
defer cancel()
|
|
286
|
+
var b strings.Builder
|
|
287
|
+
for _, name := range r.Names() {
|
|
288
|
+
card, err := r.Card(cctx, name)
|
|
289
|
+
switch {
|
|
290
|
+
case err != nil:
|
|
291
|
+
fmt.Fprintf(&b, "- %s: %v\n", name, err)
|
|
292
|
+
default:
|
|
293
|
+
fmt.Fprintf(&b, "- %s: %s (v%s) - %s\n", name, card.Name, card.Version, card.Description)
|
|
294
|
+
for _, sk := range card.Skills {
|
|
295
|
+
fmt.Fprintf(&b, " skill %s: %s\n", sk.Name, sk.Description)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return b.String(), nil
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func (m *manager) toolSend(ctx sdk.Context, params map[string]any) (any, error) {
|
|
303
|
+
agent, err := stringParam(params, "agent", true)
|
|
304
|
+
if err != nil {
|
|
305
|
+
return nil, err
|
|
306
|
+
}
|
|
307
|
+
message, err := stringParam(params, "message", true)
|
|
308
|
+
if err != nil {
|
|
309
|
+
return nil, err
|
|
310
|
+
}
|
|
311
|
+
contextID, _ := stringParam(params, "contextId", false)
|
|
312
|
+
taskID, _ := stringParam(params, "taskId", false)
|
|
313
|
+
r, err := m.remotesFor(ctx)
|
|
314
|
+
if err != nil {
|
|
315
|
+
return nil, err
|
|
316
|
+
}
|
|
317
|
+
cctx, cancel := callContext(ctx)
|
|
318
|
+
defer cancel()
|
|
319
|
+
var soFar strings.Builder
|
|
320
|
+
last := time.Now()
|
|
321
|
+
sum, err := r.Send(cctx, SendArgs{Agent: agent, Message: message, ContextID: contextID, TaskID: taskID}, func(chunk string) {
|
|
322
|
+
soFar.WriteString(chunk)
|
|
323
|
+
if time.Since(last) > 250*time.Millisecond {
|
|
324
|
+
last = time.Now()
|
|
325
|
+
_ = ctx.OnUpdate(soFar.String())
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
if err != nil {
|
|
329
|
+
return nil, sdk.NewToolError(err.Error())
|
|
330
|
+
}
|
|
331
|
+
return formatSummary(sum), nil
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func (m *manager) toolTask(ctx sdk.Context, params map[string]any) (any, error) {
|
|
335
|
+
agent, err := stringParam(params, "agent", true)
|
|
336
|
+
if err != nil {
|
|
337
|
+
return nil, err
|
|
338
|
+
}
|
|
339
|
+
action, err := stringParam(params, "action", true)
|
|
340
|
+
if err != nil {
|
|
341
|
+
return nil, err
|
|
342
|
+
}
|
|
343
|
+
r, err := m.remotesFor(ctx)
|
|
344
|
+
if err != nil {
|
|
345
|
+
return nil, err
|
|
346
|
+
}
|
|
347
|
+
cctx, cancel := callContext(ctx)
|
|
348
|
+
defer cancel()
|
|
349
|
+
switch action {
|
|
350
|
+
case "list":
|
|
351
|
+
list, err := r.list(cctx, agent)
|
|
352
|
+
if err != nil {
|
|
353
|
+
return nil, sdk.NewToolError(err.Error())
|
|
354
|
+
}
|
|
355
|
+
var b strings.Builder
|
|
356
|
+
for _, s := range list {
|
|
357
|
+
fmt.Fprintf(&b, "- %s (%s) context %s\n", s.TaskID, s.State, s.ContextID)
|
|
358
|
+
}
|
|
359
|
+
if b.Len() == 0 {
|
|
360
|
+
return "no tasks", nil
|
|
361
|
+
}
|
|
362
|
+
return b.String(), nil
|
|
363
|
+
case "get", "cancel":
|
|
364
|
+
taskID, err := stringParam(params, "taskId", true)
|
|
365
|
+
if err != nil {
|
|
366
|
+
return nil, err
|
|
367
|
+
}
|
|
368
|
+
var sum TaskSummary
|
|
369
|
+
if action == "get" {
|
|
370
|
+
sum, err = r.GetTask(cctx, agent, taskID)
|
|
371
|
+
} else {
|
|
372
|
+
sum, err = r.CancelTask(cctx, agent, taskID)
|
|
373
|
+
}
|
|
374
|
+
if err != nil {
|
|
375
|
+
return nil, sdk.NewToolError(err.Error())
|
|
376
|
+
}
|
|
377
|
+
return formatSummary(sum), nil
|
|
378
|
+
}
|
|
379
|
+
return nil, sdk.NewToolError(fmt.Sprintf("a2a: unknown action %q (get, cancel or list)", action))
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
var _ = errors.New
|