@zackbart/connecta 0.10.1 → 0.10.2

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.
Files changed (69) hide show
  1. package/AGENTS.md +113 -0
  2. package/CHANGELOG.md +51 -0
  3. package/README.md +53 -9
  4. package/bin/connecta.mjs +272 -0
  5. package/dist/catalog-service.d.ts +39 -1
  6. package/dist/catalog-service.d.ts.map +1 -1
  7. package/dist/catalog-service.js +133 -11
  8. package/dist/catalog-service.js.map +1 -1
  9. package/dist/catalog.d.ts +17 -0
  10. package/dist/catalog.d.ts.map +1 -1
  11. package/dist/catalog.js +113 -13
  12. package/dist/catalog.js.map +1 -1
  13. package/dist/execute.d.ts +45 -1
  14. package/dist/execute.d.ts.map +1 -1
  15. package/dist/execute.js +265 -68
  16. package/dist/execute.js.map +1 -1
  17. package/dist/invocation.d.ts.map +1 -1
  18. package/dist/invocation.js +1 -5
  19. package/dist/invocation.js.map +1 -1
  20. package/dist/meta-tools.d.ts +1 -0
  21. package/dist/meta-tools.d.ts.map +1 -1
  22. package/dist/meta-tools.js +410 -12
  23. package/dist/meta-tools.js.map +1 -1
  24. package/dist/skills.d.ts +1 -1
  25. package/dist/skills.d.ts.map +1 -1
  26. package/dist/skills.js +1 -1
  27. package/dist/tool-safety.d.ts +10 -0
  28. package/dist/tool-safety.d.ts.map +1 -0
  29. package/dist/tool-safety.js +12 -0
  30. package/dist/tool-safety.js.map +1 -0
  31. package/dist/version.d.ts +1 -1
  32. package/dist/version.js +1 -1
  33. package/documentation/architecture.md +7 -0
  34. package/documentation/auth.md +58 -0
  35. package/documentation/call-admission.md +7 -0
  36. package/documentation/code-first-exploration.md +292 -0
  37. package/documentation/code-mode.md +697 -0
  38. package/documentation/connector-guides.md +7 -0
  39. package/documentation/connectors.md +63 -0
  40. package/documentation/mcp-2026-07-28.md +46 -0
  41. package/documentation/meta-tools.md +167 -0
  42. package/documentation/operations.md +7 -0
  43. package/documentation/operator-ui.md +7 -0
  44. package/documentation/request-admission.md +7 -0
  45. package/documentation/storage-and-credentials.md +54 -0
  46. package/ethos.md +132 -0
  47. package/examples/node/README.md +53 -0
  48. package/examples/node/src/index.ts +73 -0
  49. package/examples/worker/README.md +160 -0
  50. package/examples/worker/src/cloudflare-kv.ts +43 -0
  51. package/examples/worker/src/d1-activity-row.ts +100 -0
  52. package/examples/worker/src/d1-activity.ts +144 -0
  53. package/examples/worker/src/index.ts +136 -0
  54. package/examples/worker/wrangler.jsonc +26 -0
  55. package/package.json +11 -1
  56. package/src/catalog-service.ts +177 -15
  57. package/src/catalog.ts +143 -12
  58. package/src/execute.ts +372 -96
  59. package/src/invocation.ts +1 -8
  60. package/src/meta-tools.ts +504 -11
  61. package/src/skills.ts +1 -1
  62. package/src/tool-safety.ts +15 -0
  63. package/src/version.ts +1 -1
  64. package/templates/node/.env.example +5 -0
  65. package/templates/node/AGENTS.md +19 -0
  66. package/templates/node/README.md +33 -0
  67. package/templates/node/package.json +23 -0
  68. package/templates/node/src/index.ts +43 -0
  69. package/templates/node/tsconfig.json +12 -0
