@axiom-lattice/gateway 2.1.89 → 2.1.91
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/.turbo/turbo-build.log +12 -10
- package/CHANGELOG.md +24 -0
- package/dist/a2a-ERG5RMUW.mjs +567 -0
- package/dist/a2a-ERG5RMUW.mjs.map +1 -0
- package/dist/index.js +774 -13
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +189 -0
- package/dist/index.mjs.map +1 -1
- package/jest.config.js +1 -1
- package/package.json +6 -6
- package/src/__tests__/a2a.test.ts +346 -0
- package/src/index.ts +19 -0
- package/src/routes/a2a-bridge.ts +266 -0
- package/src/routes/a2a.ts +779 -0
- package/src/routes/index.ts +5 -0
package/jest.config.js
CHANGED
|
@@ -64,7 +64,7 @@ module.exports = {
|
|
|
64
64
|
"^e2b$": "<rootDir>/src/__tests__/__mocks__/e2b.ts",
|
|
65
65
|
// Strip .js extension from ESM-style imports (must come after all @-scoped mappings)
|
|
66
66
|
"^(@.*)[.]js$": "$1",
|
|
67
|
-
"^(
|
|
67
|
+
"^(\\.{1,2}/.*)\\.js$": "$1",
|
|
68
68
|
},
|
|
69
69
|
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts", "!src/__tests__/**/*"],
|
|
70
70
|
coverageReporters: ["text", "lcov", "html"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/gateway",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.91",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"module": "dist/index.mjs",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
"redis": "^5.0.1",
|
|
41
41
|
"uuid": "^9.0.1",
|
|
42
42
|
"zod": "3.25.76",
|
|
43
|
-
"@axiom-lattice/agent-eval": "2.1.
|
|
44
|
-
"@axiom-lattice/core": "2.1.
|
|
45
|
-
"@axiom-lattice/pg-stores": "1.0.
|
|
46
|
-
"@axiom-lattice/protocols": "2.1.
|
|
47
|
-
"@axiom-lattice/queue-redis": "1.0.
|
|
43
|
+
"@axiom-lattice/agent-eval": "2.1.73",
|
|
44
|
+
"@axiom-lattice/core": "2.1.79",
|
|
45
|
+
"@axiom-lattice/pg-stores": "1.0.70",
|
|
46
|
+
"@axiom-lattice/protocols": "2.1.41",
|
|
47
|
+
"@axiom-lattice/queue-redis": "1.0.40"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/jest": "^29.5.14",
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { FastifyInstance } from "fastify";
|
|
2
|
+
import fastify from "fastify";
|
|
3
|
+
import { registerA2ARoutes } from "../routes/a2a";
|
|
4
|
+
|
|
5
|
+
const mockChunkStream = jest.fn();
|
|
6
|
+
const mockAddMessage = jest.fn();
|
|
7
|
+
const mockAbort = jest.fn();
|
|
8
|
+
|
|
9
|
+
const mockAgent = {
|
|
10
|
+
addMessage: mockAddMessage,
|
|
11
|
+
chunkStream: mockChunkStream,
|
|
12
|
+
abort: mockAbort,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const mockGetAgent = jest.fn(() => mockAgent);
|
|
16
|
+
|
|
17
|
+
jest.mock("@axiom-lattice/core", () => ({
|
|
18
|
+
agentInstanceManager: {
|
|
19
|
+
getAgent: (...args: unknown[]) => mockGetAgent(...args),
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
describe("A2A Routes", () => {
|
|
24
|
+
let app: FastifyInstance;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
mockGetAgent.mockClear();
|
|
28
|
+
mockAddMessage.mockClear();
|
|
29
|
+
mockChunkStream.mockClear();
|
|
30
|
+
mockAbort.mockClear();
|
|
31
|
+
mockGetAgent.mockReturnValue(mockAgent);
|
|
32
|
+
|
|
33
|
+
process.env.A2A_API_KEYS = "valid-key:tenant-a";
|
|
34
|
+
process.env.A2A_DEFAULT_AGENT_ID = "agent-1";
|
|
35
|
+
process.env.A2A_DEFAULT_TENANT_ID = "default-tenant";
|
|
36
|
+
delete process.env.A2A_DEFAULT_PROJECT_ID;
|
|
37
|
+
delete process.env.A2A_DEFAULT_WORKSPACE_ID;
|
|
38
|
+
|
|
39
|
+
app = fastify();
|
|
40
|
+
registerA2ARoutes(app);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await app.close();
|
|
45
|
+
delete process.env.A2A_API_KEYS;
|
|
46
|
+
delete process.env.A2A_DEFAULT_AGENT_ID;
|
|
47
|
+
delete process.env.A2A_DEFAULT_TENANT_ID;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ─── Agent Card ──────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe("GET /api/a2a/.well-known/agent.json", () => {
|
|
53
|
+
it("should return agent card without auth", async () => {
|
|
54
|
+
const response = await app.inject({
|
|
55
|
+
method: "GET",
|
|
56
|
+
url: "/api/a2a/.well-known/agent.json",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(response.statusCode).toBe(200);
|
|
60
|
+
const body = JSON.parse(response.body);
|
|
61
|
+
expect(body).toHaveProperty("name", "Axiom Lattice Agent");
|
|
62
|
+
expect(body).toHaveProperty("capabilities");
|
|
63
|
+
expect(body.capabilities).toHaveProperty("streaming", true);
|
|
64
|
+
expect(body).toHaveProperty("defaultInputModes");
|
|
65
|
+
expect(body).toHaveProperty("defaultOutputModes");
|
|
66
|
+
expect(body).toHaveProperty("url");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("GET /api/a2a/.well-known/agent-card.json", () => {
|
|
71
|
+
it("should support alternate path", async () => {
|
|
72
|
+
const response = await app.inject({
|
|
73
|
+
method: "GET",
|
|
74
|
+
url: "/api/a2a/.well-known/agent-card.json",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(response.statusCode).toBe(200);
|
|
78
|
+
const body = JSON.parse(response.body);
|
|
79
|
+
expect(body).toHaveProperty("name", "Axiom Lattice Agent");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─── Agent Card with env override ────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe("Agent Card with custom env", () => {
|
|
86
|
+
it("should reflect custom agent name from env", async () => {
|
|
87
|
+
process.env.A2A_AGENT_NAME = "Custom Bot";
|
|
88
|
+
process.env.A2A_ORGANIZATION = "Custom Org";
|
|
89
|
+
process.env.A2A_VERSION = "2.0.0";
|
|
90
|
+
|
|
91
|
+
const response = await app.inject({
|
|
92
|
+
method: "GET",
|
|
93
|
+
url: "/api/a2a/.well-known/agent.json",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(response.statusCode).toBe(200);
|
|
97
|
+
const body = JSON.parse(response.body);
|
|
98
|
+
expect(body.name).toBe("Custom Bot");
|
|
99
|
+
expect(body.provider.organization).toBe("Custom Org");
|
|
100
|
+
expect(body.version).toBe("2.0.0");
|
|
101
|
+
|
|
102
|
+
delete process.env.A2A_AGENT_NAME;
|
|
103
|
+
delete process.env.A2A_ORGANIZATION;
|
|
104
|
+
delete process.env.A2A_VERSION;
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─── Task Send ───────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe("POST /api/a2a/tasks/send", () => {
|
|
111
|
+
it("should return 400 when message.parts is missing", async () => {
|
|
112
|
+
const response = await app.inject({
|
|
113
|
+
method: "POST",
|
|
114
|
+
url: "/api/a2a/tasks/send",
|
|
115
|
+
headers: {
|
|
116
|
+
authorization: "Bearer valid-key",
|
|
117
|
+
"content-type": "application/json",
|
|
118
|
+
},
|
|
119
|
+
payload: { message: {} },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(response.statusCode).toBe(400);
|
|
123
|
+
const body = JSON.parse(response.body);
|
|
124
|
+
expect(body.error).toBe("message.parts is required");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should return 400 when message text is empty", async () => {
|
|
128
|
+
const response = await app.inject({
|
|
129
|
+
method: "POST",
|
|
130
|
+
url: "/api/a2a/tasks/send",
|
|
131
|
+
headers: {
|
|
132
|
+
authorization: "Bearer valid-key",
|
|
133
|
+
"content-type": "application/json",
|
|
134
|
+
},
|
|
135
|
+
payload: {
|
|
136
|
+
message: {
|
|
137
|
+
role: "user",
|
|
138
|
+
parts: [{ type: "text", text: "" }],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(response.statusCode).toBe(400);
|
|
144
|
+
const body = JSON.parse(response.body);
|
|
145
|
+
expect(body.error).toBe("message must contain text content");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should return 500 when no agent is configured", async () => {
|
|
149
|
+
delete process.env.A2A_DEFAULT_AGENT_ID;
|
|
150
|
+
|
|
151
|
+
const response = await app.inject({
|
|
152
|
+
method: "POST",
|
|
153
|
+
url: "/api/a2a/tasks/send",
|
|
154
|
+
headers: {
|
|
155
|
+
authorization: "Bearer valid-key",
|
|
156
|
+
"content-type": "application/json",
|
|
157
|
+
},
|
|
158
|
+
payload: {
|
|
159
|
+
message: {
|
|
160
|
+
role: "user",
|
|
161
|
+
parts: [{ type: "text", text: "Hello" }],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(response.statusCode).toBe(500);
|
|
167
|
+
const body = JSON.parse(response.body);
|
|
168
|
+
expect(body.error).toBe("No agent configured");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("should return 401 with invalid API key", async () => {
|
|
172
|
+
const response = await app.inject({
|
|
173
|
+
method: "POST",
|
|
174
|
+
url: "/api/a2a/tasks/send",
|
|
175
|
+
headers: {
|
|
176
|
+
authorization: "Bearer wrong-key",
|
|
177
|
+
"content-type": "application/json",
|
|
178
|
+
},
|
|
179
|
+
payload: {
|
|
180
|
+
message: {
|
|
181
|
+
role: "user",
|
|
182
|
+
parts: [{ type: "text", text: "Hello" }],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(response.statusCode).toBe(401);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("should stream task result with valid key and message", async () => {
|
|
191
|
+
mockAddMessage.mockResolvedValueOnce({ messageId: "msg-1" });
|
|
192
|
+
|
|
193
|
+
const mockStream = (async function* () {
|
|
194
|
+
yield {
|
|
195
|
+
type: "ai",
|
|
196
|
+
data: { id: "c1", content: "Hello " },
|
|
197
|
+
};
|
|
198
|
+
yield {
|
|
199
|
+
type: "ai",
|
|
200
|
+
data: { id: "c2", content: "World" },
|
|
201
|
+
};
|
|
202
|
+
})();
|
|
203
|
+
mockChunkStream.mockReturnValueOnce(mockStream);
|
|
204
|
+
|
|
205
|
+
const response = await app.inject({
|
|
206
|
+
method: "POST",
|
|
207
|
+
url: "/api/a2a/tasks/send",
|
|
208
|
+
headers: {
|
|
209
|
+
authorization: "Bearer valid-key",
|
|
210
|
+
"content-type": "application/json",
|
|
211
|
+
},
|
|
212
|
+
payload: {
|
|
213
|
+
message: {
|
|
214
|
+
role: "user",
|
|
215
|
+
parts: [{ type: "text", text: "Hello World" }],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(response.statusCode).toBe(200);
|
|
221
|
+
expect(response.headers["content-type"]).toBe("text/event-stream");
|
|
222
|
+
|
|
223
|
+
// Verify SSE events were emitted
|
|
224
|
+
const body = response.body;
|
|
225
|
+
expect(body).toContain("event: status-update");
|
|
226
|
+
expect(body).toContain('"state":"working"');
|
|
227
|
+
expect(body).toContain("Hello ");
|
|
228
|
+
expect(body).toContain("World");
|
|
229
|
+
expect(body).toContain('"state":"completed"');
|
|
230
|
+
expect(body).toContain('"final":true');
|
|
231
|
+
|
|
232
|
+
// Verify agent was called with correct scope from API key
|
|
233
|
+
expect(mockGetAgent).toHaveBeenCalledWith(
|
|
234
|
+
expect.objectContaining({
|
|
235
|
+
assistant_id: "agent-1",
|
|
236
|
+
tenant_id: "tenant-a",
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Verify agent was called with workspace/project from API key
|
|
241
|
+
// tenant-a has no project/workspace, so they should be undefined
|
|
242
|
+
const getAgentCall = mockGetAgent.mock.calls[0][0];
|
|
243
|
+
expect(getAgentCall.project_id).toBeUndefined();
|
|
244
|
+
expect(getAgentCall.workspace_id).toBeUndefined();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should propagate project scope from API key", async () => {
|
|
248
|
+
process.env.A2A_API_KEYS = "scoped-key:tenant-a:proj-x:ws-y";
|
|
249
|
+
|
|
250
|
+
mockAddMessage.mockResolvedValueOnce({ messageId: "msg-1" });
|
|
251
|
+
const mockStream = (async function* () {
|
|
252
|
+
yield { type: "ai", data: { id: "c1", content: "ok" } };
|
|
253
|
+
})();
|
|
254
|
+
mockChunkStream.mockReturnValueOnce(mockStream);
|
|
255
|
+
|
|
256
|
+
const response = await app.inject({
|
|
257
|
+
method: "POST",
|
|
258
|
+
url: "/api/a2a/tasks/send",
|
|
259
|
+
headers: {
|
|
260
|
+
authorization: "Bearer scoped-key",
|
|
261
|
+
"content-type": "application/json",
|
|
262
|
+
},
|
|
263
|
+
payload: {
|
|
264
|
+
message: {
|
|
265
|
+
role: "user",
|
|
266
|
+
parts: [{ type: "text", text: "Hello" }],
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(response.statusCode).toBe(200);
|
|
272
|
+
|
|
273
|
+
const getAgentCall = mockGetAgent.mock.calls[0][0];
|
|
274
|
+
expect(getAgentCall.tenant_id).toBe("tenant-a");
|
|
275
|
+
expect(getAgentCall.project_id).toBe("proj-x");
|
|
276
|
+
expect(getAgentCall.workspace_id).toBe("ws-y");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ─── Task Cancel ─────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
describe("POST /api/a2a/tasks/:taskId/cancel", () => {
|
|
283
|
+
it("should return 404 for unknown task", async () => {
|
|
284
|
+
const response = await app.inject({
|
|
285
|
+
method: "POST",
|
|
286
|
+
url: "/api/a2a/tasks/nonexistent/cancel",
|
|
287
|
+
headers: {
|
|
288
|
+
authorization: "Bearer valid-key",
|
|
289
|
+
"content-type": "application/json",
|
|
290
|
+
},
|
|
291
|
+
payload: {},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(response.statusCode).toBe(404);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ─── Task Get ────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
describe("GET /api/a2a/tasks/:taskId", () => {
|
|
301
|
+
it("should return 404 for unknown task", async () => {
|
|
302
|
+
const response = await app.inject({
|
|
303
|
+
method: "GET",
|
|
304
|
+
url: "/api/a2a/tasks/nonexistent",
|
|
305
|
+
headers: {
|
|
306
|
+
authorization: "Bearer valid-key",
|
|
307
|
+
"content-type": "application/json",
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(response.statusCode).toBe(404);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ─── No auth mode ────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
describe("when no API keys configured", () => {
|
|
318
|
+
beforeEach(() => {
|
|
319
|
+
delete process.env.A2A_API_KEYS;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("should accept requests without auth", async () => {
|
|
323
|
+
mockAddMessage.mockResolvedValueOnce({ messageId: "msg-1" });
|
|
324
|
+
const mockStream = (async function* () {
|
|
325
|
+
yield { type: "ai", data: { id: "c1", content: "Hi" } };
|
|
326
|
+
})();
|
|
327
|
+
mockChunkStream.mockReturnValueOnce(mockStream);
|
|
328
|
+
|
|
329
|
+
const response = await app.inject({
|
|
330
|
+
method: "POST",
|
|
331
|
+
url: "/api/a2a/tasks/send",
|
|
332
|
+
headers: {
|
|
333
|
+
"content-type": "application/json",
|
|
334
|
+
},
|
|
335
|
+
payload: {
|
|
336
|
+
message: {
|
|
337
|
+
role: "user",
|
|
338
|
+
parts: [{ type: "text", text: "Hello" }],
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(response.statusCode).toBe(200);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -131,6 +131,14 @@ app.addHook("preHandler", async (request, reply) => {
|
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
// Add default header values for project/workspace isolation
|
|
135
|
+
app.addHook("onRequest", (request, reply, done) => {
|
|
136
|
+
if (!request.headers["x-project-id"]) {
|
|
137
|
+
request.headers["x-project-id"] = "default";
|
|
138
|
+
}
|
|
139
|
+
done();
|
|
140
|
+
});
|
|
141
|
+
|
|
134
142
|
// Add custom logging hooks
|
|
135
143
|
app.addHook("onRequest", (request, reply, done) => {
|
|
136
144
|
const context = {
|
|
@@ -294,6 +302,17 @@ const start = async (config?: LatticeGatewayConfig) => {
|
|
|
294
302
|
});
|
|
295
303
|
|
|
296
304
|
channelDeps = { router, installationStore };
|
|
305
|
+
|
|
306
|
+
// Initialize A2A API key store
|
|
307
|
+
try {
|
|
308
|
+
const a2aKeyStore = getStoreLattice("default", "a2aApiKey").store as import("@axiom-lattice/protocols").A2AApiKeyStore;
|
|
309
|
+
const a2a = await import("./routes/a2a");
|
|
310
|
+
a2a.setA2AKeyStore(a2aKeyStore);
|
|
311
|
+
await a2a.refreshStoreKeyMap();
|
|
312
|
+
logger.info("A2A key store initialized");
|
|
313
|
+
} catch {
|
|
314
|
+
// A2A key store optional — env-based keys still work
|
|
315
|
+
}
|
|
297
316
|
} catch {
|
|
298
317
|
// Stores not registered — channel routes will be skipped gracefully
|
|
299
318
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A Bridge — WebSocket-based reverse proxy for agents behind NAT.
|
|
3
|
+
*
|
|
4
|
+
* Agents open an outbound WebSocket to the gateway. The gateway stores
|
|
5
|
+
* the connection and exposes an HTTP proxy endpoint per agent. When the
|
|
6
|
+
* orchestrator calls the agent, the gateway proxies the HTTP request
|
|
7
|
+
* over the persistent WebSocket instead of trying to reach a private IP.
|
|
8
|
+
*
|
|
9
|
+
* Agent flow:
|
|
10
|
+
* 1. Connect ws://gateway/api/a2a/bridge
|
|
11
|
+
* 2. Send { type: "agent:register", agentId: "...", agentCard: {...} }
|
|
12
|
+
* 3. Wait for { type: "http:request", requestId, method, path, headers, body }
|
|
13
|
+
* 4. Process locally, respond with { type: "http:response", requestId, status, headers, body }
|
|
14
|
+
*
|
|
15
|
+
* Gateway flow:
|
|
16
|
+
* 1. Accept WebSocket, store connection by agentId
|
|
17
|
+
* 2. Expose GET/POST /api/a2a/bridge/proxy/{agentId}/*
|
|
18
|
+
* 3. When HTTP request arrives, push over WebSocket, await response, relay
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
22
|
+
import type { WebSocket } from 'ws';
|
|
23
|
+
import { v4 } from 'uuid';
|
|
24
|
+
|
|
25
|
+
// ─── Logging ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const log = {
|
|
28
|
+
info(msg: string, ctx?: Record<string, unknown>) {
|
|
29
|
+
console.log(JSON.stringify({ level: 'info', msg, ...ctx }));
|
|
30
|
+
},
|
|
31
|
+
warn(msg: string, ctx?: Record<string, unknown>) {
|
|
32
|
+
console.warn(JSON.stringify({ level: 'warn', msg, ...ctx }));
|
|
33
|
+
},
|
|
34
|
+
error(msg: string, ctx?: Record<string, unknown>) {
|
|
35
|
+
console.error(JSON.stringify({ level: 'error', msg, ...ctx }));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface PendingRequest {
|
|
42
|
+
resolve: (response: BridgeHttpResponse) => void;
|
|
43
|
+
reject: (error: Error) => void;
|
|
44
|
+
timer: ReturnType<typeof setTimeout>;
|
|
45
|
+
/** The agent owning this pending request — used for cleanup on disconnect */
|
|
46
|
+
agentId: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface BridgeHttpResponse {
|
|
50
|
+
status: number;
|
|
51
|
+
headers: Record<string, string>;
|
|
52
|
+
body: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface AgentConnection {
|
|
56
|
+
ws: WebSocket;
|
|
57
|
+
agentId: string;
|
|
58
|
+
agentCard: Record<string, unknown>;
|
|
59
|
+
connectedAt: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Connection Registry ────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
const connections = new Map<string, AgentConnection>();
|
|
65
|
+
const pending = new Map<string, PendingRequest>();
|
|
66
|
+
|
|
67
|
+
function getAgentIdFromPath(path: string): string | null {
|
|
68
|
+
// Path: /api/a2a/bridge/proxy/{agentId}/...
|
|
69
|
+
const match = path.match(/^\/api\/a2a\/bridge\/proxy\/([^/]+)/);
|
|
70
|
+
return match ? match[1] : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── WebSocket Handler ──────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function handleBridgeConnection(socket: WebSocket, _request: FastifyRequest): void {
|
|
76
|
+
let agentId: string | null = null;
|
|
77
|
+
|
|
78
|
+
log.info('Bridge WebSocket connected');
|
|
79
|
+
|
|
80
|
+
socket.on('message', (raw) => {
|
|
81
|
+
let msg: Record<string, unknown>;
|
|
82
|
+
try {
|
|
83
|
+
msg = JSON.parse(raw.toString());
|
|
84
|
+
} catch {
|
|
85
|
+
log.warn('Bridge invalid message');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
switch (msg.type) {
|
|
90
|
+
case 'agent:register': {
|
|
91
|
+
agentId = msg.agentId as string;
|
|
92
|
+
if (!agentId) {
|
|
93
|
+
socket.send(JSON.stringify({ type: 'error', message: 'agentId required' }));
|
|
94
|
+
socket.close();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
connections.set(agentId, {
|
|
98
|
+
ws: socket,
|
|
99
|
+
agentId,
|
|
100
|
+
agentCard: msg.agentCard as Record<string, unknown> ?? {},
|
|
101
|
+
connectedAt: Date.now(),
|
|
102
|
+
});
|
|
103
|
+
log.info('Agent registered via bridge', { agentId });
|
|
104
|
+
socket.send(JSON.stringify({ type: 'agent:registered', agentId }));
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case 'http:response': {
|
|
109
|
+
const requestId = msg.requestId as string;
|
|
110
|
+
const p = pending.get(requestId);
|
|
111
|
+
if (p) {
|
|
112
|
+
clearTimeout(p.timer);
|
|
113
|
+
pending.delete(requestId);
|
|
114
|
+
p.resolve({
|
|
115
|
+
status: (msg.status as number) ?? 200,
|
|
116
|
+
headers: (msg.headers as Record<string, string>) ?? {},
|
|
117
|
+
body: (msg.body as string) ?? '',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case 'http:error': {
|
|
124
|
+
const requestId = msg.requestId as string;
|
|
125
|
+
const p = pending.get(requestId);
|
|
126
|
+
if (p) {
|
|
127
|
+
clearTimeout(p.timer);
|
|
128
|
+
pending.delete(requestId);
|
|
129
|
+
p.reject(new Error((msg.message as string) ?? 'Agent error'));
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
socket.on('close', () => {
|
|
137
|
+
if (agentId) {
|
|
138
|
+
connections.delete(agentId);
|
|
139
|
+
log.info('Bridge WebSocket disconnected', { agentId });
|
|
140
|
+
// Reject only this agent's pending requests
|
|
141
|
+
for (const [reqId, p] of pending) {
|
|
142
|
+
if (p.agentId === agentId) {
|
|
143
|
+
clearTimeout(p.timer);
|
|
144
|
+
p.reject(new Error('Agent disconnected'));
|
|
145
|
+
pending.delete(reqId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
socket.on('error', (err) => {
|
|
152
|
+
log.error('Bridge WebSocket error', { agentId, error: err.message });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── HTTP Proxy Handler ─────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
async function handleBridgeProxy(
|
|
159
|
+
request: FastifyRequest,
|
|
160
|
+
reply: FastifyReply,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const agentId = getAgentIdFromPath(request.url);
|
|
163
|
+
if (!agentId) {
|
|
164
|
+
reply.status(400).send({ error: 'Agent ID not found in path' });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const conn = connections.get(agentId);
|
|
169
|
+
if (!conn) {
|
|
170
|
+
reply.status(503).send({
|
|
171
|
+
error: 'Agent not connected',
|
|
172
|
+
message: `Agent "${agentId}" is not connected via WebSocket bridge`,
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const requestId = v4();
|
|
178
|
+
|
|
179
|
+
// Build sub-path (strip the proxy prefix)
|
|
180
|
+
const subPath = request.url.replace(`/api/a2a/bridge/proxy/${agentId}`, '') || '/';
|
|
181
|
+
|
|
182
|
+
// Read body
|
|
183
|
+
let body: string | undefined;
|
|
184
|
+
if (request.method === 'POST' || request.method === 'PUT') {
|
|
185
|
+
body = typeof request.body === 'string'
|
|
186
|
+
? request.body
|
|
187
|
+
: JSON.stringify(request.body ?? {});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const bridgeRequest = {
|
|
191
|
+
type: 'http:request',
|
|
192
|
+
requestId,
|
|
193
|
+
method: request.method,
|
|
194
|
+
path: subPath,
|
|
195
|
+
headers: {
|
|
196
|
+
'content-type': request.headers['content-type'] ?? 'application/json',
|
|
197
|
+
'accept': request.headers['accept'] ?? '*/*',
|
|
198
|
+
},
|
|
199
|
+
body: body ?? '',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const timeout = 300_000; // 5 min
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const response = await new Promise<BridgeHttpResponse>((resolve, reject) => {
|
|
206
|
+
const timer = setTimeout(() => {
|
|
207
|
+
pending.delete(requestId);
|
|
208
|
+
reject(new Error(`Bridge request timeout after ${timeout}ms`));
|
|
209
|
+
}, timeout);
|
|
210
|
+
|
|
211
|
+
pending.set(requestId, { resolve, reject, timer, agentId });
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
if (conn.ws.readyState !== conn.ws.OPEN) {
|
|
215
|
+
pending.delete(requestId);
|
|
216
|
+
reject(new Error('WebSocket not open'));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
conn.ws.send(JSON.stringify(bridgeRequest));
|
|
220
|
+
} catch (err) {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
pending.delete(requestId);
|
|
223
|
+
reject(err as Error);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Relay response headers
|
|
228
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
229
|
+
if (!['content-length', 'transfer-encoding', 'connection'].includes(key.toLowerCase())) {
|
|
230
|
+
reply.header(key, value);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
reply.status(response.status);
|
|
235
|
+
|
|
236
|
+
// Try to send as JSON if possible
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(response.body);
|
|
239
|
+
reply.send(parsed);
|
|
240
|
+
} catch {
|
|
241
|
+
reply.send(response.body);
|
|
242
|
+
}
|
|
243
|
+
} catch (err) {
|
|
244
|
+
log.error('Bridge proxy error', { agentId, error: (err as Error).message });
|
|
245
|
+
reply.status(502).send({
|
|
246
|
+
error: 'Bridge proxy error',
|
|
247
|
+
message: (err as Error).message,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Route Registration ─────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
export function registerA2ABridgeRoutes(app: FastifyInstance): void {
|
|
255
|
+
// WebSocket endpoint for agents to connect
|
|
256
|
+
app.get('/api/a2a/bridge', { websocket: true }, (socket, req) => {
|
|
257
|
+
handleBridgeConnection(socket, req);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// HTTP proxy endpoint — all paths under /api/a2a/bridge/proxy/{agentId}/
|
|
261
|
+
app.route({
|
|
262
|
+
method: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
|
263
|
+
url: '/api/a2a/bridge/proxy/*',
|
|
264
|
+
handler: handleBridgeProxy,
|
|
265
|
+
});
|
|
266
|
+
}
|