@dainprotocol/service-sdk 2.0.91 → 2.0.93

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.
@@ -9,15 +9,15 @@ const cors_1 = require("hono/cors");
9
9
  const http_exception_1 = require("hono/http-exception");
10
10
  const auth_1 = require("./auth");
11
11
  const schemaStructure_1 = require("../lib/schemaStructure");
12
+ const toolDiscovery_1 = require("../lib/toolDiscovery");
12
13
  const oauth2_1 = require("./oauth2");
13
14
  const bs58_1 = tslib_1.__importDefault(require("bs58"));
14
15
  const processes_1 = require("./processes");
15
16
  const streaming_1 = require("hono/streaming");
16
17
  const package_json_1 = tslib_1.__importDefault(require("../../package.json"));
17
18
  const zod_1 = require("zod");
18
- const ed25519_1 = require("@noble/curves/ed25519");
19
- const sha256_1 = require("@noble/hashes/sha256");
20
- const utils_1 = require("@noble/hashes/utils");
19
+ const utils_js_1 = require("@noble/hashes/utils.js");
20
+ const sseSignature_1 = require("../lib/sseSignature");
21
21
  const auth_2 = require("./auth");
22
22
  const core_1 = require("./core");
23
23
  const hitl_1 = require("./hitl");
@@ -32,6 +32,46 @@ function debugWarn(...args) {
32
32
  console.warn(...args);
33
33
  }
34
34
  }
