@zackbart/connecta 0.9.1 → 0.10.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 (69) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +43 -92
  3. package/dist/catalog-service.d.ts.map +1 -1
  4. package/dist/catalog-service.js +1 -4
  5. package/dist/catalog-service.js.map +1 -1
  6. package/dist/errors.d.ts +5 -0
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +26 -0
  9. package/dist/errors.js.map +1 -1
  10. package/dist/execute.d.ts +3 -1
  11. package/dist/execute.d.ts.map +1 -1
  12. package/dist/execute.js +79 -18
  13. package/dist/execute.js.map +1 -1
  14. package/dist/executor-result.d.ts.map +1 -1
  15. package/dist/executor-result.js +37 -6
  16. package/dist/executor-result.js.map +1 -1
  17. package/dist/executors/quickjs-protocol.d.ts +12 -0
  18. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  19. package/dist/executors/quickjs-protocol.js +14 -0
  20. package/dist/executors/quickjs-protocol.js.map +1 -1
  21. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  22. package/dist/executors/quickjs-runtime.js +6 -3
  23. package/dist/executors/quickjs-runtime.js.map +1 -1
  24. package/dist/executors/quickjs.d.ts.map +1 -1
  25. package/dist/executors/quickjs.js +10 -4
  26. package/dist/executors/quickjs.js.map +1 -1
  27. package/dist/index.d.ts +16 -6
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +31 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/invocation.d.ts.map +1 -1
  32. package/dist/invocation.js +1 -4
  33. package/dist/invocation.js.map +1 -1
  34. package/dist/meta-tools.d.ts +26 -5
  35. package/dist/meta-tools.d.ts.map +1 -1
  36. package/dist/meta-tools.js +84 -40
  37. package/dist/meta-tools.js.map +1 -1
  38. package/dist/routes/mcp.d.ts.map +1 -1
  39. package/dist/routes/mcp.js +8 -2
  40. package/dist/routes/mcp.js.map +1 -1
  41. package/dist/routes/shared.d.ts +8 -2
  42. package/dist/routes/shared.d.ts.map +1 -1
  43. package/dist/routes/shared.js.map +1 -1
  44. package/dist/skills.d.ts +15 -3
  45. package/dist/skills.d.ts.map +1 -1
  46. package/dist/skills.js +63 -10
  47. package/dist/skills.js.map +1 -1
  48. package/dist/types.d.ts +14 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.d.ts.map +1 -1
  52. package/dist/version.js +1 -1
  53. package/dist/version.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/catalog-service.ts +1 -5
  56. package/src/errors.ts +28 -0
  57. package/src/execute.ts +123 -48
  58. package/src/executor-result.ts +50 -6
  59. package/src/executors/quickjs-protocol.ts +19 -0
  60. package/src/executors/quickjs-runtime.ts +6 -2
  61. package/src/executors/quickjs.ts +10 -3
  62. package/src/index.ts +52 -4
  63. package/src/invocation.ts +1 -5
  64. package/src/meta-tools.ts +116 -53
  65. package/src/routes/mcp.ts +8 -2
  66. package/src/routes/shared.ts +8 -1
  67. package/src/skills.ts +79 -9
  68. package/src/types.ts +15 -0
  69. package/src/version.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAC"}
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,QAAQ,CAAC"}
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@zackbart/connecta",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
- "description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind nine meta-tools.",
6
+ "description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
7
7
  "license": "MIT",
