@zackbart/connecta 0.16.1 → 0.18.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 (46) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +4 -0
  3. package/dist/catalog-service.d.ts +10 -0
  4. package/dist/catalog-service.js +77 -5
  5. package/dist/catalog.js +114 -12
  6. package/dist/errors.d.ts +4 -6
  7. package/dist/execute.d.ts +7 -0
  8. package/dist/execute.js +262 -168
  9. package/dist/invocation.js +3 -1
  10. package/dist/meta-tools.d.ts +4 -0
  11. package/dist/meta-tools.js +55 -23
  12. package/dist/operator-ui/generated.d.ts +1 -1
  13. package/dist/operator-ui/generated.js +1 -1
  14. package/dist/operator-ui/model.d.ts +3 -1
  15. package/dist/providers/mixpanel.d.ts +3 -5
  16. package/dist/providers/mixpanel.js +73 -5
  17. package/dist/providers/stripe.d.ts +25 -24
  18. package/dist/providers/stripe.js +64 -35
  19. package/dist/registry.d.ts +32 -9
  20. package/dist/registry.js +217 -33
  21. package/dist/routes/mcp.js +6 -0
  22. package/dist/routes/ui.js +1 -1
  23. package/dist/skills.d.ts +5 -1
  24. package/dist/skills.js +206 -30
  25. package/dist/types.d.ts +14 -2
  26. package/dist/ui.js +4 -1
  27. package/dist/version.d.ts +1 -1
  28. package/dist/version.js +1 -1
  29. package/documentation/architecture.md +8 -5
  30. package/documentation/code-mode.md +68 -68
  31. package/documentation/connector-guides.md +29 -27
  32. package/documentation/connectors.md +13 -1
  33. package/documentation/meta-tools.md +53 -19
  34. package/documentation/mixpanel.md +20 -0
  35. package/documentation/notion.md +17 -0
  36. package/documentation/operations.md +24 -21
  37. package/documentation/operator-ui.md +12 -2
  38. package/documentation/provider-audit.md +15 -7
  39. package/documentation/provider-conventions.md +26 -13
  40. package/documentation/stripe.md +66 -59
  41. package/documentation/upgrading.md +46 -4
  42. package/ethos.md +7 -7
  43. package/examples/worker/README.md +4 -3
  44. package/package.json +2 -2
  45. package/templates/node/README.md +7 -0
  46. package/templates/node/package.json +5 -2
