@zackbart/connecta 0.7.7 → 0.7.8

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/src/index.ts CHANGED
@@ -94,7 +94,7 @@ export interface ConnectaDiscoveryConfig {
94
94
  probeTimeoutMs?: number;
95
95
  }
96
96
 
97
- /** Deployment-wide call deadlines and inline-result paging threshold. */
97
+ /** Deployment-wide call deadlines and inline-result paging thresholds. */
98
98
  export interface ConnectaCallsConfig {
99
99
  /**
100
100
  * Deadline (ms) for `call_tool`/`batch_call` calls that pass no `timeoutMs`.
@@ -111,6 +111,13 @@ export interface ConnectaCallsConfig {
111
111
  * 50_000. Connectors may override it individually.
112
112
  */
113
113
  maxResultBytes?: number;
114
+ /**
115
+ * Max serialized `batch_call` envelope size (bytes) before the full batch is
116
+ * stashed for `get_result` and only an ordered outcome summary is returned
117
+ * inline. Must be a finite whole number >= 1; invalid values warn and fall
118
+ * back to 100_000. This cap is independent of per-connector child caps.
119
+ */
120
+ maxBatchResultBytes?: number;
114
121
  }
115
122
 
116
123
  export interface AdmissionPoolConfig {
@@ -544,6 +551,7 @@ export function createConnecta(config: ConnectaConfig): Connecta {
544
551
  persistToolCatalog: config.discovery?.persistCatalog,
545
552
  toolCatalogStaleSeconds: config.discovery?.staleCatalogSeconds,
546
553
  maxResultBytes: config.calls?.maxResultBytes,
554
+ maxBatchResultBytes: config.calls?.maxBatchResultBytes,
547
555
  credentialHealth: config.credentials?.health,
548
556
  });
549
557
  // Throws on every structural mistake it can see (see resolveToolkits): a
package/src/meta-tools.ts CHANGED
@@ -406,6 +406,15 @@ async function stashResult(
406
406
  };
407
407
  }
408
408
 
409
+ /** Keep an oversized batch's inline outcome summary at fixed string overhead. */
410
+ function batchSummaryString(value: string): string {
411
+ const bytes = enc.encode(value);
412
+ const maxBytes = 512;
413
+ if (bytes.length <= maxBytes) return value;
414
+ const end = alignEndToCharBoundary(bytes, 0, maxBytes, bytes.length);
415
+ return `${dec.decode(bytes.slice(0, end))}…`;
416
+ }
417
+
409
418
  /**
410
419
  * Return `text` as a single content block; if it exceeds `cap` bytes, stash the
411
420
  * full text and return the first `cap` bytes followed by a JSON truncation
@@ -575,10 +584,10 @@ export interface SkillArgs {
575
584
  * supplies a deadline for calls that don't carry one. (execute_code, the
576
585
  * optional tenth tool, is registered separately by registerExecuteTool.)
577
586
  *
578
- * The deployment-wide result-size cap is read off the registry view rather than
579
- * passed in: `ConnectaConfig.calls.maxResultBytes` and the per-connector
580
- * override are the only places a cap is set, so there is one answer to where a
581
- * deployment sets it (issue #44).
587
+ * Deployment-wide result-size caps are read off the registry view rather than
588
+ * passed in: `ConnectaConfig.calls.maxResultBytes`, its per-connector override,
589
+ * and the independent `calls.maxBatchResultBytes` final-envelope boundary each
590
+ * have one runtime source of truth.
582
591
  */
583
592
  export function createMetaTools(
584
593
  registry: RegistryView,
@@ -595,6 +604,7 @@ export function createMetaTools(
595
604
  ) {
596
605
  // Already normalized and warned about at registry construction.
597
606
  const globalCap = registry.maxResultBytes;
607
+ const batchCap = registry.maxBatchResultBytes;
598
608
  const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
599
609
  const probeTimeoutMs =
600
610
  normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
@@ -1141,26 +1151,36 @@ export function createMetaTools(
1141
1151
  ),
1142
1152
  ),
1143
1153
  );
