@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.
Files changed (52) hide show
  1. package/CREDITS.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +97 -0
  4. package/extensions/a2a/auth.go +120 -0
  5. package/extensions/a2a/auth_test.go +133 -0
  6. package/extensions/a2a/bench_test.go +70 -0
  7. package/extensions/a2a/binary_test.go +225 -0
  8. package/extensions/a2a/cancelqueued_test.go +142 -0
  9. package/extensions/a2a/card_cache_test.go +42 -0
  10. package/extensions/a2a/client.go +499 -0
  11. package/extensions/a2a/client_test.go +402 -0
  12. package/extensions/a2a/config.go +282 -0
  13. package/extensions/a2a/config_test.go +194 -0
  14. package/extensions/a2a/e2e_test.go +311 -0
  15. package/extensions/a2a/executor.go +318 -0
  16. package/extensions/a2a/extension.go +382 -0
  17. package/extensions/a2a/extension_test.go +396 -0
  18. package/extensions/a2a/fakehost_test.go +548 -0
  19. package/extensions/a2a/fakepig_test.go +212 -0
  20. package/extensions/a2a/gaps_test.go +59 -0
  21. package/extensions/a2a/go.mod +15 -0
  22. package/extensions/a2a/go.sum +14 -0
  23. package/extensions/a2a/interop_test.go +163 -0
  24. package/extensions/a2a/procattr_other.go +25 -0
  25. package/extensions/a2a/procattr_windows.go +26 -0
  26. package/extensions/a2a/resubscribe_test.go +113 -0
  27. package/extensions/a2a/review_test.go +293 -0
  28. package/extensions/a2a/server.go +267 -0
  29. package/extensions/a2a/server_test.go +792 -0
  30. package/extensions/a2a/survivors_test.go +302 -0
  31. package/extensions/a2a/worker.go +388 -0
  32. package/extensions/a2a/worker_test.go +257 -0
  33. package/package.json +41 -0
  34. package/port/PORT.md +126 -0
  35. package/port/a2a-go-LICENSE +201 -0
  36. package/port/golden/flag-without-auth-refused.jsonl +5 -0
  37. package/port/golden/listener-off.jsonl +4 -0
  38. package/port/golden/send-missing-message.jsonl +18 -0
  39. package/port/golden/send-without-remotes.jsonl +18 -0
  40. package/port/golden/task-unknown-action.jsonl +18 -0
  41. package/port/golden/tools-visible-to-model.jsonl +11 -0
  42. package/port/interop/kagent/main.go +73 -0
  43. package/port/mutation-run.txt +222 -0
  44. package/port/mutations.json +656 -0
  45. package/port/red.txt +105 -0
  46. package/port/scenarios/flag-without-auth-refused.json +8 -0
  47. package/port/scenarios/listener-off.json +7 -0
  48. package/port/scenarios/send-missing-message.json +11 -0
  49. package/port/scenarios/send-without-remotes.json +11 -0
  50. package/port/scenarios/task-unknown-action.json +11 -0
  51. package/port/scenarios/tools-visible-to-model.json +8 -0
  52. 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