package/dist/execute.js CHANGED
@@ -4,7 +4,7 @@ import { boundedDiscoveryText, CatalogService, DiscoveryPolicyError, flatSearchR
4
4
  import { errorResult, jsonResult } from "./meta-tools.js";
5
5
  import { guardExecuteResultValue, MAX_EXECUTE_LOG_CHARS, truncateExecuteText, } from "./executor-result.js";
6
6
  import { ExecutorAdmissionError, ExecutorExecutionError, isAdmittingExecutor, } from "./executor-admission.js";
7
- import { classifyCallError } from "./errors.js";
7
+ import { boundedEchoText, classifyCallError } from "./errors.js";
8
8
  import { InvocationFailure, InvocationService, } from "./invocation.js";
9
9
  import { hasConnectorGuides } from "./skills.js";
10
10
  import { isExplicitlyReadOnly } from "./tool-safety.js";
@@ -12,6 +12,8 @@ import { isExplicitlyReadOnly } from "./tool-safety.js";
12
12
  const EXECUTE_MAX_HOST_CALLS = 20;
13
13
  export const EXECUTE_MAX_BATCH_CALLS = 10;
14
14
  const EXECUTE_HOST_CALL_TIMEOUT_MS = 15_000;
15
+ /** Complete entries plus an exact omission count, all inside this byte cap. */
16
+ export const CONNECTOR_INVENTORY_MAX_BYTES = 256;
15
17
  /**
16
18
  * Default budgets for `connecta.emit`. The byte budget is a transport bound,
17
19
  * not a context bound — emitted image/audio blocks reach the model as media,
@@ -125,7 +127,7 @@ const EMIT_SHAPE_HINT = '{ type: "text", text } or { type: "image" | "audio", da
125
127
  */
126
128
  function requireEmittedBlock(raw) {
127
129
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
128
- throw new Error(`connecta.emit accepts exactly one content block: ${EMIT_SHAPE_HINT}`);
130
+ throw guestFailure("invalid_args", `connecta.emit accepts exactly one content block: ${EMIT_SHAPE_HINT}`);
129
131
  }
130
132
  const block = raw;
131
133
  const fields = block.type === "text"
@@ -134,16 +136,16 @@ function requireEmittedBlock(raw) {
134
136
  ? ["type", "data", "mimeType"]
135
137
  : undefined;
136
138
  if (!fields) {
137
- throw new Error(`connecta.emit supports content types "text", "image", and "audio"; got ${JSON.stringify(block.type)}`);
139
+ throw guestFailure("invalid_args", `connecta.emit supports content types "text", "image", and "audio"; got ${JSON.stringify(block.type)}`);
138
140
  }
139
141
  for (const field of fields) {
140
142
  if (typeof block[field] !== "string") {
141
- throw new Error(`connecta.emit block field "${field}" must be a string: ${EMIT_SHAPE_HINT}`);
143
+ throw guestFailure("invalid_args", `connecta.emit block field "${field}" must be a string: ${EMIT_SHAPE_HINT}`);
142
144
  }
143
145
  }
144
146
  const extra = Object.keys(block).filter((key) => !fields.includes(key));
145
147
  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(", ")} }`);
148
+ 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
149
  }
148
150
  return raw;
149
151
  }
@@ -172,20 +174,20 @@ function describeUiArgument(raw) {
172
174
  */
173
175
  function requireUiHtml(raw) {
174
176
  if (typeof raw !== "string" || raw.length === 0) {
175
- throw new Error(`${UI_SHAPE_HINT}; got ${describeUiArgument(raw)}`);
177
+ throw guestFailure("invalid_args", `${UI_SHAPE_HINT}; got ${describeUiArgument(raw)}`);
176
178
  }
177
179
  return raw;
178
180
  }
179
181
  function requireRecord(raw, label) {
180
182
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
181
- throw new Error(`${label} must be an object`);
183
+ throw guestFailure("invalid_args", `${label} must be an object`);
182
184
  }
183
185
  return raw;
184
186
  }
185
187
  function requireExactKeys(value, allowed, label) {
186
188
  const extras = Object.keys(value).filter((key) => !allowed.includes(key));
187
189
  if (extras.length > 0) {
188
- throw new Error(`${label} carries unsupported field(s) ${extras.map((key) => JSON.stringify(key)).join(", ")}`);
190
+ throw guestFailure("invalid_args", `${label} carries unsupported field(s) ${extras.map((key) => JSON.stringify(key)).join(", ")}`);
189
191
  }
190
192
  }
191
193
  function requireUiReadKey(raw, label) {
@@ -193,7 +195,7 @@ function requireUiReadKey(raw, label) {
193
195
  raw.length === 0 ||
194
196
  raw.length > 128 ||
195
197
  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`);
198
+ throw guestFailure("invalid_args", `${label} must be a non-empty string of at most 128 characters and cannot be __proto__, constructor, or prototype`);
197
199
  }
198
200
  return raw;
199
201
  }
@@ -201,32 +203,32 @@ function requireUiReads(raw) {
201
203
  const record = requireRecord(raw, "connecta.ui options.reads");
202
204
  const names = Object.keys(record);
203
205
  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`);
206
+ throw guestFailure("invalid_args", `connecta.ui options.reads must contain from 1 through ${MAX_UI_READ_BINDINGS} named bindings`);
205
207
  }
206
208
  const reads = Object.create(null);
207
209
  for (const name of names) {
208
210
  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}`);
211
+ throw guestFailure("invalid_args", `connecta.ui read binding name ${JSON.stringify(name)} must match ${UI_READ_NAME}`);
210
212
  }
