@zackbart/connecta 0.16.0 → 0.17.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.
Files changed (51) hide show
  1. package/AGENTS.md +12 -5
  2. package/CHANGELOG.md +289 -0
  3. package/README.md +6 -1
  4. package/dist/catalog-service.d.ts +4 -0
  5. package/dist/catalog-service.js +49 -5
  6. package/dist/catalog.d.ts +11 -0
  7. package/dist/catalog.js +134 -12
  8. package/dist/errors.d.ts +28 -2
  9. package/dist/errors.js +1 -0
  10. package/dist/execute.d.ts +5 -0
  11. package/dist/execute.js +229 -161
  12. package/dist/invocation.js +3 -1
  13. package/dist/meta-tools.d.ts +4 -0
  14. package/dist/meta-tools.js +46 -14
  15. package/dist/operator-ui/generated.d.ts +1 -1
  16. package/dist/operator-ui/generated.js +1 -1
  17. package/dist/operator-ui/model.d.ts +3 -1
  18. package/dist/providers/cloudflare.js +13 -25
  19. package/dist/providers/mixpanel.d.ts +3 -5
  20. package/dist/providers/mixpanel.js +73 -5
  21. package/dist/providers/stripe.d.ts +2 -2
  22. package/dist/providers/stripe.js +13 -11
  23. package/dist/registry.d.ts +32 -9
  24. package/dist/registry.js +217 -33
  25. package/dist/routes/mcp.js +6 -0
  26. package/dist/skills.d.ts +4 -0
  27. package/dist/skills.js +157 -18
  28. package/dist/types.d.ts +14 -2
  29. package/dist/ui.js +4 -1
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/documentation/architecture.md +7 -4
  33. package/documentation/cloudflare.md +40 -8
  34. package/documentation/code-first-exploration.md +2 -2
  35. package/documentation/code-mode.md +45 -53
  36. package/documentation/connector-guides.md +24 -19
  37. package/documentation/connectors.md +13 -1
  38. package/documentation/meta-tools.md +33 -18
  39. package/documentation/mixpanel.md +20 -0
  40. package/documentation/notion.md +7 -2
  41. package/documentation/operations.md +74 -29
  42. package/documentation/operator-ui.md +12 -2
  43. package/documentation/provider-audit.md +4 -4
  44. package/documentation/provider-conventions.md +68 -19
  45. package/documentation/stripe.md +45 -14
  46. package/documentation/upgrading.md +478 -0
  47. package/ethos.md +4 -4
  48. package/examples/worker/README.md +13 -6
  49. package/package.json +7 -2
  50. package/templates/node/AGENTS.md +5 -0
  51. package/templates/node/package.json +1 -1
package/dist/execute.js CHANGED
@@ -125,7 +125,7 @@ const EMIT_SHAPE_HINT = '{ type: "text", text } or { type: "image" | "audio", da
125
125
  */
126
126
  function requireEmittedBlock(raw) {
127
127
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
128
- throw new Error(`connecta.emit accepts exactly one content block: ${EMIT_SHAPE_HINT}`);
128
+ throw guestFailure("invalid_args", `connecta.emit accepts exactly one content block: ${EMIT_SHAPE_HINT}`);
129
129
  }
130
130
  const block = raw;
131
131
  const fields = block.type === "text"
@@ -134,16 +134,16 @@ function requireEmittedBlock(raw) {
134
134
  ? ["type", "data", "mimeType"]
135
135
  : undefined;
136
136
  if (!fields) {
137
- throw new Error(`connecta.emit supports content types "text", "image", and "audio"; got ${JSON.stringify(block.type)}`);
137
+ throw guestFailure("invalid_args", `connecta.emit supports content types "text", "image", and "audio"; got ${JSON.stringify(block.type)}`);
138
138
  }
139
139
  for (const field of fields) {
140
140
  if (typeof block[field] !== "string") {
141
- throw new Error(`connecta.emit block field "${field}" must be a string: ${EMIT_SHAPE_HINT}`);
141
+ throw guestFailure("invalid_args", `connecta.emit block field "${field}" must be a string: ${EMIT_SHAPE_HINT}`);
142
142
  }
143
143
  }
144
144
  const extra = Object.keys(block).filter((key) => !fields.includes(key));
145
145
  if (extra.length > 0) {
146
- throw new Error(`connecta.emit block carries unsupported field(s) ${extra.map((key) => JSON.stringify(key)).join(", ")}; a "${String(block.type)}" block is exactly { ${fields.join(", ")} }`);
146
+ throw guestFailure("invalid_args", `connecta.emit block carries unsupported field(s) ${extra.map((key) => JSON.stringify(key)).join(", ")}; a "${String(block.type)}" block is exactly { ${fields.join(", ")} }`);
147
147
  }
148
148
  return raw;
149
149
  }
