@axiom-lattice/gateway 2.1.116 → 3.0.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/.turbo/turbo-build.log +15 -15
- package/CHANGELOG.md +37 -0
- package/dist/{a2a-ERG5RMUW.mjs → a2a-QMHEH3ZT.mjs} +77 -29
- package/dist/a2a-QMHEH3ZT.mjs.map +1 -0
- package/dist/index.js +1144 -177
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1013 -92
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -5
- package/src/__tests__/a2a.test.ts +82 -0
- package/src/__tests__/workspace.test.ts +13 -3
- package/src/config/local-a2a-templates.ts +59 -0
- package/src/controllers/__tests__/assistant-local-a2a.test.ts +147 -0
- package/src/controllers/__tests__/local-a2a-templates.test.ts +112 -0
- package/src/controllers/assistant.ts +126 -4
- package/src/controllers/local-a2a-templates.ts +18 -0
- package/src/controllers/local-a2a.ts +149 -0
- package/src/controllers/tasks.ts +2 -3
- package/src/controllers/workspace.ts +144 -17
- package/src/index.ts +2 -10
- package/src/routes/__tests__/a2a-test-connection.test.ts +177 -0
- package/src/routes/a2a-test-connection.ts +126 -0
- package/src/routes/a2a.ts +100 -28
- package/src/routes/index.ts +27 -0
- package/src/routes/stt.ts +102 -0
- package/src/services/__tests__/local-a2a.test.ts +124 -0
- package/src/services/eval-runner.ts +6 -0
- package/src/services/local-a2a.ts +352 -0
- package/src/utils/__tests__/local-a2a-runtime.test.ts +77 -0
- package/src/utils/__tests__/path-containment.test.ts +35 -0
- package/src/utils/a2a-url.ts +20 -0
- package/src/utils/local-a2a-runtime.ts +107 -0
- package/src/utils/path-containment.ts +38 -0
- package/dist/a2a-ERG5RMUW.mjs.map +0 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { FastifyInstance } from "fastify";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { AgentCard, LocalRuntimeConfig } from "@axiom-lattice/protocols";
|
|
4
|
+
import { localA2AService } from "../services/local-a2a";
|
|
5
|
+
import { normalizeA2AAgentCardUrl } from "../utils/a2a-url";
|
|
6
|
+
import {
|
|
7
|
+
validateLocalRuntimeCommand,
|
|
8
|
+
validateLocalRuntimePort,
|
|
9
|
+
} from "../utils/local-a2a-runtime";
|
|
10
|
+
|
|
11
|
+
type TestConnectionError = Error & {
|
|
12
|
+
code?: string;
|
|
13
|
+
cause?: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function describeTestConnectionError(error: unknown, agentCardUrl: string): string {
|
|
17
|
+
if (!(error instanceof Error)) {
|
|
18
|
+
return `Failed to fetch agent card from ${agentCardUrl}: ${String(error)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const cause = (error as TestConnectionError).cause;
|
|
22
|
+
const causeParts: string[] = [];
|
|
23
|
+
if (cause instanceof Error) {
|
|
24
|
+
causeParts.push(cause.message);
|
|
25
|
+
const causeCode = (cause as TestConnectionError).code;
|
|
26
|
+
if (causeCode && !causeParts.some((part) => part.includes(causeCode))) {
|
|
27
|
+
causeParts.push(causeCode);
|
|
28
|
+
}
|
|
29
|
+
} else if (typeof cause === "object" && cause !== null) {
|
|
30
|
+
const code = "code" in cause ? String(cause.code) : "";
|
|
31
|
+
const message = "message" in cause ? String(cause.message) : "";
|
|
32
|
+
if (message) causeParts.push(message);
|
|
33
|
+
if (code && !message.includes(code)) causeParts.push(code);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const detail = causeParts.length > 0 ? ` (${causeParts.join(", ")})` : "";
|
|
37
|
+
return `Failed to fetch agent card from ${agentCardUrl}: ${error.message}${detail}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function ensureLocalRuntimeForConnectionTest(
|
|
41
|
+
localRuntime: LocalRuntimeConfig,
|
|
42
|
+
agentCardUrl: string,
|
|
43
|
+
): Promise<{ runtimeId: string } | { error: string }> {
|
|
44
|
+
const commandValidation = validateLocalRuntimeCommand(localRuntime);
|
|
45
|
+
if (!commandValidation.ok) {
|
|
46
|
+
return { error: commandValidation.error };
|
|
47
|
+
}
|
|
48
|
+
const testRuntime = {
|
|
49
|
+
...localRuntime,
|
|
50
|
+
agentCardUrl,
|
|
51
|
+
command: {
|
|
52
|
+
...localRuntime.command,
|
|
53
|
+
executable: localRuntime.command.executable.trim(),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const portValidation = validateLocalRuntimePort(testRuntime, agentCardUrl);
|
|
57
|
+
if (!portValidation.ok) {
|
|
58
|
+
return { error: portValidation.error };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const state = await localA2AService.startProvider(
|
|
62
|
+
`test-${randomUUID()}`,
|
|
63
|
+
testRuntime,
|
|
64
|
+
);
|
|
65
|
+
if (state.status === "running") return { runtimeId: state.runtimeId };
|
|
66
|
+
const detail = state.message ? ` ${state.message}` : "";
|
|
67
|
+
localA2AService.removeState(state.runtimeId);
|
|
68
|
+
return { error: `Local managed A2A server could not start for connection test.${detail}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readShortResponseBody(resp: Response): Promise<string> {
|
|
72
|
+
const body = await resp.text().catch(() => "");
|
|
73
|
+
return body.length > 500 ? `${body.slice(0, 500)}...` : body;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerA2ATestConnectionRoute(app: FastifyInstance): void {
|
|
77
|
+
app.post("/api/a2a/test-connection", async (request, reply) => {
|
|
78
|
+
const { agentCardUrl, apiKey, localRuntime } = (request.body ?? {}) as {
|
|
79
|
+
agentCardUrl?: string;
|
|
80
|
+
apiKey?: string;
|
|
81
|
+
localRuntime?: LocalRuntimeConfig;
|
|
82
|
+
};
|
|
83
|
+
const normalizedAgentCardUrl = normalizeA2AAgentCardUrl(
|
|
84
|
+
agentCardUrl,
|
|
85
|
+
localRuntime ? "http://" : "https://",
|
|
86
|
+
);
|
|
87
|
+
if (!normalizedAgentCardUrl) {
|
|
88
|
+
return reply.status(400).send({ success: false, error: "agentCardUrl is required" });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let testRuntimeId: string | null = null;
|
|
92
|
+
try {
|
|
93
|
+
const headers: Record<string, string> = {};
|
|
94
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
95
|
+
|
|
96
|
+
if (localRuntime) {
|
|
97
|
+
const localStartResult = await ensureLocalRuntimeForConnectionTest(localRuntime, normalizedAgentCardUrl);
|
|
98
|
+
if ("error" in localStartResult) {
|
|
99
|
+
return reply.send({ success: true, data: { reachable: false, error: localStartResult.error } });
|
|
100
|
+
}
|
|
101
|
+
testRuntimeId = localStartResult.runtimeId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const resp = await fetch(normalizedAgentCardUrl, { headers, signal: AbortSignal.timeout(8000) });
|
|
105
|
+
if (!resp.ok) {
|
|
106
|
+
const body = await readShortResponseBody(resp);
|
|
107
|
+
const suffix = body ? `: ${body}` : "";
|
|
108
|
+
return reply.send({ success: true, data: { reachable: false, error: `HTTP ${resp.status} ${resp.statusText}${suffix}` } });
|
|
109
|
+
}
|
|
110
|
+
const card = await resp.json() as AgentCard;
|
|
111
|
+
return reply.send({ success: true, data: { reachable: true, agentName: card.name, agentCard: card } });
|
|
112
|
+
} catch (error: unknown) {
|
|
113
|
+
return reply.send({
|
|
114
|
+
success: true,
|
|
115
|
+
data: {
|
|
116
|
+
reachable: false,
|
|
117
|
+
error: describeTestConnectionError(error, normalizedAgentCardUrl),
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
} finally {
|
|
121
|
+
if (testRuntimeId) {
|
|
122
|
+
localA2AService.removeState(testRuntimeId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
package/src/routes/a2a.ts
CHANGED
|
@@ -23,6 +23,8 @@ import type {
|
|
|
23
23
|
interface TaskRecord {
|
|
24
24
|
id: string;
|
|
25
25
|
threadId: string;
|
|
26
|
+
contextId?: string;
|
|
27
|
+
referenceTaskIds?: string[];
|
|
26
28
|
messageId?: string;
|
|
27
29
|
assistantId: string;
|
|
28
30
|
tenantId: string;
|
|
@@ -36,6 +38,17 @@ interface TaskRecord {
|
|
|
36
38
|
|
|
37
39
|
const taskStore = new Map<string, TaskRecord>();
|
|
38
40
|
|
|
41
|
+
const MAX_CONTEXT_THREADS = 100;
|
|
42
|
+
const contextThreadMap = new Map<string, string>();
|
|
43
|
+
|
|
44
|
+
function cacheContextThread(contextId: string, threadId: string): void {
|
|
45
|
+
if (contextThreadMap.size >= MAX_CONTEXT_THREADS) {
|
|
46
|
+
const oldest = contextThreadMap.keys().next().value;
|
|
47
|
+
if (oldest !== undefined) contextThreadMap.delete(oldest);
|
|
48
|
+
}
|
|
49
|
+
contextThreadMap.set(contextId, threadId);
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
// ─── Key Store ────────────────────────────────────────────────────────────
|
|
40
53
|
|
|
41
54
|
let _a2aKeyStore: A2AApiKeyStore | null = null;
|
|
@@ -200,10 +213,12 @@ type SendEventFn = (event: string, data: Record<string, unknown>) => void;
|
|
|
200
213
|
async function streamChunksAsA2A(
|
|
201
214
|
stream: AsyncIterable<{ type: unknown; data?: { content?: string } }>,
|
|
202
215
|
taskId: string,
|
|
216
|
+
contextId: string | undefined,
|
|
203
217
|
sendEvent: SendEventFn,
|
|
204
218
|
onInterrupt?: () => void,
|
|
205
|
-
): Promise<string> {
|
|
219
|
+
): Promise<{ text: string; interrupted: boolean }> {
|
|
206
220
|
let accumulatedText = "";
|
|
221
|
+
let interrupted = false;
|
|
207
222
|
|
|
208
223
|
for await (const chunk of stream) {
|
|
209
224
|
const chunkText = (chunk as { data?: { content?: string } }).data?.content || "";
|
|
@@ -212,6 +227,7 @@ async function streamChunksAsA2A(
|
|
|
212
227
|
if (chunkType === MessageChunkTypes.INTERRUPT) {
|
|
213
228
|
sendEvent("status-update", {
|
|
214
229
|
id: taskId,
|
|
230
|
+
contextId,
|
|
215
231
|
status: {
|
|
216
232
|
state: "input-required",
|
|
217
233
|
message: {
|
|
@@ -223,7 +239,8 @@ async function streamChunksAsA2A(
|
|
|
223
239
|
final: false,
|
|
224
240
|
});
|
|
225
241
|
onInterrupt?.();
|
|
226
|
-
|
|
242
|
+
interrupted = true;
|
|
243
|
+
break;
|
|
227
244
|
}
|
|
228
245
|
|
|
229
246
|
if (
|
|
@@ -234,6 +251,7 @@ async function streamChunksAsA2A(
|
|
|
234
251
|
accumulatedText += chunkText;
|
|
235
252
|
sendEvent("status-update", {
|
|
236
253
|
id: taskId,
|
|
254
|
+
contextId,
|
|
237
255
|
status: {
|
|
238
256
|
state: "working",
|
|
239
257
|
message: {
|
|
@@ -248,7 +266,7 @@ async function streamChunksAsA2A(
|
|
|
248
266
|
}
|
|
249
267
|
}
|
|
250
268
|
|
|
251
|
-
return accumulatedText;
|
|
269
|
+
return { text: accumulatedText, interrupted };
|
|
252
270
|
}
|
|
253
271
|
|
|
254
272
|
// ─── Task Status ──────────────────────────────────────────────────────────
|
|
@@ -331,8 +349,52 @@ async function handleTasksSend(
|
|
|
331
349
|
return;
|
|
332
350
|
}
|
|
333
351
|
|
|
352
|
+
const contextId = body.message.contextId;
|
|
353
|
+
const referenceTaskIds = body.message.referenceTaskIds;
|
|
334
354
|
const taskId = body.id || v4();
|
|
335
|
-
|
|
355
|
+
|
|
356
|
+
let threadId: string;
|
|
357
|
+
let resolvedReferenceTaskIds: string[] | undefined;
|
|
358
|
+
|
|
359
|
+
// Explicit task continuation: if body.id matches a stored task
|
|
360
|
+
if (body.id) {
|
|
361
|
+
const existingTask = taskStore.get(body.id);
|
|
362
|
+
if (existingTask) {
|
|
363
|
+
if (existingTask.status === "input-required") {
|
|
364
|
+
threadId = existingTask.threadId;
|
|
365
|
+
resolvedReferenceTaskIds = referenceTaskIds;
|
|
366
|
+
} else if (
|
|
367
|
+
existingTask.status === "completed" ||
|
|
368
|
+
existingTask.status === "failed" ||
|
|
369
|
+
existingTask.status === "canceled"
|
|
370
|
+
) {
|
|
371
|
+
threadId = v4();
|
|
372
|
+
resolvedReferenceTaskIds = referenceTaskIds
|
|
373
|
+
? [...referenceTaskIds, body.id]
|
|
374
|
+
: [body.id];
|
|
375
|
+
} else {
|
|
376
|
+
threadId = v4();
|
|
377
|
+
resolvedReferenceTaskIds = referenceTaskIds;
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
threadId = v4();
|
|
381
|
+
resolvedReferenceTaskIds = referenceTaskIds
|
|
382
|
+
? [...referenceTaskIds, body.id]
|
|
383
|
+
: [body.id];
|
|
384
|
+
}
|
|
385
|
+
} else if (contextId && contextThreadMap.has(contextId)) {
|
|
386
|
+
threadId = contextThreadMap.get(contextId)!;
|
|
387
|
+
console.warn({
|
|
388
|
+
event: "a2a:deprecated:contextId_thread_reuse",
|
|
389
|
+
message: "contextId-based thread reuse is deprecated; use referenceTaskIds and application-layer conversation state instead",
|
|
390
|
+
contextId,
|
|
391
|
+
threadId,
|
|
392
|
+
});
|
|
393
|
+
resolvedReferenceTaskIds = referenceTaskIds;
|
|
394
|
+
} else {
|
|
395
|
+
threadId = v4();
|
|
396
|
+
resolvedReferenceTaskIds = referenceTaskIds;
|
|
397
|
+
}
|
|
336
398
|
|
|
337
399
|
const tenantId = auth.tenantId || config.defaultTenantId;
|
|
338
400
|
|
|
@@ -341,6 +403,8 @@ async function handleTasksSend(
|
|
|
341
403
|
const record: TaskRecord = {
|
|
342
404
|
id: taskId,
|
|
343
405
|
threadId,
|
|
406
|
+
contextId,
|
|
407
|
+
referenceTaskIds: resolvedReferenceTaskIds,
|
|
344
408
|
assistantId,
|
|
345
409
|
tenantId,
|
|
346
410
|
projectId: auth.projectId || undefined,
|
|
@@ -401,26 +465,29 @@ async function handleTasksSend(
|
|
|
401
465
|
MessageChunkTypes.MESSAGE_COMPLETED,
|
|
402
466
|
]);
|
|
403
467
|
|
|
404
|
-
const accumulatedText = await streamChunksAsA2A(stream, taskId, sendEvent, () => {
|
|
468
|
+
const { text: accumulatedText, interrupted } = await streamChunksAsA2A(stream, taskId, contextId, sendEvent, () => {
|
|
405
469
|
record.status = "input-required";
|
|
406
470
|
record.updatedAt = isoNow();
|
|
407
471
|
});
|
|
408
472
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
473
|
+
if (!interrupted) {
|
|
474
|
+
record.status = "completed";
|
|
475
|
+
record.updatedAt = isoNow();
|
|
476
|
+
record.history.push({
|
|
477
|
+
role: "agent",
|
|
478
|
+
parts: [buildTextPart(accumulatedText)],
|
|
479
|
+
});
|
|
415
480
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
481
|
+
sendEvent("status-update", {
|
|
482
|
+
id: taskId,
|
|
483
|
+
contextId,
|
|
484
|
+
status: {
|
|
485
|
+
state: "completed",
|
|
486
|
+
timestamp: isoNow(),
|
|
487
|
+
},
|
|
488
|
+
final: true,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
424
491
|
} catch (err) {
|
|
425
492
|
record.status = "failed";
|
|
426
493
|
record.updatedAt = isoNow();
|
|
@@ -432,6 +499,7 @@ async function handleTasksSend(
|
|
|
432
499
|
|
|
433
500
|
sendEvent("status-update", {
|
|
434
501
|
id: taskId,
|
|
502
|
+
contextId,
|
|
435
503
|
status: {
|
|
436
504
|
state: "failed",
|
|
437
505
|
timestamp: isoNow(),
|
|
@@ -542,6 +610,7 @@ async function handleTasksStream(
|
|
|
542
610
|
if (!record.messageId) {
|
|
543
611
|
sendEvent("status-update", {
|
|
544
612
|
id: taskId,
|
|
613
|
+
contextId: record.contextId,
|
|
545
614
|
status: {
|
|
546
615
|
state: record.status,
|
|
547
616
|
timestamp: record.updatedAt,
|
|
@@ -557,16 +626,19 @@ async function handleTasksStream(
|
|
|
557
626
|
MessageChunkTypes.MESSAGE_COMPLETED,
|
|
558
627
|
]);
|
|
559
628
|
|
|
560
|
-
await streamChunksAsA2A(stream, taskId, sendEvent);
|
|
629
|
+
const { interrupted } = await streamChunksAsA2A(stream, taskId, record.contextId, sendEvent);
|
|
561
630
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
631
|
+
if (!interrupted) {
|
|
632
|
+
sendEvent("status-update", {
|
|
633
|
+
id: taskId,
|
|
634
|
+
contextId: record.contextId,
|
|
635
|
+
status: {
|
|
636
|
+
state: record.status,
|
|
637
|
+
timestamp: record.updatedAt,
|
|
638
|
+
},
|
|
639
|
+
final: true,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
570
642
|
} catch (err) {
|
|
571
643
|
sendEvent("error", {
|
|
572
644
|
code: "STREAM_ERROR",
|
package/src/routes/index.ts
CHANGED
|
@@ -65,11 +65,38 @@ import type { ChannelInstallationStore } from "@axiom-lattice/protocols";
|
|
|
65
65
|
// } from "../controllers/thread_status";
|
|
66
66
|
|
|
67
67
|
import { registerA2ABridgeRoutes } from './a2a-bridge';
|
|
68
|
+
import { registerSTTRoutes } from './stt';
|
|
69
|
+
import {
|
|
70
|
+
getLocalA2AProvider,
|
|
71
|
+
getLocalA2AStatus,
|
|
72
|
+
restartLocalA2AProvider,
|
|
73
|
+
startLocalA2AProvider,
|
|
74
|
+
stopLocalA2AProvider,
|
|
75
|
+
} from "../controllers/local-a2a";
|
|
76
|
+
import {
|
|
77
|
+
getLocalA2ATemplates,
|
|
78
|
+
} from "../controllers/local-a2a-templates";
|
|
79
|
+
import { registerA2ATestConnectionRoute } from "./a2a-test-connection";
|
|
68
80
|
|
|
69
81
|
export const registerLatticeRoutes = (app: FastifyInstance, channelDeps?: { router: MessageRouter; installationStore: ChannelInstallationStore }): void => {
|
|
70
82
|
// A2A Bridge — WebSocket-based reverse proxy for agents behind NAT
|
|
71
83
|
registerA2ABridgeRoutes(app);
|
|
72
84
|
|
|
85
|
+
// STT (speech-to-text) transcription
|
|
86
|
+
registerSTTRoutes(app);
|
|
87
|
+
|
|
88
|
+
// Local A2A runtime management
|
|
89
|
+
app.get("/api/local-a2a", getLocalA2AStatus);
|
|
90
|
+
|
|
91
|
+
app.get("/api/local-a2a/templates", getLocalA2ATemplates);
|
|
92
|
+
|
|
93
|
+
app.get("/api/local-a2a/:assistantId", getLocalA2AProvider);
|
|
94
|
+
app.post("/api/local-a2a/:assistantId/start", startLocalA2AProvider);
|
|
95
|
+
app.post("/api/local-a2a/:assistantId/stop", stopLocalA2AProvider);
|
|
96
|
+
app.post("/api/local-a2a/:assistantId/restart", restartLocalA2AProvider);
|
|
97
|
+
|
|
98
|
+
registerA2ATestConnectionRoute(app);
|
|
99
|
+
|
|
73
100
|
// 运行路由
|
|
74
101
|
app.post<{
|
|
75
102
|
Body: any;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { FastifyInstance } from "fastify";
|
|
2
|
+
import { getSTTClient, sttModelLatticeManager } from "@axiom-lattice/core";
|
|
3
|
+
|
|
4
|
+
const ALLOWED_AUDIO_FORMATS = new Set([
|
|
5
|
+
"webm",
|
|
6
|
+
"wav",
|
|
7
|
+
"mp3",
|
|
8
|
+
"m4a",
|
|
9
|
+
"ogg",
|
|
10
|
+
"flac",
|
|
11
|
+
"aac",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register STT (speech-to-text) transcription route.
|
|
18
|
+
*
|
|
19
|
+
* POST /api/stt/transcribe
|
|
20
|
+
* Content-Type: multipart/form-data
|
|
21
|
+
* Body: { file: File }
|
|
22
|
+
* Query: ?model=default
|
|
23
|
+
* Response: { text: string, confidence?: number }
|
|
24
|
+
*/
|
|
25
|
+
export const registerSTTRoutes = (app: FastifyInstance): void => {
|
|
26
|
+
// List registered STT models
|
|
27
|
+
app.get("/api/stt/models", async (_request, reply) => {
|
|
28
|
+
const models = sttModelLatticeManager.getAllSTTModelInfo();
|
|
29
|
+
return reply.send({ models });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
app.post<{ Querystring: { model?: string } }>(
|
|
33
|
+
"/api/stt/transcribe",
|
|
34
|
+
{
|
|
35
|
+
schema: {
|
|
36
|
+
querystring: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
model: { type: "string" },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
response: {
|
|
43
|
+
200: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
text: { type: "string" },
|
|
47
|
+
confidence: { type: "number" },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
async (request, reply) => {
|
|
54
|
+
const modelKey = request.query.model || "default";
|
|
55
|
+
const data = await request.file({
|
|
56
|
+
limits: { fileSize: MAX_AUDIO_FILE_SIZE },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!data) {
|
|
60
|
+
return reply.status(400).send({ error: "No audio file provided" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const buffer = await data.toBuffer();
|
|
64
|
+
if (buffer.length === 0) {
|
|
65
|
+
return reply.status(400).send({ error: "Empty audio file" });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const format = (data.filename.split(".").pop() || "webm").toLowerCase();
|
|
69
|
+
|
|
70
|
+
if (!ALLOWED_AUDIO_FORMATS.has(format)) {
|
|
71
|
+
return reply
|
|
72
|
+
.status(400)
|
|
73
|
+
.send({ error: `Unsupported audio format: ${format}` });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
let client;
|
|
78
|
+
try {
|
|
79
|
+
client = getSTTClient(modelKey);
|
|
80
|
+
} catch {
|
|
81
|
+
return reply.status(404).send({ error: `STT model "${modelKey}" not found` });
|
|
82
|
+
}
|
|
83
|
+
const result = await client.transcribe(buffer, format);
|
|
84
|
+
|
|
85
|
+
return reply.send({
|
|
86
|
+
text: result.text,
|
|
87
|
+
confidence: result.confidence,
|
|
88
|
+
});
|
|
89
|
+
} catch (error: unknown) {
|
|
90
|
+
const sanitizedMessage =
|
|
91
|
+
error instanceof Error ? error.message : "Transcription failed";
|
|
92
|
+
request.log.error(
|
|
93
|
+
{ error: sanitizedMessage },
|
|
94
|
+
"STT transcription failed",
|
|
95
|
+
);
|
|
96
|
+
return reply
|
|
97
|
+
.status(500)
|
|
98
|
+
.send({ error: "Transcription service error. Please try again." });
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
appendStderrTail,
|
|
6
|
+
LocalA2AService,
|
|
7
|
+
resolveLocalA2AWorkingDirectory,
|
|
8
|
+
} from "../local-a2a";
|
|
9
|
+
import type { LocalRuntimeConfig } from "@axiom-lattice/protocols";
|
|
10
|
+
|
|
11
|
+
describe("LocalA2AService working directory resolution", () => {
|
|
12
|
+
let tempDir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tempDir = mkdtempSync(join(tmpdir(), "local-a2a-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("prefers env override, then explicit command cwd", () => {
|
|
23
|
+
expect(resolveLocalA2AWorkingDirectory({
|
|
24
|
+
envWorkdir: "/env/root",
|
|
25
|
+
commandCwd: "/command/root",
|
|
26
|
+
currentCwd: tempDir,
|
|
27
|
+
})).toBe("/env/root");
|
|
28
|
+
|
|
29
|
+
expect(resolveLocalA2AWorkingDirectory({
|
|
30
|
+
envWorkdir: "",
|
|
31
|
+
commandCwd: "/command/root",
|
|
32
|
+
currentCwd: tempDir,
|
|
33
|
+
})).toBe("/command/root");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("falls back to the nearest pnpm workspace root", () => {
|
|
37
|
+
writeFileSync(join(tempDir, "pnpm-workspace.yaml"), "packages: []\n");
|
|
38
|
+
const nested = join(tempDir, "examples", "deep_research");
|
|
39
|
+
mkdirSync(nested, { recursive: true });
|
|
40
|
+
|
|
41
|
+
expect(resolveLocalA2AWorkingDirectory({
|
|
42
|
+
commandCwd: undefined,
|
|
43
|
+
currentCwd: nested,
|
|
44
|
+
})).toBe(tempDir);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("falls back to current cwd when no workspace root exists", () => {
|
|
48
|
+
expect(resolveLocalA2AWorkingDirectory({
|
|
49
|
+
commandCwd: undefined,
|
|
50
|
+
currentCwd: tempDir,
|
|
51
|
+
})).toBe(tempDir);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("LocalA2AService startup diagnostics", () => {
|
|
56
|
+
const originalAutoStart = process.env.LOCAL_A2A_AUTO_START;
|
|
57
|
+
const originalWorkdir = process.env.LOCAL_A2A_WORKDIR;
|
|
58
|
+
let tempDir: string;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
tempDir = mkdtempSync(join(tmpdir(), "local-a2a-"));
|
|
62
|
+
process.env.LOCAL_A2A_AUTO_START = "true";
|
|
63
|
+
delete process.env.LOCAL_A2A_WORKDIR;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
if (originalAutoStart === undefined) {
|
|
68
|
+
delete process.env.LOCAL_A2A_AUTO_START;
|
|
69
|
+
} else {
|
|
70
|
+
process.env.LOCAL_A2A_AUTO_START = originalAutoStart;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (originalWorkdir === undefined) {
|
|
74
|
+
delete process.env.LOCAL_A2A_WORKDIR;
|
|
75
|
+
} else {
|
|
76
|
+
process.env.LOCAL_A2A_WORKDIR = originalWorkdir;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function runtime(command: LocalRuntimeConfig["command"]): LocalRuntimeConfig {
|
|
83
|
+
return {
|
|
84
|
+
label: "Test Runtime",
|
|
85
|
+
agentCardUrl: "http://127.0.0.1:1/.well-known/agent-card.json",
|
|
86
|
+
command,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
it("fails before spawn when cwd is not accessible", async () => {
|
|
91
|
+
const service = new LocalA2AService();
|
|
92
|
+
|
|
93
|
+
const state = await service.startProvider("runtime-1", runtime({
|
|
94
|
+
executable: process.execPath,
|
|
95
|
+
args: ["-e", "process.exit(0)"],
|
|
96
|
+
cwd: join(tempDir, "missing"),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
expect(state.status).toBe("failed");
|
|
100
|
+
expect(state.message).toContain("Working directory is not accessible");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("includes stderr tail when the child exits before ready", async () => {
|
|
104
|
+
const service = new LocalA2AService();
|
|
105
|
+
|
|
106
|
+
const state = await service.startProvider("runtime-2", runtime({
|
|
107
|
+
executable: process.execPath,
|
|
108
|
+
args: ["-e", "console.error('diagnostic stderr'); process.exit(1)"],
|
|
109
|
+
cwd: tempDir,
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
expect(state.status).toBe("failed");
|
|
113
|
+
expect(state.message).toContain("Exited before becoming ready (1)");
|
|
114
|
+
expect(state.message).toContain("diagnostic stderr");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("keeps only the last 4KB of stderr", () => {
|
|
118
|
+
const tail = appendStderrTail("prefix", `${"x".repeat(5000)}end`);
|
|
119
|
+
|
|
120
|
+
expect(tail.length).toBe(4096);
|
|
121
|
+
expect(tail.endsWith("end")).toBe(true);
|
|
122
|
+
expect(tail.startsWith("prefix")).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -109,6 +109,10 @@ class EvalRunner {
|
|
|
109
109
|
const concurrency = Math.max(1, Math.floor((project as unknown as Record<string, unknown>).concurrency as number) || 3);
|
|
110
110
|
await store.createRun(tenantId, projectId, runId, { totalCases, concurrency });
|
|
111
111
|
|
|
112
|
+
const tsc = (project.targetServerConfig ?? {}) as Record<string, unknown>;
|
|
113
|
+
const workspaceId = (tsc.workspace_id as string) || "";
|
|
114
|
+
const projectIdFromConfig = (tsc.project_id as string) || "";
|
|
115
|
+
|
|
112
116
|
const projectConfig: LatticeEvalProjectType = {
|
|
113
117
|
projectName: project.name,
|
|
114
118
|
version: project.version,
|
|
@@ -117,6 +121,8 @@ class EvalRunner {
|
|
|
117
121
|
judge_agent_config: { modelKey: resolvedJudgeModelKey },
|
|
118
122
|
lattice_server_config: {
|
|
119
123
|
tenant_id: tenantId,
|
|
124
|
+
workspace_id: workspaceId,
|
|
125
|
+
project_id: projectIdFromConfig,
|
|
120
126
|
},
|
|
121
127
|
concurrency,
|
|
122
128
|
};
|