package/src/execute.ts CHANGED
@@ -34,6 +34,145 @@ import type {
34
34
  const EXECUTE_MAX_HOST_CALLS = 20;
35
35
  export const EXECUTE_MAX_BATCH_CALLS = 10;
36
36
  const EXECUTE_HOST_CALL_TIMEOUT_MS = 15_000;
37
+ const diagnosticsEncoder = new TextEncoder();
38
+
39
+ type ExecuteDiagnosticOperation = "search" | "describe" | "call" | "batch";
40
+
41
+ interface ExecuteOperationDiagnostics {
42
+ operation: ExecuteDiagnosticOperation;
43
+ count: number;
44
+ failures: number;
45
+ durationMs: number;
46
+ resultBytes: number;
47
+ catalogMs: number;
48
+ connectorMs: number;
49
+ calls?: number;
50
+ }
51
+
52
+ type MutableOperationDiagnostics = ExecuteOperationDiagnostics;
53
+
54
+ class ExecuteDiagnostics {
55
+ private readonly started = Date.now();
56
+ private readonly operations = new Map<
57
+ ExecuteDiagnosticOperation,
58
+ MutableOperationDiagnostics
59
+ >();
60
+ admissionMs = 0;
61
+ setupMs = 0;
62
+ executorWallMs = 0;
63
+
64
+ private stats(operation: ExecuteDiagnosticOperation) {
65
+ let stats = this.operations.get(operation);
66
+ if (!stats) {
67
+ stats = {
68
+ operation,
69
+ count: 0,
70
+ failures: 0,
71
+ durationMs: 0,
72
+ resultBytes: 0,
73
+ catalogMs: 0,
74
+ connectorMs: 0,
75
+ };
76
+ this.operations.set(operation, stats);
77
+ }
78
+ return stats;
79
+ }
80
+
81
+ recordCatalog(
82
+ operation: "search" | "describe",
83
+ durationMs: number,
84
+ ok: boolean,
85
+ result?: unknown,
86
+ ): void {
87
+ const stats = this.stats(operation);
88
+ stats.count++;
89
+ stats.failures += ok ? 0 : 1;
90
+ stats.durationMs += durationMs;
91
+ stats.catalogMs += durationMs;
92
+ if (ok) stats.resultBytes += serializedDiagnosticBytes(result);
93
+ }
94
+
95
+ recordCall(
96
+ operation: "call" | "batch",
97
+ outcome: {
98
+ ok: boolean;
99
+ durationMs: number;
100
+ timing: { catalogMs: number; connectorMs: number };
101
+ value?: unknown;
102
+ },
103
+ ): void {
104
+ const stats = this.stats(operation);
105
+ if (operation === "call") {
106
+ stats.count++;
107
+ if (outcome.ok) {
108
+ stats.resultBytes += serializedDiagnosticBytes(outcome.value);
109
+ }
110
+ } else {
111
+ stats.calls = (stats.calls ?? 0) + 1;
112
+ }
113
+ stats.failures += outcome.ok ? 0 : 1;
114
+ if (operation === "call") stats.durationMs += outcome.durationMs;
115
+ stats.catalogMs += outcome.timing.catalogMs;
116
+ stats.connectorMs += outcome.timing.connectorMs;
117
+ }
118
+
119
+ recordBatch(
120
+ durationMs: number,
121
+ ok: boolean,
122
+ calls: number,
123
+ result?: unknown,
124
+ ): void {
125
+ const stats = this.stats("batch");
126
+ stats.count++;
127
+ // Calls normally accrue while each child runs. Invalid batch input never
128
+ // starts children, so retain the attempted cardinality here.
129
+ if (!ok) stats.calls = Math.max(stats.calls ?? 0, calls);
130
+ stats.durationMs += durationMs;
131
+ if (!ok) stats.failures++;
132
+ else stats.resultBytes += serializedDiagnosticBytes(result);
133
+ }
134
+
135
+ finish(): {
136
+ timing: {
137
+ totalMs: number;
138
+ admissionMs: number;
139
+ setupMs: number;
140
+ executorWallMs: number;
141
+ catalogMs: number;
142
+ connectorMs: number;
143
+ };
144
+ operations: ExecuteOperationDiagnostics[];
145
+ } {
146
+ const operations = [...this.operations.values()];
147
+ return {
148
+ timing: {
149
+ totalMs: Date.now() - this.started,
150
+ admissionMs: this.admissionMs,
151
+ setupMs: this.setupMs,
152
+ executorWallMs: this.executorWallMs,
153
+ catalogMs: operations.reduce((sum, item) => sum + item.catalogMs, 0),
154
+ connectorMs: operations.reduce(
155
+ (sum, item) => sum + item.connectorMs,
156
+ 0,
157
+ ),
158
+ },
159
+ operations,
160
+ };
161
+ }
162
+ }
163
+
164
+ function serializedDiagnosticBytes(value: unknown): number {
165
+ try {
166
+ const text = JSON.stringify(value);
167
+ return text === undefined
168
+ ? 0
169
+ : diagnosticsEncoder.encode(text).byteLength;
170
+ } catch {
171
+ // The executor's normal result guard owns the error. Diagnostics must
172
+ // never turn measurement into a second failure path.
173
+ return 0;
174
+ }
175
+ }
37
176
 
38
177
  // deno-fmt-ignore
39
178
  const RESERVED = new Set([
@@ -154,6 +293,7 @@ export async function buildSandboxProviders(
154
293
  hostCallTimeoutMs?: number;
155
294
  discoveryConcurrency?: number;
156
295
  onInvocationFailure?: (failure: InvocationFailure) => void;
296
+ diagnostics?: ExecuteDiagnostics;
157
297
  } = {},
158
298
  ): Promise<ExecutorProvider[]> {
159
299
  // All host calls made by one execute_code invocation share a downstream
@@ -231,12 +371,17 @@ export async function buildSandboxProviders(
231
371
  throw err;
232
372
  }
233
373
  };
234
- const callAddress = async (address: unknown, args: unknown) => {
374
+ const callAddress = async (
375
+ address: unknown,
376
+ args: unknown,
377
+ diagnosticOperation: "call" | "batch" = "call",
378
+ ) => {
235
379
  const outcome = await invocation.invoke(
236
380
  String(address),
237
381
  args ?? {},
238
382
  invocationContext(),
239
383
  );
384
+ limits.diagnostics?.recordCall(diagnosticOperation, outcome);
240
385
  if (!outcome.ok) {
241
386
  const failure = new InvocationFailure(outcome.error);
242
387
  limits.onInvocationFailure?.(failure);
@@ -256,6 +401,7 @@ export async function buildSandboxProviders(
256
401
  args ?? {},
257
402
  invocationContext(),
258
403
  );
404
+ limits.diagnostics?.recordCall("call", outcome);
259
405
  if (!outcome.ok) {
260
406
  const failure = new InvocationFailure(outcome.error);
261
407
  limits.onInvocationFailure?.(failure);
@@ -270,83 +416,143 @@ export async function buildSandboxProviders(
270
416
  prelude: lazyNamespacePrelude(namespaces),
271
417
  fns: {
272
418
  __callNamespace: callNamespace,
273
- call: callAddress,
419
+ call: (address: unknown, args: unknown) =>
420
+ callAddress(address, args),
274
421
  batch: async (calls: unknown) => {
275
- if (!Array.isArray(calls)) throw new Error("calls must be an array");
276
- if (calls.length > EXECUTE_MAX_BATCH_CALLS) {
277
- throw new Error(
278
- `connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS} calls`,
422
+ const started = Date.now();
423
+ const callCount = Array.isArray(calls) ? calls.length : 0;
424
+ try {
425
+ if (!Array.isArray(calls)) throw new Error("calls must be an array");
426
+ if (calls.length > EXECUTE_MAX_BATCH_CALLS) {
427
+ throw new Error(
428
+ `connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS} calls`,
429
+ );
430
+ }
431
+ const result = await Promise.all(
432
+ calls.map(async (call) => {
433
+ const item = call as { address?: unknown; args?: unknown };
434
+ try {
435
+ return {
436
+ address: String(item.address),
437
+ ok: true,
438
+ data: await callAddress(
439
+ item.address,
440
+ item.args,
441
+ "batch",
442
+ ),
443
+ };
444
+ } catch (err) {
445
+ // Same failure shape batch_call reports: the message a program
446
+ // can log, plus the typed details it must classify by. A
447
+ // thrown host error crosses the sandbox bridge as a bare
448
+ // message string in every executor, so this is the one place a
449
+ // program can tell a policy refusal from a transient failure.
450
+ const details =
451
+ err instanceof InvocationFailure
452
+ ? err.details
453
+ : classifyCallError(err, "batch_call_failed");
454
+ return {
455
+ address: String(item.address),
456
+ ok: false,
457
+ error: details.message,
458
+ errorDetails: details,
459
+ };
460
+ }
461
+ }),
462
+ );
463
+ limits.diagnostics?.recordBatch(
464
+ Date.now() - started,
465
+ true,
466
+ callCount,
467
+ result,
468
+ );
469
+ return result;
470
+ } catch (err) {
471
+ limits.diagnostics?.recordBatch(
472
+ Date.now() - started,
473
+ false,
474
+ callCount,
279
475
  );
476
+ throw err;
280
477
  }
281
- return await Promise.all(
282
- calls.map(async (call) => {
283
- const item = call as { address?: unknown; args?: unknown };
284
- try {
285
- return {
286
- address: String(item.address),
287
- ok: true,
288
- data: await callAddress(item.address, item.args),
289
- };
290
- } catch (err) {
291
- // Same failure shape batch_call reports: the message a program
292
- // can log, plus the typed details it must classify by. A
293
- // thrown host error crosses the sandbox bridge as a bare
294
- // message string in every executor, so this is the one place a
295
- // program can tell a policy refusal from a transient failure.
296
- const details =
297
- err instanceof InvocationFailure
298
- ? err.details
299
- : classifyCallError(err, "batch_call_failed");
300
- return {
301
- address: String(item.address),
302
- ok: false,
303
- error: details.message,
304
- errorDetails: details,
305
- };
306
- }
307
- }),
308
- );
309
478
  },
310
- search: async (raw: unknown) =>
311
- typedDiscovery(async () => {
312
- const args = (raw ?? {}) as {
313
- query?: string;
314
- connector?: string;
315
- limit?: number;
316
- offset?: number;
317
- fullDescriptions?: boolean;
318
- includeSchemas?: "compact" | "json";
319
- includeSchemaKeys?: boolean;
320
- };
321
- const result = flatSearchResult(
322
- await catalog.search({
323
- ...args,
324
- // Key metadata rides along with schemas by default, since that
325
- // is the whole point of it in code mode. It stays opt-out
326
- // because it counts against the same discovery-byte ceiling.
327
- includeSchemaKeys: args.includeSchemaKeys !== false,
328
- }),
329
- );
330
- boundedDiscoveryText(
479
+ search: async (raw: unknown) => {
480
+ const started = Date.now();
481
+ try {
482
+ const result = await typedDiscovery(async () => {
483
+ const args = (raw ?? {}) as {
484
+ query?: string;
485
+ connector?: string;
486
+ safety?: "readOnly" | "approvalRequired" | "all";
487
+ limit?: number;
488
+ offset?: number;
489
+ fullDescriptions?: boolean;
490
+ includeSchemas?: "compact" | "json";
491
+ includeSchemaKeys?: boolean;
492
+ };
493
+ const result = flatSearchResult(
494
+ await catalog.search({
495
+ ...args,
496
+ // Key metadata rides along with schemas by default, since that
497
+ // is the whole point of it in code mode. It stays opt-out
498
+ // because it counts against the same discovery-byte ceiling.
499
+ includeSchemaKeys: args.includeSchemaKeys !== false,
500
+ }),
501
+ );
502
+ boundedDiscoveryText(
503
+ result,
504
+ "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.",
505
+ );
506
+ return result;
507
+ });
508
+ limits.diagnostics?.recordCatalog(
509
+ "search",
510
+ Date.now() - started,
511
+ true,
331
512
  result,
332
- "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.",
333
513
  );
334
514
  return result;
335
- }),
336
- describe: async (raw: unknown) =>
337
- typedDiscovery(async () => {
338
- const args = (raw ?? {}) as {
339
- addresses?: unknown;
340
- format?: "compact" | "json";
341
- fullDescriptions?: boolean;
342
- };
343
- const result = { tools: await catalog.describe(args) };
344
- boundedDiscoveryText(
515
+ } catch (err) {
516
+ limits.diagnostics?.recordCatalog(
517
+ "search",
518
+ Date.now() - started,
519
+ false,
520
+ );
521
+ throw err;
522
+ }
523
+ },
524
+ describe: async (raw: unknown) => {
525
+ const started = Date.now();
526
+ try {
527
+ const result = await typedDiscovery(async () => {
528
+ const args = (raw ?? {}) as {
529
+ addresses?: unknown;
530
+ format?: "compact" | "json";
531
+ fullDescriptions?: boolean;
532
+ };
533
+ const result = { tools: await catalog.describe(args) };
534
+ boundedDiscoveryText(
535
+ result,
536
+ 'Split the address list or use format: "compact".',
537
+ );
538
+ return result;
539
+ });
540
+ limits.diagnostics?.recordCatalog(
541
+ "describe",
542
+ Date.now() - started,
543
+ true,
345
544
  result,
346
- 'Split the address list or use format: "compact".',
347
545
  );
348
546
  return result;
349
- }),
547
+ } catch (err) {
548
+ limits.diagnostics?.recordCatalog(
549
+ "describe",
550
+ Date.now() - started,
551
+ false,
552
+ );
553
+ throw err;
554
+ }
555
+ },
350
556
  },
351
557
  },
352
558
  ];
@@ -362,7 +568,10 @@ export function createExecuteTool(
362
568
  config: { discoveryConcurrency?: number } = {},
363
569
  ) {
364
570
  return async (
365
- { code }: { code: string },
571
+ { code, diagnostics: diagnosticsRequested }: {
572
+ code: string;
573
+ diagnostics?: boolean;
574
+ },
366
575
  options: { signal?: AbortSignal } = {},
367
576
  ): Promise<ToolResult> => {
368
577
  const controller = new AbortController();
@@ -373,42 +582,64 @@ export function createExecuteTool(
373
582
  }
374
583
  let lease;
375
584
  let outcome;
585
+ const diagnostics = diagnosticsRequested ? new ExecuteDiagnostics() : undefined;
376
586
  const invocationFailures: InvocationFailure[] = [];
377
587
  try {
378
588
  // Admission comes before provider construction: queued calls retain no
379
589
  // catalogs, request scopes, or one-closure-per-tool provider arrays.
380
590
  if (isAdmittingExecutor(executor)) {
381
- lease = await executor.acquire({ signal: controller.signal });
591
+ const admissionStarted = Date.now();
592
+ try {
593
+ lease = await executor.acquire({ signal: controller.signal });
594
+ } finally {
595
+ if (diagnostics) {
596
+ diagnostics.admissionMs = Date.now() - admissionStarted;
597
+ }
598
+ }
382
599
  if ((lease.waitMs ?? 0) > 0) {
383
600
  logger.debug("[connecta] execute_code admitted after queue wait", {
384
601
  waitMs: lease.waitMs,
385
602
  });
386
603
  }
387
604
  }
388
- const providers = await buildSandboxProviders(
389
- registry,
390
- baseUrl,
391
- logger,
392
- activity,
393
- {
394
- signal: controller.signal,
395
- onInvocationFailure: (failure) => {
396
- invocationFailures.push(failure);
605
+ const setupStarted = Date.now();
606
+ let providers: ExecutorProvider[];
607
+ try {
608
+ providers = await buildSandboxProviders(
609
+ registry,
610
+ baseUrl,
611
+ logger,
612
+ activity,
613
+ {
614
+ signal: controller.signal,
615
+ onInvocationFailure: (failure) => {
616
+ invocationFailures.push(failure);
617
+ },
618
+ ...(diagnostics ? { diagnostics } : {}),
619
+ ...(config.discoveryConcurrency !== undefined
620
+ ? { discoveryConcurrency: config.discoveryConcurrency }
621
+ : {}),
397
622
  },
398
- ...(config.discoveryConcurrency !== undefined
399
- ? { discoveryConcurrency: config.discoveryConcurrency }
400
- : {}),
401
- },
402
- );
623
+ );
624
+ } finally {
625
+ if (diagnostics) diagnostics.setupMs = Date.now() - setupStarted;
626
+ }
403
627
  if (controller.signal.aborted) {
404
628
  throw new ExecutorAdmissionError(
405
629
  "executor_cancelled",
406
630
  "Execution was cancelled during sandbox setup.",
407
631
  );
408
632
  }
409
- outcome = lease
410
- ? await lease.execute(code, providers)
411
- : await executor.execute(code, providers);
633
+ const executorStarted = Date.now();
634
+ try {
635
+ outcome = lease
636
+ ? await lease.execute(code, providers)
637
+ : await executor.execute(code, providers);
638
+ } finally {
639
+ if (diagnostics) {
640
+ diagnostics.executorWallMs = Date.now() - executorStarted;
641
+ }
642
+ }
412
643
  } catch (err) {
413
644
  if (err instanceof ExecutorAdmissionError) {
414
645
  if (err.code === "executor_overloaded") {
@@ -426,6 +657,19 @@ export function createExecuteTool(
426
657
  ? { retryAfterMs: err.retryAfterMs }
427
658
  : {}),
428
659
  },
660
+ ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
661
+ });
662
+ result.isError = true;
663
+ return result;
664
+ }
665
+ if (diagnostics) {
666
+ const result = jsonResult({
667
+ error: {
668
+ code: "executor_failed",
669
+ message: `Executor failed: ${msg(err)}`,
670
+ retryable: false,
671
+ },
672
+ diagnostics: diagnostics.finish(),
429
673
  });
430
674
  result.isError = true;
431
675
  return result;
@@ -476,13 +720,26 @@ export function createExecuteTool(
476
720
  const result = jsonResult({
477
721
  error: invocationFailure.details,
478
722
  ...(logs ? { logs } : {}),
723
+ ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
479
724
  });
480
725
  result.isError = true;
481
726
  return result;
482
727
  }
483
- return errorResult(
484
- `Error: ${outcome.error}${logs ? `\n\nLogs:\n${logs}` : ""}`,
485
- );
728
+ const message = `Error: ${outcome.error}`;
729
+ if (diagnostics) {
730
+ const result = jsonResult({
731
+ error: {
732
+ code: "executor_failed",
733
+ message,
734
+ retryable: false,
735
+ },
736
+ ...(logs ? { logs } : {}),
737
+ diagnostics: diagnostics.finish(),
738
+ });
739
+ result.isError = true;
740
+ return result;
741
+ }
742
+ return errorResult(`${message}${logs ? `\n\nLogs:\n${logs}` : ""}`);
486
743
  }
487
744
  // A result crossing back as a host BigInt (or otherwise unserializable
488
745
  // value) makes JSON.stringify throw — keep that inside the structured
@@ -491,13 +748,26 @@ export function createExecuteTool(
491
748
  try {
492
749
  result = guardExecuteResultValue(outcome.result);
493
750
  } catch (err) {
494
- return errorResult(
495
- `Error: result is not JSON-serializable: ${msg(err)}${logs ? `\n\nLogs:\n${logs}` : ""}`,
496
- );
751
+ const message = `Error: result is not JSON-serializable: ${msg(err)}`;
752
+ if (diagnostics) {
753
+ const response = jsonResult({
754
+ error: {
755
+ code: "executor_failed",
756
+ message,
757
+ retryable: false,
758
+ },
759
+ ...(logs ? { logs } : {}),
760
+ diagnostics: diagnostics.finish(),
761
+ });
762
+ response.isError = true;
763
+ return response;
764
+ }
765
+ return errorResult(`${message}${logs ? `\n\nLogs:\n${logs}` : ""}`);
497
766
  }
498
767
  return jsonResult({
499
768
  result,
500
769
  ...(logs ? { logs } : {}),
770
+ ...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
501
771
  });
502
772
  };
503
773
  }
@@ -529,13 +799,13 @@ const executeDescription = (
529
799
  Write an async arrow function. It runs with NO network, filesystem, timers, or imports — the only capabilities are:
530
800
  - One global per connector: every address <connectorId>.<toolName> from search_tools is callable as <connectorId>.<toolName>(args) with a single args object matching the schema from ${EXECUTE_SCHEMA_SOURCE[surface]}. Names are sanitized to JS identifiers: characters outside [A-Za-z0-9_$] become "_" (e.g. my-service.get.thing → my_service.get_thing), leading digits get "_" prefixed, reserved words get "_" appended.
531
801
  - connecta.call(address, args) and connecta.batch(calls) — call raw addresses.
532
- - connecta.search(args) and connecta.describe(args) — load and inspect request-local catalogs on demand. Matches carrying schemas also list inputKeys, requiredInputKeys, and outputKeys — the same names the schema shows, ready to check against before building args. They are absent when a schema is not a plain object shape, so read the schema itself rather than assuming a missing list means no fields.
802
+ - connecta.search(args) and connecta.describe(args) — load and inspect request-local catalogs on demand. Use safety: "readOnly" to avoid advertising calls this sandbox cannot execute; the filter changes results, not authority. Matches carrying schemas also list inputKeys, requiredInputKeys, and outputKeys — the same names the schema shows, ready to check against before building args. They are absent when a schema is not a plain object shape, so read the schema itself rather than assuming a missing list means no fields.
533
803
  - console.log(...) — captured and returned alongside the result.
534
804
 
535
805
  Tool calls return plain values (MCP text content is JSON-parsed when possible) and throw on downstream errors — use try/catch to handle them. A thrown error carries only a message; connecta.batch reports each call as { address, ok: true, data } or { address, ok: false, error, errorDetails: { code, retryable } }, so use it when the program must tell a policy refusal from a transient failure. Never retry a failure whose retryable is false, and never retry a rate_limited one immediately — the sandbox has no timers. Return a JSON-serializable value; large results are truncated, so reduce data in code instead of returning raw payloads.
536
806
 
537
807
  Plain JavaScript only — no TypeScript syntax. For unknown-address dependent work, use one execute_code call: search inside it, read the compact schemas, and continue to the dependent calls; do not return search results for a second execute_code call. Compact schemas are TypeScript-like strings, not JSON Schema objects: write the property names they display, never a positional guess or an invented alias.
538
- Dependent example (only when the second call requires a value returned by the first): async () => { const { tools } = await connecta.search({ query: "pipeline run job logs", includeSchemas: "compact" }); const pick = (suffix) => { const match = tools.find((tool) => tool.address.endsWith(suffix)); if (!match) throw new Error("no tool matching " + suffix); return match.address; }; const run = await connecta.call(pick(".get_run"), { runId: 42 }); const logs = await connecta.call(pick(".get_job_logs"), { jobId: run.failedJobId }); return [run, logs]; }`;
808
+ Dependent example (only when the second call requires a value returned by the first): async () => { const { tools } = await connecta.search({ query: "pipeline run job logs", safety: "readOnly", includeSchemas: "compact" }); const pick = (suffix) => { const match = tools.find((tool) => tool.address.endsWith(suffix)); if (!match) throw new Error("no tool matching " + suffix); return match.address; }; const run = await connecta.call(pick(".get_run"), { runId: 42 }); const logs = await connecta.call(pick(".get_job_logs"), { jobId: run.failedJobId }); return [run, logs]; }`;
539
809
 
540
810
  /** Register the execute_code meta-tool. Only called when an executor is configured. */
541
811
  export function registerExecuteTool(
@@ -570,6 +840,12 @@ export function registerExecuteTool(
570
840
  code: z
571
841
  .string()
572
842
  .describe("A JavaScript async arrow function to execute."),
843
+ diagnostics: z
844
+ .boolean()
845
+ .optional()
846
+ .describe(
847
+ "Add request-local, payload-free timing and result-size summaries.",
848
+ ),
573
849
  }),
574
850
  // The sandbox exposes only tools that are explicitly read-only, and the
575
851
  // executor grants no network, filesystem, env, or timer capabilities.
@@ -591,7 +867,7 @@ export function registerExecuteTool(
591
867
  return { signal, forward };
592
868
  });
593
869
  try {
594
- return await handler(args as { code: string }, {
870
+ return await handler(args as { code: string; diagnostics?: boolean }, {
595
871
  signal: controller.signal,
596
872
  });
597
873
  } finally {
package/src/invocation.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from "./errors.js";
19
19
  import { unwrapMcpResult } from "./mcp-result.js";
20
20
  import type { RegistryView } from "./registry.js";
21
+ import { isExplicitlyReadOnly } from "./tool-safety.js";
21
22
  import type { ToolDef } from "./types.js";
22
23
 
23
24
  /**
@@ -55,14 +56,6 @@ export function retryBackoffMs(
55
56
  return retryAfterMs <= MAX_RETRY_BACKOFF_MS ? retryAfterMs : undefined;
56
57
  }
57
58
 
58
- /** The one fail-closed admission predicate for every invocation adapter. */
59
- function isExplicitlyReadOnly(definition: ToolDef): boolean {
60
- return (
61
- definition.annotations?.readOnlyHint === true &&
62
- definition.annotations?.destructiveHint !== true
63
- );
64
- }
65
-
66
59
  function retrySafe(definition: ToolDef): boolean {
67
60
  return (
68
61
  definition.annotations?.readOnlyHint === true ||