@crewhaus/a2a-protocol 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/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@crewhaus/a2a-protocol",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "In-crew agent-to-agent SendMessage tool with traceparent envelopes (Section 22)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/agent-context-isolation": "0.0.0",
16
+ "@crewhaus/tool-builder": "0.0.0",
17
+ "@crewhaus/tool-catalog": "0.0.0",
18
+ "@crewhaus/trace-event-bus": "0.0.0",
19
+ "zod": "^3.23.8"
20
+ },
21
+ "license": "Apache-2.0",
22
+ "author": {
23
+ "name": "Max Meier",
24
+ "email": "max@studiomax.io",
25
+ "url": "https://studiomax.io"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/crewhaus/factory.git",
30
+ "directory": "packages/a2a-protocol"
31
+ },
32
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/a2a-protocol#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/crewhaus/factory/issues"
35
+ },
36
+ "publishConfig": {
37
+ "access": "restricted"
38
+ },
39
+ "files": [
40
+ "src",
41
+ "README.md",
42
+ "LICENSE",
43
+ "NOTICE"
44
+ ]
45
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { CrewMailbox } from "@crewhaus/agent-context-isolation";
3
+ import { isValidSpanId, isValidTraceId, parseTraceparent } from "@crewhaus/trace-event-bus";
4
+ import { type A2AEnvelope, buildEnvelope, createSendMessageA2ATool } from "./index.js";
5
+
6
+ function makeMailbox(
7
+ roles: string[],
8
+ onSend?: (to: string, payload: string) => string,
9
+ ): CrewMailbox {
10
+ return {
11
+ knownRoles: roles,
12
+ currentRole: () => roles[0] ?? "",
13
+ currentTraceparent: () => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
14
+ requestHandoff: () => {
15
+ throw new Error("not used in this test");
16
+ },
17
+ sendA2A: async (to, payload) => (onSend ? onSend(to, payload) : `<<reply from ${to}>>`),
18
+ };
19
+ }
20
+
21
+ describe("createSendMessageA2ATool", () => {
22
+ test("delivers payload to peer and returns peer's reply verbatim", async () => {
23
+ let capturedTarget: string | undefined;
24
+ let capturedPayload: string | undefined;
25
+ const mailbox = makeMailbox(["critic", "researcher"], (to, payload) => {
26
+ capturedTarget = to;
27
+ capturedPayload = payload;
28
+ return "the answer is 42";
29
+ });
30
+ const tool = createSendMessageA2ATool({ from: "critic", targets: ["critic", "researcher"] });
31
+
32
+ const result = await tool.execute(
33
+ { target: "researcher", payload: "what is the answer?", kind: "question" },
34
+ { bridge: { crewMailbox: mailbox } as never },
35
+ );
36
+
37
+ expect(capturedTarget).toBe("researcher");
38
+ expect(capturedPayload).toBe("what is the answer?");
39
+ expect(result).toBe("the answer is 42");
40
+ });
41
+
42
+ test("refuses self-message", async () => {
43
+ const mailbox = makeMailbox(["solo"]);
44
+ const tool = createSendMessageA2ATool({ from: "solo", targets: ["solo"] });
45
+
46
+ const result = await tool.execute(
47
+ { target: "solo", payload: "hi me" },
48
+ { bridge: { crewMailbox: mailbox } as never },
49
+ );
50
+
51
+ expect(result).toContain("cannot send a message to yourself");
52
+ });
53
+
54
+ test("refuses unknown peer with helpful list", async () => {
55
+ const mailbox = makeMailbox(["a", "b"]);
56
+ const tool = createSendMessageA2ATool({ from: "a", targets: ["a", "b"] });
57
+
58
+ const result = await tool.execute(
59
+ { target: "c", payload: "hi" },
60
+ { bridge: { crewMailbox: mailbox } as never },
61
+ );
62
+
63
+ expect(result).toContain('unknown peer "c"');
64
+ expect(result).toContain("a");
65
+ expect(result).toContain("b");
66
+ });
67
+
68
+ test("returns clean error when no bridge / no mailbox is present", async () => {
69
+ const tool = createSendMessageA2ATool({ from: "x", targets: ["x", "y"] });
70
+
71
+ const r1 = await tool.execute({ target: "y", payload: "hi" }, {});
72
+ expect(r1).toContain("crew mailbox is not available");
73
+
74
+ const r2 = await tool.execute({ target: "y", payload: "hi" }, { bridge: {} as never });
75
+ expect(r2).toContain("crew mailbox is not available");
76
+ });
77
+
78
+ test("flag profile: read-only, not destructive, classifier enabled", () => {
79
+ const tool = createSendMessageA2ATool({ from: "a", targets: ["a", "b"] });
80
+ expect(tool.readOnly).toBe(true);
81
+ expect(tool.destructive).toBe(false);
82
+ // Default true — peer text could carry attacker-supplied content.
83
+ expect(tool.classifyOutput).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe("buildEnvelope", () => {
88
+ test("packs all wire fields with default kind=question", () => {
89
+ const env: A2AEnvelope = buildEnvelope({
90
+ from: "critic",
91
+ to: "researcher",
92
+ payload: "where did the data come from?",
93
+ traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
94
+ });
95
+
96
+ expect(env.from).toBe("critic");
97
+ expect(env.to).toBe("researcher");
98
+ expect(env.kind).toBe("question");
99
+ expect(env.payload).toBe("where did the data come from?");
100
+ expect(env.traceparent).toBe("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01");
101
+ });
102
+
103
+ test("traceparent parses to a valid W3C trace context (T9 envelope invariant)", () => {
104
+ const env = buildEnvelope({
105
+ from: "a",
106
+ to: "b",
107
+ payload: "hi",
108
+ traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
109
+ });
110
+ const parsed = parseTraceparent(env.traceparent);
111
+ expect(parsed).not.toBeNull();
112
+ expect(parsed && isValidTraceId(parsed.traceId)).toBe(true);
113
+ expect(parsed && isValidSpanId(parsed.parentSpanId)).toBe(true);
114
+ });
115
+ });
package/src/index.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Catalog R5 `a2a-protocol` — Section 22.
3
+ *
4
+ * In-crew agent-to-agent peer messaging. Distinct from Section 12's
5
+ * channel-bot `SendMessage` (which posts to Slack/etc.) — this one routes
6
+ * INSIDE the crew via the orchestrator's mailbox.
7
+ *
8
+ * Wire shape (the Google A2A inspiration):
9
+ * { from, to, kind, payload, traceparent }
10
+ *
11
+ * `traceparent` is the W3C trace-context header. The orchestrator's
12
+ * mailbox stamps it from the bus's `currentTraceparent()` so subscribers
13
+ * see every A2A message under the same trace id, even when the
14
+ * receiving role's `runChatLoop` opens its own spans. The crew's OTel
15
+ * exporter therefore stitches every role's spans into one trace.
16
+ *
17
+ * The tool itself is synchronous-from-the-sender's-POV: `SendMessage`
18
+ * waits for the target role's reply and returns it as the tool result
19
+ * so the sender's model can continue its turn with the answer in hand.
20
+ */
21
+ import type { CrewMailbox, RuntimeBridge } from "@crewhaus/agent-context-isolation";
22
+ import { buildTool } from "@crewhaus/tool-builder";
23
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
24
+ import { z } from "zod";
25
+
26
+ /** Wire envelope an A2A SendMessage produces. */
27
+ export type A2AEnvelope = {
28
+ readonly from: string;
29
+ readonly to: string;
30
+ readonly kind: A2AMessageKind;
31
+ readonly payload: string;
32
+ readonly traceparent: string;
33
+ };
34
+
35
+ export type A2AMessageKind = "question" | "answer" | "notify";
36
+
37
+ export type CreateSendMessageA2AToolOptions = {
38
+ readonly from: string;
39
+ readonly targets: ReadonlyArray<string>;
40
+ };
41
+
42
+ const inputSchemaTemplate = z
43
+ .object({
44
+ target: z
45
+ .string()
46
+ .min(1)
47
+ .describe("Role name to message — must be a peer in this crew, not yourself."),
48
+ payload: z
49
+ .string()
50
+ .min(1)
51
+ .describe(
52
+ "The question or notification you want to send. The peer agent runs once with this as its input and returns a single reply string.",
53
+ ),
54
+ kind: z
55
+ .enum(["question", "answer", "notify"])
56
+ .optional()
57
+ .describe(
58
+ "Optional message classifier. Defaults to 'question'. Used by trace subscribers to colour-code A2A flows; semantically a no-op.",
59
+ ),
60
+ })
61
+ .strict();
62
+
63
+ /**
64
+ * Build the description with the live target list so the model sees
65
+ * which peers are actually addressable.
66
+ */
67
+ function describe(opts: CreateSendMessageA2AToolOptions): string {
68
+ const others = opts.targets.filter((t) => t !== opts.from);
69
+ if (others.length === 0) {
70
+ return "Ask a peer agent in this crew a question and receive its reply. (No other roles defined — calling SendMessage will return an error.)";
71
+ }
72
+ return `Ask a peer agent in this crew a question and receive its reply synchronously. Use this when you need a brief, focused response from another role WITHOUT yielding control. The peer runs one turn with your payload as input and returns a single reply. Available peers: ${others.join(", ")}.`;
73
+ }
74
+
75
+ export function createSendMessageA2ATool(opts: CreateSendMessageA2AToolOptions): RegisteredTool {
76
+ return buildTool({
77
+ name: "SendMessage",
78
+ description: describe(opts),
79
+ inputSchema: inputSchemaTemplate,
80
+ concurrencySafe: false,
81
+ readOnly: true,
82
+ destructive: false,
83
+ // The peer reply is model-generated text — keep the post-tool
84
+ // injection classifier on (default) so prompt-injection hidden in a
85
+ // peer's response is caught.
86
+ execute: async (input, ctx) => {
87
+ const bridge = ctx?.bridge as RuntimeBridge | undefined;
88
+ const mailbox: CrewMailbox | undefined = bridge?.crewMailbox;
89
+ if (mailbox === undefined) {
90
+ return "[A2A error] crew mailbox is not available — SendMessage can only be invoked from inside a crew-orchestrator turn.";
91
+ }
92
+ if (input.target === opts.from) {
93
+ return `[A2A error] cannot send a message to yourself ("${opts.from}").`;
94
+ }
95
+ if (!mailbox.knownRoles.includes(input.target)) {
96
+ return `[A2A error] unknown peer "${input.target}". Known peers: ${mailbox.knownRoles.join(", ")}.`;
97
+ }
98
+ try {
99
+ const reply = await mailbox.sendA2A(input.target, input.payload);
100
+ return reply;
101
+ } catch (err) {
102
+ return `[A2A error] ${(err as Error).message ?? String(err)}`;
103
+ }
104
+ },
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Compose a wire envelope from raw fields. Exported so the orchestrator
110
+ * (which actually emits the trace event) can build a consistent object
111
+ * shape; tests also use it to assert envelope structure.
112
+ */
113
+ export function buildEnvelope(args: {
114
+ from: string;
115
+ to: string;
116
+ kind?: A2AMessageKind;
117
+ payload: string;
118
+ traceparent: string;
119
+ }): A2AEnvelope {
120
+ return {
121
+ from: args.from,
122
+ to: args.to,
123
+ kind: args.kind ?? "question",
124
+ payload: args.payload,
125
+ traceparent: args.traceparent,
126
+ };
127
+ }
128
+
129
+ export { createSendMessageA2ATool as default };