@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,225 @@
1
+ package a2aext
2
+
3
+ // The Piglet Binary hosts the listener itself: `pig-a2a --mode rpc` with an a2a.json, a fused a2a extension, and a
4
+ // worker that is the same Binary. Set PIG_A2A_BINARY to the executable built from piglets/a2a. Rule 17: a temporary
5
+ // HOME, PIG_HOME and PIG_CODING_AGENT_DIR; the model is a local OpenAI-compatible server.
6
+
7
+ import (
8
+ "bufio"
9
+ "context"
10
+ "encoding/json"
11
+ "fmt"
12
+ "io"
13
+ "net"
14
+ "net/http"
15
+ "os"
16
+ "os/exec"
17
+ "path/filepath"
18
+ "strings"
19
+ "testing"
20
+ "time"
21
+
22
+ "github.com/a2aproject/a2a-go/v2/a2a"
23
+ )
24
+
25
+ func binaryHome(t *testing.T, bin string, a2aJSON func(port int, work string) string) (env []string, work string, port int, llm *e2eLLM) {
26
+ t.Helper()
27
+ home := t.TempDir()
28
+ agent := filepath.Join(home, "pig", "agent")
29
+ if err := os.MkdirAll(agent, 0o700); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ llm = startE2ELLM(t)
33
+ models := map[string]any{"providers": map[string]any{"e2e": map[string]any{
34
+ "baseUrl": llm.baseURL(), "api": "openai-completions", "apiKey": "e2e-key",
35
+ "models": []any{map[string]any{"id": "e2e-1", "name": "e2e-1", "reasoning": false, "input": []string{"text"},
36
+ "contextWindow": 100000, "maxTokens": 4096, "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}}},
37
+ }}}
38
+ b, _ := json.Marshal(models)
39
+ if err := os.WriteFile(filepath.Join(agent, "models.json"), b, 0o600); err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ work = filepath.Join(home, "workspace")
43
+ if err := os.MkdirAll(work, 0o700); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ if err := os.WriteFile(filepath.Join(work, "note.txt"), []byte("hello from the workspace"), 0o600); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
50
+ if err != nil {
51
+ t.Fatal(err)
52
+ }
53
+ port = ln.Addr().(*net.TCPAddr).Port
54
+ _ = ln.Close()
55
+ if cfg := a2aJSON(port, work); cfg != "" {
56
+ if err := os.WriteFile(filepath.Join(agent, "a2a.json"), []byte(cfg), 0o600); err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ }
60
+ env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, "PIG_HOME=" + filepath.Join(home, "pig"),
61
+ "PIG_CODING_AGENT_DIR=" + agent, "PI_CODING_AGENT_DIR=" + agent, "A2A_TOKEN_A=" + tokenA, "A2A_TOKEN_B=" + tokenB}
62
+ return env, work, port, llm
63
+ }
64
+
65
+ // binHost is a running Binary in RPC mode: commands go to stdin, JSONL events come back on lines.
66
+ type binHost struct {
67
+ stdin io.Writer
68
+ lines chan string
69
+ }
70
+
71
+ // status asks the running Binary what /a2a says (a UI notification event) and returns it.
72
+ func (h *binHost) status(t *testing.T) string {
73
+ t.Helper()
74
+ if _, err := io.WriteString(h.stdin, `{"type":"prompt","message":"/a2a"}`+"\n"); err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ deadline := time.After(30 * time.Second)
78
+ for {
79
+ select {
80
+ case l := <-h.lines:
81
+ if strings.Contains(l, "a2a:") && strings.Contains(l, "extension_ui_request") {
82
+ return l
83
+ }
84
+ case <-deadline:
85
+ t.Fatal("the Binary never answered /a2a")
86
+ }
87
+ }
88
+ }
89
+
90
+ func startBinaryHost(t *testing.T, bin string, env []string, work string, extra ...string) *binHost {
91
+ t.Helper()
92
+ t.Helper()
93
+ args := append([]string{"--mode", "rpc", "--offline", "--provider", "e2e", "--model", "e2e-1", "--no-session"}, extra...)
94
+ cmd := exec.Command(bin, args...)
95
+ cmd.Dir, cmd.Env = work, env
96
+ stdin, err := cmd.StdinPipe()
97
+ if err != nil {
98
+ t.Fatal(err)
99
+ }
100
+ stdout, _ := cmd.StdoutPipe()
101
+ cmd.Stderr = io.Discard
102
+ if err := cmd.Start(); err != nil {
103
+ t.Fatal(err)
104
+ }
105
+ h := &binHost{stdin: stdin, lines: make(chan string, 256)}
106
+ go func() {
107
+ sc := bufio.NewScanner(stdout)
108
+ sc.Buffer(make([]byte, 1<<20), 1<<20)
109
+ for sc.Scan() {
110
+ select {
111
+ case h.lines <- sc.Text():
112
+ default:
113
+ }
114
+ }
115
+ }()
116
+ t.Cleanup(func() {
117
+ _ = stdin.Close()
118
+ done := make(chan struct{})
119
+ go func() { _ = cmd.Wait(); close(done) }()
120
+ select {
121
+ case <-done:
122
+ case <-time.After(10 * time.Second):
123
+ _ = cmd.Process.Kill()
124
+ <-done
125
+ }
126
+ })
127
+ return h
128
+ }
129
+
130
+ func waitDial(t *testing.T, addr string, want bool, d time.Duration) bool {
131
+ t.Helper()
132
+ deadline := time.Now().Add(d)
133
+ for time.Now().Before(deadline) {
134
+ c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
135
+ if err == nil {
136
+ _ = c.Close()
137
+ if want {
138
+ return true
139
+ }
140
+ } else if !want {
141
+ return true
142
+ }
143
+ time.Sleep(100 * time.Millisecond)
144
+ }
145
+ return false
146
+ }
147
+
148
+ func TestBinary_ListenerInsideThePigletBinary(t *testing.T) {
149
+ bin := os.Getenv("PIG_A2A_BINARY")
150
+ if bin == "" {
151
+ t.Skip("set PIG_A2A_BINARY to the Piglet Binary built from piglets/a2a to run the Binary proof")
152
+ }
153
+ env, work, port, llm := binaryHome(t, bin, func(port int, work string) string {
154
+ return fmt.Sprintf(`{"listen":"127.0.0.1:%d","name":"binary-pig","tokens":[{"name":"alice","tokenEnv":"A2A_TOKEN_A","tenant":"team-a"},{"name":"bob","tokenEnv":"A2A_TOKEN_B"}],
155
+ "worker":{"command":%q,"cwd":%q,"provider":"e2e","model":"e2e-1","tools":["read","grep","find","ls"]}}`, port, bin, work)
156
+ })
157
+ startBinaryHost(t, bin, env, work)
158
+ addr := fmt.Sprintf("127.0.0.1:%d", port)
159
+ if !waitDial(t, addr, true, 60*time.Second) {
160
+ t.Fatal("the Binary never opened the configured A2A listener")
161
+ }
162
+ base := "http://" + addr
163
+
164
+ // Discovery is public; everything else needs a token.
165
+ resp, err := http.Get(base + "/.well-known/agent-card.json")
166
+ if err != nil || resp.StatusCode != 200 {
167
+ t.Fatalf("card: %v %v", resp, err)
168
+ }
169
+ var card map[string]any
170
+ _ = json.NewDecoder(resp.Body).Decode(&card)
171
+ resp.Body.Close()
172
+ if card["name"] != "binary-pig" {
173
+ t.Fatalf("card %v", card)
174
+ }
175
+ if resp, err = http.Post(base+"/", "application/json", strings.NewReader(`{}`)); err != nil || resp.StatusCode != 401 {
176
+ t.Fatalf("an unauthenticated call must be 401: %v %v", resp, err)
177
+ }
178
+ resp.Body.Close()
179
+
180
+ // A task runs a real PiG worker (the same Binary) that calls a read tool (named in a2a.json) against the local model.
181
+ ca := a2aClient(t, base, tokenA)
182
+ task := sendTask(t, ca, &a2a.SendMessageRequest{Message: textMessage("please read the note")})
183
+ if task.Status.State != a2a.TaskStateCompleted || !strings.Contains(taskText(task), "hello from the workspace") {
184
+ t.Fatalf("state %s: %q %+v", task.Status.State, taskText(task), task.Status.Message)
185
+ }
186
+ llm.mu.Lock()
187
+ for _, tools := range llm.tools {
188
+ if strings.Join(tools, ",") != "find,grep,ls,read" {
189
+ t.Fatalf("the worker must get exactly the configured tools; the model was offered %v", tools)
190
+ }
191
+ }
192
+ llm.mu.Unlock()
193
+
194
+ // The worker was started without extensions, so it did not open a second listener, and another tenant cannot see the task.
195
+ cb := a2aClient(t, base, tokenB)
196
+ if _, err := cb.GetTask(context.Background(), &a2a.GetTaskRequest{ID: task.ID}); err == nil {
197
+ t.Fatal("bob read alice's task")
198
+ }
199
+ }
200
+
201
+ func TestBinary_ListenerStaysOffWithoutConfiguration(t *testing.T) {
202
+ bin := os.Getenv("PIG_A2A_BINARY")
203
+ if bin == "" {
204
+ t.Skip("set PIG_A2A_BINARY to the Piglet Binary built from piglets/a2a to run the Binary proof")
205
+ }
206
+ env, work, port, _ := binaryHome(t, bin, func(int, string) string { return "" })
207
+ h := startBinaryHost(t, bin, env, work)
208
+ if got := h.status(t); !strings.Contains(got, "listener off") {
209
+ t.Fatalf("the extension is loaded but /a2a says: %s", got)
210
+ }
211
+ _ = port
212
+ }
213
+
214
+ func TestBinary_FlagWithoutTokensIsRefused(t *testing.T) {
215
+ bin := os.Getenv("PIG_A2A_BINARY")
216
+ if bin == "" {
217
+ t.Skip("set PIG_A2A_BINARY to the Piglet Binary built from piglets/a2a to run the Binary proof")
218
+ }
219
+ env, work, port, _ := binaryHome(t, bin, func(int, string) string { return "" })
220
+ addr := fmt.Sprintf("127.0.0.1:%d", port)
221
+ h := startBinaryHost(t, bin, env, work, "--a2a-listen", addr)
222
+ if got := h.status(t); !strings.Contains(got, "needs at least one entry in tokens") {
223
+ t.Fatalf("--a2a-listen without tokens must be refused with the reason: %s", got)
224
+ }
225
+ }
@@ -0,0 +1,142 @@
1
+ package a2aext
2
+
3
+ // A cancel that arrives before the task's worker has started: the task is queued behind the concurrency limit,
4
+ // or behind another task of the same context. It must end canceled without ever running a worker, and must not
5
+ // disturb the task ahead of it.
6
+
7
+ import (
8
+ "context"
9
+ "sync"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/a2aproject/a2a-go/v2/a2a"
14
+ "github.com/a2aproject/a2a-go/v2/a2aclient"
15
+ )
16
+
17
+ // streamTask starts a streaming task and returns its id once the server has created it, and a channel
18
+ // carrying the last state seen when the stream ends.
19
+ func streamTask(t *testing.T, c *a2aclient.Client, m *a2a.Message) (a2a.TaskID, <-chan a2a.TaskState) {
20
+ t.Helper()
21
+ ids := make(chan a2a.TaskID, 1)
22
+ last := make(chan a2a.TaskState, 1)
23
+ go func() {
24
+ var state a2a.TaskState
25
+ sent := false
26
+ for ev, err := range c.SendStreamingMessage(context.Background(), &a2a.SendMessageRequest{Message: m}) {
27
+ if err != nil {
28
+ break
29
+ }
30
+ if !sent {
31
+ ids <- ev.TaskInfo().TaskID
32
+ sent = true
33
+ }
34
+ switch e := ev.(type) {
35
+ case *a2a.Task:
36
+ state = e.Status.State
37
+ case *a2a.TaskStatusUpdateEvent:
38
+ state = e.Status.State
39
+ }
40
+ }
41
+ last <- state
42
+ }()
43
+ select {
44
+ case id := <-ids:
45
+ return id, last
46
+ case <-time.After(10 * time.Second):
47
+ t.Fatal("the server never created the task")
48
+ return "", nil
49
+ }
50
+ }
51
+
52
+ func testCancelWhileQueued(t *testing.T, sameContext bool) {
53
+ gate := make(chan struct{})
54
+ started := make(chan struct{}, 4)
55
+ var mu sync.Mutex
56
+ var prompts []string
57
+ w := &scriptedWorker{run: func(ctx context.Context, tn Turn, up func(Update)) (Result, error) {
58
+ mu.Lock()
59
+ prompts = append(prompts, tn.Prompt)
60
+ mu.Unlock()
61
+ started <- struct{}{}
62
+ select {
63
+ case <-gate:
64
+ return Result{Text: "done " + tn.Prompt}, nil
65
+ case <-ctx.Done():
66
+ return Result{}, ctx.Err()
67
+ }
68
+ }}
69
+ cfg := serverConfig()
70
+ cfg.MaxConcurrentTasks = 1
71
+ if sameContext {
72
+ cfg.MaxConcurrentTasks = 2 // a free slot: the second task waits for the context, not for a slot
73
+ }
74
+ s := startServer(t, cfg, w)
75
+ base := "http://" + s.Addr()
76
+ ca := a2aClient(t, base, tokenA)
77
+
78
+ first := textMessage("first")
79
+ first.ContextID = "shared-context"
80
+ firstID, firstLast := streamTask(t, ca, first)
81
+ waitChan(t, chanOf(started), "the first worker")
82
+
83
+ second := textMessage("second")
84
+ if sameContext {
85
+ second.ContextID = "shared-context"
86
+ }
87
+ secondID, secondLast := streamTask(t, ca, second)
88
+ time.Sleep(300 * time.Millisecond) // the second task is queued: the worker has not been asked to run it
89
+
90
+ mu.Lock()
91
+ if len(prompts) != 1 {
92
+ mu.Unlock()
93
+ t.Fatalf("the queued task started a worker: %v", prompts)
94
+ }
95
+ mu.Unlock()
96
+
97
+ got, err := ca.CancelTask(context.Background(), &a2a.CancelTaskRequest{ID: secondID})
98
+ if err != nil {
99
+ t.Fatalf("cancelling a queued task: %v", err)
100
+ }
101
+ if got.Status.State != a2a.TaskStateCanceled {
102
+ t.Fatalf("queued task ended %s", got.Status.State)
103
+ }
104
+ select {
105
+ case st := <-secondLast:
106
+ if st != a2a.TaskStateCanceled {
107
+ t.Fatalf("the queued task's stream ended in %s", st)
108
+ }
109
+ case <-time.After(10 * time.Second):
110
+ t.Fatal("the queued task's stream never ended")
111
+ }
112
+
113
+ // The first task is untouched and finishes normally; the cancelled one never reached a worker.
114
+ close(gate)
115
+ select {
116
+ case st := <-firstLast:
117
+ if st != a2a.TaskStateCompleted {
118
+ t.Fatalf("the first task ended %s", st)
119
+ }
120
+ case <-time.After(10 * time.Second):
121
+ t.Fatal("the first task never finished")
122
+ }
123
+ mu.Lock()
124
+ defer mu.Unlock()
125
+ if len(prompts) != 1 || prompts[0] != "first" {
126
+ t.Fatalf("workers that ran: %v (task %s, cancelled %s)", prompts, firstID, secondID)
127
+ }
128
+ }
129
+
130
+ func chanOf(c chan struct{}) <-chan struct{} {
131
+ out := make(chan struct{})
132
+ go func() { <-c; close(out) }()
133
+ return out
134
+ }
135
+
136
+ func TestCancelBeforeTheWorkerStartsBehindTheConcurrencyLimit(t *testing.T) {
137
+ testCancelWhileQueued(t, false)
138
+ }
139
+
140
+ func TestCancelBeforeTheWorkerStartsBehindTheSameContext(t *testing.T) {
141
+ testCancelWhileQueued(t, true)
142
+ }
@@ -0,0 +1,42 @@
1
+ package a2aext
2
+
3
+ import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "testing"
7
+ )
8
+
9
+ type discardWriter struct{ h http.Header }
10
+
11
+ func (d *discardWriter) Header() http.Header { return d.h }
12
+ func (d *discardWriter) Write(p []byte) (int, error) { return len(p), nil }
13
+ func (d *discardWriter) WriteHeader(int) {}
14
+
15
+ // The Agent Card is the same document for every request, so it is built and encoded once per advertised URL, not
16
+ // on every GET: it used to cost a card build, a JSON encoding and a handler for each request, 11 KB and 67 allocations.
17
+ func TestAgentCardIsEncodedOncePerAdvertisedURL(t *testing.T) {
18
+ s, err := NewServer(serverConfig(), &scriptedWorker{}, serverEnv)
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ r := httptest.NewRequest(http.MethodGet, "http://x/.well-known/agent-card.json", nil)
23
+ w := &discardWriter{h: http.Header{}}
24
+ s.serveCard(w, r) // warm
25
+ if allocs := testing.AllocsPerRun(50, func() { s.serveCard(w, r) }); allocs > 25 {
26
+ t.Fatalf("serving the card allocated %.0f times per request; it must be served from a cached encoding", allocs)
27
+ }
28
+ first := httptest.NewRecorder()
29
+ s.serveCard(first, r)
30
+ second := httptest.NewRecorder()
31
+ s.serveCard(second, r)
32
+ if first.Body.String() != second.Body.String() || first.Body.Len() == 0 {
33
+ t.Fatal("the card must be identical across requests")
34
+ }
35
+ // A different advertised URL (the listener's address changes when it starts) must not serve the old document.
36
+ s.cfg.ExternalURL = "https://agent.example.com"
37
+ third := httptest.NewRecorder()
38
+ s.serveCard(third, r)
39
+ if third.Body.String() == first.Body.String() {
40
+ t.Fatal("the card must follow the advertised URL")
41
+ }
42
+ }