211
213
  const value = requireRecord(record[name], `connecta.ui read binding ${JSON.stringify(name)}`);
212
214
  requireExactKeys(value, ["address", "fixedArgs", "viewArgs"], `connecta.ui read binding ${JSON.stringify(name)}`);
213
215
  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`);
216
+ throw guestFailure("invalid_args", `connecta.ui read binding ${JSON.stringify(name)} address must be a non-empty string`);
215
217
  }
216
218
  const fixedArgs = value.fixedArgs === undefined
217
219
  ? {}
218
220
  : requireRecord(value.fixedArgs, `connecta.ui read binding ${JSON.stringify(name)} fixedArgs`);
219
221
  const rawViewArgs = value.viewArgs ?? [];
220
222
  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`);
223
+ 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
224
  }
223
225
  const viewArgs = rawViewArgs.map((key) => requireUiReadKey(key, `connecta.ui read binding ${JSON.stringify(name)} viewArgs entry`));
224
226
  if (new Set(viewArgs).size !== viewArgs.length) {
225
- throw new Error(`connecta.ui read binding ${JSON.stringify(name)} viewArgs must not repeat a key`);
227
+ throw guestFailure("invalid_args", `connecta.ui read binding ${JSON.stringify(name)} viewArgs must not repeat a key`);
226
228
  }
227
229
  for (const key of viewArgs) {
228
230
  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`);
231
+ throw guestFailure("invalid_args", `connecta.ui read binding ${JSON.stringify(name)} view argument ${JSON.stringify(key)} cannot override a fixed argument`);
230
232
  }
231
233
  }
232
234
  reads[name] = {
@@ -239,7 +241,7 @@ function requireUiReads(raw) {
239
241
  }
240
242
  function requireUiPayload(values) {
241
243
  if (values.length !== 1 && values.length !== 2) {
242
- throw new Error(`${UI_SHAPE_HINT}; got ${values.length} arguments`);
244
+ throw guestFailure("invalid_args", `${UI_SHAPE_HINT}; got ${values.length} arguments`);
243
245
  }
244
246
  const html = requireUiHtml(values[0]);
245
247
  if (values.length === 1)
@@ -247,7 +249,7 @@ function requireUiPayload(values) {
247
249
  const options = requireRecord(values[1], "connecta.ui options");
248
250
  requireExactKeys(options, ["reads"], "connecta.ui options");
249
251
  if (!Object.prototype.hasOwnProperty.call(options, "reads")) {
250
- throw new Error("connecta.ui options must contain reads");
252
+ throw guestFailure("invalid_args", "connecta.ui options must contain reads");
251
253
  }
252
254
  return { html, reads: requireUiReads(options.reads) };
253
255
  }
@@ -280,11 +282,11 @@ export class EmitCollector {
280
282
  accept(raw) {
281
283
  const block = requireEmittedBlock(raw);
282
284
  if (this.blocks.length >= this.maxBlocks) {
283
- throw new Error(`connecta.emit block-count budget exceeded: ${this.maxBlocks} block(s) maximum, 0 remaining`);
285
+ throw guestFailure("budget_exceeded", `connecta.emit block-count budget exceeded: ${this.maxBlocks} block(s) maximum, 0 remaining`);
284
286
  }
285
287
  const size = diagnosticsEncoder.encode(JSON.stringify(block)).byteLength;
286
288
  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`);
289
+ throw guestFailure("budget_exceeded", `connecta.emit byte budget exceeded: block is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`);
288
290
  }
289
291
  this.blocks.push(block);
290
292
  this.bytes += size;