1144
- let orderBase = 0;
1145
- catalogs.forEach((catalog, connectorIndex) => {
1146
- const c = conns[connectorIndex];
1147
- if (catalog.status === "fulfilled") {
1148
- for (const ranked of rankTools(catalog.value, q)) {
1149
- matches.push({
1150
- connectorId: c.id,
1151
- connectorTitle: c.title,
1152
- connectorDescription: c.description,
1153
- ...(connectorGuide(c)
1154
- ? { connectorGuideSkill: connectorSkillName(c.id) }
1155
- : {}),
1156
- tool: ranked.tool,
1157
- score: ranked.score,
1158
- order: orderBase + ranked.order,
1159
- });
1154
+ let matchMode: "all" | "partial" = "all";
1155
+ const collectMatches = (mode: "all" | "partial") => {
1156
+ matches.length = 0;
1157
+ let orderBase = 0;
1158
+ catalogs.forEach((catalog, connectorIndex) => {
1159
+ const c = conns[connectorIndex];
1160
+ if (catalog.status === "fulfilled") {
1161
+ for (const ranked of rankTools(catalog.value, q, mode)) {
1162
+ matches.push({
1163
+ connectorId: c.id,
1164
+ connectorTitle: c.title,
1165
+ connectorDescription: c.description,
1166
+ ...(connectorGuide(c)
1167
+ ? { connectorGuideSkill: connectorSkillName(c.id) }
1168
+ : {}),
1169
+ tool: ranked.tool,
1170
+ score: ranked.score,
1171
+ order: orderBase + ranked.order,
1172
+ });
1173
+ }
1160
1174
  }
1161
- }
1162
- orderBase += catalog.status === "fulfilled" ? catalog.value.length : 1;
1163
- });
1175
+ orderBase +=
1176
+ catalog.status === "fulfilled" ? catalog.value.length : 1;
1177
+ });
1178
+ };
1179
+ collectMatches("all");
1180
+ if (q.trim() && matches.length === 0) {
1181
+ matchMode = "partial";
1182
+ collectMatches(matchMode);
1183
+ }
1164
1184
  matches.sort((a, b) => b.score - a.score || a.order - b.order);
1165
1185
  const page = matches.slice(offset, offset + limit);
1166
1186
  const groups: {
@@ -1235,6 +1255,9 @@ export function createMetaTools(
1235
1255
  limit,
1236
1256
  hasMore: nextOffset !== undefined,
1237
1257
  ...(nextOffset !== undefined ? { nextOffset } : {}),
1258
+ ...(matchMode === "partial" && matches.length > 0
1259
+ ? { matchMode }
1260
+ : {}),
1238
1261
  },
1239
1262
  "Request a smaller limit, omit fullDescriptions, or use compact schemas.",
1240
1263
  );
@@ -1457,9 +1480,50 @@ export function createMetaTools(
1457
1480
  : {}),
1458
1481
  };
1459
1482
  });
1460
- return jsonResult({
1483
+ const envelope = {
1461
1484
  results,
1462
1485
  durationMs: Date.now() - batchStarted,
1486
+ };
1487
+ const text = serializeResultText(envelope);
1488
+ const bytes = enc.encode(text);
1489
+ if (bytes.length <= batchCap) return jsonResult(envelope);
1490
+
1491
+ const notice = await stashResult(
1492
+ text,
1493
+ registry.resultsStorage(),
1494
+ bytes.length,
1495
+ );
1496
+ return jsonResult({
1497
+ results: results.map((result) => {
1498
+ const common = {
1499
+ address: batchSummaryString(result.address),
1500
+ ok: !("error" in result),
1501
+ ...("durationMs" in result
1502
+ ? { durationMs: result.durationMs }
1503
+ : {}),
1504
+ ...("attempts" in result ? { attempts: result.attempts } : {}),
1505
+ ...("timing" in result ? { timing: result.timing } : {}),
1506
+ };
1507
+ if (!("error" in result)) return common;
1508
+ const error = result.error ?? "Batch call failed";
1509
+ const details =
1510
+ result.errorDetails ??
1511
+ errorDetails("batch_call_failed", error);
1512
+ return {
1513
+ ...common,
1514
+ error: batchSummaryString(error),
1515
+ errorDetails: {
1516
+ code: batchSummaryString(details.code),
1517
+ message: batchSummaryString(details.message),
1518
+ retryable: details.retryable,
1519
+ ...(details.retryAfterMs !== undefined
1520
+ ? { retryAfterMs: details.retryAfterMs }
1521
+ : {}),
1522
+ },
1523
+ };
1524
+ }),
1525
+ durationMs: envelope.durationMs,
1526
+ ...notice,
1463
1527
  });
1464
1528
  },
1465
1529
 
@@ -1527,7 +1591,7 @@ const CALL_DESTRUCTIVE_DESC =
1527
1591
  const GET_RESULT_DESC =
1528
1592
  "Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. maxBytes is a whole number of bytes >= 1 (omit for the deployment default) and offset a whole number of bytes >= 0; an offset inside a multi-byte character is moved back to that character's first byte and the offset served is returned. Unknown/expired id is an error.";
1529
1593
  const BATCH_DESC =
1530
- "Use for 2–10 independent tools explicitly annotated readOnlyHint: true. Calls run in parallel with shared request-scoped clients; use execute_code when available instead for dependencies or in-sandbox reduction. Unannotated, write-capable, and destructive tools are refused. Batch timeout, safe retry, result mode, and diagnostics defaults may be overridden per call.";
1594
+ "Use for 2–10 independent tools explicitly annotated readOnlyHint: true. Calls run in parallel with shared request-scoped clients; use execute_code when available instead for dependencies or in-sandbox reduction. Unannotated, write-capable, and destructive tools are refused. Batch timeout, safe retry, result mode, and diagnostics defaults may be overridden per call. An oversized final envelope returns ordered outcome summaries plus a get_result page handle.";
1531
1595
  const AUTHORIZE_DESC =
1532
1596
  "Use after a connector reports auth_required. Starts downstream OAuth and returns an authorizationUrl for the operator to open. force=true wipes stored credentials first and restarts consent.";
1533
1597
  const SKILLS_DESC =
package/src/registry.ts CHANGED
@@ -25,6 +25,8 @@ const ID_RE = /^[a-z0-9_-]+$/;
25
25
  const DEFAULT_TTL_SECONDS = 300;
26
26
  const DEFAULT_STALE_SECONDS = 3600;
27
27
  export const DEFAULT_MAX_RESULT_BYTES = 50_000;
28
+ /** Independent final-envelope boundary for `batch_call`. */
29
+ export const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
28
30
 
29
31
  /**
30
32
  * Smallest accepted inline-result cap. One byte is pathological but harmless:
@@ -143,6 +145,11 @@ export interface RegistryOptions {
143
145
  * to the default 50_000.
144
146
  */
145
147
  maxResultBytes?: number;
148
+ /**
149
+ * Cap on the complete serialized batch_call envelope. Must be a whole number
150
+ * of bytes >= 1; anything else warns and falls back to 100_000.
151
+ */
152
+ maxBatchResultBytes?: number;
146
153
  /** Tuning for the credential liveness checks (issue #24). */
147
154
  credentialHealth?: CredentialHealthConfig;
148
155
  }