@@ -172,20 +172,20 @@ function describeUiArgument(raw) {
172
172
  */
173
173
  function requireUiHtml(raw) {
174
174
  if (typeof raw !== "string" || raw.length === 0) {
175
- throw new Error(`${UI_SHAPE_HINT}; got ${describeUiArgument(raw)}`);
175
+ throw guestFailure("invalid_args", `${UI_SHAPE_HINT}; got ${describeUiArgument(raw)}`);
176
176
  }
177
177
  return raw;
178
178
  }
179
179
  function requireRecord(raw, label) {
180
180
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
181
- throw new Error(`${label} must be an object`);
181
+ throw guestFailure("invalid_args", `${label} must be an object`);
182
182
  }
183
183
  return raw;
184
184
  }
185
185
  function requireExactKeys(value, allowed, label) {
186
186
  const extras = Object.keys(value).filter((key) => !allowed.includes(key));
187
187
  if (extras.length > 0) {
188
- throw new Error(`${label} carries unsupported field(s) ${extras.map((key) => JSON.stringify(key)).join(", ")}`);
188
+ throw guestFailure("invalid_args", `${label} carries unsupported field(s) ${extras.map((key) => JSON.stringify(key)).join(", ")}`);
189
189
  }
190
190
  }
191
191
  function requireUiReadKey(raw, label) {
@@ -193,7 +193,7 @@ function requireUiReadKey(raw, label) {
193
193
  raw.length === 0 ||
194
194
  raw.length > 128 ||
195
195
  FORBIDDEN_UI_KEY.has(raw)) {
196
- throw new Error(`${label} must be a non-empty string of at most 128 characters and cannot be __proto__, constructor, or prototype`);
196
+ throw guestFailure("invalid_args", `${label} must be a non-empty string of at most 128 characters and cannot be __proto__, constructor, or prototype`);
197
197
  }
198
198
  return raw;
199
199
  }