@@ -305,24 +307,24 @@ export class EmitCollector {
305
307
  */
306
308
  acceptUi(...values) {
307
309
  if (this.ui) {
308
- throw new Error("connecta.ui accepts at most one payload per run: a view was already accepted and stands");
310
+ throw guestFailure("invalid_args", "connecta.ui accepts at most one payload per run: a view was already accepted and stands");
309
311
  }
310
312
  this.acceptUiPayload(requireUiPayload(values));
311
313
  }
312
314
  acceptUiPayload(payload) {
313
315
  if (this.ui) {
314
- throw new Error("connecta.ui accepts at most one payload per run: a view was already accepted and stands");
316
+ throw guestFailure("invalid_args", "connecta.ui accepts at most one payload per run: a view was already accepted and stands");
315
317
  }
316
318
  let serialized;
317
319
  try {
318
320
  serialized = JSON.stringify(payload);
319
321
  }
320
322
  catch {
321
- throw new Error("connecta.ui payload must be JSON-serializable");
323
+ throw guestFailure("invalid_args", "connecta.ui payload must be JSON-serializable");
322
324
  }
323
325
  const size = diagnosticsEncoder.encode(serialized).byteLength;
324
326
  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`);
327
+ throw guestFailure("budget_exceeded", `connecta.ui byte budget exceeded: payload is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`);
326
328
  }
327
329
  this.ui = payload;
328
330
  this.bytes += size;
@@ -430,18 +432,75 @@ const SANDBOX_RESERVED_NAMES = new Set([
430
432
  function msg(err) {
431
433
  return err instanceof Error ? err.message : String(err);
432
434
  }
433
- function lazyNamespacePrelude(connectors) {
435
+ function guestFailure(code, message, retryable = false) {
436
+ return new InvocationFailure({ code, message, retryable });
437
+ }
438
+ const GUEST_FAILURE_FRAME = "\u001econnecta-error:";
439
+ const guestFailureFrames = new WeakMap();
440
+ /** Per-execution secret: guest prose cannot collide with this host frame. */
441
+ function guestFailureSecret() {
442
+ const words = new Uint32Array(4);
443
+ crypto.getRandomValues(words);
444
+ return Array.from(words, (word) => word.toString(16).padStart(8, "0")).join("");
445
+ }
446
+ function framedGuestFailure(secret, failure) {
447
+ const framed = new InvocationFailure(failure.details);
448
+ framed.message =
449
+ `${GUEST_FAILURE_FRAME}${secret}:${JSON.stringify(failure.details)}`;
450
+ guestFailureFrames.set(failure, framed.message);
451
+ return framed;
452
+ }
453
+ /**
454
+ * Rebuilds host failures as guest Error instances, then installs connector
455
+ * shortcuts. The random frame is hidden in this closure and Error is locked:
456
+ * connector prose and model code can neither collide with nor forge its frame.
457
+ */
458
+ function lazyNamespacePrelude(connectors, failureSecret) {
434
459
  const declarations = connectors
435
460
  .map(({ id, namespace }) => `globalThis[${JSON.stringify(namespace)}] = __makeConnectaNamespace(${JSON.stringify(id)});`)
436
461
  .join("\n");
437
- return `(() => {
462
+ return `((failurePrefix) => {
463
+ const NativeError = globalThis.Error;
464
+ const startsWith = Function.prototype.call.bind(String.prototype.startsWith);
465
+ const slice = Function.prototype.call.bind(String.prototype.slice);
466
+ const parse = JSON.parse;
467
+ const freeze = Object.freeze;
468
+ const defineProperties = Object.defineProperties;
469
+ const construct = Reflect.construct;
470
+ function ConnectaError(message, options) {
471
+ let details;
472
+ if (typeof message === "string" && startsWith(message, failurePrefix)) {
473
+ try { details = parse(slice(message, failurePrefix.length)); } catch {}
474
+ }
475
+ const error = construct(
476
+ NativeError,
477
+ options === undefined ? [details ? details.message : message] : [details ? details.message : message, options],
478
+ new.target || NativeError
479
+ );
480
+ if (details) {
481
+ freeze(details);
482
+ defineProperties(error, {
483
+ code: { value: details.code, enumerable: true },
484
+ retryable: { value: details.retryable, enumerable: true },
485
+ details: { value: details, enumerable: true }
486
+ });
487
+ }
488
+ return error;
489
+ }
490
+ ConnectaError.prototype = NativeError.prototype;
491
+ Object.setPrototypeOf(ConnectaError, NativeError);
492
+ Object.defineProperty(globalThis, "Error", {
493
+ value: ConnectaError,
494
+ writable: false,
495
+ configurable: false
496
+ });
438
497
  const __makeConnectaNamespace = (connectorId) => Object.freeze(new Proxy(Object.create(null), {
439
498
  get: (_target, toolName) => typeof toolName === "string"
440
499
  ? (args) => connecta.__callNamespace(connectorId, toolName, args)
441
500
  : undefined
442
501
  }));
443
502
  ${declarations}
444
- })();`;
503
+ })(${JSON.stringify(`${GUEST_FAILURE_FRAME}${failureSecret}:`)});`;
445
504
  }
446
505
  /**
447
506
  * Expose one fixed host provider plus trusted sandbox setup that creates a
@@ -462,10 +521,12 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
462
521
  ...(limits.probeTimeoutMs !== undefined
463
522
  ? { probeTimeoutMs: limits.probeTimeoutMs }
464
523
  : {}),
524
+ ...(limits.defer !== undefined ? { defer: limits.defer } : {}),
465
525
  });
466
526
  const invocation = new InvocationService(registry, catalog, activity);
467
527
  const maxHostCalls = Math.max(1, Math.trunc(limits.maxHostCalls ?? EXECUTE_MAX_HOST_CALLS));
468
528
  const hostCallTimeoutMs = Math.max(1, Math.trunc(limits.hostCallTimeoutMs ?? EXECUTE_HOST_CALL_TIMEOUT_MS));
529
+ const failureSecret = guestFailureSecret();
469
530
  let hostCalls = 0;
470
531
  const connectors = registry.listConnectors();
471
532
  const namespaces = [];
@@ -493,16 +554,13 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
493
554
  beforeDispatch: () => {
494
555
  hostCalls++;
495
556
  if (hostCalls > maxHostCalls) {
496
- throw new Error(`execute_code host-call budget exceeded (${maxHostCalls} calls maximum)`);
557
+ throw guestFailure("budget_exceeded", `execute_code host-call budget exceeded (${maxHostCalls} calls maximum)`);
497
558
  }
498
559
  },
499
560
  });
500
561
  /**
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.
562
+ * Discovery policy failures use the same thrown vocabulary as calls and
563
+ * utilities. The transport below reconstructs their code inside the guest.
506
564
  */
507
565
  const typedDiscovery = async (operation) => {
508
566
  try {
@@ -510,11 +568,7 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
510
568
  }
511
569
  catch (err) {
512
570
  if (err instanceof DiscoveryPolicyError) {
513
- limits.onInvocationFailure?.(new InvocationFailure({
514
- code: err.code,
515
- message: err.message,
516
- retryable: false,
517
- }));
571
+ throw guestFailure(err.code, err.message);
518
572
  }
519
573
  throw err;
520
574
  }
@@ -524,7 +578,6 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
524
578
  limits.diagnostics?.recordCall(diagnosticOperation, outcome);
525
579
  if (!outcome.ok) {
526
580
  const failure = new InvocationFailure(outcome.error);
527
- limits.onInvocationFailure?.(failure);
528
581
  throw failure;
529
582
  }
530
583
  return outcome.value;
@@ -534,7 +587,6 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
534
587
  limits.diagnostics?.recordCall("call", outcome);
535
588
  if (!outcome.ok) {
536
589
  const failure = new InvocationFailure(outcome.error);
537
- limits.onInvocationFailure?.(failure);
538
590
  throw failure;
539
591
  }
540
592
  return outcome.value;
@@ -554,10 +606,13 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
554
606
  for (const [name, binding] of Object.entries(payload.reads)) {
555
607
  const resolution = await catalog.resolveTool(binding.address, limits.signal !== undefined ? { signal: limits.signal } : {});
556
608
  if (!resolution.ok) {
557
- throw new Error(`connecta.ui read binding ${JSON.stringify(name)} could not resolve ${JSON.stringify(binding.address)}: ${resolution.error.message}`);
609
+ throw new InvocationFailure({
610
+ ...resolution.error,
611
+ message: `connecta.ui read binding ${JSON.stringify(name)} could not resolve ${JSON.stringify(binding.address)}: ${resolution.error.message}`,
612
+ });
558
613
  }
559
614
  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`);
615
+ 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
616
  }