35
+ /**
36
+ * Extract a user-safe error message. If `err` is a ZodError (or `err.message`
37
+ * looks like a raw Zod v4 issue-array JSON `"[{...}]"`), re-summarize as
38
+ * `"<path>: <msg>; <path>: <msg>"` so it never lands verbatim in chat text.
39
+ *
40
+ * Backstop for the tool-output validation leak: the 2026-04-17 whitelist
41
+ * regression surfaced `[{"code":"invalid_type","path":["markets"],...}]` in
42
+ * user-visible chat because the four SSE/non-SSE error exits below all do
43
+ * `text: \`Error: ${error.message}\``. This helper neutralizes that class
44
+ * of bug for ANY ZodError thrown anywhere in the tool-exec pipeline, not
45
+ * just the one we already fixed at the source in core.ts.
46
+ */
47
+ function sanitizeErrorMessage(err) {
48
+ if (err instanceof zod_1.z.ZodError) {
49
+ const summary = err.issues
50
+ .map((i) => `${i.path.join(".") || "root"}: ${i.message}`)
51
+ .join("; ");
52
+ return summary || "Validation failed";
53
+ }
54
+ const raw = err?.message;
55
+ if (typeof raw !== "string" || raw.length === 0)
56
+ return "Unknown error";
57
+ const trimmed = raw.trimStart();
58
+ if (trimmed.startsWith("[{") || trimmed.startsWith("[ {")) {
59
+ try {
60
+ const parsed = JSON.parse(trimmed);
61
+ if (Array.isArray(parsed)) {
62
+ const summary = parsed
63
+ .map((i) => `${Array.isArray(i.path) ? i.path.join(".") || "root" : "root"}: ${i.message ?? "invalid"}`)
64
+ .join("; ");
65
+ if (summary)
66
+ return summary;
67
+ }
68
+ }
69
+ catch {
70
+ /* fall through, return raw message */
71
+ }
72
+ }
73
+ return raw;
74
+ }
35
75
  function getFirstForwardedValue(value) {
36
76
  if (!value)
37
77
  return undefined;
@@ -78,23 +118,28 @@ function requireScope(requiredScope) {
78
118
  // Helper to sign and stream SSE events
79
119
  function signedStreamSSE(c, privateKey, config, handler) {
80
120
  return (0, streaming_1.streamSSE)(c, async (stream) => {
121
+ const requestSignal = c.req.raw?.signal;
122
+ const isAborted = () => stream.aborted || stream.closed || requestSignal?.aborted === true;
81
123
  const signedStream = {
82
124
  writeSSE: async (event) => {
125
+ if (isAborted())
126
+ return;
83
127
  const timestamp = Date.now().toString();
84
- const message = `${event.data}:${timestamp}`;
85
- const messageHash = (0, sha256_1.sha256)(message);
86
- const signatureBytes = ed25519_1.ed25519.sign(messageHash, privateKey);
87
- const signature = (0, utils_1.bytesToHex)(signatureBytes);
128
+ const messageHash = (0, sseSignature_1.hashEventMessage)(event.data, timestamp);
129
+ const signature = (0, utils_js_1.bytesToHex)(sseSignature_1.ed25519.sign(messageHash, privateKey));
88
130
  // Fast path for non-critical events (progress/UI updates)
89
- const isCriticalEvent = event.event === 'result' || event.event === 'process-created';
131
+ const isCriticalEvent = event.event === 'result' ||
132
+ event.event === 'process-created' ||
133
+ event.event === 'datasource-update';
90
134
  const dataWithSignature = isCriticalEvent
91
135
  ? JSON.stringify({
92
136
  data: JSON.parse(event.data),
93
137
  _signature: { signature, timestamp, agentId: config.identity.agentId, orgId: config.identity.orgId, address: config.identity.publicKey }
94
138
  })
95
139
  : `{"data":${event.data},"_signature":{"signature":"${signature}","timestamp":"${timestamp}","agentId":"${config.identity.agentId}","orgId":"${config.identity.orgId}","address":"${config.identity.publicKey}"}}`;
96
- return stream.writeSSE({ event: event.event, data: dataWithSignature, id: event.id });
97
- }
140
+ await stream.writeSSE({ event: event.event, data: dataWithSignature, id: event.id });
141
+ },
142
+ isAborted,
98
143
  };
99
144
  await handler(signedStream);
100
145
  });
@@ -174,11 +219,7 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
174
219
  const isHITLPath = hitlPath && c.req.path === hitlPath;
175
220
  if (c.req.path.startsWith("/oauth2/callback/") ||
176
221
  c.req.path.startsWith("/addons") ||
177
- c.req.path.startsWith("/getAllToolsAsJsonSchema") ||
178
- c.req.path.startsWith("/getSkills") ||
179
- c.req.path.startsWith("/getWebhookTriggers") ||
180
222
  c.req.path.startsWith("/metadata") ||
181
- c.req.path.startsWith("/recommendations") ||
182
223
  c.req.path.startsWith("/ping") ||
183
224
  isWebhookPath ||
184
225
  isHITLPath) {
@@ -300,15 +341,73 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
300
341
  debugLog("[getAgentInfo] API Key - Returning agent info:", agentInfo);
301
342
  return agentInfo;
302
343
  }
344
+ async function resolveHealthStatus() {
345
+ if (!config.healthCheck)
346
+ return true;
347
+ try {
348
+ return await config.healthCheck();
349
+ }
350
+ catch {
351
+ return false;
352
+ }
353
+ }
303
354
  // Setup default ping route
304
- app.get("/ping", (c) => c.json({ message: "pong", platform: "DAIN", service_version: metadata.version, dain_sdk_version: package_json_1.default.version }));
355
+ app.get("/ping", async (c) => {
356
+ const healthy = await resolveHealthStatus();
357
+ return c.json({
358
+ message: healthy ? "pong" : "unhealthy",
359
+ platform: "DAIN",
360
+ service_version: metadata.version,
361
+ dain_sdk_version: package_json_1.default.version,
362
+ }, healthy ? 200 : 503);
363
+ });
305
364
  // Metadata endpoint - includes computed supportsUserActions from tools
306
365
  app.get("/metadata", (c) => {
307
366
  // Compute service-level capability: does ANY tool support user actions (HITL)?
308
367
  const supportsUserActions = tools.some((tool) => tool.supportsUserActions === true);
368
+ const requestedContract = c.req.header("x-butterfly-contract") || c.req.header("X-Butterfly-Contract");
369
+ const sdkMajor = Number.parseInt(String(package_json_1.default.version).split(".")[0] || "0", 10);
370
+ const contractVersion = Number.isFinite(sdkMajor) && sdkMajor > 0 ? `${sdkMajor}.0.0` : "0.0.0";
371
+ const capabilities = {
372
+ tools: true,
373
+ contexts: true,
374
+ widgets: true,
375
+ datasources: true,
376
+ streamingTools: true,
377
+ streamingDatasources: true,
378
+ datasourcePolicy: true,
379
+ widgetPolicy: true,
380
+ toolSafety: true,
381
+ };
382
+ const compatibility = (() => {
383
+ if (!requestedContract)
384
+ return undefined;
385
+ const requestedMajor = Number.parseInt(String(requestedContract).split(".")[0] || "0", 10);
386
+ if (!Number.isFinite(requestedMajor) || requestedMajor <= 0) {
387
+ return {
388
+ requested: requestedContract,
389
+ contractVersion,
390
+ ok: false,
391
+ reason: "Invalid requested contract version",
392
+ };
393
+ }
394
+ const ok = requestedMajor === sdkMajor;
395
+ return ok
396
+ ? { requested: requestedContract, contractVersion, ok: true }
397
+ : {
398
+ requested: requestedContract,
399
+ contractVersion,
400
+ ok: false,
401
+ reason: `Requested major ${requestedMajor} does not match service major ${sdkMajor}`,
402
+ };
403
+ })();
309
404
  return c.json({
310
405
  ...metadata,
311
406
  supportsUserActions,
407
+ dainSdkVersion: package_json_1.default.version,
408
+ contractVersion,
409
+ capabilities,
410
+ ...(compatibility ? { compatibility } : {}),
312
411
  });
313
412
  });
314
413
  // Tools list endpoint
@@ -327,6 +426,10 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
327
426
  outputSchema,
328
427
  interface: tool.interface,
329
428
  suggestConfirmation: tool.suggestConfirmation,
429
+ sideEffectClass: tool.sideEffectClass,
430
+ supportsParallel: tool.supportsParallel,
431
+ idempotencyScope: tool.idempotencyScope,
432
+ maxConcurrencyHint: tool.maxConcurrencyHint,
330
433
  supportsUserActions: tool.supportsUserActions,
331
434
  };
332
435
  });
