@zackbart/connecta 0.15.1 → 0.16.1
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/AGENTS.md +40 -20
- package/CHANGELOG.md +653 -0
- package/README.md +25 -5
- package/bin/connecta.mjs +44 -3
- package/dist/access-tokens.d.ts +0 -1
- package/dist/access-tokens.js +0 -1
- package/dist/activity.d.ts +50 -2
- package/dist/activity.js +34 -1
- package/dist/apps-shell.d.ts +0 -1
- package/dist/apps-shell.js +0 -1
- package/dist/auth/bearer.d.ts +0 -1
- package/dist/auth/bearer.js +0 -1
- package/dist/auth/clerk.d.ts +0 -1
- package/dist/auth/clerk.js +45 -9
- package/dist/auth/downstream-oauth.d.ts +0 -1
- package/dist/auth/downstream-oauth.js +0 -1
- package/dist/call-admission.d.ts +0 -1
- package/dist/call-admission.js +0 -1
- package/dist/catalog-drift.d.ts +99 -0
- package/dist/catalog-drift.js +251 -0
- package/dist/catalog-fingerprint.d.ts +0 -1
- package/dist/catalog-fingerprint.js +0 -1
- package/dist/catalog-limits.d.ts +0 -1
- package/dist/catalog-limits.js +0 -1
- package/dist/catalog-service.d.ts +0 -1
- package/dist/catalog-service.js +41 -5
- package/dist/catalog.d.ts +11 -1
- package/dist/catalog.js +20 -1
- package/dist/concurrency.d.ts +0 -1
- package/dist/concurrency.js +0 -1
- package/dist/connector-scope.d.ts +0 -1
- package/dist/connector-scope.js +0 -1
- package/dist/connectors/api.d.ts +29 -21
- package/dist/connectors/api.js +45 -19
- package/dist/connectors/guarded-fetch.d.ts +106 -0
- package/dist/connectors/guarded-fetch.js +289 -0
- package/dist/connectors/remote-mcp.d.ts +0 -1
- package/dist/connectors/remote-mcp.js +0 -1
- package/dist/credentials.d.ts +0 -1
- package/dist/credentials.js +0 -1
- package/dist/errors.d.ts +30 -3
- package/dist/errors.js +1 -1
- package/dist/execute.d.ts +0 -1
- package/dist/execute.js +0 -1
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +28 -1
- package/dist/executor-result.d.ts +0 -1
- package/dist/executor-result.js +0 -1
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-child.js +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -1
- package/dist/executors/quickjs-protocol.js +0 -1
- package/dist/executors/quickjs-runtime.d.ts +0 -1
- package/dist/executors/quickjs-runtime.js +0 -1
- package/dist/executors/quickjs.d.ts +0 -1
- package/dist/executors/quickjs.js +2 -1
- package/dist/favicon.d.ts +0 -1
- package/dist/favicon.js +0 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.js +24 -7
- package/dist/invocation.d.ts +0 -1
- package/dist/invocation.js +0 -1
- package/dist/json-schema.d.ts +0 -1
- package/dist/json-schema.js +0 -1
- package/dist/mcp-result.d.ts +0 -1
- package/dist/mcp-result.js +0 -1
- package/dist/meta-tools.d.ts +0 -1
- package/dist/meta-tools.js +0 -1
- package/dist/node.d.ts +0 -1
- package/dist/node.js +0 -1
- package/dist/operator-ui/generated.d.ts +2 -3
- package/dist/operator-ui/generated.js +3 -4
- package/dist/operator-ui/model.d.ts +10 -1
- package/dist/operator-ui/model.js +0 -1
- package/dist/operator-ui/view.d.ts +167 -0
- package/dist/operator-ui/view.js +270 -0
- package/dist/providers/cloudflare.d.ts +0 -1
- package/dist/providers/cloudflare.js +261 -299
- package/dist/providers/linear.d.ts +26 -5
- package/dist/providers/linear.js +32 -61
- package/dist/providers/mixpanel.d.ts +35 -4
- package/dist/providers/mixpanel.js +53 -79
- package/dist/providers/notion.d.ts +0 -1
- package/dist/providers/notion.js +87 -42
- package/dist/providers/stripe.d.ts +13 -1
- package/dist/providers/stripe.js +31 -60
- package/dist/registry.d.ts +34 -2
- package/dist/registry.js +85 -5
- package/dist/retired-toolkits.d.ts +0 -1
- package/dist/retired-toolkits.js +0 -1
- package/dist/routes/access-tokens.d.ts +0 -1
- package/dist/routes/access-tokens.js +0 -1
- package/dist/routes/activity.d.ts +0 -1
- package/dist/routes/activity.js +0 -1
- package/dist/routes/credentials.d.ts +0 -1
- package/dist/routes/credentials.js +0 -1
- package/dist/routes/mcp.d.ts +0 -1
- package/dist/routes/mcp.js +0 -1
- package/dist/routes/oauth.d.ts +0 -1
- package/dist/routes/oauth.js +0 -1
- package/dist/routes/shared.d.ts +2 -1
- package/dist/routes/shared.js +0 -1
- package/dist/routes/ui.d.ts +0 -1
- package/dist/routes/ui.js +0 -1
- package/dist/server.d.ts +0 -1
- package/dist/server.js +11 -1
- package/dist/skills.d.ts +0 -1
- package/dist/skills.js +0 -1
- package/dist/storage/file.d.ts +0 -1
- package/dist/storage/file.js +0 -1
- package/dist/storage/memory.d.ts +0 -1
- package/dist/storage/memory.js +0 -1
- package/dist/timeout.d.ts +0 -1
- package/dist/timeout.js +0 -1
- package/dist/tool-safety.d.ts +0 -1
- package/dist/tool-safety.js +0 -1
- package/dist/types.d.ts +45 -1
- package/dist/types.js +0 -1
- package/dist/ui.d.ts +0 -1
- package/dist/ui.js +13 -137
- package/dist/validate.d.ts +13 -15
- package/dist/validate.js +24 -11
- package/dist/version.d.ts +1 -2
- package/dist/version.js +1 -2
- package/documentation/architecture.md +186 -5
- package/documentation/auth.md +11 -0
- package/documentation/call-admission.md +142 -6
- package/documentation/cloudflare.md +92 -10
- package/documentation/code-first-exploration.md +2 -2
- package/documentation/code-mode.md +14 -14
- package/documentation/connector-guides.md +172 -5
- package/documentation/connectors.md +89 -0
- package/documentation/linear.md +20 -2
- package/documentation/mcp-ui-design.md +2 -2
- package/documentation/meta-tools.md +7 -1
- package/documentation/mixpanel.md +42 -9
- package/documentation/notion.md +23 -2
- package/documentation/operations.md +325 -5
- package/documentation/operator-ui.md +120 -5
- package/documentation/provider-audit.md +160 -0
- package/documentation/provider-conventions.md +667 -0
- package/documentation/request-admission.md +167 -5
- package/documentation/rich-output-design.md +1 -2
- package/documentation/stripe.md +15 -0
- package/documentation/upgrading.md +454 -0
- package/ethos.md +14 -9
- package/examples/worker/README.md +102 -12
- package/examples/worker/src/index.ts +46 -1
- package/examples/worker/wrangler.jsonc +14 -1
- package/package.json +10 -6
- package/templates/node/.dockerignore +10 -0
- package/templates/node/.env.example +40 -3
- package/templates/node/AGENTS.md +16 -1
- package/templates/node/Dockerfile +49 -0
- package/templates/node/README.md +117 -2
- package/templates/node/docker-compose.yml +39 -0
- package/templates/node/package.json +1 -1
- package/templates/node/src/file-activity.ts +182 -0
- package/templates/node/src/index.ts +80 -4
- package/assets/connecta-clay-hero.png +0 -0
- package/dist/access-tokens.d.ts.map +0 -1
- package/dist/access-tokens.js.map +0 -1
- package/dist/activity.d.ts.map +0 -1
- package/dist/activity.js.map +0 -1
- package/dist/apps-shell.d.ts.map +0 -1
- package/dist/apps-shell.js.map +0 -1
- package/dist/auth/bearer.d.ts.map +0 -1
- package/dist/auth/bearer.js.map +0 -1
- package/dist/auth/clerk.d.ts.map +0 -1
- package/dist/auth/clerk.js.map +0 -1
- package/dist/auth/downstream-oauth.d.ts.map +0 -1
- package/dist/auth/downstream-oauth.js.map +0 -1
- package/dist/call-admission.d.ts.map +0 -1
- package/dist/call-admission.js.map +0 -1
- package/dist/catalog-fingerprint.d.ts.map +0 -1
- package/dist/catalog-fingerprint.js.map +0 -1
- package/dist/catalog-limits.d.ts.map +0 -1
- package/dist/catalog-limits.js.map +0 -1
- package/dist/catalog-service.d.ts.map +0 -1
- package/dist/catalog-service.js.map +0 -1
- package/dist/catalog.d.ts.map +0 -1
- package/dist/catalog.js.map +0 -1
- package/dist/concurrency.d.ts.map +0 -1
- package/dist/concurrency.js.map +0 -1
- package/dist/connector-scope.d.ts.map +0 -1
- package/dist/connector-scope.js.map +0 -1
- package/dist/connectors/api.d.ts.map +0 -1
- package/dist/connectors/api.js.map +0 -1
- package/dist/connectors/remote-mcp.d.ts.map +0 -1
- package/dist/connectors/remote-mcp.js.map +0 -1
- package/dist/credentials.d.ts.map +0 -1
- package/dist/credentials.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/execute.d.ts.map +0 -1
- package/dist/execute.js.map +0 -1
- package/dist/executor-admission.d.ts.map +0 -1
- package/dist/executor-admission.js.map +0 -1
- package/dist/executor-result.d.ts.map +0 -1
- package/dist/executor-result.js.map +0 -1
- package/dist/executors/quickjs-child.d.ts.map +0 -1
- package/dist/executors/quickjs-child.js.map +0 -1
- package/dist/executors/quickjs-protocol.d.ts.map +0 -1
- package/dist/executors/quickjs-protocol.js.map +0 -1
- package/dist/executors/quickjs-runtime.d.ts.map +0 -1
- package/dist/executors/quickjs-runtime.js.map +0 -1
- package/dist/executors/quickjs.d.ts.map +0 -1
- package/dist/executors/quickjs.js.map +0 -1
- package/dist/favicon.d.ts.map +0 -1
- package/dist/favicon.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/invocation.d.ts.map +0 -1
- package/dist/invocation.js.map +0 -1
- package/dist/json-schema.d.ts.map +0 -1
- package/dist/json-schema.js.map +0 -1
- package/dist/mcp-result.d.ts.map +0 -1
- package/dist/mcp-result.js.map +0 -1
- package/dist/meta-tools.d.ts.map +0 -1
- package/dist/meta-tools.js.map +0 -1
- package/dist/node.d.ts.map +0 -1
- package/dist/node.js.map +0 -1
- package/dist/operator-ui/generated.d.ts.map +0 -1
- package/dist/operator-ui/generated.js.map +0 -1
- package/dist/operator-ui/model.d.ts.map +0 -1
- package/dist/operator-ui/model.js.map +0 -1
- package/dist/providers/cloudflare.d.ts.map +0 -1
- package/dist/providers/cloudflare.js.map +0 -1
- package/dist/providers/linear.d.ts.map +0 -1
- package/dist/providers/linear.js.map +0 -1
- package/dist/providers/mixpanel.d.ts.map +0 -1
- package/dist/providers/mixpanel.js.map +0 -1
- package/dist/providers/notion.d.ts.map +0 -1
- package/dist/providers/notion.js.map +0 -1
- package/dist/providers/stripe.d.ts.map +0 -1
- package/dist/providers/stripe.js.map +0 -1
- package/dist/registry.d.ts.map +0 -1
- package/dist/registry.js.map +0 -1
- package/dist/retired-toolkits.d.ts.map +0 -1
- package/dist/retired-toolkits.js.map +0 -1
- package/dist/routes/access-tokens.d.ts.map +0 -1
- package/dist/routes/access-tokens.js.map +0 -1
- package/dist/routes/activity.d.ts.map +0 -1
- package/dist/routes/activity.js.map +0 -1
- package/dist/routes/credentials.d.ts.map +0 -1
- package/dist/routes/credentials.js.map +0 -1
- package/dist/routes/mcp.d.ts.map +0 -1
- package/dist/routes/mcp.js.map +0 -1
- package/dist/routes/oauth.d.ts.map +0 -1
- package/dist/routes/oauth.js.map +0 -1
- package/dist/routes/shared.d.ts.map +0 -1
- package/dist/routes/shared.js.map +0 -1
- package/dist/routes/ui.d.ts.map +0 -1
- package/dist/routes/ui.js.map +0 -1
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/skills.d.ts.map +0 -1
- package/dist/skills.js.map +0 -1
- package/dist/storage/file.d.ts.map +0 -1
- package/dist/storage/file.js.map +0 -1
- package/dist/storage/memory.d.ts.map +0 -1
- package/dist/storage/memory.js.map +0 -1
- package/dist/timeout.d.ts.map +0 -1
- package/dist/timeout.js.map +0 -1
- package/dist/tool-safety.d.ts.map +0 -1
- package/dist/tool-safety.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/ui.d.ts.map +0 -1
- package/dist/ui.js.map +0 -1
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
- package/examples/node/README.md +0 -52
- package/examples/node/src/index.ts +0 -71
- package/src/access-tokens.ts +0 -289
- package/src/activity.ts +0 -234
- package/src/apps-shell.ts +0 -370
- package/src/auth/bearer.ts +0 -61
- package/src/auth/clerk.ts +0 -553
- package/src/auth/downstream-oauth.ts +0 -575
- package/src/call-admission.ts +0 -521
- package/src/catalog-fingerprint.ts +0 -52
- package/src/catalog-limits.ts +0 -11
- package/src/catalog-service.ts +0 -1153
- package/src/catalog.ts +0 -790
- package/src/concurrency.ts +0 -39
- package/src/connector-scope.ts +0 -66
- package/src/connectors/api.ts +0 -159
- package/src/connectors/remote-mcp.ts +0 -1090
- package/src/credentials.ts +0 -428
- package/src/errors.ts +0 -364
- package/src/execute.ts +0 -1406
- package/src/executor-admission.ts +0 -328
- package/src/executor-result.ts +0 -107
- package/src/executors/quickjs-child.ts +0 -172
- package/src/executors/quickjs-protocol.ts +0 -99
- package/src/executors/quickjs-runtime.ts +0 -470
- package/src/executors/quickjs.ts +0 -776
- package/src/favicon.ts +0 -53
- package/src/index.ts +0 -666
- package/src/invocation.ts +0 -655
- package/src/json-schema.ts +0 -11
- package/src/mcp-result.ts +0 -43
- package/src/meta-tools.ts +0 -1607
- package/src/node.ts +0 -206
- package/src/operator-ui/browser.css +0 -423
- package/src/operator-ui/browser.ts +0 -1281
- package/src/operator-ui/generated.ts +0 -4
- package/src/operator-ui/model.ts +0 -111
- package/src/providers/cloudflare.ts +0 -3999
- package/src/providers/linear.ts +0 -302
- package/src/providers/mixpanel.ts +0 -229
- package/src/providers/notion.ts +0 -1879
- package/src/providers/stripe.ts +0 -307
- package/src/registry.ts +0 -1085
- package/src/retired-toolkits.ts +0 -14
- package/src/routes/access-tokens.ts +0 -115
- package/src/routes/activity.ts +0 -215
- package/src/routes/credentials.ts +0 -254
- package/src/routes/mcp.ts +0 -429
- package/src/routes/oauth.ts +0 -322
- package/src/routes/shared.ts +0 -247
- package/src/routes/ui.ts +0 -152
- package/src/server.ts +0 -195
- package/src/skills.ts +0 -271
- package/src/storage/file.ts +0 -123
- package/src/storage/memory.ts +0 -44
- package/src/timeout.ts +0 -84
- package/src/tool-safety.ts +0 -15
- package/src/types.ts +0 -523
- package/src/ui.ts +0 -744
- package/src/validate.ts +0 -387
- package/src/version.ts +0 -7
package/src/execute.ts
DELETED
|
@@ -1,1406 +0,0 @@
|
|
|
1
|
-
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { ActivityRequestContext } from "./activity.js";
|
|
4
|
-
import {
|
|
5
|
-
PROGRAM_UI_META_KEY,
|
|
6
|
-
PROGRAM_UI_RESOURCE_URI,
|
|
7
|
-
} from "./apps-shell.js";
|
|
8
|
-
import {
|
|
9
|
-
boundedDiscoveryText,
|
|
10
|
-
CatalogService,
|
|
11
|
-
DiscoveryPolicyError,
|
|
12
|
-
flatSearchResult,
|
|
13
|
-
} from "./catalog-service.js";
|
|
14
|
-
import { errorResult, jsonResult, type ToolResult } from "./meta-tools.js";
|
|
15
|
-
import {
|
|
16
|
-
guardExecuteResultValue,
|
|
17
|
-
MAX_EXECUTE_LOG_CHARS,
|
|
18
|
-
truncateExecuteText,
|
|
19
|
-
} from "./executor-result.js";
|
|
20
|
-
import {
|
|
21
|
-
ExecutorAdmissionError,
|
|
22
|
-
ExecutorExecutionError,
|
|
23
|
-
isAdmittingExecutor,
|
|
24
|
-
} from "./executor-admission.js";
|
|
25
|
-
import { classifyCallError } from "./errors.js";
|
|
26
|
-
import {
|
|
27
|
-
InvocationFailure,
|
|
28
|
-
InvocationService,
|
|
29
|
-
} from "./invocation.js";
|
|
30
|
-
import type { RegistryView } from "./registry.js";
|
|
31
|
-
import { hasConnectorGuides } from "./skills.js";
|
|
32
|
-
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
33
|
-
import type {
|
|
34
|
-
Executor,
|
|
35
|
-
ExecutorProvider,
|
|
36
|
-
Logger,
|
|
37
|
-
} from "./types.js";
|
|
38
|
-
|
|
39
|
-
/** Keep one model-written program from amplifying into an unbounded fan-out. */
|
|
40
|
-
const EXECUTE_MAX_HOST_CALLS = 20;
|
|
41
|
-
export const EXECUTE_MAX_BATCH_CALLS = 10;
|
|
42
|
-
const EXECUTE_HOST_CALL_TIMEOUT_MS = 15_000;
|
|
43
|
-
/**
|
|
44
|
-
* Default budgets for `connecta.emit`. The byte budget is a transport bound,
|
|
45
|
-
* not a context bound — emitted image/audio blocks reach the model as media,
|
|
46
|
-
* not base64 text — so it sits far above the 24k return-value guard: room for
|
|
47
|
-
* two or three real screenshots after base64's 4/3 inflation, well short of a
|
|
48
|
-
* file-hosting ambition (design record M5).
|
|
49
|
-
*/
|
|
50
|
-
export const EXECUTE_MAX_EMITTED_BYTES = 4_000_000;
|
|
51
|
-
export const EXECUTE_MAX_EMITTED_BLOCKS = 32;
|
|
52
|
-
const diagnosticsEncoder = new TextEncoder();
|
|
53
|
-
|
|
54
|
-
type ExecuteDiagnosticOperation = "search" | "describe" | "call" | "batch";
|
|
55
|
-
|
|
56
|
-
interface ExecuteOperationDiagnostics {
|
|
57
|
-
operation: ExecuteDiagnosticOperation;
|
|
58
|
-
count: number;
|
|
59
|
-
failures: number;
|
|
60
|
-
durationMs: number;
|
|
61
|
-
resultBytes: number;
|
|
62
|
-
catalogMs: number;
|
|
63
|
-
connectorMs: number;
|
|
64
|
-
calls?: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
type MutableOperationDiagnostics = ExecuteOperationDiagnostics;
|
|
68
|
-
|
|
69
|
-
class ExecuteDiagnostics {
|
|
70
|
-
private readonly started = Date.now();
|
|
71
|
-
private readonly operations = new Map<
|
|
72
|
-
ExecuteDiagnosticOperation,
|
|
73
|
-
MutableOperationDiagnostics
|
|
74
|
-
>();
|
|
75
|
-
admissionMs = 0;
|
|
76
|
-
setupMs = 0;
|
|
77
|
-
executorWallMs = 0;
|
|
78
|
-
private emitted?: { count: number; bytes: number };
|
|
79
|
-
private ui?: number;
|
|
80
|
-
|
|
81
|
-
/** Numbers only, per R8 — and only once something was emitted, so a
|
|
82
|
-
* non-emitting run's diagnostics stay byte-for-byte what they were. */
|
|
83
|
-
recordEmitted(count: number, bytes: number): void {
|
|
84
|
-
if (count > 0) this.emitted = { count, bytes };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* U9: the UI payload gets its own aggregate — one number, the payload's
|
|
89
|
-
* serialized size. Folding it into `emitted` would desync that aggregate's
|
|
90
|
-
* pair, which reports the bytes a specific block count cost.
|
|
91
|
-
*/
|
|
92
|
-
recordUi(bytes: number): void {
|
|
93
|
-
this.ui = bytes;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
private stats(operation: ExecuteDiagnosticOperation) {
|
|
97
|
-
let stats = this.operations.get(operation);
|
|
98
|
-
if (!stats) {
|
|
99
|
-
stats = {
|
|
100
|
-
operation,
|
|
101
|
-
count: 0,
|
|
102
|
-
failures: 0,
|
|
103
|
-
durationMs: 0,
|
|
104
|
-
resultBytes: 0,
|
|
105
|
-
catalogMs: 0,
|
|
106
|
-
connectorMs: 0,
|
|
107
|
-
};
|
|
108
|
-
this.operations.set(operation, stats);
|
|
109
|
-
}
|
|
110
|
-
return stats;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
recordCatalog(
|
|
114
|
-
operation: "search" | "describe",
|
|
115
|
-
durationMs: number,
|
|
116
|
-
ok: boolean,
|
|
117
|
-
result?: unknown,
|
|
118
|
-
): void {
|
|
119
|
-
const stats = this.stats(operation);
|
|
120
|
-
stats.count++;
|
|
121
|
-
stats.failures += ok ? 0 : 1;
|
|
122
|
-
stats.durationMs += durationMs;
|
|
123
|
-
stats.catalogMs += durationMs;
|
|
124
|
-
if (ok) stats.resultBytes += serializedDiagnosticBytes(result);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
recordCall(
|
|
128
|
-
operation: "call" | "batch",
|
|
129
|
-
outcome: {
|
|
130
|
-
ok: boolean;
|
|
131
|
-
durationMs: number;
|
|
132
|
-
timing: { catalogMs: number; connectorMs: number };
|
|
133
|
-
value?: unknown;
|
|
134
|
-
},
|
|
135
|
-
): void {
|
|
136
|
-
const stats = this.stats(operation);
|
|
137
|
-
if (operation === "call") {
|
|
138
|
-
stats.count++;
|
|
139
|
-
if (outcome.ok) {
|
|
140
|
-
stats.resultBytes += serializedDiagnosticBytes(outcome.value);
|
|
141
|
-
}
|
|
142
|
-
} else {
|
|
143
|
-
stats.calls = (stats.calls ?? 0) + 1;
|
|
144
|
-
}
|
|
145
|
-
stats.failures += outcome.ok ? 0 : 1;
|
|
146
|
-
if (operation === "call") stats.durationMs += outcome.durationMs;
|
|
147
|
-
stats.catalogMs += outcome.timing.catalogMs;
|
|
148
|
-
stats.connectorMs += outcome.timing.connectorMs;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
recordBatch(
|
|
152
|
-
durationMs: number,
|
|
153
|
-
ok: boolean,
|
|
154
|
-
calls: number,
|
|
155
|
-
result?: unknown,
|
|
156
|
-
): void {
|
|
157
|
-
const stats = this.stats("batch");
|
|
158
|
-
stats.count++;
|
|
159
|
-
// Calls normally accrue while each child runs. Invalid batch input never
|
|
160
|
-
// starts children, so retain the attempted cardinality here.
|
|
161
|
-
if (!ok) stats.calls = Math.max(stats.calls ?? 0, calls);
|
|
162
|
-
stats.durationMs += durationMs;
|
|
163
|
-
if (!ok) stats.failures++;
|
|
164
|
-
else stats.resultBytes += serializedDiagnosticBytes(result);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
finish(): {
|
|
168
|
-
timing: {
|
|
169
|
-
totalMs: number;
|
|
170
|
-
admissionMs: number;
|
|
171
|
-
setupMs: number;
|
|
172
|
-
executorWallMs: number;
|
|
173
|
-
catalogMs: number;
|
|
174
|
-
connectorMs: number;
|
|
175
|
-
};
|
|
176
|
-
operations: ExecuteOperationDiagnostics[];
|
|
177
|
-
emitted?: { count: number; bytes: number };
|
|
178
|
-
ui?: number;
|
|
179
|
-
} {
|
|
180
|
-
const operations = [...this.operations.values()];
|
|
181
|
-
return {
|
|
182
|
-
timing: {
|
|
183
|
-
totalMs: Date.now() - this.started,
|
|
184
|
-
admissionMs: this.admissionMs,
|
|
185
|
-
setupMs: this.setupMs,
|
|
186
|
-
executorWallMs: this.executorWallMs,
|
|
187
|
-
catalogMs: operations.reduce((sum, item) => sum + item.catalogMs, 0),
|
|
188
|
-
connectorMs: operations.reduce(
|
|
189
|
-
(sum, item) => sum + item.connectorMs,
|
|
190
|
-
0,
|
|
191
|
-
),
|
|
192
|
-
},
|
|
193
|
-
operations,
|
|
194
|
-
...(this.emitted ? { emitted: this.emitted } : {}),
|
|
195
|
-
...(this.ui !== undefined ? { ui: this.ui } : {}),
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* One MCP content block a program may emit. The complete set, by design:
|
|
202
|
-
* `resource` and `resource_link` are refused in ethos.md — pointers get
|
|
203
|
-
* followed, and connecta serves no resources for them to point at.
|
|
204
|
-
*/
|
|
205
|
-
export type EmittedBlock =
|
|
206
|
-
| { type: "text"; text: string }
|
|
207
|
-
| { type: "image"; data: string; mimeType: string }
|
|
208
|
-
| { type: "audio"; data: string; mimeType: string };
|
|
209
|
-
|
|
210
|
-
const EMIT_SHAPE_HINT =
|
|
211
|
-
'{ type: "text", text } or { type: "image" | "audio", data (base64), mimeType }';
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Strict M1 validation: required fields present and string-valued, nothing
|
|
215
|
-
* else — no `annotations`, no `_meta`, no sugar forms. Rejected rather than
|
|
216
|
-
* stripped, because silently deleting fields would deliver something the
|
|
217
|
-
* program did not ask to emit.
|
|
218
|
-
*/
|
|
219
|
-
function requireEmittedBlock(raw: unknown): EmittedBlock {
|
|
220
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
221
|
-
throw new Error(
|
|
222
|
-
`connecta.emit accepts exactly one content block: ${EMIT_SHAPE_HINT}`,
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
const block = raw as Record<string, unknown>;
|
|
226
|
-
const fields =
|
|
227
|
-
block.type === "text"
|
|
228
|
-
? ["type", "text"]
|
|
229
|
-
: block.type === "image" || block.type === "audio"
|
|
230
|
-
? ["type", "data", "mimeType"]
|
|
231
|
-
: undefined;
|
|
232
|
-
if (!fields) {
|
|
233
|
-
throw new Error(
|
|
234
|
-
`connecta.emit supports content types "text", "image", and "audio"; got ${JSON.stringify(block.type)}`,
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
for (const field of fields) {
|
|
238
|
-
if (typeof block[field] !== "string") {
|
|
239
|
-
throw new Error(
|
|
240
|
-
`connecta.emit block field "${field}" must be a string: ${EMIT_SHAPE_HINT}`,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
const extra = Object.keys(block).filter((key) => !fields.includes(key));
|
|
245
|
-
if (extra.length > 0) {
|
|
246
|
-
throw new Error(
|
|
247
|
-
`connecta.emit block carries unsupported field(s) ${extra.map((key) => JSON.stringify(key)).join(", ")}; a "${String(block.type)}" block is exactly { ${fields.join(", ")} }`,
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
return raw as EmittedBlock;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const UI_SHAPE_HINT =
|
|
254
|
-
"connecta.ui accepts exactly one HTML argument and, optionally, one read-binding options object";
|
|
255
|
-
|
|
256
|
-
const MAX_UI_READ_BINDINGS = 32;
|
|
257
|
-
const MAX_UI_VIEW_ARGS = 32;
|
|
258
|
-
const UI_READ_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
259
|
-
const FORBIDDEN_UI_KEY = new Set(["__proto__", "constructor", "prototype"]);
|
|
260
|
-
|
|
261
|
-
interface UiReadBinding {
|
|
262
|
-
address: string;
|
|
263
|
-
fixedArgs: Record<string, unknown>;
|
|
264
|
-
viewArgs: string[];
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
interface UiPayload {
|
|
268
|
-
html: string;
|
|
269
|
-
reads?: Record<string, UiReadBinding>;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** What the argument was, named the way the emit validator names a bad field. */
|
|
273
|
-
function describeUiArgument(raw: unknown): string {
|
|
274
|
-
if (raw === null) return "null";
|
|
275
|
-
if (raw === undefined) return "undefined";
|
|
276
|
-
if (Array.isArray(raw)) return "an array";
|
|
277
|
-
if (typeof raw === "string") return "an empty string";
|
|
278
|
-
const kind = typeof raw;
|
|
279
|
-
return `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}`;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Strict U1/V1 validation. The first argument remains HTML; the only second
|
|
284
|
-
* argument is one read-binding manifest. There are no alternate object or MCP
|
|
285
|
-
* block forms.
|
|
286
|
-
*/
|
|
287
|
-
function requireUiHtml(raw: unknown): string {
|
|
288
|
-
if (typeof raw !== "string" || raw.length === 0) {
|
|
289
|
-
throw new Error(`${UI_SHAPE_HINT}; got ${describeUiArgument(raw)}`);
|
|
290
|
-
}
|
|
291
|
-
return raw;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function requireRecord(raw: unknown, label: string): Record<string, unknown> {
|
|
295
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
296
|
-
throw new Error(`${label} must be an object`);
|
|
297
|
-
}
|
|
298
|
-
return raw as Record<string, unknown>;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function requireExactKeys(
|
|
302
|
-
value: Record<string, unknown>,
|
|
303
|
-
allowed: readonly string[],
|
|
304
|
-
label: string,
|
|
305
|
-
): void {
|
|
306
|
-
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
307
|
-
if (extras.length > 0) {
|
|
308
|
-
throw new Error(
|
|
309
|
-
`${label} carries unsupported field(s) ${extras.map((key) => JSON.stringify(key)).join(", ")}`,
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function requireUiReadKey(raw: unknown, label: string): string {
|
|
315
|
-
if (
|
|
316
|
-
typeof raw !== "string" ||
|
|
317
|
-
raw.length === 0 ||
|
|
318
|
-
raw.length > 128 ||
|
|
319
|
-
FORBIDDEN_UI_KEY.has(raw)
|
|
320
|
-
) {
|
|
321
|
-
throw new Error(
|
|
322
|
-
`${label} must be a non-empty string of at most 128 characters and cannot be __proto__, constructor, or prototype`,
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
return raw;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function requireUiReads(raw: unknown): Record<string, UiReadBinding> {
|
|
329
|
-
const record = requireRecord(raw, "connecta.ui options.reads");
|
|
330
|
-
const names = Object.keys(record);
|
|
331
|
-
if (names.length === 0 || names.length > MAX_UI_READ_BINDINGS) {
|
|
332
|
-
throw new Error(
|
|
333
|
-
`connecta.ui options.reads must contain from 1 through ${MAX_UI_READ_BINDINGS} named bindings`,
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
const reads = Object.create(null) as Record<string, UiReadBinding>;
|
|
337
|
-
for (const name of names) {
|
|
338
|
-
if (!UI_READ_NAME.test(name) || FORBIDDEN_UI_KEY.has(name)) {
|
|
339
|
-
throw new Error(
|
|
340
|
-
`connecta.ui read binding name ${JSON.stringify(name)} must match ${UI_READ_NAME}`,
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
const value = requireRecord(
|
|
344
|
-
record[name],
|
|
345
|
-
`connecta.ui read binding ${JSON.stringify(name)}`,
|
|
346
|
-
);
|
|
347
|
-
requireExactKeys(
|
|
348
|
-
value,
|
|
349
|
-
["address", "fixedArgs", "viewArgs"],
|
|
350
|
-
`connecta.ui read binding ${JSON.stringify(name)}`,
|
|
351
|
-
);
|
|
352
|
-
if (typeof value.address !== "string" || value.address.length === 0) {
|
|
353
|
-
throw new Error(
|
|
354
|
-
`connecta.ui read binding ${JSON.stringify(name)} address must be a non-empty string`,
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
const fixedArgs =
|
|
358
|
-
value.fixedArgs === undefined
|
|
359
|
-
? {}
|
|
360
|
-
: requireRecord(
|
|
361
|
-
value.fixedArgs,
|
|
362
|
-
`connecta.ui read binding ${JSON.stringify(name)} fixedArgs`,
|
|
363
|
-
);
|
|
364
|
-
const rawViewArgs = value.viewArgs ?? [];
|
|
365
|
-
if (!Array.isArray(rawViewArgs) || rawViewArgs.length > MAX_UI_VIEW_ARGS) {
|
|
366
|
-
throw new Error(
|
|
367
|
-
`connecta.ui read binding ${JSON.stringify(name)} viewArgs must be an array of at most ${MAX_UI_VIEW_ARGS} strings`,
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
const viewArgs = rawViewArgs.map((key) =>
|
|
371
|
-
requireUiReadKey(
|
|
372
|
-
key,
|
|
373
|
-
`connecta.ui read binding ${JSON.stringify(name)} viewArgs entry`,
|
|
374
|
-
)
|
|
375
|
-
);
|
|
376
|
-
if (new Set(viewArgs).size !== viewArgs.length) {
|
|
377
|
-
throw new Error(
|
|
378
|
-
`connecta.ui read binding ${JSON.stringify(name)} viewArgs must not repeat a key`,
|
|
379
|
-
);
|
|
380
|
-
}
|
|
381
|
-
for (const key of viewArgs) {
|
|
382
|
-
if (Object.prototype.hasOwnProperty.call(fixedArgs, key)) {
|
|
383
|
-
throw new Error(
|
|
384
|
-
`connecta.ui read binding ${JSON.stringify(name)} view argument ${JSON.stringify(key)} cannot override a fixed argument`,
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
reads[name] = {
|
|
389
|
-
address: value.address,
|
|
390
|
-
fixedArgs,
|
|
391
|
-
viewArgs,
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
return reads;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function requireUiPayload(values: unknown[]): UiPayload {
|
|
398
|
-
if (values.length !== 1 && values.length !== 2) {
|
|
399
|
-
throw new Error(
|
|
400
|
-
`${UI_SHAPE_HINT}; got ${values.length} arguments`,
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
const html = requireUiHtml(values[0]);
|
|
404
|
-
if (values.length === 1) return { html };
|
|
405
|
-
const options = requireRecord(values[1], "connecta.ui options");
|
|
406
|
-
requireExactKeys(options, ["reads"], "connecta.ui options");
|
|
407
|
-
if (!Object.prototype.hasOwnProperty.call(options, "reads")) {
|
|
408
|
-
throw new Error("connecta.ui options must contain reads");
|
|
409
|
-
}
|
|
410
|
-
return { html, reads: requireUiReads(options.reads) };
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Request-local collection for `connecta.emit` and `connecta.ui`. Budgets fail
|
|
415
|
-
* loudly at the crossing call — nothing is partially accepted and prior blocks
|
|
416
|
-
* are unaffected — so a program learns it is over budget while it can still
|
|
417
|
-
* choose differently (M5, U4). Accepted output never rides `ExecuteResult`; the
|
|
418
|
-
* handler that owns this collector delivers it on the final tool result.
|
|
419
|
-
*
|
|
420
|
-
* The two channels share the byte aggregate and nothing else: a UI payload is
|
|
421
|
-
* not a block, so it spends no block count, and at most one is ever accepted.
|
|
422
|
-
*/
|
|
423
|
-
export class EmitCollector {
|
|
424
|
-
readonly blocks: EmittedBlock[] = [];
|
|
425
|
-
/** The shared transport aggregate: emitted blocks plus the UI payload. */
|
|
426
|
-
bytes = 0;
|
|
427
|
-
/** The one accepted UI payload (U2), delivered in result `_meta` on success. */
|
|
428
|
-
ui?: UiPayload;
|
|
429
|
-
/** What the blocks alone cost, so the `emitted` aggregate stays a true pair. */
|
|
430
|
-
private blockBytes = 0;
|
|
431
|
-
constructor(
|
|
432
|
-
private readonly maxBytes: number,
|
|
433
|
-
private readonly maxBlocks: number,
|
|
434
|
-
private readonly diagnostics?: ExecuteDiagnostics,
|
|
435
|
-
) {}
|
|
436
|
-
|
|
437
|
-
accept(raw: unknown): void {
|
|
438
|
-
const block = requireEmittedBlock(raw);
|
|
439
|
-
if (this.blocks.length >= this.maxBlocks) {
|
|
440
|
-
throw new Error(
|
|
441
|
-
`connecta.emit block-count budget exceeded: ${this.maxBlocks} block(s) maximum, 0 remaining`,
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
const size = diagnosticsEncoder.encode(JSON.stringify(block)).byteLength;
|
|
445
|
-
if (this.bytes + size > this.maxBytes) {
|
|
446
|
-
throw new Error(
|
|
447
|
-
`connecta.emit byte budget exceeded: block is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`,
|
|
448
|
-
);
|
|
449
|
-
}
|
|
450
|
-
this.blocks.push(block);
|
|
451
|
-
this.bytes += size;
|
|
452
|
-
this.blockBytes += size;
|
|
453
|
-
this.diagnostics?.recordEmitted(this.blocks.length, this.blockBytes);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* U2 and U4: one payload per run, measured as the serialized bytes of
|
|
458
|
-
* `{ html }` against the same aggregate emit spends. A second call throws
|
|
459
|
-
* naming the constraint rather than replacing the first — one tool result
|
|
460
|
-
* renders one view, and last-wins would silently discard a payload the
|
|
461
|
-
* program deliberately supplied.
|
|
462
|
-
*
|
|
463
|
-
* Multiplicity is checked before shape, so the second call is told what it
|
|
464
|
-
* actually broke. A program whose second payload is also malformed has one
|
|
465
|
-
* problem worth naming — that there is a second payload at all — and a
|
|
466
|
-
* complaint about its type would send the author to fix the wrong thing.
|
|
467
|
-
*/
|
|
468
|
-
acceptUi(...values: unknown[]): void {
|
|
469
|
-
if (this.ui) {
|
|
470
|
-
throw new Error(
|
|
471
|
-
"connecta.ui accepts at most one payload per run: a view was already accepted and stands",
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
this.acceptUiPayload(requireUiPayload(values));
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
acceptUiPayload(payload: UiPayload): void {
|
|
478
|
-
if (this.ui) {
|
|
479
|
-
throw new Error(
|
|
480
|
-
"connecta.ui accepts at most one payload per run: a view was already accepted and stands",
|
|
481
|
-
);
|
|
482
|
-
}
|
|
483
|
-
let serialized: string;
|
|
484
|
-
try {
|
|
485
|
-
serialized = JSON.stringify(payload);
|
|
486
|
-
} catch {
|
|
487
|
-
throw new Error("connecta.ui payload must be JSON-serializable");
|
|
488
|
-
}
|
|
489
|
-
const size = diagnosticsEncoder.encode(serialized).byteLength;
|
|
490
|
-
if (this.bytes + size > this.maxBytes) {
|
|
491
|
-
throw new Error(
|
|
492
|
-
`connecta.ui byte budget exceeded: payload is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`,
|
|
493
|
-
);
|
|
494
|
-
}
|
|
495
|
-
this.ui = payload;
|
|
496
|
-
this.bytes += size;
|
|
497
|
-
this.diagnostics?.recordUi(size);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/** A configured emit budget must be a finite number >= 1; anything else falls back. */
|
|
502
|
-
function resolveEmitBudget(
|
|
503
|
-
value: number | undefined,
|
|
504
|
-
fallback: number,
|
|
505
|
-
): number {
|
|
506
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 1
|
|
507
|
-
? Math.trunc(value)
|
|
508
|
-
: fallback;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function serializedDiagnosticBytes(value: unknown): number {
|
|
512
|
-
try {
|
|
513
|
-
const text = JSON.stringify(value);
|
|
514
|
-
return text === undefined
|
|
515
|
-
? 0
|
|
516
|
-
: diagnosticsEncoder.encode(text).byteLength;
|
|
517
|
-
} catch {
|
|
518
|
-
// The executor's normal result guard owns the error. Diagnostics must
|
|
519
|
-
// never turn measurement into a second failure path.
|
|
520
|
-
return 0;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// deno-fmt-ignore
|
|
525
|
-
const RESERVED = new Set([
|
|
526
|
-
"break",
|
|
527
|
-
"case",
|
|
528
|
-
"catch",
|
|
529
|
-
"class",
|
|
530
|
-
"const",
|
|
531
|
-
"continue",
|
|
532
|
-
"debugger",
|
|
533
|
-
"default",
|
|
534
|
-
"delete",
|
|
535
|
-
"do",
|
|
536
|
-
"else",
|
|
537
|
-
"enum",
|
|
538
|
-
"export",
|
|
539
|
-
"extends",
|
|
540
|
-
"false",
|
|
541
|
-
"finally",
|
|
542
|
-
"for",
|
|
543
|
-
"function",
|
|
544
|
-
"if",
|
|
545
|
-
"import",
|
|
546
|
-
"in",
|
|
547
|
-
"instanceof",
|
|
548
|
-
"let",
|
|
549
|
-
"new",
|
|
550
|
-
"null",
|
|
551
|
-
"return",
|
|
552
|
-
"static",
|
|
553
|
-
"super",
|
|
554
|
-
"switch",
|
|
555
|
-
"this",
|
|
556
|
-
"throw",
|
|
557
|
-
"true",
|
|
558
|
-
"try",
|
|
559
|
-
"typeof",
|
|
560
|
-
"var",
|
|
561
|
-
"void",
|
|
562
|
-
"while",
|
|
563
|
-
"with",
|
|
564
|
-
"yield",
|
|
565
|
-
"await",
|
|
566
|
-
"async",
|
|
567
|
-
]);
|
|
568
|
-
|
|
569
|
-
/** Convert a connector/tool name into a valid JS identifier. */
|
|
570
|
-
export function sanitizeIdentifier(name: string): string {
|
|
571
|
-
let id = name.replace(/[^A-Za-z0-9_$]/g, "_");
|
|
572
|
-
if (/^[0-9]/.test(id)) id = `_${id}`;
|
|
573
|
-
if (RESERVED.has(id)) id = `${id}_`;
|
|
574
|
-
return id;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
const SANDBOX_RESERVED_NAMES = new Set([
|
|
578
|
-
"connecta",
|
|
579
|
-
"console",
|
|
580
|
-
"arguments",
|
|
581
|
-
"result",
|
|
582
|
-
"undefined",
|
|
583
|
-
"setTimeout",
|
|
584
|
-
"Promise",
|
|
585
|
-
"Error",
|
|
586
|
-
"WorkerEntrypoint",
|
|
587
|
-
"CodeExecutor",
|
|
588
|
-
"__invoke",
|
|
589
|
-
"__namespace",
|
|
590
|
-
"__call",
|
|
591
|
-
"__log",
|
|
592
|
-
"__dispatchers",
|
|
593
|
-
"__connectors",
|
|
594
|
-
"__logs",
|
|
595
|
-
"__CODEMODE_BINARY_TAG",
|
|
596
|
-
"__bytesToBase64",
|
|
597
|
-
"__base64ToBytes",
|
|
598
|
-
"__encodeCodemodeValue",
|
|
599
|
-
"__decodeCodemodeValue",
|
|
600
|
-
"__stringifyForCodemode",
|
|
601
|
-
"__parseForCodemode",
|
|
602
|
-
]);
|
|
603
|
-
|
|
604
|
-
function msg(err: unknown): string {
|
|
605
|
-
return err instanceof Error ? err.message : String(err);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function lazyNamespacePrelude(
|
|
609
|
-
connectors: Array<{ id: string; namespace: string }>,
|
|
610
|
-
): string {
|
|
611
|
-
const declarations = connectors
|
|
612
|
-
.map(
|
|
613
|
-
({ id, namespace }) =>
|
|
614
|
-
`globalThis[${JSON.stringify(namespace)}] = __makeConnectaNamespace(${JSON.stringify(id)});`,
|
|
615
|
-
)
|
|
616
|
-
.join("\n");
|
|
617
|
-
return `(() => {
|
|
618
|
-
const __makeConnectaNamespace = (connectorId) => Object.freeze(new Proxy(Object.create(null), {
|
|
619
|
-
get: (_target, toolName) => typeof toolName === "string"
|
|
620
|
-
? (args) => connecta.__callNamespace(connectorId, toolName, args)
|
|
621
|
-
: undefined
|
|
622
|
-
}));
|
|
623
|
-
${declarations}
|
|
624
|
-
})();`;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* Expose one fixed host provider plus trusted sandbox setup that creates a
|
|
629
|
-
* lazy proxy global per connector. No connector catalog is touched until code
|
|
630
|
-
* calls that namespace or explicitly asks search/describe.
|
|
631
|
-
*/
|
|
632
|
-
export async function buildSandboxProviders(
|
|
633
|
-
registry: RegistryView,
|
|
634
|
-
baseUrl: string,
|
|
635
|
-
logger: Logger,
|
|
636
|
-
activity?: ActivityRequestContext,
|
|
637
|
-
limits: {
|
|
638
|
-
signal?: AbortSignal;
|
|
639
|
-
maxHostCalls?: number;
|
|
640
|
-
hostCallTimeoutMs?: number;
|
|
641
|
-
discoveryConcurrency?: number;
|
|
642
|
-
/** Per-connector deadline for in-program catalog probes. Default 30_000. */
|
|
643
|
-
probeTimeoutMs?: number;
|
|
644
|
-
onInvocationFailure?: (failure: InvocationFailure) => void;
|
|
645
|
-
diagnostics?: ExecuteDiagnostics;
|
|
646
|
-
/**
|
|
647
|
-
* Where `connecta.emit` collects. The handler that will deliver the
|
|
648
|
-
* blocks owns it; without one, emit fails loudly rather than accept
|
|
649
|
-
* blocks nobody will ever return.
|
|
650
|
-
*/
|
|
651
|
-
emitCollector?: EmitCollector;
|
|
652
|
-
} = {},
|
|
653
|
-
): Promise<ExecutorProvider[]> {
|
|
654
|
-
// All host calls made by one execute_code invocation share a downstream
|
|
655
|
-
// connection, while a later invocation receives a fresh request scope.
|
|
656
|
-
const requestScope = {};
|
|
657
|
-
const catalog = new CatalogService(registry, baseUrl, {
|
|
658
|
-
requestScope,
|
|
659
|
-
// A program that just missed an address cannot call search_tools.
|
|
660
|
-
searchRoute: "connecta.search",
|
|
661
|
-
...(limits.discoveryConcurrency !== undefined
|
|
662
|
-
? { concurrency: limits.discoveryConcurrency }
|
|
663
|
-
: {}),
|
|
664
|
-
...(limits.probeTimeoutMs !== undefined
|
|
665
|
-
? { probeTimeoutMs: limits.probeTimeoutMs }
|
|
666
|
-
: {}),
|
|
667
|
-
});
|
|
668
|
-
const invocation = new InvocationService(registry, catalog, activity);
|
|
669
|
-
const maxHostCalls = Math.max(
|
|
670
|
-
1,
|
|
671
|
-
Math.trunc(limits.maxHostCalls ?? EXECUTE_MAX_HOST_CALLS),
|
|
672
|
-
);
|
|
673
|
-
const hostCallTimeoutMs = Math.max(
|
|
674
|
-
1,
|
|
675
|
-
Math.trunc(limits.hostCallTimeoutMs ?? EXECUTE_HOST_CALL_TIMEOUT_MS),
|
|
676
|
-
);
|
|
677
|
-
let hostCalls = 0;
|
|
678
|
-
const connectors = registry.listConnectors();
|
|
679
|
-
const namespaces: Array<{ id: string; namespace: string }> = [];
|
|
680
|
-
const namespaceOwners = new Map<string, string>();
|
|
681
|
-
for (const connector of connectors) {
|
|
682
|
-
const namespace = sanitizeIdentifier(connector.id);
|
|
683
|
-
const owner = namespaceOwners.get(namespace);
|
|
684
|
-
const problem = SANDBOX_RESERVED_NAMES.has(namespace)
|
|
685
|
-
? `Connector "${connector.id}" sanitizes to reserved execute_code namespace "${namespace}". Rename or exclude the connector before using execute_code.`
|
|
686
|
-
: owner
|
|
687
|
-
? `Connector ids "${owner}" and "${connector.id}" both sanitize to execute_code namespace "${namespace}". Rename or exclude one connector before using execute_code.`
|
|
688
|
-
: undefined;
|
|
689
|
-
if (problem) {
|
|
690
|
-
logger.warn(`[connecta] execute_code: ${problem}`);
|
|
691
|
-
throw new Error(problem);
|
|
692
|
-
}
|
|
693
|
-
namespaceOwners.set(namespace, connector.id);
|
|
694
|
-
namespaces.push({ id: connector.id, namespace });
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
const invocationContext = () => ({
|
|
698
|
-
source: "execute_code" as const,
|
|
699
|
-
timeoutMs: hostCallTimeoutMs,
|
|
700
|
-
...(limits.signal !== undefined ? { requestSignal: limits.signal } : {}),
|
|
701
|
-
unwrapResult: true,
|
|
702
|
-
beforeDispatch: () => {
|
|
703
|
-
hostCalls++;
|
|
704
|
-
if (hostCalls > maxHostCalls) {
|
|
705
|
-
throw new Error(
|
|
706
|
-
`execute_code host-call budget exceeded (${maxHostCalls} calls maximum)`,
|
|
707
|
-
);
|
|
708
|
-
}
|
|
709
|
-
},
|
|
710
|
-
});
|
|
711
|
-
/**
|
|
712
|
-
* A discovery bound is as typed a failure as a tool call is, and a program
|
|
713
|
-
* that lets one escape deserves the same envelope: register it on the same
|
|
714
|
-
* request-local channel so an unhandled `invalid_args`/`result_too_large`
|
|
715
|
-
* reaches the model with its code instead of as prose. The guest still sees
|
|
716
|
-
* only the message — that is the bridge's limit, not a policy.
|
|
717
|
-
*/
|
|
718
|
-
const typedDiscovery = async <T>(operation: () => Promise<T>): Promise<T> => {
|
|
719
|
-
try {
|
|
720
|
-
return await operation();
|
|
721
|
-
} catch (err) {
|
|
722
|
-
if (err instanceof DiscoveryPolicyError) {
|
|
723
|
-
limits.onInvocationFailure?.(
|
|
724
|
-
new InvocationFailure({
|
|
725
|
-
code: err.code,
|
|
726
|
-
message: err.message,
|
|
727
|
-
retryable: false,
|
|
728
|
-
}),
|
|
729
|
-
);
|
|
730
|
-
}
|
|
731
|
-
throw err;
|
|
732
|
-
}
|
|
733
|
-
};
|
|
734
|
-
const callAddress = async (
|
|
735
|
-
address: unknown,
|
|
736
|
-
args: unknown,
|
|
737
|
-
diagnosticOperation: "call" | "batch" = "call",
|
|
738
|
-
) => {
|
|
739
|
-
const outcome = await invocation.invoke(
|
|
740
|
-
String(address),
|
|
741
|
-
args ?? {},
|
|
742
|
-
invocationContext(),
|
|
743
|
-
);
|
|
744
|
-
limits.diagnostics?.recordCall(diagnosticOperation, outcome);
|
|
745
|
-
if (!outcome.ok) {
|
|
746
|
-
const failure = new InvocationFailure(outcome.error);
|
|
747
|
-
limits.onInvocationFailure?.(failure);
|
|
748
|
-
throw failure;
|
|
749
|
-
}
|
|
750
|
-
return outcome.value;
|
|
751
|
-
};
|
|
752
|
-
const callNamespace = async (
|
|
753
|
-
connectorId: unknown,
|
|
754
|
-
toolAlias: unknown,
|
|
755
|
-
args: unknown,
|
|
756
|
-
) => {
|
|
757
|
-
const outcome = await invocation.invokeToolAlias(
|
|
758
|
-
String(connectorId),
|
|
759
|
-
String(toolAlias),
|
|
760
|
-
sanitizeIdentifier,
|
|
761
|
-
args ?? {},
|
|
762
|
-
invocationContext(),
|
|
763
|
-
);
|
|
764
|
-
limits.diagnostics?.recordCall("call", outcome);
|
|
765
|
-
if (!outcome.ok) {
|
|
766
|
-
const failure = new InvocationFailure(outcome.error);
|
|
767
|
-
limits.onInvocationFailure?.(failure);
|
|
768
|
-
throw failure;
|
|
769
|
-
}
|
|
770
|
-
return outcome.value;
|
|
771
|
-
};
|
|
772
|
-
|
|
773
|
-
/**
|
|
774
|
-
* A read binding is admitted while the program still owns the request. The
|
|
775
|
-
* shell later calls the ordinary `call_tool`, which repeats this same
|
|
776
|
-
* fail-closed check against the then-current catalog; validating here keeps
|
|
777
|
-
* a typo or destructive address from producing a view whose controls can
|
|
778
|
-
* never work, while validation at use keeps a stale view from retaining old
|
|
779
|
-
* authority.
|
|
780
|
-
*/
|
|
781
|
-
const validateUiReads = async (payload: UiPayload): Promise<UiPayload> => {
|
|
782
|
-
if (!payload.reads) return payload;
|
|
783
|
-
const reads = Object.create(null) as Record<string, UiReadBinding>;
|
|
784
|
-
for (const [name, binding] of Object.entries(payload.reads)) {
|
|
785
|
-
const resolution = await catalog.resolveTool(
|
|
786
|
-
binding.address,
|
|
787
|
-
limits.signal !== undefined ? { signal: limits.signal } : {},
|
|
788
|
-
);
|
|
789
|
-
if (!resolution.ok) {
|
|
790
|
-
throw new Error(
|
|
791
|
-
`connecta.ui read binding ${JSON.stringify(name)} could not resolve ${JSON.stringify(binding.address)}: ${resolution.error.message}`,
|
|
792
|
-
);
|
|
793
|
-
}
|
|
794
|
-
if (!isExplicitlyReadOnly(resolution.resolved.definition)) {
|
|
795
|
-
throw new Error(
|
|
796
|
-
`connecta.ui read binding ${JSON.stringify(name)} refuses ${JSON.stringify(binding.address)}: the tool is not explicitly read-only`,
|
|
797
|
-
);
|
|
798
|
-
}
|
|
799
|
-
reads[name] = {
|
|
800
|
-
...binding,
|
|
801
|
-
address:
|
|
802
|
-
`${resolution.resolved.connector.id}.${resolution.resolved.toolName}`,
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
return { html: payload.html, reads };
|
|
806
|
-
};
|
|
807
|
-
|
|
808
|
-
return [
|
|
809
|
-
{
|
|
810
|
-
name: "connecta",
|
|
811
|
-
prelude: lazyNamespacePrelude(namespaces),
|
|
812
|
-
fns: {
|
|
813
|
-
__callNamespace: callNamespace,
|
|
814
|
-
call: (address: unknown, args: unknown) =>
|
|
815
|
-
callAddress(address, args),
|
|
816
|
-
// Emission is a provider function, never an ExecuteResult field —
|
|
817
|
-
// that is what keeps the Executor contract untouched and parity
|
|
818
|
-
// structural (M8). It spends no host-call budget (M7); its own
|
|
819
|
-
// budgets live in the collector.
|
|
820
|
-
emit: async (block: unknown) => {
|
|
821
|
-
if (!limits.emitCollector) {
|
|
822
|
-
throw new Error(
|
|
823
|
-
"connecta.emit is unavailable: no emission collector was configured for this execution",
|
|
824
|
-
);
|
|
825
|
-
}
|
|
826
|
-
limits.emitCollector.accept(block);
|
|
827
|
-
},
|
|
828
|
-
// The rendered-output channel rides the same bridge for the same
|
|
829
|
-
// reason (U7): one more provider fn, no change to ExecuteResult or
|
|
830
|
-
// the Executor contract. Delivery is the handler's job, not the
|
|
831
|
-
// guest's — nothing here becomes addressable.
|
|
832
|
-
ui: async (...values: unknown[]) => {
|
|
833
|
-
if (!limits.emitCollector) {
|
|
834
|
-
throw new Error(
|
|
835
|
-
"connecta.ui is unavailable: no emission collector was configured for this execution",
|
|
836
|
-
);
|
|
837
|
-
}
|
|
838
|
-
const payload = await validateUiReads(requireUiPayload(values));
|
|
839
|
-
limits.emitCollector.acceptUiPayload(payload);
|
|
840
|
-
},
|
|
841
|
-
batch: async (calls: unknown) => {
|
|
842
|
-
const started = Date.now();
|
|
843
|
-
const callCount = Array.isArray(calls) ? calls.length : 0;
|
|
844
|
-
try {
|
|
845
|
-
if (!Array.isArray(calls)) throw new Error("calls must be an array");
|
|
846
|
-
if (calls.length > EXECUTE_MAX_BATCH_CALLS) {
|
|
847
|
-
throw new Error(
|
|
848
|
-
`connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS} calls`,
|
|
849
|
-
);
|
|
850
|
-
}
|
|
851
|
-
const result = await Promise.all(
|
|
852
|
-
calls.map(async (call) => {
|
|
853
|
-
const item = call as { address?: unknown; args?: unknown };
|
|
854
|
-
try {
|
|
855
|
-
return {
|
|
856
|
-
address: String(item.address),
|
|
857
|
-
ok: true,
|
|
858
|
-
data: await callAddress(
|
|
859
|
-
item.address,
|
|
860
|
-
item.args,
|
|
861
|
-
"batch",
|
|
862
|
-
),
|
|
863
|
-
};
|
|
864
|
-
} catch (err) {
|
|
865
|
-
// The failure shape connecta.batch reports: the message a
|
|
866
|
-
// program can log, plus the typed details it must classify by. A
|
|
867
|
-
// thrown host error crosses the sandbox bridge as a bare
|
|
868
|
-
// message string in every executor, so this is the one place a
|
|
869
|
-
// program can tell a policy refusal from a transient failure.
|
|
870
|
-
const details =
|
|
871
|
-
err instanceof InvocationFailure
|
|
872
|
-
? err.details
|
|
873
|
-
: classifyCallError(err, "batch_call_failed");
|
|
874
|
-
return {
|
|
875
|
-
address: String(item.address),
|
|
876
|
-
ok: false,
|
|
877
|
-
error: details.message,
|
|
878
|
-
errorDetails: details,
|
|
879
|
-
};
|
|
880
|
-
}
|
|
881
|
-
}),
|
|
882
|
-
);
|
|
883
|
-
limits.diagnostics?.recordBatch(
|
|
884
|
-
Date.now() - started,
|
|
885
|
-
true,
|
|
886
|
-
callCount,
|
|
887
|
-
result,
|
|
888
|
-
);
|
|
889
|
-
return result;
|
|
890
|
-
} catch (err) {
|
|
891
|
-
limits.diagnostics?.recordBatch(
|
|
892
|
-
Date.now() - started,
|
|
893
|
-
false,
|
|
894
|
-
callCount,
|
|
895
|
-
);
|
|
896
|
-
throw err;
|
|
897
|
-
}
|
|
898
|
-
},
|
|
899
|
-
search: async (raw: unknown) => {
|
|
900
|
-
const started = Date.now();
|
|
901
|
-
try {
|
|
902
|
-
const result = await typedDiscovery(async () => {
|
|
903
|
-
const args = (raw ?? {}) as {
|
|
904
|
-
query?: string;
|
|
905
|
-
connector?: string;
|
|
906
|
-
safety?: "readOnly" | "approvalRequired" | "all";
|
|
907
|
-
limit?: number;
|
|
908
|
-
offset?: number;
|
|
909
|
-
fullDescriptions?: boolean;
|
|
910
|
-
includeSchemas?: "compact" | "json";
|
|
911
|
-
includeSchemaKeys?: boolean;
|
|
912
|
-
};
|
|
913
|
-
const result = flatSearchResult(
|
|
914
|
-
await catalog.search({
|
|
915
|
-
...args,
|
|
916
|
-
// Key metadata rides along with schemas by default, since that
|
|
917
|
-
// is the whole point of it in code mode. It stays opt-out
|
|
918
|
-
// because it counts against the same discovery-byte ceiling.
|
|
919
|
-
includeSchemaKeys: args.includeSchemaKeys !== false,
|
|
920
|
-
}),
|
|
921
|
-
);
|
|
922
|
-
boundedDiscoveryText(
|
|
923
|
-
result,
|
|
924
|
-
"Request a smaller limit, omit fullDescriptions, use compact schemas, or pass includeSchemaKeys: false.",
|
|
925
|
-
);
|
|
926
|
-
return result;
|
|
927
|
-
});
|
|
928
|
-
limits.diagnostics?.recordCatalog(
|
|
929
|
-
"search",
|
|
930
|
-
Date.now() - started,
|
|
931
|
-
true,
|
|
932
|
-
result,
|
|
933
|
-
);
|
|
934
|
-
return result;
|
|
935
|
-
} catch (err) {
|
|
936
|
-
limits.diagnostics?.recordCatalog(
|
|
937
|
-
"search",
|
|
938
|
-
Date.now() - started,
|
|
939
|
-
false,
|
|
940
|
-
);
|
|
941
|
-
throw err;
|
|
942
|
-
}
|
|
943
|
-
},
|
|
944
|
-
describe: async (raw: unknown) => {
|
|
945
|
-
const started = Date.now();
|
|
946
|
-
try {
|
|
947
|
-
const result = await typedDiscovery(async () => {
|
|
948
|
-
const args = (raw ?? {}) as {
|
|
949
|
-
address?: unknown;
|
|
950
|
-
addresses?: unknown;
|
|
951
|
-
format?: "compact" | "json";
|
|
952
|
-
fullDescriptions?: boolean;
|
|
953
|
-
};
|
|
954
|
-
const result = { tools: await catalog.describe(args) };
|
|
955
|
-
boundedDiscoveryText(
|
|
956
|
-
result,
|
|
957
|
-
'Split the address list or use format: "compact".',
|
|
958
|
-
);
|
|
959
|
-
return result;
|
|
960
|
-
});
|
|
961
|
-
limits.diagnostics?.recordCatalog(
|
|
962
|
-
"describe",
|
|
963
|
-
Date.now() - started,
|
|
964
|
-
true,
|
|
965
|
-
result,
|
|
966
|
-
);
|
|
967
|
-
return result;
|
|
968
|
-
} catch (err) {
|
|
969
|
-
limits.diagnostics?.recordCatalog(
|
|
970
|
-
"describe",
|
|
971
|
-
Date.now() - started,
|
|
972
|
-
false,
|
|
973
|
-
);
|
|
974
|
-
throw err;
|
|
975
|
-
}
|
|
976
|
-
},
|
|
977
|
-
},
|
|
978
|
-
},
|
|
979
|
-
];
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
/** The execute_code handler. Exported for direct testing. */
|
|
983
|
-
export function createExecuteTool(
|
|
984
|
-
registry: RegistryView,
|
|
985
|
-
baseUrl: string,
|
|
986
|
-
executor: Executor,
|
|
987
|
-
logger: Logger,
|
|
988
|
-
activity?: ActivityRequestContext,
|
|
989
|
-
config: {
|
|
990
|
-
discoveryConcurrency?: number;
|
|
991
|
-
probeTimeoutMs?: number;
|
|
992
|
-
maxEmittedBytes?: number;
|
|
993
|
-
maxEmittedBlocks?: number;
|
|
994
|
-
} = {},
|
|
995
|
-
) {
|
|
996
|
-
return async (
|
|
997
|
-
{ code, diagnostics: diagnosticsRequested }: {
|
|
998
|
-
code: string;
|
|
999
|
-
diagnostics?: boolean;
|
|
1000
|
-
},
|
|
1001
|
-
options: { signal?: AbortSignal } = {},
|
|
1002
|
-
): Promise<ToolResult> => {
|
|
1003
|
-
const controller = new AbortController();
|
|
1004
|
-
const forwardAbort = () => controller.abort(options.signal?.reason);
|
|
1005
|
-
if (options.signal?.aborted) forwardAbort();
|
|
1006
|
-
else {
|
|
1007
|
-
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
1008
|
-
}
|
|
1009
|
-
let lease;
|
|
1010
|
-
let outcome;
|
|
1011
|
-
const diagnostics = diagnosticsRequested ? new ExecuteDiagnostics() : undefined;
|
|
1012
|
-
const emitted = new EmitCollector(
|
|
1013
|
-
resolveEmitBudget(config.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES),
|
|
1014
|
-
resolveEmitBudget(config.maxEmittedBlocks, EXECUTE_MAX_EMITTED_BLOCKS),
|
|
1015
|
-
diagnostics,
|
|
1016
|
-
);
|
|
1017
|
-
const invocationFailures: InvocationFailure[] = [];
|
|
1018
|
-
try {
|
|
1019
|
-
// Admission comes before provider construction: queued calls retain no
|
|
1020
|
-
// catalogs, request scopes, or one-closure-per-tool provider arrays.
|
|
1021
|
-
if (isAdmittingExecutor(executor)) {
|
|
1022
|
-
const admissionStarted = Date.now();
|
|
1023
|
-
try {
|
|
1024
|
-
lease = await executor.acquire({ signal: controller.signal });
|
|
1025
|
-
} finally {
|
|
1026
|
-
if (diagnostics) {
|
|
1027
|
-
diagnostics.admissionMs = Date.now() - admissionStarted;
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
if ((lease.waitMs ?? 0) > 0) {
|
|
1031
|
-
logger.debug("[connecta] execute_code admitted after queue wait", {
|
|
1032
|
-
waitMs: lease.waitMs,
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
const setupStarted = Date.now();
|
|
1037
|
-
let providers: ExecutorProvider[];
|
|
1038
|
-
try {
|
|
1039
|
-
providers = await buildSandboxProviders(
|
|
1040
|
-
registry,
|
|
1041
|
-
baseUrl,
|
|
1042
|
-
logger,
|
|
1043
|
-
activity,
|
|
1044
|
-
{
|
|
1045
|
-
signal: controller.signal,
|
|
1046
|
-
onInvocationFailure: (failure) => {
|
|
1047
|
-
invocationFailures.push(failure);
|
|
1048
|
-
},
|
|
1049
|
-
emitCollector: emitted,
|
|
1050
|
-
...(diagnostics ? { diagnostics } : {}),
|
|
1051
|
-
...(config.discoveryConcurrency !== undefined
|
|
1052
|
-
? { discoveryConcurrency: config.discoveryConcurrency }
|
|
1053
|
-
: {}),
|
|
1054
|
-
...(config.probeTimeoutMs !== undefined
|
|
1055
|
-
? { probeTimeoutMs: config.probeTimeoutMs }
|
|
1056
|
-
: {}),
|
|
1057
|
-
},
|
|
1058
|
-
);
|
|
1059
|
-
} finally {
|
|
1060
|
-
if (diagnostics) diagnostics.setupMs = Date.now() - setupStarted;
|
|
1061
|
-
}
|
|
1062
|
-
if (controller.signal.aborted) {
|
|
1063
|
-
throw new ExecutorAdmissionError(
|
|
1064
|
-
"executor_cancelled",
|
|
1065
|
-
"Execution was cancelled during sandbox setup.",
|
|
1066
|
-
);
|
|
1067
|
-
}
|
|
1068
|
-
const executorStarted = Date.now();
|
|
1069
|
-
try {
|
|
1070
|
-
outcome = lease
|
|
1071
|
-
? await lease.execute(code, providers)
|
|
1072
|
-
: await executor.execute(code, providers);
|
|
1073
|
-
} finally {
|
|
1074
|
-
if (diagnostics) {
|
|
1075
|
-
diagnostics.executorWallMs = Date.now() - executorStarted;
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
} catch (err) {
|
|
1079
|
-
if (err instanceof ExecutorAdmissionError) {
|
|
1080
|
-
if (err.code === "executor_overloaded") {
|
|
1081
|
-
logger.warn("[connecta] execute_code admission rejected", {
|
|
1082
|
-
code: err.code,
|
|
1083
|
-
retryAfterMs: err.retryAfterMs,
|
|
1084
|
-
});
|
|
1085
|
-
}
|
|
1086
|
-
const result = jsonResult({
|
|
1087
|
-
error: {
|
|
1088
|
-
code: err.code,
|
|
1089
|
-
message: err.message,
|
|
1090
|
-
retryable: err.retryable,
|
|
1091
|
-
...(err.retryAfterMs !== undefined
|
|
1092
|
-
? { retryAfterMs: err.retryAfterMs }
|
|
1093
|
-
: {}),
|
|
1094
|
-
},
|
|
1095
|
-
...(err instanceof ExecutorExecutionError
|
|
1096
|
-
? discardedEmits(emitted)
|
|
1097
|
-
: {}),
|
|
1098
|
-
...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
|
|
1099
|
-
});
|
|
1100
|
-
result.isError = true;
|
|
1101
|
-
return result;
|
|
1102
|
-
}
|
|
1103
|
-
if (diagnostics) {
|
|
1104
|
-
const result = jsonResult({
|
|
1105
|
-
error: {
|
|
1106
|
-
code: "executor_failed",
|
|
1107
|
-
message: `Executor failed: ${msg(err)}`,
|
|
1108
|
-
retryable: false,
|
|
1109
|
-
},
|
|
1110
|
-
...discardedEmits(emitted),
|
|
1111
|
-
diagnostics: diagnostics.finish(),
|
|
1112
|
-
});
|
|
1113
|
-
result.isError = true;
|
|
1114
|
-
return result;
|
|
1115
|
-
}
|
|
1116
|
-
return errorResult(
|
|
1117
|
-
`Executor failed: ${msg(err)}${discardedEmitsText(emitted)}`,
|
|
1118
|
-
);
|
|
1119
|
-
} finally {
|
|
1120
|
-
// A sandbox timeout or early return must also release any outstanding
|
|
1121
|
-
// host waits and signal cooperative connectors to stop their work.
|
|
1122
|
-
controller.abort();
|
|
1123
|
-
lease?.release();
|
|
1124
|
-
options.signal?.removeEventListener("abort", forwardAbort);
|
|
1125
|
-
}
|
|
1126
|
-
const logs =
|
|
1127
|
-
outcome.logs && outcome.logs.length > 0
|
|
1128
|
-
? truncateExecuteText(
|
|
1129
|
-
outcome.logs.join("\n"),
|
|
1130
|
-
MAX_EXECUTE_LOG_CHARS,
|
|
1131
|
-
)
|
|
1132
|
-
: undefined;
|
|
1133
|
-
if (outcome.error) {
|
|
1134
|
-
// Executor bridges necessarily reduce thrown host errors to strings.
|
|
1135
|
-
// Match that terminal string back to the request-local typed failure so
|
|
1136
|
-
// an unhandled tool failure keeps the same structured contract as
|
|
1137
|
-
// call_tool. Failures caught by model code never reach
|
|
1138
|
-
// outcome.error and therefore remain under that code's control.
|
|
1139
|
-
//
|
|
1140
|
-
// An error the program let through unchanged matches exactly, and an
|
|
1141
|
-
// exact match always wins: a program that wrapped one failure's message
|
|
1142
|
-
// around another's must not have the wrong type attached. Containment is
|
|
1143
|
-
// the fallback, so a wrapped message still reports its underlying type
|
|
1144
|
-
// rather than losing it to prose.
|
|
1145
|
-
let invocationFailure: InvocationFailure | undefined;
|
|
1146
|
-
for (const match of [
|
|
1147
|
-
(candidate: InvocationFailure) => outcome.error === candidate.message,
|
|
1148
|
-
(candidate: InvocationFailure) =>
|
|
1149
|
-
outcome.error?.includes(candidate.message) === true,
|
|
1150
|
-
]) {
|
|
1151
|
-
for (let i = invocationFailures.length - 1; i >= 0; i--) {
|
|
1152
|
-
const candidate = invocationFailures[i];
|
|
1153
|
-
if (candidate && match(candidate)) {
|
|
1154
|
-
invocationFailure = candidate;
|
|
1155
|
-
break;
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
if (invocationFailure) break;
|
|
1159
|
-
}
|
|
1160
|
-
if (invocationFailure) {
|
|
1161
|
-
// Handed back whole, with no size guard of its own — the failure was
|
|
1162
|
-
// framed with bounded caller text (`boundedEchoText`) precisely so
|
|
1163
|
-
// this path never needs one. Adding a cap here instead would leave the
|
|
1164
|
-
// top-level surfaces, which have the same amplification, uncovered.
|
|
1165
|
-
const result = jsonResult({
|
|
1166
|
-
error: invocationFailure.details,
|
|
1167
|
-
...(logs ? { logs } : {}),
|
|
1168
|
-
...discardedEmits(emitted),
|
|
1169
|
-
...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
|
|
1170
|
-
});
|
|
1171
|
-
result.isError = true;
|
|
1172
|
-
return result;
|
|
1173
|
-
}
|
|
1174
|
-
const message = `Error: ${outcome.error}`;
|
|
1175
|
-
if (diagnostics) {
|
|
1176
|
-
const result = jsonResult({
|
|
1177
|
-
error: {
|
|
1178
|
-
code: "executor_failed",
|
|
1179
|
-
message,
|
|
1180
|
-
retryable: false,
|
|
1181
|
-
},
|
|
1182
|
-
...(logs ? { logs } : {}),
|
|
1183
|
-
...discardedEmits(emitted),
|
|
1184
|
-
diagnostics: diagnostics.finish(),
|
|
1185
|
-
});
|
|
1186
|
-
result.isError = true;
|
|
1187
|
-
return result;
|
|
1188
|
-
}
|
|
1189
|
-
return errorResult(
|
|
1190
|
-
`${message}${logs ? `\n\nLogs:\n${logs}` : ""}${discardedEmitsText(emitted)}`,
|
|
1191
|
-
);
|
|
1192
|
-
}
|
|
1193
|
-
// A result crossing back as a host BigInt (or otherwise unserializable
|
|
1194
|
-
// value) makes JSON.stringify throw — keep that inside the structured
|
|
1195
|
-
// error path so captured logs survive instead of a raw SDK 500.
|
|
1196
|
-
let result: unknown;
|
|
1197
|
-
try {
|
|
1198
|
-
result = guardExecuteResultValue(outcome.result);
|
|
1199
|
-
} catch (err) {
|
|
1200
|
-
const message = `Error: result is not JSON-serializable: ${msg(err)}`;
|
|
1201
|
-
if (diagnostics) {
|
|
1202
|
-
const response = jsonResult({
|
|
1203
|
-
error: {
|
|
1204
|
-
code: "executor_failed",
|
|
1205
|
-
message,
|
|
1206
|
-
retryable: false,
|
|
1207
|
-
},
|
|
1208
|
-
...(logs ? { logs } : {}),
|
|
1209
|
-
...discardedEmits(emitted),
|
|
1210
|
-
diagnostics: diagnostics.finish(),
|
|
1211
|
-
});
|
|
1212
|
-
response.isError = true;
|
|
1213
|
-
return response;
|
|
1214
|
-
}
|
|
1215
|
-
return errorResult(
|
|
1216
|
-
`${message}${logs ? `\n\nLogs:\n${logs}` : ""}${discardedEmitsText(emitted)}`,
|
|
1217
|
-
);
|
|
1218
|
-
}
|
|
1219
|
-
const response = jsonResult({
|
|
1220
|
-
result,
|
|
1221
|
-
...(emitted.blocks.length > 0 ? { emitted: emitted.blocks.length } : {}),
|
|
1222
|
-
// U3: the model learns a view rendered without seeing its bytes. Spread
|
|
1223
|
-
// conditionally so a program that never called connecta.ui produces
|
|
1224
|
-
// today's byte-for-byte response.
|
|
1225
|
-
...(emitted.ui ? { ui: true } : {}),
|
|
1226
|
-
...(logs ? { logs } : {}),
|
|
1227
|
-
...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
|
|
1228
|
-
});
|
|
1229
|
-
if (emitted.ui) {
|
|
1230
|
-
// _meta is where the Apps spec's best practices put data "not intended
|
|
1231
|
-
// for model context", and how shipped hosts behave. The shell reads
|
|
1232
|
-
// exactly this key out of the tool result the host delivers to it.
|
|
1233
|
-
response._meta = { [PROGRAM_UI_META_KEY]: emitted.ui };
|
|
1234
|
-
}
|
|
1235
|
-
if (emitted.blocks.length > 0) {
|
|
1236
|
-
// Emitted image/audio blocks are valid MCP content that ToolResult's
|
|
1237
|
-
// text-only typing does not model — the same acknowledged gap
|
|
1238
|
-
// guardContent lives with for downstream block passthrough.
|
|
1239
|
-
response.content.push(
|
|
1240
|
-
...(emitted.blocks as unknown as typeof response.content),
|
|
1241
|
-
);
|
|
1242
|
-
}
|
|
1243
|
-
return response;
|
|
1244
|
-
};
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
/**
|
|
1248
|
-
* M4 and U3: a failed program delivers no blocks and no view, but each
|
|
1249
|
-
* discard is visible — and one failure can discard both.
|
|
1250
|
-
*/
|
|
1251
|
-
function discardedEmits(emitted: EmitCollector): {
|
|
1252
|
-
emittedDiscarded?: number;
|
|
1253
|
-
uiDiscarded?: true;
|
|
1254
|
-
} {
|
|
1255
|
-
return {
|
|
1256
|
-
...(emitted.blocks.length > 0
|
|
1257
|
-
? { emittedDiscarded: emitted.blocks.length }
|
|
1258
|
-
: {}),
|
|
1259
|
-
...(emitted.ui ? { uiDiscarded: true as const } : {}),
|
|
1260
|
-
};
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
/** The same visibility for the plain-text error paths. */
|
|
1264
|
-
function discardedEmitsText(emitted: EmitCollector): string {
|
|
1265
|
-
const lines = [
|
|
1266
|
-
...(emitted.blocks.length > 0
|
|
1267
|
-
? [`emittedDiscarded: ${emitted.blocks.length}`]
|
|
1268
|
-
: []),
|
|
1269
|
-
...(emitted.ui ? ["uiDiscarded: true"] : []),
|
|
1270
|
-
];
|
|
1271
|
-
return lines.length > 0 ? `\n\n${lines.join("\n")}` : "";
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
const executeDescription = (
|
|
1275
|
-
emitBudgets: { maxBytes: number; maxBlocks: number },
|
|
1276
|
-
connectorGuides: boolean,
|
|
1277
|
-
) => `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.
|
|
1278
|
-
|
|
1279
|
-
Write an async arrow function. NO network, filesystem, timers, or imports; only:
|
|
1280
|
-
- 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.
|
|
1281
|
-
- 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.
|
|
1282
|
-
- top-level search_tools returns { connectors: [{ id, tools }], total, offset, limit, hasMore }; connecta.search returns { tools, total, offset, limit, hasMore }; connecta.describe returns { tools }.
|
|
1283
|
-
- 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." : ""}
|
|
1284
|
-
- 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.
|
|
1285
|
-
- 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.
|
|
1286
|
-
- console.log(...) — captured and returned with the result.
|
|
1287
|
-
|
|
1288
|
-
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]; }
|
|
1289
|
-
|
|
1290
|
-
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.
|
|
1291
|
-
|
|
1292
|
-
Plain JS, no TypeScript. Compact schemas are TypeScript-like, not JSON Schema: write the property names they display; never guess positions or aliases.`;
|
|
1293
|
-
|
|
1294
|
-
/** Register the execute_code meta-tool. Only called when an executor is configured. */
|
|
1295
|
-
export function registerExecuteTool(
|
|
1296
|
-
server: McpServer,
|
|
1297
|
-
registry: RegistryView,
|
|
1298
|
-
ctx: {
|
|
1299
|
-
baseUrl: string;
|
|
1300
|
-
executor: Executor;
|
|
1301
|
-
logger: Logger;
|
|
1302
|
-
activity?: ActivityRequestContext;
|
|
1303
|
-
requestSignal?: AbortSignal;
|
|
1304
|
-
discoveryConcurrency?: number;
|
|
1305
|
-
/**
|
|
1306
|
-
* The deployment's configured per-connector probe deadline. Programs probe
|
|
1307
|
-
* the same downstream catalogs the top-level tools do, so an operator who
|
|
1308
|
-
* tightened `discovery.probeTimeoutMs` gets it honored inside the sandbox
|
|
1309
|
-
* too rather than silently falling back to the 30s default.
|
|
1310
|
-
*/
|
|
1311
|
-
probeTimeoutMs?: number;
|
|
1312
|
-
/** Aggregate serialized-byte budget for connecta.emit. Default 4_000_000. */
|
|
1313
|
-
maxEmittedBytes?: number;
|
|
1314
|
-
/** Block-count budget for connecta.emit. Default 32. */
|
|
1315
|
-
maxEmittedBlocks?: number;
|
|
1316
|
-
},
|
|
1317
|
-
): void {
|
|
1318
|
-
// Resolved once so the description and the collector cannot disagree about
|
|
1319
|
-
// the budgets this deployment actually enforces.
|
|
1320
|
-
const emitBudgets = {
|
|
1321
|
-
maxBytes: resolveEmitBudget(ctx.maxEmittedBytes, EXECUTE_MAX_EMITTED_BYTES),
|
|
1322
|
-
maxBlocks: resolveEmitBudget(
|
|
1323
|
-
ctx.maxEmittedBlocks,
|
|
1324
|
-
EXECUTE_MAX_EMITTED_BLOCKS,
|
|
1325
|
-
),
|
|
1326
|
-
};
|
|
1327
|
-
const handler = createExecuteTool(
|
|
1328
|
-
registry,
|
|
1329
|
-
ctx.baseUrl,
|
|
1330
|
-
ctx.executor,
|
|
1331
|
-
ctx.logger,
|
|
1332
|
-
ctx.activity,
|
|
1333
|
-
{
|
|
1334
|
-
...(ctx.discoveryConcurrency !== undefined
|
|
1335
|
-
? { discoveryConcurrency: ctx.discoveryConcurrency }
|
|
1336
|
-
: {}),
|
|
1337
|
-
...(ctx.probeTimeoutMs !== undefined
|
|
1338
|
-
? { probeTimeoutMs: ctx.probeTimeoutMs }
|
|
1339
|
-
: {}),
|
|
1340
|
-
maxEmittedBytes: emitBudgets.maxBytes,
|
|
1341
|
-
maxEmittedBlocks: emitBudgets.maxBlocks,
|
|
1342
|
-
},
|
|
1343
|
-
);
|
|
1344
|
-
server.registerTool(
|
|
1345
|
-
"execute_code",
|
|
1346
|
-
{
|
|
1347
|
-
description: executeDescription(
|
|
1348
|
-
emitBudgets,
|
|
1349
|
-
hasConnectorGuides(registry.listConnectors()),
|
|
1350
|
-
),
|
|
1351
|
-
inputSchema: z.object({
|
|
1352
|
-
code: z
|
|
1353
|
-
.string()
|
|
1354
|
-
.describe(
|
|
1355
|
-
"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.",
|
|
1356
|
-
),
|
|
1357
|
-
diagnostics: z
|
|
1358
|
-
.boolean()
|
|
1359
|
-
.optional()
|
|
1360
|
-
.describe(
|
|
1361
|
-
"Add request-local, payload-free timing and result-size summaries.",
|
|
1362
|
-
),
|
|
1363
|
-
}),
|
|
1364
|
-
// The sandbox exposes only tools that are explicitly read-only, and the
|
|
1365
|
-
// executor grants no network, filesystem, env, or timer capabilities.
|
|
1366
|
-
annotations: {
|
|
1367
|
-
readOnlyHint: true,
|
|
1368
|
-
destructiveHint: false,
|
|
1369
|
-
openWorldHint: true,
|
|
1370
|
-
},
|
|
1371
|
-
// U5 and U10: declared unconditionally. A host without the Apps
|
|
1372
|
-
// extension ignores unknown _meta and sees the ordinary envelope, which
|
|
1373
|
-
// is the text fallback the spec mandates — and a stateless aggregator
|
|
1374
|
-
// has nowhere dependable to hold a negotiation check anyway. The
|
|
1375
|
-
// explicit visibility keeps hosts from being told the view may call
|
|
1376
|
-
// execute_code; the default ["model","app"] would say exactly that.
|
|
1377
|
-
_meta: {
|
|
1378
|
-
ui: {
|
|
1379
|
-
resourceUri: PROGRAM_UI_RESOURCE_URI,
|
|
1380
|
-
visibility: ["model"],
|
|
1381
|
-
},
|
|
1382
|
-
},
|
|
1383
|
-
},
|
|
1384
|
-
async (args, extra) => {
|
|
1385
|
-
const controller = new AbortController();
|
|
1386
|
-
const signals = [extra.mcpReq.signal, ctx.requestSignal].filter(
|
|
1387
|
-
(signal): signal is AbortSignal => signal !== undefined,
|
|
1388
|
-
);
|
|
1389
|
-
const forwarders = signals.map((signal) => {
|
|
1390
|
-
const forward = () => controller.abort(signal.reason);
|
|
1391
|
-
if (signal.aborted) forward();
|
|
1392
|
-
else signal.addEventListener("abort", forward, { once: true });
|
|
1393
|
-
return { signal, forward };
|
|
1394
|
-
});
|
|
1395
|
-
try {
|
|
1396
|
-
return await handler(args as { code: string; diagnostics?: boolean }, {
|
|
1397
|
-
signal: controller.signal,
|
|
1398
|
-
});
|
|
1399
|
-
} finally {
|
|
1400
|
-
for (const { signal, forward } of forwarders) {
|
|
1401
|
-
signal.removeEventListener("abort", forward);
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
},
|
|
1405
|
-
);
|
|
1406
|
-
}
|