8
8
  "engines": {
9
9
  "node": ">=20.9.0"
@@ -10,7 +10,7 @@ import {
10
10
  mapSettledWithConcurrency,
11
11
  resolveDiscoveryConcurrency,
12
12
  } from "./concurrency.js";
13
- import { classifyCallError, messageLooksRetryable } from "./errors.js";
13
+ import { classifyCallError, framingError } from "./errors.js";
14
14
  import type { CallErrorDetails } from "./errors.js";
15
15
  import type {
16
16
  ConnectorOperationOptions,
@@ -199,10 +199,6 @@ export type CatalogResolution =
199
199
  cause?: unknown;
200
200
  };
201
201
 
202
- function framingError(code: string, message: string): CallErrorDetails {
203
- return { code, message, retryable: messageLooksRetryable(message) };
204
- }
205
-
206
202
  function renderSchema(schema: JsonSchema, format: "compact" | "json"): unknown {
207
203
  return format === "json" ? schema : compactSchema(schema);
208
204
  }
package/src/errors.ts CHANGED
@@ -102,6 +102,34 @@ export interface CallErrorDetails {
102
102
  retry?: string;
103
103
  }
104
104
 
105
+ /**
106
+ * Codes whose retryability is a fact about connecta's own framing, never a
107
+ * guess from text. The message embeds the address the caller asked for, so a
108
+ * connector named `svc-503` or `temporary-export` would otherwise flip a policy
109
+ * refusal into `retryable: true` through the heuristic below — and a caller that
110
+ * trusts the flag would cheerfully retry a refusal forever.
111
+ */
112
+ const NEVER_RETRYABLE_FRAMING = new Set([
113
+ "unknown_address",
114
+ "unknown_tool",
115
+ "ambiguous_tool_alias",
116
+ "destructive_tool_requires_approval",
117
+ ]);
118
+
119
+ /**
120
+ * Details for a failure connecta itself framed — an address it could not
121
+ * resolve, a tool it refuses to run — rather than one a connector threw.
122
+ */
123
+ export function framingError(code: string, message: string): CallErrorDetails {
124
+ return {
125
+ code,
126
+ message,
127
+ retryable: NEVER_RETRYABLE_FRAMING.has(code)
128
+ ? false
129
+ : messageLooksRetryable(message),
130
+ };
131
+ }
132
+
105
133
  const RETRYABLE_MESSAGE_RE =
106
134
  /timeout|timed out|econnreset|econnrefused|temporar|rate.?limit|429|502|503|504|refcountedcanceler|different request/i;
107
135
  const TIMEOUT_MESSAGE_RE = /timed out|timeout/i;
package/src/execute.ts CHANGED
@@ -4,6 +4,7 @@ import type { ActivityRequestContext } from "./activity.js";
4
4
  import {
5
5
  boundedDiscoveryText,
6
6
  CatalogService,
7
+ DiscoveryPolicyError,
7
8
  flatSearchResult,
8
9
  } from "./catalog-service.js";
9
10
  import { errorResult, jsonResult, type ToolResult } from "./meta-tools.js";
@@ -16,12 +17,14 @@ import {
16
17
  ExecutorAdmissionError,
17
18
  isAdmittingExecutor,
18
19
  } from "./executor-admission.js";
20
+ import { classifyCallError } from "./errors.js";
19
21
  import {
20
22
  InvocationFailure,
21
23
  InvocationService,
22
24
  } from "./invocation.js";
23
25
  import type { RegistryView } from "./registry.js";
24
26
  import type {
27
+ ConnectaSurface,
25
28
  Executor,
26
29
  ExecutorProvider,
27
30
  Logger,
@@ -205,6 +208,29 @@ export async function buildSandboxProviders(
205
208
  }
206
209
  },
207
210
  });
211
+ /**
212
+ * A discovery bound is as typed a failure as a tool call is, and a program
213
+ * that lets one escape deserves the same envelope: register it on the same
214
+ * request-local channel so an unhandled `invalid_args`/`result_too_large`
215
+ * reaches the model with its code instead of as prose. The guest still sees
216
+ * only the message — that is the bridge's limit, not a policy.
217
+ */
218
+ const typedDiscovery = async <T>(operation: () => Promise<T>): Promise<T> => {
219
+ try {
220
+ return await operation();
221
+ } catch (err) {
222
+ if (err instanceof DiscoveryPolicyError) {
223
+ limits.onInvocationFailure?.(
224
+ new InvocationFailure({
225
+ code: err.code,
226
+ message: err.message,
227
+ retryable: false,
228
+ }),
229
+ );
230
+ }
231
+ throw err;
232
+ }
233
+ };
208
234
  const callAddress = async (address: unknown, args: unknown) => {
209
235
  const outcome = await invocation.invoke(
210
236
  String(address),
@@ -262,53 +288,65 @@ export async function buildSandboxProviders(
262
288
  data: await callAddress(item.address, item.args),
263
289
  };
264
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");
265
300
  return {
266
301
  address: String(item.address),
267
302
  ok: false,
268
- error: msg(err),
303
+ error: details.message,
304
+ errorDetails: details,
269
305
  };
270
306
  }
271
307
  }),