@@ -427,14 +530,26 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
427
530
  oauth2Client,
428
531
  app
429
532
  });
430
- return {
533
+ const response = {
431
534
  id: widget.id,
432
535
  name: widget.name,
433
536
  description: widget.description,
434
537
  icon: widget.icon,
435
538
  size: widget.size || "sm",
539
+ refreshIntervalMs: widget.refreshIntervalMs,
436
540
  ...widgetData
437
541
  };
542
+ if (!("freshness" in response)) {
543
+ response.freshness = {
544
+ freshAt: Date.now(),
545
+ transport: "poll",
546
+ requestedPolicy: {
547
+ refreshIntervalMs: widget.refreshIntervalMs,
548
+ },
549
+ scope: "account",
550
+ };
551
+ }
552
+ return response;
438
553
  }));
439
554
  const validWidgets = widgetsFull.filter(w => w !== null);
440
555
  const processedResponse = await processPluginsForResponse(validWidgets, body, { extraData: { plugins: processedPluginData.plugins } });
@@ -475,8 +590,19 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
475
590
  description: widget.description,
476
591
  icon: widget.icon,
477
592
  size: widget.size || "sm",
593
+ refreshIntervalMs: widget.refreshIntervalMs,
478
594
  ...widgetData
479
595
  };
596
+ if (!("freshness" in response)) {
597
+ response.freshness = {
598
+ freshAt: Date.now(),
599
+ transport: "poll",
600
+ requestedPolicy: {
601
+ refreshIntervalMs: widget.refreshIntervalMs,
602
+ },
603
+ scope: "account",
604
+ };
605
+ }
480
606
  const processedResponse = await processPluginsForResponse(response, body, { extraData: { plugins: processedPluginData.plugins } });
481
607
  return c.json(processedResponse);
482
608
  });
@@ -506,6 +632,12 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
506
632
  name: datasource.name,
507
633
  description: datasource.description,
508
634
  type: datasource.type,
635
+ refreshIntervalMs: datasource.refreshIntervalMs,
636
+ maxStalenessMs: datasource.maxStalenessMs,
637
+ transport: datasource.transport,
638
+ priority: datasource.priority,
639
+ scope: datasource.scope,
640
+ dataClass: datasource.dataClass,
509
641
  inputSchema: (0, schemaStructure_1.getDetailedSchemaStructure)(datasource.input),
510
642
  };
511
643
  }
@@ -543,12 +675,32 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
543
675
  plugins: pluginsData,
544
676
  oauth2Client
545
677
  });
678
+ const requestedPolicy = {
679
+ refreshIntervalMs: datasource.refreshIntervalMs,
680
+ maxStalenessMs: datasource.maxStalenessMs,
681
+ transport: datasource.transport,
682
+ priority: datasource.priority,
683
+ scope: datasource.scope,
684
+ dataClass: datasource.dataClass,
685
+ };
546
686
  const response = {
547
687
  id: datasource.id,
548
688
  name: datasource.name,
549
689
  description: datasource.description,
550
690
  type: datasource.type,
691
+ refreshIntervalMs: datasource.refreshIntervalMs,
692
+ maxStalenessMs: datasource.maxStalenessMs,
693
+ transport: datasource.transport,
694
+ priority: datasource.priority,
695
+ scope: datasource.scope,
696
+ dataClass: datasource.dataClass,
551
697
  data,
698
+ freshness: {
699
+ freshAt: Date.now(),
700
+ transport: datasource.transport || "poll",
701
+ requestedPolicy,
702
+ scope: datasource.scope,
703
+ },
552
704
  };
553
705
  const processedResponse = await processPluginsForResponse(response, { plugins: pluginsData }, { extraData: { plugins: pluginsData } });
554
706
  return c.json(processedResponse);
@@ -566,6 +718,149 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
566
718
  throw error;
567
719
  }
568
720
  });