562
617
  reads[name] = {
563
618
  ...binding,
@@ -566,118 +621,128 @@ export async function buildSandboxProviders(registry, baseUrl, logger, activity,
566
621
  }
567
622
  return { html: payload.html, reads };
568
623
  };
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();
624
+ const fns = {
625
+ __callNamespace: callNamespace,
626
+ call: (address, args) => callAddress(address, args),
627
+ // Emission is a provider function, never an ExecuteResult field —
628
+ // that is what keeps the Executor contract untouched and parity
629
+ // structural (M8). It spends no host-call budget (M7); its own
630
+ // budgets live in the collector.
631
+ emit: async (block) => {
632
+ if (!limits.emitCollector) {
633
+ throw guestFailure("unavailable", "connecta.emit is unavailable: no emission collector was configured for this execution", true);
634
+ }
635
+ limits.emitCollector.accept(block);
636
+ },
637
+ // The rendered-output channel rides the same bridge for the same
638
+ // reason (U7): one more provider fn, no change to ExecuteResult or
639
+ // the Executor contract. Delivery is the handler's job, not the
640
+ // guest's — nothing here becomes addressable.
641
+ ui: async (...values) => {
642
+ if (!limits.emitCollector) {
643
+ throw guestFailure("unavailable", "connecta.ui is unavailable: no emission collector was configured for this execution", true);
644
+ }
645
+ const payload = await validateUiReads(requireUiPayload(values));
646
+ limits.emitCollector.acceptUiPayload(payload);
647
+ },
648
+ batch: async (calls) => {
649
+ const started = Date.now();
650
+ const callCount = Array.isArray(calls) ? calls.length : 0;
651
+ try {
652
+ if (!Array.isArray(calls)) {
653
+ throw guestFailure("invalid_args", "calls must be an array");
654
+ }
655
+ if (calls.length > EXECUTE_MAX_BATCH_CALLS) {
656
+ throw guestFailure("invalid_args", `connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS} calls`);
657
+ }
658
+ const result = await Promise.all(calls.map(async (call) => {
659
+ const item = call;
665
660
  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;
661
+ return {
662
+ address: String(item.address),
663
+ ok: true,
664
+ data: await callAddress(item.address, item.args, "batch"),
665
+ };
674
666
  }
675
667
  catch (err) {
676
- limits.diagnostics?.recordCatalog("describe", Date.now() - started, false);
677
- throw err;
668
+ const details = err instanceof InvocationFailure
669
+ ? err.details
670
+ : classifyCallError(err, "batch_call_failed");
671
+ return {
672
+ address: String(item.address),
673
+ ok: false,
674
+ error: details.message,
675
+ errorDetails: details,
676
+ };
678
677
  }
679
- },
680
- },
678
+ }));
679
+ limits.diagnostics?.recordBatch(Date.now() - started, true, callCount, result);
680
+ return result;
681
+ }
682
+ catch (err) {
683
+ limits.diagnostics?.recordBatch(Date.now() - started, false, callCount);
684
+ throw err;
685
+ }
686
+ },
687
+ search: async (raw) => {
688
+ const started = Date.now();
689
+ try {
690
+ const result = await typedDiscovery(async () => {
691
+ const args = (raw ?? {});
692
+ const result = flatSearchResult(await catalog.search({
693
+ ...args,
694
+ includeSchemaKeys: args.includeSchemaKeys !== false,
695
+ }));
696
+ boundedDiscoveryText(result, "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.");
697
+ return result;
698
+ });
699
+ limits.diagnostics?.recordCatalog("search", Date.now() - started, true, result);
700
+ return result;
701
+ }
702
+ catch (err) {
703
+ limits.diagnostics?.recordCatalog("search", Date.now() - started, false);
704
+ throw err;
705
+ }
706
+ },
707
+ describe: async (raw) => {
708
+ const started = Date.now();
709
+ try {
710
+ const result = await typedDiscovery(async () => {
711
+ const args = (raw ?? {});
712
+ const result = { tools: await catalog.describe(args) };
713
+ boundedDiscoveryText(result, 'Split the address list or use format: "compact".');
714
+ return result;
715
+ });
716
+ limits.diagnostics?.recordCatalog("describe", Date.now() - started, true, result);
717
+ return result;
718
+ }
719
+ catch (err) {
720
+ limits.diagnostics?.recordCatalog("describe", Date.now() - started, false);
721
+ throw err;
722
+ }
723
+ },
724
+ };
725
+ const transportedFns = Object.fromEntries(Object.entries(fns).map(([name, fn]) => [
726
+ name,
727
+ async (...args) => {
728
+ try {
729
+ return await fn(...args);
730
+ }
731
+ catch (err) {
732
+ if (err instanceof InvocationFailure) {
733
+ const framed = framedGuestFailure(failureSecret, err);
734
+ limits.onInvocationFailure?.(err);
735
+ throw framed;
736
+ }
737
+ throw err;
738
+ }
739
+ },
740
+ ]));
741
+ return [
742
+ {
743
+ name: "connecta",
744
+ prelude: lazyNamespacePrelude(namespaces, failureSecret),
745
+ fns: transportedFns,
681
746
  },
682
747
  ];