272
308
  );
273
309
  },
274
- search: async (raw: unknown) => {
275
- const args = (raw ?? {}) as {
276
- query?: string;
277
- connector?: string;
278
- limit?: number;
279
- offset?: number;
280
- fullDescriptions?: boolean;
281
- includeSchemas?: "compact" | "json";
282
- includeSchemaKeys?: boolean;
283
- };
284
- const result = flatSearchResult(
285
- await catalog.search({
286
- ...args,
287
- // Key metadata rides along with schemas by default, since that is
288
- // the whole point of it in code mode. It stays opt-out because it
289
- // counts against the same hard discovery-byte ceiling.
290
- includeSchemaKeys: args.includeSchemaKeys !== false,
291
- }),
292
- );
293
- boundedDiscoveryText(
294
- result,
295
- "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.",
296
- );
297
- return result;
298
- },
299
- describe: async (raw: unknown) => {
300
- const args = (raw ?? {}) as {
301
- addresses?: unknown;
302
- format?: "compact" | "json";
303
- fullDescriptions?: boolean;
304
- };
305
- const result = { tools: await catalog.describe(args) };
306
- boundedDiscoveryText(
307
- result,
308
- 'Split the address list or use format: "compact".',
309
- );
310
- return result;
311
- },
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(
331
+ result,
332
+ "Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.",
333
+ );
334
+ 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(
345
+ result,
346
+ 'Split the address list or use format: "compact".',
347
+ );
348
+ return result;
349
+ }),
312
350
  },
313
351
  },
314
352
  ];
