@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.
- package/CHANGELOG.md +96 -0
- package/README.md +43 -92
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +1 -4
- package/dist/catalog-service.js.map +1 -1
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +3 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +79 -18
- package/dist/execute.js.map +1 -1
- package/dist/executor-result.d.ts.map +1 -1
- package/dist/executor-result.js +37 -6
- package/dist/executor-result.js.map +1 -1
- package/dist/executors/quickjs-protocol.d.ts +12 -0
- package/dist/executors/quickjs-protocol.d.ts.map +1 -1
- package/dist/executors/quickjs-protocol.js +14 -0
- package/dist/executors/quickjs-protocol.js.map +1 -1
- package/dist/executors/quickjs-runtime.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.js +6 -3
- package/dist/executors/quickjs-runtime.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +10 -4
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +16 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts.map +1 -1
- package/dist/invocation.js +1 -4
- package/dist/invocation.js.map +1 -1
- package/dist/meta-tools.d.ts +26 -5
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +84 -40
- package/dist/meta-tools.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +8 -2
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/shared.d.ts +8 -2
- package/dist/routes/shared.d.ts.map +1 -1
- package/dist/routes/shared.js.map +1 -1
- package/dist/skills.d.ts +15 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +63 -10
- package/dist/skills.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +2 -2
- package/src/catalog-service.ts +1 -5
- package/src/errors.ts +28 -0
- package/src/execute.ts +123 -48
- package/src/executor-result.ts +50 -6
- package/src/executors/quickjs-protocol.ts +19 -0
- package/src/executors/quickjs-runtime.ts +6 -2
- package/src/executors/quickjs.ts +10 -3
- package/src/index.ts +52 -4
- package/src/invocation.ts +1 -5
- package/src/meta-tools.ts +116 -53
- package/src/routes/mcp.ts +8 -2
- package/src/routes/shared.ts +8 -1
- package/src/skills.ts +79 -9
- package/src/types.ts +15 -0
- package/src/version.ts +1 -1
package/dist/version.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,
|
|
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.
|
|
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
|
|
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"
|
package/src/catalog-service.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
mapSettledWithConcurrency,
|
|
11
11
|
resolveDiscoveryConcurrency,
|
|
12
12
|
} from "./concurrency.js";
|
|
13
|
-
import { classifyCallError,
|
|
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:
|
|
303
|
+
error: details.message,
|
|
304
|
+
errorDetails: details,
|
|
269
305
|
};
|
|
270
306
|
}
|
|
271
307
|
}),
|
|
272
308
|
);
|
|
273
309
|
},
|
|
274
|
-
search: async (raw: unknown) =>
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
result
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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 (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
568
|
+
description: executeDescription(ctx.surface ?? "classic"),
|
|
494
569
|
inputSchema: z.object({
|
|
495
570
|
code: z
|
|
496
571
|
.string()
|
package/src/executor-result.ts
CHANGED
|
@@ -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 ${
|
|
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 ${
|
|
263
|
+
`Host result from ${label} exceeds the ${MAX_HOST_RESULT_BYTES}-byte serialized bridge limit.`,
|
|
260
264
|
);
|
|
261
265
|
}
|
|
262
266
|
return json;
|
package/src/executors/quickjs.ts
CHANGED
|
@@ -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
|
|
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 ${
|
|
620
|
-
: `Host result from ${
|
|
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
|
|
173
|
-
*
|
|
174
|
-
* `new DynamicWorkerExecutor({ loader: env.LOADER })`
|
|
175
|
-
* `@cloudflare/codemode`. Node: `quickJsExecutor()` from
|
|
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
|
-
|
|
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",
|