@@ -179,6 +186,8 @@ type ConnectorOperationOptions = Pick<
179
186
  export interface RegistryView {
180
187
  /** Deployment-wide result-size cap threaded to the meta-tools. */
181
188
  readonly maxResultBytes: number;
189
+ /** Independent cap for the complete serialized batch_call envelope. */
190
+ readonly maxBatchResultBytes: number;
182
191
  listConnectors(): Connector[];
183
192
  getConnector(id: string): Connector | undefined;
184
193
  resolveAddress(
@@ -246,6 +255,8 @@ export class Registry implements RegistryView {
246
255
  private readonly persistToolCatalog: boolean;
247
256
  /** Result-size guard cap threaded to the meta-tools. */
248
257
  readonly maxResultBytes: number;
258
+ /** Final batch envelope cap threaded to the meta-tools. */
259
+ readonly maxBatchResultBytes: number;
249
260
  /** Proactive liveness checks over stored downstream credentials (issue #24). */
250
261
  private readonly credentialHealth: CredentialHealthChecker;
251
262
 
@@ -262,6 +273,10 @@ export class Registry implements RegistryView {
262
273
  opts.maxResultBytes,
263
274
  DEFAULT_MAX_RESULT_BYTES,
264
275
  );
276
+ this.maxBatchResultBytes = resolveMaxResultBytes(
277
+ opts.maxBatchResultBytes,
278
+ DEFAULT_MAX_BATCH_RESULT_BYTES,
279
+ );
265
280
  for (const c of connectors) {
266
281
  if (!ID_RE.test(c.id)) {
267
282
  throw new Error(
@@ -274,7 +289,11 @@ export class Registry implements RegistryView {
274
289
  this.connectors.set(c.id, c);
275
290
  }
276
291
  this.checkConventions(opts.logger);
277
- this.checkResultCaps(opts.logger, opts.maxResultBytes);
292
+ this.checkResultCaps(
293
+ opts.logger,
294
+ opts.maxResultBytes,
295
+ opts.maxBatchResultBytes,
296
+ );
278
297
  this.credentialHealth = new CredentialHealthChecker(
279
298
  {
280
299
  listConnectors: () => this.listConnectors(),
@@ -300,6 +319,7 @@ export class Registry implements RegistryView {
300
319
  private checkResultCaps(
301
320
  logger: Logger,
302
321
  configured: number | undefined,
322
+ configuredBatch: number | undefined,
303
323
  ): void {
304
324
  if (configured !== undefined && !isValidMaxResultBytes(configured)) {
305
325
  logger.warn(
@@ -309,6 +329,17 @@ export class Registry implements RegistryView {
309
329
  `default ${DEFAULT_MAX_RESULT_BYTES} instead.`,
310
330
  );
311
331
  }
332
+ if (
333
+ configuredBatch !== undefined &&
334
+ !isValidMaxResultBytes(configuredBatch)
335
+ ) {
336
+ logger.warn(
337
+ `[connecta] calls.maxBatchResultBytes ${configuredBatch} is not a whole ` +
338
+ `number of bytes >= ${MIN_MAX_RESULT_BYTES}: it would leave the final ` +
339
+ "batch envelope unbounded or serve an unusable page. Using the " +
340
+ `default ${DEFAULT_MAX_BATCH_RESULT_BYTES} instead.`,
341
+ );
342
+ }
312
343
  for (const c of this.connectors.values()) {
313
344
  if (
314
345
  c.maxResultBytes !== undefined &&
@@ -831,6 +862,10 @@ export class ScopedRegistry implements RegistryView {
831
862
  return this.base.maxResultBytes;
832
863
  }
833
864
 
865
+ get maxBatchResultBytes(): number {
866
+ return this.base.maxBatchResultBytes;
867
+ }
868
+
834
869
  /** In scope AND actually registered. */
835
870
  private visible(id: string): boolean {
836
871
  return (
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.7.7";
7
+ export const CONNECTA_VERSION = "0.7.8";