721
+ // Stream datasource updates over SSE. This is primarily used for "fresh" UIs
722
+ // (positions/orders) where clients want stream-first updates with a poll fallback.
723
+ //
724
+ // Note: This does not require services to implement a separate streaming backend.
725
+ // The server re-runs the datasource handler on an interval and streams results.
726
+ app.post("/datasources/:datasourceId/stream", async (c) => {
727
+ const datasource = datasources.find((ds) => ds.id === c.req.param("datasourceId"));
728
+ if (!datasource) {
729
+ throw new http_exception_1.HTTPException(404, { message: "Datasource not found" });
730
+ }
731
+ const agentInfo = await getAgentInfo(c);
732
+ const rawBody = await safeParseBody(c);
733
+ const hasWrappedParams = rawBody &&
734
+ typeof rawBody === "object" &&
735
+ !Array.isArray(rawBody) &&
736
+ "params" in rawBody;
737
+ const requestedIntervalMsRaw = hasWrappedParams ? rawBody.intervalMs : undefined;
738
+ const requestParamsRaw = hasWrappedParams ? rawBody.params : rawBody;
739
+ let params = await processPluginsForRequest(requestParamsRaw && typeof requestParamsRaw === "object" ? requestParamsRaw : {}, agentInfo);
740
+ const pluginsData = (params.plugins && typeof params.plugins === "object"
741
+ ? params.plugins
742
+ : {});
743
+ delete params.plugins;
744
+ let parsedParams;
745
+ try {
746
+ parsedParams = datasource.input.parse(params);
747
+ }
748
+ catch (error) {
749
+ if (error instanceof zod_1.z.ZodError) {
750
+ const missingParams = error.issues
751
+ .map((issue) => issue.path.join("."))
752
+ .join(", ");
753
+ return c.json({
754
+ error: `Missing or invalid parameters: ${missingParams}`,
755
+ code: "INVALID_PARAMS"
756
+ }, 400);
757
+ }
758
+ throw error;
759
+ }
760
+ const pluginRequestContext = hasWrappedParams
761
+ ? {
762
+ params: parsedParams,
763
+ intervalMs: requestedIntervalMsRaw,
764
+ plugins: pluginsData,
765
+ }
766
+ : {
767
+ ...(typeof parsedParams === "object" && parsedParams !== null
768
+ ? parsedParams
769
+ : {}),
770
+ plugins: pluginsData,
771
+ };
772
+ const oauth2Client = app.oauth2?.getClient();
773
+ const requestedPolicy = {
774
+ refreshIntervalMs: datasource.refreshIntervalMs,
775
+ maxStalenessMs: datasource.maxStalenessMs,
776
+ transport: datasource.transport,
777
+ priority: datasource.priority,
778
+ scope: datasource.scope,
779
+ dataClass: datasource.dataClass,
780
+ };
781
+ const requestedIntervalMs = typeof requestedIntervalMsRaw === "number" && Number.isFinite(requestedIntervalMsRaw) && requestedIntervalMsRaw > 0
782
+ ? requestedIntervalMsRaw
783
+ : null;
784
+ const baseIntervalMs = requestedIntervalMs ??
785
+ (typeof datasource.refreshIntervalMs === "number" && datasource.refreshIntervalMs > 0
786
+ ? datasource.refreshIntervalMs
787
+ : 15_000);
788
+ const intervalMs = Math.max(1_000, Math.floor(baseIntervalMs));
789
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
790
+ debugLog(`[SSE] Datasource ${datasource.id} stream start (${intervalMs}ms interval)`);
791
+ return signedStreamSSE(c, privateKey, config, async (stream) => {
792
+ let eventId = 0;
793
+ const emitUpdate = async () => {
794
+ const data = await datasource.getDatasource(agentInfo, parsedParams, {
795
+ plugins: pluginsData,
796
+ oauth2Client,
797
+ });
798
+ const response = {
799
+ id: datasource.id,
800
+ name: datasource.name,
801
+ description: datasource.description,
802
+ type: datasource.type,
803
+ refreshIntervalMs: datasource.refreshIntervalMs,
804
+ maxStalenessMs: datasource.maxStalenessMs,
805
+ transport: datasource.transport,
806
+ priority: datasource.priority,
807
+ scope: datasource.scope,
808
+ dataClass: datasource.dataClass,
809
+ data,
810
+ freshness: {
811
+ freshAt: Date.now(),
812
+ transport: "stream",
813
+ requestedPolicy,
814
+ scope: datasource.scope,
815
+ },
816
+ };
817
+ const processedResponse = await processPluginsForResponse(response, pluginRequestContext, { extraData: { plugins: pluginsData } });
818
+ await stream.writeSSE({
819
+ event: "datasource-update",
820
+ data: JSON.stringify(processedResponse),
821
+ id: String(eventId++),
822
+ });
823
+ };
824
+ // Send initial snapshot immediately.
825
+ try {
826
+ await emitUpdate();
827
+ }
828
+ catch (error) {
829
+ console.error(`[SSE] Datasource ${datasource.id} initial update failed:`, error);
830
+ if (!stream.isAborted()) {
831
+ await stream.writeSSE({
832
+ event: "error",
833
+ data: JSON.stringify({ message: error?.message || "Datasource stream error" }),
834
+ });
835
+ }
836
+ return;
837
+ }
838
+ // Continue sending updates until client disconnects.
839
+ // Hono exposes the underlying Request via c.req.raw, which includes an AbortSignal.
840
+ const signal = c.req.raw?.signal;
841
+ while (!stream.isAborted() && !signal?.aborted) {
842
+ await sleep(intervalMs);
843
+ if (stream.isAborted() || signal?.aborted)
844
+ break;
845
+ try {
846
+ await emitUpdate();
847
+ }
848
+ catch (error) {
849
+ if (stream.isAborted() || signal?.aborted)
850
+ break;
851
+ console.error(`[SSE] Datasource ${datasource.id} update failed:`, error);
852
+ // Keep the stream alive; clients should rely on poll fallback if needed.
853
+ if (!stream.isAborted()) {
854
+ await stream.writeSSE({
855
+ event: "error",
856
+ data: JSON.stringify({ message: error?.message || "Datasource stream error" }),
857
+ });
858
+ }
859
+ }
860
+ }
861
+ debugLog(`[SSE] Datasource ${datasource.id} stream end`);
862
+ });
863
+ });
569
864
  function mapAgentInfo(agent) {
570
865
  return {
571
866
  id: agent.id,
@@ -642,13 +937,23 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
642
937
  outputSchema,
643
938
  interface: tool.interface,
644
939
  suggestConfirmation: tool.suggestConfirmation,
940
+ sideEffectClass: tool.sideEffectClass,
941
+ supportsParallel: tool.supportsParallel,
942
+ idempotencyScope: tool.idempotencyScope,
943
+ maxConcurrencyHint: tool.maxConcurrencyHint,
645
944
  supportsUserActions: tool.supportsUserActions,
646
945
  };
647
946
  }