683
748
  }
@@ -731,6 +796,7 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
731
796
  ...(config.probeTimeoutMs !== undefined
732
797
  ? { probeTimeoutMs: config.probeTimeoutMs }
733
798
  : {}),
799
+ ...(config.defer !== undefined ? { defer: config.defer } : {}),
734
800
  });
735
801
  }
736
802
  finally {
@@ -816,8 +882,8 @@ export function createExecuteTool(registry, baseUrl, executor, logger, activity,
816
882
  // rather than losing it to prose.
817
883
  let invocationFailure;
818
884
  for (const match of [
819
- (candidate) => outcome.error === candidate.message,
820
- (candidate) => outcome.error?.includes(candidate.message) === true,
885
+ (candidate) => [candidate.message, guestFailureFrames.get(candidate)].includes(outcome.error),
886
+ (candidate) => [candidate.message, guestFailureFrames.get(candidate)].some((message) => message !== undefined && outcome.error?.includes(message) === true),
821
887
  ]) {
822
888
  for (let i = invocationFailures.length - 1; i >= 0; i--) {
823
889
  const candidate = invocationFailures[i];
@@ -932,22 +998,47 @@ function discardedEmitsText(emitted) {
932
998
  ];
933
999
  return lines.length > 0 ? `\n\n${lines.join("\n")}` : "";
934
1000
  }
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.
936
-
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.
939
- - 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
- - 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.
1001
+ function connectorInventory(connectors) {
1002
+ const prefix = "Connectors: ";
1003
+ if (connectors.length === 0)
1004
+ return `${prefix}none.`;
1005
+ const entries = connectors.map((connector) => {
1006
+ const shortcut = sanitizeIdentifier(connector.id);
1007
+ return shortcut === connector.id
1008
+ ? connector.id
1009
+ : `${connector.id} (shortcut ${shortcut})`;
1010
+ });
1011
+ const shown = [];
1012
+ for (let index = 0; index < entries.length; index++) {
1013
+ const entry = entries[index];
1014
+ if (entry === undefined)
1015
+ break;
1016
+ const candidate = [...shown, entry].join(", ");
1017
+ const omitted = entries.length - index - 1;
1018
+ const suffix = omitted > 0 ? `; +${omitted} more.` : ".";
1019
+ const serialized = prefix + candidate + suffix;
1020
+ if (boundedEchoText(serialized, CONNECTOR_INVENTORY_MAX_BYTES) !== serialized) {
1021
+ break;
1022
+ }
1023
+ shown.push(entry);
1024
+ }
1025
+ const omitted = entries.length - shown.length;
1026
+ if (omitted === 0)
1027
+ return `${prefix}${shown.join(", ")}.`;
1028
+ return `${prefix}${shown.join(", ")}${shown.length > 0 ? "; " : ""}+${omitted} more.`;
1029
+ }
1030
+ const executeDescription = (emitBudgets, connectorGuides, connectors) => `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.
945
1031
 
946
- 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]; }
1032
+ ${connectorInventory(connectors)}
947
1033
 
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.
1034
+ Write one plain-JavaScript async arrow function. Use only:
1035
+ - <connectorId>.<toolName>(args) for a sanitized shortcut, or connecta.call(address, args) for a canonical address.
1036
+ - connecta.search(args), connecta.describe(args), and connecta.batch(calls) for discovery and independent read-only calls.
1037
+ - 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.
1038
+ - connecta.ui(html, options?) for one success-only view; return the same initial summary the HTML renders.
1039
+ - console.log(...) — captured.
949
1040
 
950
- Plain JS, no TypeScript. Compact schemas are TypeScript-like, not JSON Schema: write the property names they display; never guess positions or aliases.`;
1041
+ Programs have no portable ambient capabilities. Return JSON and reduce large results before they truncate. Fetch skills({ name: "usage" }) once for selection rules, exact result shapes, repair, examples, guide handling${connectorGuides ? ", connector-guide rules" : ""}, and runtime differences.`;
951
1042
  /** Register the execute_code meta-tool. Only called when an executor is configured. */
952
1043
  export function registerExecuteTool(server, registry, ctx) {
953
1044
  // Resolved once so the description and the collector cannot disagree about
@@ -956,6 +1047,7 @@ export function registerExecuteTool(server, registry, ctx) {
956
1047
  maxBytes: resolveEmitBudget(ctx.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES),
957
1048
  maxBlocks: resolveEmitBudget(ctx.maxEmittedBlocks, EXECUTE_MAX_EMITTED_BLOCKS),
958
1049
  };
1050
+ const connectors = registry.listConnectors();
959
1051
  const handler = createExecuteTool(registry, ctx.baseUrl, ctx.executor, ctx.logger, ctx.activity, {
960
1052
  ...(ctx.discoveryConcurrency !== undefined
961
1053
  ? { discoveryConcurrency: ctx.discoveryConcurrency }
@@ -965,20 +1057,22 @@ export function registerExecuteTool(server, registry, ctx) {
965
1057
  : {}),
966
1058
  maxEmittedBytes: emitBudgets.maxBytes,
967
1059
  maxEmittedBlocks: emitBudgets.maxBlocks,
1060
+ ...(ctx.defer !== undefined ? { defer: ctx.defer } : {}),
968
1061
  });
969
1062
  server.registerTool("execute_code", {
970
- description: executeDescription(emitBudgets, hasConnectorGuides(registry.listConnectors())),
1063
+ description: executeDescription(emitBudgets, hasConnectorGuides(connectors), connectors),
971
1064
  inputSchema: z.object({
972
1065
  code: z
973
1066
  .string()
974
- .describe("One complete JavaScript async arrow function. Consume search/describe results and finish the task inside it; returning catalog data for a later call spends a round trip and buys nothing. So does aborting on a missing tool match or result key — re-search, describe, or read the result's actual keys here instead."),
1067
+ .describe("One complete JavaScript async arrow function that discovers, calls, and returns the reduced answer."),
975
1068
  diagnostics: z
976
1069
  .boolean()
977
1070
  .optional()
978
1071
  .describe("Add request-local, payload-free timing and result-size summaries."),
979
1072
  }),
980
- // The sandbox exposes only tools that are explicitly read-only, and the
981
- // executor grants no network, filesystem, env, or timer capabilities.
1073
+ // This hint describes connector calls, all explicitly read-only. The
1074
+ // supported executor constructions deny outbound access, filesystem,
1075
+ // and deployment config; X5 documents Dynamic runtime modules separately.
982
1076
  annotations: {
983
1077
  readOnlyHint: true,
984
1078
  destructiveHint: false,