@@ -413,13 +451,26 @@ export function createExecuteTool(
413
451
  // an unhandled tool failure keeps the same structured contract as
414
452
  // call_tool and batch_call. Failures caught by model code never reach
415
453
  // outcome.error and therefore remain under that code's control.
454
+ //
455
+ // An error the program let through unchanged matches exactly, and an
456
+ // exact match always wins: a program that wrapped one failure's message
457
+ // around another's must not have the wrong type attached. Containment is
458
+ // the fallback, so a wrapped message still reports its underlying type
459
+ // rather than losing it to prose.
416
460
  let invocationFailure: InvocationFailure | undefined;
417
- for (let i = invocationFailures.length - 1; i >= 0; i--) {
418
- const candidate = invocationFailures[i];
419
- if (candidate && outcome.error.includes(candidate.message)) {
420
- invocationFailure = candidate;
421
- break;
461
+ for (const match of [
462
+ (candidate: InvocationFailure) => outcome.error === candidate.message,
463
+ (candidate: InvocationFailure) =>
464
+ outcome.error?.includes(candidate.message) === true,
465
+ ]) {
466
+ for (let i = invocationFailures.length - 1; i >= 0; i--) {
467
+ const candidate = invocationFailures[i];
468
+ if (candidate && match(candidate)) {
469
+ invocationFailure = candidate;
470
+ break;
471
+ }
422
472
  }
473
+ if (invocationFailure) break;
423
474
  }
424
475
  if (invocationFailure) {
425
476
  const result = jsonResult({
@@ -451,15 +502,37 @@ export function createExecuteTool(
451
502
  };
452
503
  }
453
504
 
454
- const EXECUTE_DESC = `Use for dependent multi-step calls, loops, joins, branching, or reducing large results in a sandbox. Never use execute_code for search-only discovery or one downstream call: use search_tools, then call_tool when needed. For 2–10 independent calls use batch_call. Only tools explicitly annotated readOnlyHint: true are available. Each run is limited to ${EXECUTE_MAX_HOST_CALLS} host calls; connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS}; each host call has a ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}-second deadline.
505
+ /**
506
+ * How the tool opens, and where a program's argument schemas come from. Both
507
+ * differ by surface: on the classic surface `execute_code` is the tool of last
508
+ * resort and its neighbours (`batch_call`, `describe_tools`) own the simpler
509
+ * jobs, while on the code-first surface those tools are gone and the program is
510
+ * where all of that work happens. Everything after these two phrases is
511
+ * identical, so the shared body below has one source of truth.
512
+ */
513
+ const EXECUTE_ROUTING = {
514
+ classic:
515
+ "Use for dependent multi-step calls, loops, joins, branching, or reducing large results in a sandbox. Never use execute_code for search-only discovery or one downstream call: use search_tools, then call_tool when needed. For 2–10 independent calls use batch_call.",
516
+ "code-first":
517
+ "The primary surface. Use for discovery beyond one lookup, two or more calls, dependent steps, loops, joins, branching, or reducing large results before they reach the model — connecta.search and connecta.describe browse and expand catalogs in the run, and connecta.batch replaces a separate batch tool. The exception is a single call at an address already in hand: search_tools then one call_tool is cheaper than a program.",
518
+ } as const;
519
+
520
+ const EXECUTE_SCHEMA_SOURCE = {
521
+ classic: "describe_tools",
522
+ "code-first": "connecta.describe",
523
+ } as const;
524
+
525
+ const executeDescription = (
526
+ surface: ConnectaSurface,
527
+ ) => `${EXECUTE_ROUTING[surface]} Only tools explicitly annotated readOnlyHint: true are available. Each run is limited to ${EXECUTE_MAX_HOST_CALLS} host calls; connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS}; each host call has a ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}-second deadline.
455
528
 
456
529
  Write an async arrow function. It runs with NO network, filesystem, timers, or imports — the only capabilities are:
457
- - 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 describe_tools. 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.
530
+ - 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.
458
531
  - connecta.call(address, args) and connecta.batch(calls) — call raw addresses.
459
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.
460
533
  - console.log(...) — captured and returned alongside the result.
461
534
 
462
- Tool calls return plain values (MCP text content is JSON-parsed when possible) and throw on downstream errors — use try/catch to handle them. Return a JSON-serializable value; large results are truncated, so reduce data in code instead of returning raw payloads.
535
+ 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.
463
536
 
464
537
  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.
465
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]; }`;
@@ -475,6 +548,8 @@ export function registerExecuteTool(
475
548
  activity?: ActivityRequestContext;
476
549
  requestSignal?: AbortSignal;
477
550
  discoveryConcurrency?: number;
551
+ /** The advertised surface, which decides this tool's routing copy. */
552
+ surface?: ConnectaSurface;
478
553
  },
479
554
  ): void {
480
555
  const handler = createExecuteTool(
@@ -490,7 +565,7 @@ export function registerExecuteTool(
490
565
  server.registerTool(
491
566
  "execute_code",
492
567
  {
493
- description: EXECUTE_DESC,
568
+ description: executeDescription(ctx.surface ?? "classic"),
494
569
  inputSchema: z.object({
495
570
  code: z
496
571
  .string()
@@ -13,15 +13,59 @@ function serializeExecuteValue(value: unknown): string {
13
13
  return serialized === undefined ? String(value) : serialized;
14
14
  }
15
15
 
16
+ const TRUNCATION_HINT =
17
+ "filter/map/slice data inside execute_code and return only what you need";
18
+
19
+ /**
20
+ * Shape the over-cap notice so the **serialized envelope** fits the same cap
21
+ * the raw value missed. Escaping matters: a preview sliced to the cap is JSON
22
+ * text whose quotes and newlines re-escape to well over it, so a fixed slice
23
+ * would leave the envelope over-cap and a second pass through this guard would
24
+ * truncate the truncation — reporting the envelope's length as `totalChars` and
25
+ * burying the real size. Shrinking proportionally until it fits keeps the guard
26
+ * idempotent by construction: `totalChars` is always the true serialized size
27
+ * of what the program returned, and truncation happens exactly once no matter
28
+ * how many hops the value takes.
29
+ */
30
+ function truncationEnvelope(text: string): {
31
+ truncated: true;
32
+ preview: string;
33
+ totalChars: number;
34
+ hint: string;
35
+ } {
36
+ const base = {
37
+ truncated: true as const,
38
+ preview: "",
39
+ totalChars: text.length,
40
+ hint: TRUNCATION_HINT,
41
+ };
42
+ let budget = Math.max(
43
+ 0,
44
+ MAX_EXECUTE_RESULT_CHARS - JSON.stringify(base).length,
45
+ );
46
+ for (let attempt = 0; attempt < 8 && budget > 0; attempt += 1) {
47
+ const candidate = { ...base, preview: text.slice(0, budget) };
48
+ const size = JSON.stringify(candidate).length;
49
+ if (size <= MAX_EXECUTE_RESULT_CHARS) return candidate;
50
+ // Every character costs at least one serialized character, so scaling by
51
+ // the overshoot ratio (minus a step) strictly shrinks the budget.
52
+ budget = Math.max(
53
+ 0,
54
+ Math.floor(budget * (MAX_EXECUTE_RESULT_CHARS / size)) - 8,
55
+ );
56
+ }
57
+ // The loop shrinks monotonically, so this is unreachable in practice — but an
58
+ // unchecked slice is exactly how a "bounded" envelope stops being bounded.
59
+ const clamped = { ...base, preview: text.slice(0, Math.max(0, budget)) };
60
+ return JSON.stringify(clamped).length <= MAX_EXECUTE_RESULT_CHARS
61
+ ? clamped
62
+ : { ...base, preview: "" };
63
+ }
64
+
16
65
  export function guardExecuteResultValue(value: unknown): unknown {
17
66
  const text = serializeExecuteValue(value);
18
67
  if (text.length <= MAX_EXECUTE_RESULT_CHARS) return value;
19
- return {
20
- truncated: true,
21
- preview: text.slice(0, MAX_EXECUTE_RESULT_CHARS),
22
- totalChars: text.length,
23
- hint: "filter/map/slice data inside execute_code and return only what you need",
24
- };
68
+ return truncationEnvelope(text);
25
69
  }
26
70
 
27
71
  export function truncateExecuteText(text: string, max: number): string {
@@ -57,6 +57,25 @@ export interface ExecutionPayload {
57
57
  timedOut?: boolean;
58
58
  }
59
59
 
60
+ /**
61
+ * How a host call should be named in an error a program will read. The lazy
62
+ * connector namespaces all dispatch through one internal function, so the raw
63
+ * provider/function pair would report every shortcut call as
64
+ * `connecta.__callNamespace` — an internal name that appears nowhere in the
65
+ * documented surface. Report the address the program actually called.
66
+ */
67
+ export function hostCallLabel(payload: {
68
+ namespace: string;
69
+ functionName: string;
70
+ args: unknown[];
71
+ }): string {
72
+ if (payload.functionName === "__callNamespace") {
73
+ const [connectorId, toolAlias] = payload.args;
74
+ return `${String(connectorId)}.${String(toolAlias)}`;
75
+ }
76
+ return `${payload.namespace}.${payload.functionName}`;
77
+ }
78
+
60
79
  export function serializedBytes(text: string): number {
61
80
  return new TextEncoder().encode(text).length;
62
81
  }
@@ -15,6 +15,7 @@ import {
15
15
  } from "quickjs-emscripten";
16
16
  import type { ExecuteResult, ExecutorProvider } from "../types.js";
17
17
  import {
18
+ hostCallLabel,
18
19
  MAX_QUICKJS_LOG_TRANSPORT_BYTES,
19
20
  serializedBytes,
20
21
  } from "./quickjs-protocol.js";
@@ -245,18 +246,21 @@ function installBridge(
245
246
  : undefined;
246
247
  if (!f) throw new Error(`Unknown function ${ns}.${fn}`);
247
248
  const args = JSON.parse(argsJson) as unknown[];
249
+ // Name the address the program called, never the internal dispatcher the
250
+ // lazy connector namespaces share.
251
+ const label = hostCallLabel({ namespace: ns, functionName: fn, args });
248
252
  const value = await f(...args);
249
253
  let json: string;
250
254
  try {
251
255
  json = JSON.stringify({ ok: true, value });
252
256
  } catch (err) {
253
257
  throw new Error(
254
- `Host result from ${ns}.${fn} could not be serialized: ${msg(err)}`,
258
+ `Host result from ${label} could not be serialized: ${msg(err)}`,
255
259
  );
256
260
  }
257
261
  if (exceedsUtf8ByteLimit(json, MAX_HOST_RESULT_BYTES)) {
258
262
  throw new Error(
259
- `Host result from ${ns}.${fn} exceeds the ${MAX_HOST_RESULT_BYTES}-byte serialized bridge limit.`,
263
+ `Host result from ${label} exceeds the ${MAX_HOST_RESULT_BYTES}-byte serialized bridge limit.`,
260
264
  );
261
265
  }
262
266
  return json;
@@ -18,6 +18,7 @@ import type {
18
18
  ExecutorProvider,
19
19
  } from "../types.js";
20
20
  import {
21
+ hostCallLabel,
21
22
  MAX_QUICKJS_IPC_BYTES,
22
23
  MAX_QUICKJS_HOST_RPC_BYTES,
23
24
  type ChildToParentMessage,
@@ -593,6 +594,9 @@ class QuickJsChildPool implements AdmittingExecutor {
593
594
  serializedBytes(message.payloadJson) >
594
595
  MAX_QUICKJS_HOST_RPC_BYTES
595
596
  ) {
597
+ // Refused before parsing, so there is no address to name here: parsing
598
+ // an over-limit payload to improve its error message would spend the
599
+ // work the limit exists to refuse.
596
600
  throw new RangeError("Host call arguments exceeded the IPC limit.");
597
601
  }
598
602
  const payload = JSON.parse(message.payloadJson) as HostCallPayload;
@@ -610,14 +614,17 @@ class QuickJsChildPool implements AdmittingExecutor {
610
614
  try {
611
615
  payloadJson = stringifyBounded(
612
616
  { ok: true, value } satisfies HostResultPayload,
613
- `Host result from ${payload.namespace}.${payload.functionName}`,
617
+ `Host result from ${hostCallLabel(payload)}`,
614
618
  MAX_QUICKJS_HOST_RPC_BYTES,
615
619
  );
616
620
  } catch (err) {
621
+ // The guest reads this text, so it names the address the program called
622
+ // rather than the internal dispatcher every shortcut namespace shares.
623
+ const label = hostCallLabel(payload);
617
624
  const detail =
618
625
  err instanceof RangeError
619
- ? `Host result from ${payload.namespace}.${payload.functionName} exceeds the ${MAX_QUICKJS_HOST_RPC_BYTES}-byte serialized bridge limit.`
620
- : `Host result from ${payload.namespace}.${payload.functionName} could not be serialized: ${msg(err)}`;
626
+ ? `Host result from ${label} exceeds the ${MAX_QUICKJS_HOST_RPC_BYTES}-byte serialized bridge limit.`
627
+ : `Host result from ${label} could not be serialized: ${msg(err)}`;
621
628
  payloadJson = errorPayload(detail);
622
629
  }
623
630
  } catch (err) {
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import type { ActivityReadGate, ActivityStore } from "./activity.js";
17
17
  import type {
18
18
  Connector,
19
19
  ConnectaBranding,
20
+ ConnectaSurface,
20
21
  Executor,
21
22
  InboundAuth,
22
23
  KVStorage,
@@ -169,12 +170,22 @@ export interface ConnectaConfig {
169
170
  /** Deployment metadata exposed by /health (for example a Worker version). */
170
171
  deploymentInfo?: Record<string, unknown>;
171
172
  /**
172
- * Sandbox for the optional execute_code meta-tool (code mode). Omit the
173
- * tool is not registered and connecta serves the nine base tools. Workers:
174
- * `new DynamicWorkerExecutor({ loader: env.LOADER })` from
175
- * `@cloudflare/codemode`. Node: `quickJsExecutor()` from "@zackbart/connecta/quickjs".
173
+ * Sandbox for `execute_code`, and the switch that decides the surface: with
174
+ * an executor a model sees the seven code-first tools, without one the nine
175
+ * classic ones. Workers: `new DynamicWorkerExecutor({ loader: env.LOADER })`
176
+ * from `@cloudflare/codemode`. Node: `quickJsExecutor()` from
177
+ * "@zackbart/connecta/quickjs".
176
178
  */
177
179
  executor?: Executor;
180
+ /**
181
+ * Override the surface the `executor` implies. The only reason to set it is
182
+ * `"classic"` alongside an executor — ten tools, the shape the eval gate's
183
+ * *incremental* arm measures ("does adding `execute_code` to classic help on
184
+ * its own?"). The gate's control arm is executor-free classic, which needs no
185
+ * override. `"code-first"` is the default wherever an executor exists and
186
+ * throws without one.
187
+ */
188
+ surface?: ConnectaSurface;
178
189
  }
179
190
 
180
191
  export interface Connecta {
@@ -406,8 +417,43 @@ function warnInsecureConfig(
406
417
  }
407
418
  }
408
419
 
420
+ /**
421
+ * The advertised surface: the executor is the switch. Configure one and the
422
+ * deployment serves the seven-tool code-first surface; omit it and there is no
423
+ * program to fold discovery and batching into, so it serves classic.
424
+ *
425
+ * Two mistakes are structural rather than recoverable, so neither is warned
426
+ * past: a surface name connecta does not implement, which would otherwise
427
+ * resolve to something the operator did not ask for; and `code-first` without
428
+ * an executor, which would advertise six tools and no program surface.
429
+ */
430
+ function resolveSurface(config: ConnectaConfig): ConnectaSurface {
431
+ const surface = config.surface;
432
+ if (surface === undefined) {
433
+ return config.executor ? "code-first" : "classic";
434
+ }
435
+ if (surface !== "classic" && surface !== "code-first") {
436
+ throw new Error(
437
+ `ConnectaConfig.surface must be "classic" or "code-first", not ` +
438
+ `${JSON.stringify(surface)}.`,
439
+ );
440
+ }
441
+ if (surface === "code-first" && !config.executor) {
442
+ throw new Error(
443
+ 'ConnectaConfig.surface "code-first" requires an executor: it folds ' +
444
+ "list_connectors, describe_tools, and batch_call into connecta.search, " +
445
+ "connecta.describe, and connecta.batch inside execute_code, so without " +
446
+ "an executor there is nothing left to reach them through. Configure " +
447
+ "one (quickJsExecutor() from \"@zackbart/connecta/quickjs\" on Node, " +
448
+ "new DynamicWorkerExecutor({ loader: env.LOADER }) on Workers).",
449
+ );
450
+ }
451
+ return surface;
452
+ }
453
+
409
454
  export function createConnecta(config: ConnectaConfig): Connecta {
410
455
  assertNoLegacyConfig(config);
456
+ const surface = resolveSurface(config);
411
457
  const storage = config.storage ?? memoryStorage();
412
458
  const logger = config.logger ?? defaultLogger();
413
459
  const credentialConnectors = config.connectors.filter((c) => c.credential);
@@ -484,6 +530,7 @@ export function createConnecta(config: ConnectaConfig): Connecta {
484
530
  ? { activityDeploymentId: config.activity.deploymentId }
485
531
  : {}),
486
532
  ...(executor !== undefined ? { executor } : {}),
533
+ surface,
487
534
  requestAdmission,
488
535
  ...(config.calls?.defaultTimeoutMs !== undefined
489
536
  ? { defaultToolTimeoutMs: config.calls.defaultTimeoutMs }
@@ -553,6 +600,7 @@ export type {
553
600
  ConnectorCallAdmissionRule,
554
601
  ConnectorRollingWindowBudget,
555
602
  ConnectaBranding,
603
+ ConnectaSurface,
556
604
  ConnectorCredentialAccess,
557
605
  ConnectorCredentialConfig,
558
606
  ConnectorCredentialFieldConfig,
package/src/invocation.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  import {
13
13
  classifyCallError,
14
14
  ConnectorCallError,
15
- messageLooksRetryable,
15
+ framingError,
16
16
  type AuthRecoveryMode,
17
17
  type CallErrorDetails,
18
18
  } from "./errors.js";
@@ -70,10 +70,6 @@ function retrySafe(definition: ToolDef): boolean {
70
70
  );
71
71
  }
72
72
 
73
- function framingError(code: string, message: string): CallErrorDetails {
74
- return { code, message, retryable: messageLooksRetryable(message) };
75
- }
76
-
77
73
  function callerCancelledDetails(): CallErrorDetails {
78
74
  return {
79
75
  code: "cancelled",