@@ -201,32 +201,32 @@ function requireUiReads(raw) {
201
201
  const record = requireRecord(raw, "connecta.ui options.reads");
202
202
  const names = Object.keys(record);
203
203
  if (names.length === 0 || names.length > MAX_UI_READ_BINDINGS) {
204
- throw new Error(`connecta.ui options.reads must contain from 1 through ${MAX_UI_READ_BINDINGS} named bindings`);
204
+ throw guestFailure("invalid_args", `connecta.ui options.reads must contain from 1 through ${MAX_UI_READ_BINDINGS} named bindings`);
205
205
  }
206
206
  const reads = Object.create(null);
207
207
  for (const name of names) {
208
208
  if (!UI_READ_NAME.test(name) || FORBIDDEN_UI_KEY.has(name)) {
209
- throw new Error(`connecta.ui read binding name ${JSON.stringify(name)} must match ${UI_READ_NAME}`);
209
+ throw guestFailure("invalid_args", `connecta.ui read binding name ${JSON.stringify(name)} must match ${UI_READ_NAME}`);
210
210
  }
211
211
  const value = requireRecord(record[name], `connecta.ui read binding ${JSON.stringify(name)}`);
212
212
  requireExactKeys(value, ["address", "fixedArgs", "viewArgs"], `connecta.ui read binding ${JSON.stringify(name)}`);
213
213
  if (typeof value.address !== "string" || value.address.length === 0) {
214
- throw new Error(`connecta.ui read binding ${JSON.stringify(name)} address must be a non-empty string`);
214
+ throw guestFailure("invalid_args", `connecta.ui read binding ${JSON.stringify(name)} address must be a non-empty string`);
215
215
  }
216
216
  const fixedArgs = value.fixedArgs === undefined
217
217
  ? {}
218
218
  : requireRecord(value.fixedArgs, `connecta.ui read binding ${JSON.stringify(name)} fixedArgs`);
219
219
  const rawViewArgs = value.viewArgs ?? [];
220
220
  if (!Array.isArray(rawViewArgs) || rawViewArgs.length > MAX_UI_VIEW_ARGS) {
221
- throw new Error(`connecta.ui read binding ${JSON.stringify(name)} viewArgs must be an array of at most ${MAX_UI_VIEW_ARGS} strings`);
221
+ throw guestFailure("invalid_args", `connecta.ui read binding ${JSON.stringify(name)} viewArgs must be an array of at most ${MAX_UI_VIEW_ARGS} strings`);
222
222
  }
223
223
  const viewArgs = rawViewArgs.map((key) => requireUiReadKey(key, `connecta.ui read binding ${JSON.stringify(name)} viewArgs entry`));
224
224
  if (new Set(viewArgs).size !== viewArgs.length) {
225
- throw new Error(`connecta.ui read binding ${JSON.stringify(name)} viewArgs must not repeat a key`);
225
+ throw guestFailure("invalid_args", `connecta.ui read binding ${JSON.stringify(name)} viewArgs must not repeat a key`);
226
226
  }
227
227
  for (const key of viewArgs) {
228
228
  if (Object.prototype.hasOwnProperty.call(fixedArgs, key)) {
229
- throw new Error(`connecta.ui read binding ${JSON.stringify(name)} view argument ${JSON.stringify(key)} cannot override a fixed argument`);
229
+ throw guestFailure("invalid_args", `connecta.ui read binding ${JSON.stringify(name)} view argument ${JSON.stringify(key)} cannot override a fixed argument`);
230
230
  }
231
231
  }
232
232
  reads[name] = {
@@ -239,7 +239,7 @@ function requireUiReads(raw) {
239
239
  }
240
240
  function requireUiPayload(values) {
241
241
  if (values.length !== 1 && values.length !== 2) {
242
- throw new Error(`${UI_SHAPE_HINT}; got ${values.length} arguments`);
242
+ throw guestFailure("invalid_args", `${UI_SHAPE_HINT}; got ${values.length} arguments`);
243
243
  }
244
244
  const html = requireUiHtml(values[0]);
245
245
  if (values.length === 1)
@@ -247,7 +247,7 @@ function requireUiPayload(values) {
247
247
  const options = requireRecord(values[1], "connecta.ui options");
248
248
  requireExactKeys(options, ["reads"], "connecta.ui options");
249
249
  if (!Object.prototype.hasOwnProperty.call(options, "reads")) {
250
- throw new Error("connecta.ui options must contain reads");
250
+ throw guestFailure("invalid_args", "connecta.ui options must contain reads");
251
251
  }
252
252
  return { html, reads: requireUiReads(options.reads) };
253
253
  }
@@ -280,11 +280,11 @@ export class EmitCollector {
280
280
  accept(raw) {
281
281
  const block = requireEmittedBlock(raw);
282
282
  if (this.blocks.length >= this.maxBlocks) {
283
- throw new Error(`connecta.emit block-count budget exceeded: ${this.maxBlocks} block(s) maximum, 0 remaining`);
283
+ throw guestFailure("budget_exceeded", `connecta.emit block-count budget exceeded: ${this.maxBlocks} block(s) maximum, 0 remaining`);
284
284
  }
285
285
  const size = diagnosticsEncoder.encode(JSON.stringify(block)).byteLength;
286
286
  if (this.bytes + size > this.maxBytes) {
287
- throw new Error(`connecta.emit byte budget exceeded: block is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`);
287
+ throw guestFailure("budget_exceeded", `connecta.emit byte budget exceeded: block is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`);
288
288
  }
289
289
  this.blocks.push(block);
290
290
  this.bytes += size;
@@ -305,24 +305,24 @@ export class EmitCollector {
305
305
  */
306
306
  acceptUi(...values) {
307
307
  if (this.ui) {
308
- throw new Error("connecta.ui accepts at most one payload per run: a view was already accepted and stands");
308
+ throw guestFailure("invalid_args", "connecta.ui accepts at most one payload per run: a view was already accepted and stands");
309
309
  }
310
310
  this.acceptUiPayload(requireUiPayload(values));
311
311
  }
312
312
  acceptUiPayload(payload) {
313
313
  if (this.ui) {
314
- throw new Error("connecta.ui accepts at most one payload per run: a view was already accepted and stands");
314
+ throw guestFailure("invalid_args", "connecta.ui accepts at most one payload per run: a view was already accepted and stands");
315
315
  }
316
316
  let serialized;
317
317
  try {
318
318
  serialized = JSON.stringify(payload);
319
319
  }
320
320
  catch {
321
- throw new Error("connecta.ui payload must be JSON-serializable");
321
+ throw guestFailure("invalid_args", "connecta.ui payload must be JSON-serializable");
322
322
  }
323
323
  const size = diagnosticsEncoder.encode(serialized).byteLength;
324
324
  if (this.bytes + size > this.maxBytes) {
325
- throw new Error(`connecta.ui byte budget exceeded: payload is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`);
325
+ throw guestFailure("budget_exceeded", `connecta.ui byte budget exceeded: payload is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`);
326
326
  }
327
327
  this.ui = payload;
328
328
  this.bytes += size;
@@ -430,18 +430,75 @@ const SANDBOX_RESERVED_NAMES = new Set([
430
430
  function msg(err) {
431
431
  return err instanceof Error ? err.message : String(err);
432
432
  }
433
- function lazyNamespacePrelude(connectors) {
433
+ function guestFailure(code, message, retryable = false) {
434
+ return new InvocationFailure({ code, message, retryable });
435
+ }
436
+ const GUEST_FAILURE_FRAME = "\u001econnecta-error:";
437
+ const guestFailureFrames = new WeakMap();
438
+ /** Per-execution secret: guest prose cannot collide with this host frame. */
439
+ function guestFailureSecret() {
440
+ const words = new Uint32Array(4);
441
+ crypto.getRandomValues(words);
442
+ return Array.from(words, (word) => word.toString(16).padStart(8, "0")).join("");
443
+ }
444
+ function framedGuestFailure(secret, failure) {
445
+ const framed = new InvocationFailure(failure.details);
446
+ framed.message =
447
+ `${GUEST_FAILURE_FRAME}${secret}:${JSON.stringify(failure.details)}`;
448
+ guestFailureFrames.set(failure, framed.message);
449
+ return framed;
450
+ }
451
+ /**
452
+ * Rebuilds host failures as guest Error instances, then installs connector
453
+ * shortcuts. The random frame is hidden in this closure and Error is locked:
454
+ * connector prose and model code can neither collide with nor forge its frame.
455
+ */
456
+ function lazyNamespacePrelude(connectors, failureSecret) {
434
457
  const declarations = connectors
435
458
  .map(({ id, namespace }) => `globalThis[${JSON.stringify(namespace)}] = __makeConnectaNamespace(${JSON.stringify(id)});`)
436
459
  .join("\n");
437
- return `(() => {
460
+ return `((failurePrefix) => {
461
+ const NativeError = globalThis.Error;
462
+ const startsWith = Function.prototype.call.bind(String.prototype.startsWith);
463
+ const slice = Function.prototype.call.bind(String.prototype.slice);
464
+ const parse = JSON.parse;
465
+ const freeze = Object.freeze;
466
+ const defineProperties = Object.defineProperties;
467
+ const construct = Reflect.construct;
468
+ function ConnectaError(message, options) {
469
+ let details;
470
+ if (typeof message === "string" && startsWith(message, failurePrefix)) {
471
+ try { details = parse(slice(message, failurePrefix.length)); } catch {}
472
+ }
473
+ const error = construct(
474
+ NativeError,
475
+ options === undefined ? [details ? details.message : message] : [details ? details.message : message, options],
476
+ new.target || NativeError
477
+ );
478
+ if (details) {
479
+ freeze(details);
480
+ defineProperties(error, {
481
+ code: { value: details.code, enumerable: true },
482
+ retryable: { value: details.retryable, enumerable: true },
483
+ details: { value: details, enumerable: true }
484
+ });
485
+ }
486
+ return error;
487
+ }
488
+ ConnectaError.prototype = NativeError.prototype;
489
+ Object.setPrototypeOf(ConnectaError, NativeError);
490
+ Object.defineProperty(globalThis, "Error", {
491
+ value: ConnectaError,
492
+ writable: false,
493
+ configurable: false
494
+ });
438
495
  const __makeConnectaNamespace = (connectorId) => Object.freeze(new Proxy(Object.create(null), {
439
496
  get: (_target, toolName) => typeof toolName === "string"
440
497
  ? (args) => connecta.__callNamespace(connectorId, toolName, args)
441
498
  : undefined
442
499
  }));
443
500
  ${declarations}
444
- })();`;
501
+ })(${JSON.stringify(`${GUEST_FAILURE_FRAME}${failureSecret}:`)});`;
445
502
  }
446
503
  /**
447
504
  * Expose one fixed host provider plus trusted sandbox setup that creates a
@@ -462,10 +519,12 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
462
519
  ...(limits.probeTimeoutMs !== undefined
463
520
  ? { probeTimeoutMs: limits.probeTimeoutMs }
464
521
  : {}),
522
+ ...(limits.defer !== undefined ? { defer: limits.defer } : {}),
465
523
  });
466
524
  const invocation = new InvocationService(registry, catalog, activity);
467
525
  const maxHostCalls = Math.max(1, Math.trunc(limits.maxHostCalls ?? EXECUTE_MAX_HOST_CALLS));
468
526
  const hostCallTimeoutMs = Math.max(1, Math.trunc(limits.hostCallTimeoutMs ?? EXECUTE_HOST_CALL_TIMEOUT_MS));
527
+ const failureSecret = guestFailureSecret();
469
528
  let hostCalls = 0;
470
529
  const connectors = registry.listConnectors();
471
530
  const namespaces = [];
@@ -493,16 +552,13 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
493
552
  beforeDispatch: () => {
494
553
  hostCalls++;
495
554
  if (hostCalls > maxHostCalls) {
496
- throw new Error(`execute_code host-call budget exceeded (${maxHostCalls} calls maximum)`);
555
+ throw guestFailure("budget_exceeded", `execute_code host-call budget exceeded (${maxHostCalls} calls maximum)`);
497
556
  }
498
557
  },
499
558
  });
500
559
  /**
501
- * A discovery bound is as typed a failure as a tool call is, and a program
502
- * that lets one escape deserves the same envelope: register it on the same
503
- * request-local channel so an unhandled `invalid_args`/`result_too_large`
504
- * reaches the model with its code instead of as prose. The guest still sees
505
- * only the message — that is the bridge's limit, not a policy.
560
+ * Discovery policy failures use the same thrown vocabulary as calls and
561
+ * utilities. The transport below reconstructs their code inside the guest.
506
562
  */
507
563
  const typedDiscovery = async (operation) => {
508
564
  try {
@@ -510,11 +566,7 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
510
566
  }
511
567
  catch (err) {
512
568
  if (err instanceof DiscoveryPolicyError) {
513
- limits.onInvocationFailure?.(new InvocationFailure({
514
- code: err.code,
515
- message: err.message,
516
- retryable: false,
517
- }));
569
+ throw guestFailure(err.code, err.message);
518
570
  }
519
571
  throw err;
520
572
  }
@@ -524,7 +576,6 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
524
576
  limits.diagnostics?.recordCall(diagnosticOperation, outcome);
525
577
  if (!outcome.ok) {
526
578
  const failure = new InvocationFailure(outcome.error);
527
- limits.onInvocationFailure?.(failure);
528
579
  throw failure;
529
580
  }
530
581
  return outcome.value;
@@ -534,7 +585,6 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
534
585
  limits.diagnostics?.recordCall("call", outcome);
535
586
  if (!outcome.ok) {
536
587
  const failure = new InvocationFailure(outcome.error);
537
- limits.onInvocationFailure?.(failure);
538
588
  throw failure;
539
589
  }
540
590
  return outcome.value;
@@ -554,10 +604,13 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
554
604
  for (const [name, binding] of Object.entries(payload.reads)) {
555
605
  const resolution = await catalog.resolveTool(binding.address, limits.signal !== undefined ? { signal: limits.signal } : {});
556
606
  if (!resolution.ok) {
557
- throw new Error(`connecta.ui read binding ${JSON.stringify(name)} could not resolve ${JSON.stringify(binding.address)}: ${resolution.error.message}`);
607
+ throw new InvocationFailure({
608
+ ...resolution.error,
609
+ message: `connecta.ui read binding ${JSON.stringify(name)} could not resolve ${JSON.stringify(binding.address)}: ${resolution.error.message}`,
610
+ });
558
611
  }
559
612
  if (!isExplicitlyReadOnly(resolution.resolved.definition)) {
560
- throw new Error(`connecta.ui read binding ${JSON.stringify(name)} refuses ${JSON.stringify(binding.address)}: the tool is not explicitly read-only`);
613
+ throw guestFailure("destructive_tool_requires_approval", `connecta.ui read binding ${JSON.stringify(name)} refuses ${JSON.stringify(binding.address)}: the tool is not explicitly read-only`);
561
614
  }
562
615
  reads[name] = {
563
616
  ...binding,
@@ -566,118 +619,128 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
566
619
  }
567
620
  return { html: payload.html, reads };
568
621
  };
569
- return [
570
- {
571
- name: "connecta",
572
- prelude: lazyNamespacePrelude(namespaces),
573
- fns: {
574
- __callNamespace: callNamespace,
575
- call: (address, args) => callAddress(address, args),
576
- // Emission is a provider function, never an ExecuteResult field —
577
- // that is what keeps the Executor contract untouched and parity
578
- // structural (M8). It spends no host-call budget (M7); its own
579
- // budgets live in the collector.
580
- emit: async (block) => {
581
- if (!limits.emitCollector) {
582
- throw new Error("connecta.emit is unavailable: no emission collector was configured for this execution");
583
- }
584
- limits.emitCollector.accept(block);
585
- },
586
- // The rendered-output channel rides the same bridge for the same
587
- // reason (U7): one more provider fn, no change to ExecuteResult or
588
- // the Executor contract. Delivery is the handler's job, not the
589
- // guest's — nothing here becomes addressable.
590
- ui: async (...values) => {
591
- if (!limits.emitCollector) {
592
- throw new Error("connecta.ui is unavailable: no emission collector was configured for this execution");
593
- }
594
- const payload = await validateUiReads(requireUiPayload(values));
595
- limits.emitCollector.acceptUiPayload(payload);
596
- },
597
- batch: async (calls) => {
598
- const started = Date.now();
599
- const callCount = Array.isArray(calls) ? calls.length : 0;
600
- try {
601
- if (!Array.isArray(calls))
602
- throw new Error("calls must be an array");
603
- if (calls.length > EXECUTE_MAX_BATCH_CALLS) {
604
- throw new Error(`connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS} calls`);
605
- }
606
- const result = await Promise.all(calls.map(async (call) => {
607
- const item = call;
608
- try {
609
- return {
610
- address: String(item.address),
611
- ok: true,
612
- data: await callAddress(item.address, item.args, "batch"),
613
- };
614
- }
615
- catch (err) {
616
- // The failure shape connecta.batch reports: the message a
617
- // program can log, plus the typed details it must classify by. A
618
- // thrown host error crosses the sandbox bridge as a bare
619
- // message string in every executor, so this is the one place a
620
- // program can tell a policy refusal from a transient failure.
621
- const details = err instanceof InvocationFailure
622
- ? err.details
623
- : classifyCallError(err, "batch_call_failed");
624
- return {
625
- address: String(item.address),
626
- ok: false,
627
- error: details.message,
628
- errorDetails: details,
629
- };
630
- }
631
- }));
632
- limits.diagnostics?.recordBatch(Date.now() - started, true, callCount, result);
633
- return result;
634
- }
635
- catch (err) {
636
- limits.diagnostics?.recordBatch(Date.now() - started, false, callCount);
637
- throw err;
638
- }
639
- },
640
- search: async (raw) => {
641
- const started = Date.now();
642
- try {
643
- const result = await typedDiscovery(async () => {
644
- const args = (raw ?? {});
645
- const result = flatSearchResult(await catalog.search({
646
- ...args,
647
- // Key metadata rides along with schemas by default, since that
648
- // is the whole point of it in code mode. It stays opt-out
649
- // because it counts against the same discovery-byte ceiling.
650
- includeSchemaKeys: args.includeSchemaKeys !== false,
651
- }));
652
- boundedDiscoveryText(result, "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.");
653
- return result;
654
- });
655
- limits.diagnostics?.recordCatalog("search", Date.now() - started, true, result);
656
- return result;
657
- }
658
- catch (err) {
659
- limits.diagnostics?.recordCatalog("search", Date.now() - started, false);
660
- throw err;
661
- }
662
- },
663
- describe: async (raw) => {
664
- const started = Date.now();
622
+ const fns = {
623
+ __callNamespace: callNamespace,
624
+ call: (address, args) => callAddress(address, args),
625
+ // Emission is a provider function, never an ExecuteResult field —
626
+ // that is what keeps the Executor contract untouched and parity
627
+ // structural (M8). It spends no host-call budget (M7); its own
628
+ // budgets live in the collector.
629
+ emit: async (block) => {
630
+ if (!limits.emitCollector) {
631
+ throw guestFailure("unavailable", "connecta.emit is unavailable: no emission collector was configured for this execution", true);
632
+ }
633
+ limits.emitCollector.accept(block);
634
+ },
635
+ // The rendered-output channel rides the same bridge for the same
636
+ // reason (U7): one more provider fn, no change to ExecuteResult or
637
+ // the Executor contract. Delivery is the handler's job, not the
638
+ // guest's — nothing here becomes addressable.
639
+ ui: async (...values) => {
640
+ if (!limits.emitCollector) {
641
+ throw guestFailure("unavailable", "connecta.ui is unavailable: no emission collector was configured for this execution", true);
642
+ }
643
+ const payload = await validateUiReads(requireUiPayload(values));
644
+ limits.emitCollector.acceptUiPayload(payload);
645
+ },
646
+ batch: async (calls) => {
647
+ const started = Date.now();
648
+ const callCount = Array.isArray(calls) ? calls.length : 0;
649
+ try {
650
+ if (!Array.isArray(calls)) {
651
+ throw guestFailure("invalid_args", "calls must be an array");
652
+ }
653
+ if (calls.length > EXECUTE_MAX_BATCH_CALLS) {
654
+ throw guestFailure("invalid_args", `connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS} calls`);
655
+ }
656
+ const result = await Promise.all(calls.map(async (call) => {
657
+ const item = call;
665
658
  try {
666
- const result = await typedDiscovery(async () => {
667
- const args = (raw ?? {});
668
- const result = { tools: await catalog.describe(args) };
669
- boundedDiscoveryText(result, 'Split the address list or use format: "compact".');
670
- return result;
671
- });
672
- limits.diagnostics?.recordCatalog("describe", Date.now() - started, true, result);
673
- return result;
659
+ return {
660
+ address: String(item.address),
661
+ ok: true,
662
+ data: await callAddress(item.address, item.args, "batch"),
663
+ };
674
664
  }
675
665
  catch (err) {
676
- limits.diagnostics?.recordCatalog("describe", Date.now() - started, false);
677
- throw err;
666
+ const details = err instanceof InvocationFailure
667
+ ? err.details
668
+ : classifyCallError(err, "batch_call_failed");
669
+ return {
670
+ address: String(item.address),
671
+ ok: false,
672
+ error: details.message,
673
+ errorDetails: details,
674
+ };
678
675
  }
679
- },
680
- },
676
+ }));
677
+ limits.diagnostics?.recordBatch(Date.now() - started, true, callCount, result);
678
+ return result;
679
+ }
680
+ catch (err) {
681
+ limits.diagnostics?.recordBatch(Date.now() - started, false, callCount);
682
+ throw err;
683
+ }
684
+ },
685
+ search: async (raw) => {
686
+ const started = Date.now();
687
+ try {
688
+ const result = await typedDiscovery(async () => {
689
+ const args = (raw ?? {});
690
+ const result = flatSearchResult(await catalog.search({
691
+ ...args,
692
+ includeSchemaKeys: args.includeSchemaKeys !== false,
693
+ }));
694
+ boundedDiscoveryText(result, "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.");
695
+ return result;
696
+ });
697
+ limits.diagnostics?.recordCatalog("search", Date.now() - started, true, result);
698
+ return result;
699
+ }
700
+ catch (err) {
701
+ limits.diagnostics?.recordCatalog("search", Date.now() - started, false);
702
+ throw err;
703
+ }
704
+ },
705
+ describe: async (raw) => {
706
+ const started = Date.now();
707
+ try {
708
+ const result = await typedDiscovery(async () => {
709
+ const args = (raw ?? {});
710
+ const result = { tools: await catalog.describe(args) };
711
+ boundedDiscoveryText(result, 'Split the address list or use format: "compact".');
712
+ return result;
713
+ });
714
+ limits.diagnostics?.recordCatalog("describe", Date.now() - started, true, result);
715
+ return result;
716
+ }
717
+ catch (err) {
718
+ limits.diagnostics?.recordCatalog("describe", Date.now() - started, false);
719
+ throw err;
720
+ }
721
+ },
722
+ };
723
+ const transportedFns = Object.fromEntries(Object.entries(fns).map(([name, fn]) => [
724
+ name,
725
+ async (...args) => {
726
+ try {
727
+ return await fn(...args);
728
+ }
729
+ catch (err) {
730
+ if (err instanceof InvocationFailure) {
731
+ const framed = framedGuestFailure(failureSecret, err);
732
+ limits.onInvocationFailure?.(err);
733
+ throw framed;
734
+ }
735
+ throw err;
736
+ }
737
+ },
738
+ ]));
739
+ return [
740
+ {
741
+ name: "connecta",
742
+ prelude: lazyNamespacePrelude(namespaces, failureSecret),
743
+ fns: transportedFns,
681
744
  },
682
745
  ];
683
746
  }
@@ -731,6 +794,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
731
794
  ...(config.probeTimeoutMs !== undefined
732
795
  ? { probeTimeoutMs: config.probeTimeoutMs }
733
796
  : {}),
797
+ ...(config.defer !== undefined ? { defer: config.defer } : {}),
734
798
  });
735
799
  }
736
800
  finally {
@@ -816,8 +880,8 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
816
880
  // rather than losing it to prose.
817
881
  let invocationFailure;
818
882
  for (const match of [
819
- (candidate) => outcome.error === candidate.message,
820
- (candidate) => outcome.error?.includes(candidate.message) === true,
883
+ (candidate) => [candidate.message, guestFailureFrames.get(candidate)].includes(outcome.error),
884
+ (candidate) => [candidate.message, guestFailureFrames.get(candidate)].some((message) => message !== undefined && outcome.error?.includes(message) === true),
821
885
  ]) {
822
886
  for (let i = invocationFailures.length - 1; i >= 0; i--) {
823
887
  const candidate = invocationFailures[i];
@@ -932,22 +996,24 @@ function discardedEmitsText(emitted) {
932
996
  ];
933
997
  return lines.length > 0 ? `\n\n${lines.join("\n")}` : "";
934
998
  }
935
- const executeDescription = (emitBudgets, connectorGuides) => `Choose the route before discovery. Exactly one unknown-address read uses top-level search_tools then call_tool; a known address uses call_tool directly. This is the primary surface for everything wider. For any reduction, dependency, multiple calls, loop, join, or branch, make exactly one execute_code call that searches, selects, calls, and reduces. A discovery-only program wastes its round trip: finish here, don't return catalog matches for a later call. Only readOnlyHint: true tools are available. Limits: ${EXECUTE_MAX_HOST_CALLS} host calls per run, ${EXECUTE_MAX_BATCH_CALLS} per batch, ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}-second host deadline.
999
+ const executeDescription = (emitBudgets, connectorGuides) => `Choose the route before discovery. Exactly one unknown-address read uses top-level search_tools then call_tool. execute_code is the primary surface for everything wider: make exactly one execute_code call that searches, selects, calls, and reduces. A discovery-only program wastes its round trip: finish here, don't return catalog matches for a later call. Only readOnlyHint: true tools are available. Limits: ${EXECUTE_MAX_HOST_CALLS} host calls per run, ${EXECUTE_MAX_BATCH_CALLS} per batch, ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}-second host deadline.
936
1000
 
937
- Write an async arrow function. NO network, filesystem, timers, or imports; only:
938
- - Connector globals call <connectorId>.<toolName>(args). Sanitization: non-[A-Za-z0-9_$] "_" (my-service.get.thing → my_service.get_thing); prefix a leading digit; suffix a reserved word.
1001
+ Write an async arrow function. Portable programs use no ambient capabilities. Use only:
1002
+ - Connector globals call sanitized <connectorId>.<toolName>(args): non-identifier characters become "_"; prefix a leading digit; suffix a reserved word.
939
1003
  - connecta.call(address, args) and connecta.batch(calls) use canonical addresses. Every batch entry is { address, ok: true, data } or { address, ok: false, error, errorDetails: { code, retryable } }; destructure it.
940
1004
  - top-level search_tools returns { connectors: [{ id, tools }], total, offset, limit, hasMore }; connecta.search returns { tools, total, offset, limit, hasMore }; connecta.describe returns { tools }.
941
- - connecta.search(args) loads catalogs and must be followed by selection and calls in this program; set connector to the obvious id to load one, otherwise it loads all. For distinct operations, make separate short searches here. Require address/description to match the operation, then check requiredInputKeys, truncation, safety, and outputs; never take the first lexical or merely input-compatible match. Select by fit; do not require it to be the only match. Missing outputKeys means inspect outputSchema, not discard the candidate. Every required key needs task/prior-result data; do not prefer zero required keys. Put every requiredInputKey in call args. For dependencies, match an earlier outputKey to the later requiredInputKey. [] means no required keys, not permission to invent args. Describe only a truncated/insufficient compact shape. Reducers use declared outputKeys, never guessed items/results roots. connecta.describe takes { address: "<connectorId>.<toolName>" } or { addresses: [...] }. Use safety: "readOnly" to avoid advertising calls this sandbox cannot execute; it changes results, not authority. Missing key list = non-object, not no fields; read the schema.${connectorGuides ? " guideRequired: true = stop. Describe clears only schema_truncated; otherwise return its exact guide, fetch with top-level skills, then write the informed call." : ""}
942
- - connecta.emit(block) — emit exactly { type: "text", text } or { type: "image" | "audio", data (base64), mimeType }. Success-only, no host call, ${emitBudgets.maxBlocks} blocks/${emitBudgets.maxBytes} bytes; invalid or over-budget throws before accepting.
943
- - connecta.ui(html, options?) — one success-only view; one arg is display-only. Reads declare { reads: { name: { address, fixedArgs?, viewArgs? } } }; markup calls connecta.read(name, args). Admission applies. It shares the ${emitBudgets.maxBytes}-byte emit budget: one budget, not two; a second, over-budget, or invalid call throws catchably. Bytes stay out; the model reads the return value, not the view; return the initial summary from its variables.
944
- - console.log(...) — captured and returned with the result.
1005
+ - connecta.search(args) loads catalogs and must be followed by selection and calls in this program; set connector to the obvious id to load one, otherwise it loads all. For distinct operations, make separate short searches here. Check address, description, requiredInputKeys, truncation, safety, and outputs; never take the first lexical or merely input-compatible match. Select by fit; do not require it to be the only match. Missing outputKeys means inspect outputSchema, not discard the candidate. Every required key needs task or prior-result data; do not prefer zero required keys. Put every requiredInputKey in call args. For dependencies, match an earlier outputKey to the later requiredInputKey. [] means no required keys, not permission to invent args. Describe only a truncated/insufficient compact shape. Reducers use declared outputKeys, never guessed items/results roots. Describe one address or an addresses array. Use safety: "readOnly" to avoid advertising calls this sandbox cannot execute.${connectorGuides ? " guideRequired: true = stop. Describe clears only schema_truncated; otherwise return its exact guide, fetch with top-level skills, then write the informed call." : ""}
1006
+ - connecta.emit(block) — { type: "text", text } or { type: "image" | "audio", data (base64), mimeType }. Success-only; ${emitBudgets.maxBlocks} blocks/${emitBudgets.maxBytes} bytes; invalid/over-budget throws.
1007
+ - connecta.ui(html, options?) — one success-only view. One arg is display-only; reads use { reads: { name: { address, fixedArgs?, viewArgs? } } } and markup calls connecta.read(name, args). Admission and one budget, not two, apply; a second, over-budget, or invalid call throws catchably; the model reads the return value, not the view; return the initial summary from its variables.
1008
+ - console.log(...) — captured.
1009
+
1010
+ QuickJS blocks imports and has no fetch, process, timers, crypto, or WebSocket. Dynamic Workers require only { loader }; bindings/modules/globalOutbound violate the contract. Then env maps are empty; node:fs/http/https are absent; outbound fetch/WebSocket/node:net/tls are denied; DNS is unresolved. Runtime builtins remain through import() and process.getBuiltinModule(), including node:path and cloudflare:workers; the set can drift. Timers/process/crypto/WebSocket and data: fetch remain. Avoid runtime-only capabilities; QuickJS fails.
945
1011
 
946
1012
  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((t) => t.address.endsWith(suffix)); if (!match) throw new Error("no tool for " + 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]; }
947
1013
 
948
- Calls return plain values (JSON-parsing MCP text when possible) and throw; catch errors. A thrown error is only a message; connecta.batch tells a policy refusal from a transient failure. Never retry retryable: false, or rate_limited immediately; no timers. Return JSON; reduce large results before they truncate.
1014
+ A caught Connecta failure keeps its message and exposes code, retryable, and details; batch uses the same fields. Branch on fields, never prose. Never retry retryable: false, or rate_limited immediately; portable code cannot wait. Return JSON; reduce large results before they truncate.
949
1015
 
950
- Plain JS, no TypeScript. Compact schemas are TypeScript-like, not JSON Schema: write the property names they display; never guess positions or aliases.`;
1016
+ Plain JS. Compact schemas are TypeScript-like: write the property names they display; never guess positions or aliases.`;
951
1017
  /** Register the execute_code meta-tool. Only called when an executor is configured. */
952
1018
  export function registerExecuteTool(server, registry, ctx) {
953
1019
  // Resolved once so the description and the collector cannot disagree about
@@ -965,6 +1031,7 @@ export function registerExecuteTool(server, registry, ctx) {
965
1031
  : {}),
966
1032
  maxEmittedBytes: emitBudgets.maxBytes,
967
1033
  maxEmittedBlocks: emitBudgets.maxBlocks,
1034
+ ...(ctx.defer !== undefined ? { defer: ctx.defer } : {}),
968
1035
  });
969
1036
  server.registerTool("execute_code", {
970
1037
  description: executeDescription(emitBudgets, hasConnectorGuides(registry.listConnectors())),
@@ -977,8 +1044,9 @@ export function registerExecuteTool(server, registry, ctx) {
977
1044
  .optional()
978
1045
  .describe("Add request-local, payload-free timing and result-size summaries."),
979
1046
  }),
980
- // The sandbox exposes only tools that are explicitly read-only, and the
981
- // executor grants no network, filesystem, env, or timer capabilities.
1047
+ // This hint describes connector calls, all explicitly read-only. The
1048
+ // supported executor constructions deny outbound access, filesystem,
1049
+ // and deployment config; X5 documents Dynamic runtime modules separately.
982
1050
  annotations: {
983
1051
  readOnlyHint: true,
984
1052
  destructiveHint: false,