648
947
  app.get("/getAllToolsAsJsonSchema", (c) => {
948
+ const toolInfo = tools.map(mapToolToJsonSchema);
949
+ const reccomendedPrompts = toolboxes.map((toolbox) => toolbox.recommendedPrompt);
649
950
  return c.json({
650
- tools: tools.map(mapToolToJsonSchema),
651
- reccomendedPrompts: toolboxes.map((toolbox) => toolbox.recommendedPrompt),
951
+ tools: toolInfo,
952
+ reccomendedPrompts,
953
+ schemaVersion: (0, toolDiscovery_1.computeToolDiscoverySchemaVersion)({
954
+ tools: toolInfo,
955
+ reccomendedPrompts,
956
+ }),
652
957
  });
653
958
  });
654
959
  app.post("/getAllToolsAsJsonSchema", async (c) => {
@@ -657,9 +962,14 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
657
962
  const processedPluginData = await processPluginsForRequest(body, agentInfo);
658
963
  const toolInfo = tools.map(mapToolToJsonSchema);
659
964
  debugLog(`[getAllToolsAsJsonSchema POST] Returning ${toolInfo.length} tools`);
965
+ const reccomendedPrompts = toolboxes.map((toolbox) => toolbox.recommendedPrompt);
660
966
  const response = {
661
967
  tools: toolInfo,
662
- reccomendedPrompts: toolboxes.map((toolbox) => toolbox.recommendedPrompt),
968
+ reccomendedPrompts,
969
+ schemaVersion: (0, toolDiscovery_1.computeToolDiscoverySchemaVersion)({
970
+ tools: toolInfo,
971
+ reccomendedPrompts,
972
+ }),
663
973
  };
664
974
  const processedResponse = await processPluginsForResponse(response, body, { extraData: { plugins: processedPluginData.plugins } });
665
975
  return c.json(processedResponse);
@@ -846,6 +1156,10 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
846
1156
  outputSchema: (0, schemaStructure_1.getDetailedSchemaStructure)(tool.output),
847
1157
  interface: tool.interface,
848
1158
  suggestConfirmation: tool.suggestConfirmation,
1159
+ sideEffectClass: tool.sideEffectClass,
1160
+ supportsParallel: tool.supportsParallel,
1161
+ idempotencyScope: tool.idempotencyScope,
1162
+ maxConcurrencyHint: tool.maxConcurrencyHint,
849
1163
  };
850
1164
  return c.json(toolDetails);
851
1165
  }
@@ -873,6 +1187,11 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
873
1187
  pricing: tool.pricing,
874
1188
  inputSchema: (0, schemaStructure_1.getDetailedSchemaStructure)(tool.input),
875
1189
  outputSchema: (0, schemaStructure_1.getDetailedSchemaStructure)(tool.output),
1190
+ suggestConfirmation: tool.suggestConfirmation,
1191
+ sideEffectClass: tool.sideEffectClass,
1192
+ supportsParallel: tool.supportsParallel,
1193
+ idempotencyScope: tool.idempotencyScope,
1194
+ maxConcurrencyHint: tool.maxConcurrencyHint,
876
1195
  }
877
1196
  : {
878
1197
  id: toolId,
@@ -898,7 +1217,13 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
898
1217
  }
899
1218
  });
900
1219
  // Health check endpoint
901
- app.get("/health", (c) => c.json({ status: "healthy", timestamp: new Date().toISOString() }));
1220
+ app.get("/health", async (c) => {
1221
+ const healthy = await resolveHealthStatus();
1222
+ return c.json({
1223
+ status: healthy ? "healthy" : "unhealthy",
1224
+ timestamp: new Date().toISOString(),
1225
+ }, healthy ? 200 : 503);
1226
+ });
902
1227
  // Setup custom routes if provided
903
1228
  if (config.routes) {
904
1229
  config.routes(app);
@@ -1182,64 +1507,231 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
1182
1507
  });
1183
1508
  // Toolboxes list endpoint
1184
1509
  app.get("/toolboxes", (c) => c.json(toolboxes));
1185
- // Recommendations endpoint - returns cards for AI selection
1186
- app.get("/recommendations", (c) => {
1187
- const cards = [];
1510
+ function normalizeRecommendationText(value) {
1511
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
1512
+ }
1513
+ function tokenizeRecommendationQuery(query) {
1514
+ const normalized = normalizeRecommendationText(query);
1515
+ if (!normalized)
1516
+ return [];
1517
+ return Array.from(new Set(normalized.split(/\s+/).filter((token) => token.length >= 2)));
1518
+ }
1519
+ function buildRecommendationIndex() {
1520
+ const index = new Map();
1188
1521
  for (const tool of tools) {
1189
- if (tool.recommendations?.length) {
1190
- for (const card of tool.recommendations) {
1191
- const hasDynamicSchema = !!card.inputSchema;
1192
- cards.push({
1193
- id: card.id,
1194
- tags: card.tags,
1195
- ui: hasDynamicSchema ? undefined : card.ui,
1196
- inputSchema: hasDynamicSchema ? (0, schemaStructure_1.zodToJsonSchema)(card.inputSchema) : undefined,
1197
- toolId: tool.id,
1198
- toolName: tool.name,
1199
- });
1522
+ if (!tool.recommendations?.length)
1523
+ continue;
1524
+ for (const card of tool.recommendations) {
1525
+ const scopedCardId = `${tool.id}:${card.id}`;
1526
+ if (index.has(scopedCardId)) {
1527
+ throw new Error(`Duplicate recommendation card ID "${scopedCardId}". Card IDs must be unique per tool.`);
1200
1528
  }
1529
+ const isDynamic = !!card.inputSchema;
1530
+ const inputSchema = isDynamic ? (0, schemaStructure_1.zodToJsonSchema)(card.inputSchema) : undefined;
1531
+ const searchText = normalizeRecommendationText([tool.id, tool.name, card.id, ...card.tags].join(" "));
1532
+ index.set(scopedCardId, {
1533
+ cardId: scopedCardId,
1534
+ localCardId: card.id,
1535
+ toolId: tool.id,
1536
+ toolName: tool.name,
1537
+ tags: card.tags,
1538
+ inputSchema,
1539
+ ui: isDynamic ? undefined : card.ui,
1540
+ isDynamic,
1541
+ card,
1542
+ searchText,
1543
+ });
1201
1544
  }
1202
1545
  }
1203
- return c.json({ cards, count: cards.length });
1204
- });
1205
- // Generate recommendation card UI
1206
- app.post("/recommendations/:id/generate", async (c) => {
1207
- const cardId = c.req.param("id");
1208
- const body = await c.req.json().catch(() => ({}));
1209
- const params = body.params ?? {};
1210
- const context = body.context ?? {};
1211
- let foundCard;
1212
- let foundTool;
1213
- for (const tool of tools) {
1214
- const card = tool.recommendations?.find((r) => r.id === cardId);
1215
- if (card) {
1216
- foundCard = card;
1217
- foundTool = tool;
1218
- break;
1546
+ return index;
1547
+ }
1548
+ function scoreRecommendationCard(card, tokens) {
1549
+ if (tokens.length === 0)
1550
+ return 0;
1551
+ let score = 0;
1552
+ const tagSet = new Set(card.tags.map((tag) => normalizeRecommendationText(tag)));
1553
+ const scopedId = normalizeRecommendationText(card.cardId);
1554
+ const toolName = normalizeRecommendationText(card.toolName);
1555
+ for (const token of tokens) {
1556
+ if (scopedId === token || card.localCardId.toLowerCase() === token) {
1557
+ score += 6;
1558
+ continue;
1559
+ }
1560
+ if (tagSet.has(token)) {
1561
+ score += 4;
1562
+ }
1563
+ if (toolName.includes(token)) {
1564
+ score += 3;
1565
+ }
1566
+ if (card.searchText.includes(token)) {
1567
+ score += 1;
1219
1568
  }
1220
1569
  }
1221
- if (!foundCard) {
1222
- return c.json({ error: `Card not found: ${cardId}` }, 404);
1223
- }
1224
- const baseResponse = { id: cardId, toolId: foundTool?.id, toolName: foundTool?.name };
1225
- if (!foundCard.inputSchema) {
1226
- return c.json({ ...baseResponse, ui: foundCard.ui });
1227
- }
1228
- const parseResult = foundCard.inputSchema.safeParse(params);
1229
- if (!parseResult.success) {
1230
- return c.json({ error: "Invalid parameters", details: parseResult.error.format() }, 400);
1231
- }
1232
- if (typeof foundCard.ui !== "function") {
1233
- return c.json({ error: `Card ${cardId} has inputSchema but ui is not a function` }, 500);
1234
- }
1235
- try {
1236
- const ui = await foundCard.ui(parseResult.data, context);
1237
- return c.json({ ...baseResponse, ui, params: parseResult.data });
1238
- }
1239
- catch (e) {
1240
- const message = e instanceof Error ? e.message : "Unknown error";
1241
- return c.json({ error: `Failed to generate UI: ${message}` }, 500);
1570
+ return score;
1571
+ }
1572
+ const recommendationCardsById = buildRecommendationIndex();
1573
+ const recommendationSearchSchema = zod_1.z.object({
1574
+ query: zod_1.z.string().optional(),
1575
+ limit: zod_1.z.number().int().min(1).max(50).optional().default(12),
1576
+ toolIds: zod_1.z.array(zod_1.z.string()).optional(),
1577
+ includeStaticUI: zod_1.z.boolean().optional().default(false),
1578
+ });
1579
+ app.post("/recommendations/search", async (c) => {
1580
+ const agentInfo = await getAgentInfo(c);
1581
+ const body = await safeParseBody(c);
1582
+ const processedPluginData = await processPluginsForRequest(body, agentInfo);
1583
+ const requestData = { ...processedPluginData };
1584
+ delete requestData.plugins;
1585
+ const parsed = recommendationSearchSchema.safeParse(requestData);
1586
+ if (!parsed.success) {
1587
+ return c.json({
1588
+ error: "Invalid recommendation search request",
1589
+ details: parsed.error.format(),
1590
+ }, 400);
1591
+ }
1592
+ const { query, limit, toolIds, includeStaticUI, } = parsed.data;
1593
+ const toolIdSet = toolIds?.length ? new Set(toolIds) : null;
1594
+ const filteredCards = Array.from(recommendationCardsById.values()).filter((card) => !toolIdSet || toolIdSet.has(card.toolId));
1595
+ const tokens = tokenizeRecommendationQuery(query ?? "");
1596
+ const ranked = filteredCards
1597
+ .map((card, index) => ({
1598
+ ...card,
1599
+ score: scoreRecommendationCard(card, tokens),
1600
+ index,
1601
+ }))
1602
+ .sort((a, b) => {
1603
+ if (tokens.length === 0)
1604
+ return a.index - b.index;
1605
+ if (b.score !== a.score)
1606
+ return b.score - a.score;
1607
+ if (a.toolName !== b.toolName)
1608
+ return a.toolName.localeCompare(b.toolName);
1609
+ return a.localCardId.localeCompare(b.localCardId);
1610
+ })
1611
+ .slice(0, limit);
1612
+ const cards = ranked.map((card) => ({
1613
+ cardId: card.cardId,
1614
+ localCardId: card.localCardId,
1615
+ toolId: card.toolId,
1616
+ toolName: card.toolName,
1617
+ tags: card.tags,
1618
+ score: card.score,
1619
+ inputSchema: card.inputSchema,
1620
+ ui: includeStaticUI && !card.isDynamic ? card.ui : undefined,
1621
+ isDynamic: card.isDynamic,
1622
+ }));
1623
+ const response = {
1624
+ cards,
1625
+ count: cards.length,
1626
+ query,
1627
+ };
1628
+ const processedResponse = await processPluginsForResponse(response, body, { extraData: { plugins: processedPluginData.plugins } });
1629
+ return c.json(processedResponse);
1630
+ });
1631
+ const recommendationRenderSchema = zod_1.z.object({
1632
+ cards: zod_1.z
1633
+ .array(zod_1.z.object({
1634
+ cardId: zod_1.z.string().min(1),
1635
+ params: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
1636
+ context: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
1637
+ }))
1638
+ .min(1)
1639
+ .max(8),
1640
+ continueOnError: zod_1.z.boolean().optional().default(true),
1641
+ });
1642
+ app.post("/recommendations/render", async (c) => {
1643
+ const agentInfo = await getAgentInfo(c);
1644
+ const body = await safeParseBody(c);
1645
+ const processedPluginData = await processPluginsForRequest(body, agentInfo);
1646
+ const requestData = { ...processedPluginData };
1647
+ delete requestData.plugins;
1648
+ const parsed = recommendationRenderSchema.safeParse(requestData);
1649
+ if (!parsed.success) {
1650
+ return c.json({
1651
+ error: "Invalid recommendation render request",
1652
+ details: parsed.error.format(),
1653
+ }, 400);
1654
+ }
1655
+ const { cards: requestedCards, continueOnError } = parsed.data;
1656
+ const renderedCards = [];
1657
+ const renderErrors = [];
1658
+ for (const requested of requestedCards) {
1659
+ const card = recommendationCardsById.get(requested.cardId);
1660
+ if (!card) {
1661
+ renderErrors.push({
1662
+ cardId: requested.cardId,
1663
+ code: "not_found",
1664
+ message: `Card not found: ${requested.cardId}`,
1665
+ });
1666
+ if (!continueOnError)
1667
+ break;
1668
+ continue;
1669
+ }
1670
+ if (!card.isDynamic) {
1671
+ renderedCards.push({
1672
+ cardId: card.cardId,
1673
+ localCardId: card.localCardId,
1674
+ toolId: card.toolId,
1675
+ toolName: card.toolName,
1676
+ tags: card.tags,
1677
+ ui: card.card.ui,
1678
+ });
1679
+ continue;
1680
+ }
1681
+ const paramsInput = requested.params ?? {};
1682
+ const parseResult = card.card.inputSchema.safeParse(paramsInput);
1683
+ if (!parseResult.success) {
1684
+ renderErrors.push({
1685
+ cardId: card.cardId,
1686
+ code: "invalid_params",
1687
+ message: "Invalid parameters",
1688
+ details: parseResult.error.format(),
1689
+ });
1690
+ if (!continueOnError)
1691
+ break;
1692
+ continue;
1693
+ }
1694
+ if (typeof card.card.ui !== "function") {
1695
+ renderErrors.push({
1696
+ cardId: card.cardId,
1697
+ code: "render_failed",
1698
+ message: `Card "${card.cardId}" is dynamic but has no UI generator function`,
1699
+ });
1700
+ if (!continueOnError)
1701
+ break;
1702
+ continue;
1703
+ }
1704
+ try {
1705
+ const ui = await card.card.ui(parseResult.data, requested.context ?? {});
1706
+ renderedCards.push({
1707
+ cardId: card.cardId,
1708
+ localCardId: card.localCardId,
1709
+ toolId: card.toolId,
1710
+ toolName: card.toolName,
1711
+ tags: card.tags,
1712
+ ui,
1713
+ params: parseResult.data,
1714
+ });
1715
+ }
1716
+ catch (error) {
1717
+ renderErrors.push({
1718
+ cardId: card.cardId,
1719
+ code: "render_failed",
1720
+ message: error instanceof Error ? error.message : "Failed to render recommendation card",
1721
+ });
1722
+ if (!continueOnError)
1723
+ break;
1724
+ }
1242
1725
  }
1726
+ const response = {
1727
+ cards: renderedCards,
1728
+ errors: renderErrors,
1729
+ renderedCount: renderedCards.length,
1730
+ requestedCount: requestedCards.length,
1731
+ };
1732
+ const processedResponse = await processPluginsForResponse(response, body, { extraData: { plugins: processedPluginData.plugins } });
1733
+ const statusCode = !continueOnError && renderErrors.length > 0 ? 400 : 200;
1734
+ return c.json(processedResponse, statusCode);
1243
1735
  });
1244
1736
  // Process request with plugins
1245
1737
  async function processPluginsForRequest(request, agentInfo) {
@@ -1420,6 +1912,7 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
1420
1912
  }
1421
1913
  catch (error) {
1422
1914
  console.error(`Error in stream for ${tool.id}:`, error);
1915
+ const safeMsg = sanitizeErrorMessage(error);
1423
1916
  // Send an error result rather than letting the stream end without a response
1424
1917
  try {
1425
1918
  if (withContext) {
@@ -1427,8 +1920,8 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
1427
1920
  event: 'result',
1428
1921
  data: JSON.stringify({
1429
1922
  toolResult: {
1430
- error: error.message || "Unknown error",
1431
- text: `Error: ${error.message || "Unknown error"}`,
1923
+ error: safeMsg,
1924
+ text: `Error: ${safeMsg}`,
1432
1925
  data: null,
1433
1926
  ui: null
1434
1927
  },
@@ -1441,8 +1934,8 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
1441
1934
  await stream.writeSSE({
1442
1935
  event: 'result',
1443
1936
  data: JSON.stringify({
1444
- error: error.message || "Unknown error",
1445
- text: `Error: ${error.message || "Unknown error"}`,
1937
+ error: safeMsg,
1938
+ text: `Error: ${safeMsg}`,
1446
1939
  data: null,
1447
1940
  ui: null
1448
1941
  }),
@@ -1534,13 +2027,14 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
1534
2027
  }
1535
2028
  catch (error) {
1536
2029
  console.error(`Error executing tool ${tool.id} (non-streaming):`, error);
2030
+ const safeMsg = sanitizeErrorMessage(error);
1537
2031
  // Return formatted error response to match streaming behavior
1538
2032
  let errorResponse;
1539
2033
  if (withContext) {
1540
2034
  errorResponse = {
1541
2035
  toolResult: {
1542
- error: error.message || "Unknown error",
1543
- text: `Error: ${error.message || "Unknown error"}`,
2036
+ error: safeMsg,
2037
+ text: `Error: ${safeMsg}`,
1544
2038
  data: null,
1545
2039
  ui: null
1546
2040
  },
@@ -1549,8 +2043,8 @@ function setupHttpServer(config, tools, services, toolboxes, metadata, privateKe
1549
2043
  }
1550
2044
  else {
1551
2045
  errorResponse = {
1552
- error: error.message || "Unknown error",
1553
- text: `Error: ${error.message || "Unknown error"}`,
2046
+ error: safeMsg,
2047
+ text: `Error: ${safeMsg}`,
1554
2048
  data: null,
1555
2049
  ui: null
1556
2050
  };