@zackbart/connecta 0.24.4 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/AGENTS.md +40 -10
  2. package/CHANGELOG.md +253 -0
  3. package/dist/advertised-schema.js +53 -0
  4. package/dist/auth/downstream-oauth.js +490 -106
  5. package/dist/call-admission.js +139 -113
  6. package/dist/catalog-service.js +141 -105
  7. package/dist/catalog.js +258 -33
  8. package/dist/concurrency.js +24 -23
  9. package/dist/connector-scope.js +6 -37
  10. package/dist/connectors/api.d.ts +2 -2
  11. package/dist/connectors/remote-mcp.d.ts +6 -5
  12. package/dist/connectors/remote-mcp.js +317 -241
  13. package/dist/credential-contract.d.ts +9 -0
  14. package/dist/credential-rules.js +2 -2
  15. package/dist/credentials.d.ts +6 -1
  16. package/dist/credentials.js +45 -3
  17. package/dist/execute.js +363 -257
  18. package/dist/executor-admission.js +67 -50
  19. package/dist/executors/quickjs-runtime.js +4 -2
  20. package/dist/executors/quickjs.js +251 -210
  21. package/dist/fix-prompt.js +37 -0
  22. package/dist/index.d.ts +10 -1
  23. package/dist/index.js +51 -20
  24. package/dist/invocation.js +324 -285
  25. package/dist/meta-tools.js +206 -92
  26. package/dist/oauth-callback-outcome.js +94 -0
  27. package/dist/oauth-sealing.js +29 -0
  28. package/dist/operator-ui/fix-prompts.js +87 -0
  29. package/dist/operator-ui/generated.js +2 -2
  30. package/dist/operator-ui/model.d.ts +46 -1
  31. package/dist/operator-ui/setup-commands.js +53 -0
  32. package/dist/operator-ui/view.js +38 -2
  33. package/dist/program-source.js +205 -0
  34. package/dist/providers/notion.js +2 -2
  35. package/dist/providers/revenuecat.js +32 -5
  36. package/dist/providers/stripe.d.ts +3 -2
  37. package/dist/providers/vercel.js +1 -1
  38. package/dist/registry.d.ts +1 -1
  39. package/dist/registry.js +305 -260
  40. package/dist/routes/activity.js +120 -137
  41. package/dist/routes/credentials.js +174 -163
  42. package/dist/routes/mcp.js +129 -70
  43. package/dist/routes/oauth-management.js +60 -71
  44. package/dist/routes/oauth.js +44 -16
  45. package/dist/routes/operator.js +82 -0
  46. package/dist/routes/shared.d.ts +2 -0
  47. package/dist/routes/ui-data.js +210 -0
  48. package/dist/routes/ui.js +64 -32
  49. package/dist/runtime/admission.js +43 -0
  50. package/dist/runtime/call-admission.js +67 -0
  51. package/dist/runtime/connector-scope.js +72 -0
  52. package/dist/runtime/run.js +174 -0
  53. package/dist/runtime/services.js +130 -0
  54. package/dist/runtime/storage.js +66 -0
  55. package/dist/server.js +111 -89
  56. package/dist/skills.js +1 -1
  57. package/dist/storage/file.js +39 -2
  58. package/dist/storage/memory.js +28 -16
  59. package/dist/timeout.js +17 -47
  60. package/dist/types.d.ts +27 -7
  61. package/dist/ui.d.ts +16 -2
  62. package/dist/ui.js +50 -166
  63. package/dist/url-safety.js +157 -0
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/documentation/architecture.md +184 -10
  67. package/documentation/auth.md +80 -0
  68. package/documentation/code-mode.md +69 -24
  69. package/documentation/meta-tools.md +164 -27
  70. package/ethos.md +44 -47
  71. package/examples/worker/README.md +51 -5
  72. package/examples/worker/src/d1-storage.ts +116 -0
  73. package/examples/worker/src/index.ts +6 -6
  74. package/examples/worker/wrangler.jsonc +16 -4
  75. package/package.json +11 -3
  76. package/templates/node/package.json +1 -1
  77. package/dist/auth/downstream-oauth.d.ts +0 -200
  78. package/dist/catalog-fingerprint.d.ts +0 -19
  79. package/dist/catalog-limits.d.ts +0 -9
  80. package/dist/catalog-service.d.ts +0 -263
  81. package/dist/catalog.d.ts +0 -87
  82. package/dist/concurrency.d.ts +0 -3
  83. package/dist/connectors/guarded-fetch.d.ts +0 -96
  84. package/dist/credential-rules.d.ts +0 -85
  85. package/dist/execute.d.ts +0 -154
  86. package/dist/executor-result.d.ts +0 -10
  87. package/dist/executors/quickjs-child.d.ts +0 -1
  88. package/dist/executors/quickjs-protocol.d.ts +0 -67
  89. package/dist/favicon.d.ts +0 -1
  90. package/dist/identity.d.ts +0 -4
  91. package/dist/invocation.d.ts +0 -74
  92. package/dist/mcp-result.d.ts +0 -8
  93. package/dist/meta-tools.d.ts +0 -129
  94. package/dist/operator-ui/generated.d.ts +0 -2
  95. package/dist/operator-ui/view.d.ts +0 -188
  96. package/dist/result-shapes.d.ts +0 -13
  97. package/dist/retired-toolkits.d.ts +0 -2
  98. package/dist/routes/activity.d.ts +0 -2
  99. package/dist/routes/credentials.d.ts +0 -2
  100. package/dist/routes/mcp.d.ts +0 -10
  101. package/dist/routes/oauth-management.d.ts +0 -2
  102. package/dist/routes/oauth.d.ts +0 -2
  103. package/dist/routes/ui.d.ts +0 -2
  104. package/dist/server.d.ts +0 -9
  105. package/dist/skills.d.ts +0 -44
  106. package/dist/timeout.d.ts +0 -21
  107. package/dist/tool-safety.d.ts +0 -9
@@ -1,21 +1,48 @@
1
- import { isCallAdmissionError } from "./call-admission.js";
1
+ import { Cause, Effect, Exit } from "effect";
2
+ import { isCallAdmissionError, } from "./call-admission.js";
2
3
  import { boundedEchoText, classifyCallError, ConnectorCallError, echoedCallArgs, framingError, } from "./errors.js";
3
4
  import { unwrapMcpResult } from "./mcp-result.js";
4
5
  import { splitAddress } from "./registry.js";
6
+ import { runEdge, withDeadlineEffect } from "./runtime/run.js";
5
7
  import { isExplicitlyReadOnly } from "./tool-safety.js";
6
- import { withDeadline } from "./timeout.js";
7
8
  import { validateToolInput } from "./validate.js";
8
9
  function defined(values) {
9
10
  return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
10
11
  }
11
- async function timed(bucket, fn) {
12
- const started = Date.now();
13
- try {
14
- return await fn();
15
- }
16
- finally {
17
- bucket(Date.now() - started);
18
- }
12
+ /** Add the effect's wall time to `bucket` however it ends, interruption included. */
13
+ export function timed(bucket, effect) {
14
+ return Effect.suspend(() => {
15
+ const started = Date.now();
16
+ return Effect.ensuring(effect, Effect.sync(() => bucket(Date.now() - started)));
17
+ });
18
+ }
19
+ /**
20
+ * The connector's call permit, owned by the enclosing Scope.
21
+ *
22
+ * Admission goes through `registry.admitCall`, a Promise, on purpose. The
23
+ * permit queue is shared by every request in the isolate, and a queued
24
+ * waiter is resumed from inside the request that released the slot. Were
25
+ * this fiber to wait on the controller's Effect program itself
26
+ * (`acquireCallScoped`), it would be resumed there and run this call's
27
+ * downstream I/O in the releasing request, which workerd refuses. Awaiting
28
+ * admitCall's promise resumes it in its own request instead.
29
+ *
30
+ * The wait stays interruptible, so a deadline ends it. A permit granted just
31
+ * as the wait was interrupted still arrives, later, and is released on
32
+ * arrival rather than holding its slot forever.
33
+ */
34
+ function admitted(registry, target, args, signal) {
35
+ return Effect.acquireRelease(Effect.suspend(() => {
36
+ const pending = registry.admitCall(target.connector.id, {
37
+ toolName: target.toolName,
38
+ args: args ?? {},
39
+ ...defined({ signal }),
40
+ });
41
+ return Effect.tryPromise({ try: () => pending, catch: (error) => error })
42
+ .pipe(Effect.onInterrupt(() => Effect.sync(() => {
43
+ pending.then((permit) => permit.release(), () => { });
44
+ })));
45
+ }), (permit) => Effect.sync(() => permit.release()), { interruptible: true });
19
46
  }
20
47
  function callerCancelledDetails() {
21
48
  return {
@@ -76,6 +103,11 @@ export class InvocationFailure extends Error {
76
103
  * Shared downstream invocation engine. Address/catalog resolution is delegated
77
104
  * to the request-local CatalogService; every remaining call semantic lives
78
105
  * here so MCP and code mode cannot drift independently.
106
+ *
107
+ * One call is one fiber: resolve, refuse or validate, admit, dispatch, then
108
+ * process the result, with every way the call can go wrong turned into an
109
+ * outcome. Only a throw while recording that outcome, or a bug, rejects
110
+ * `invoke`.
79
111
  */
80
112
  export class InvocationService {
81
113
  registry;
@@ -86,301 +118,308 @@ export class InvocationService {
86
118
  this.catalog = catalog;
87
119
  this.activity = activity;
88
120
  }
89
- async invoke(address, args, context) {
90
- const started = Date.now();
91
- let catalogMs = 0;
92
- let admissionMs = 0;
93
- let connectorMs = 0;
94
- let resultProcessingMs = 0;
95
- let attempts = 0;
96
- let resolved;
97
- let activityTarget;
98
- // The address as written, used for activity when resolution never reached
99
- // a connector. Only its two halves are recorded — the same fields activity
100
- // has always carried — so no new class of payload enters the log.
101
- const attempted = splitAddress(address);
102
- const timing = () => ({
103
- catalogMs,
104
- admissionMs,
105
- connectorMs,
106
- resultProcessingMs,
107
- totalMs: Date.now() - started,
108
- });
109
- const record = (outcome, classification = {}) => {
110
- const identity = activityTarget
111
- ? {
112
- connectorId: activityTarget.connector.id,
113
- toolName: activityTarget.toolName,
114
- }
115
- : attempted;
116
- if (!identity)
117
- return;
118
- this.activity?.recordTool?.(this.activity, {
119
- connectorId: identity.connectorId,
120
- toolName: identity.toolName,
121
- address: `${identity.connectorId}.${identity.toolName}`,
122
- source: context.source,
123
- outcome,
124
- durationMs: Date.now() - started,
125
- attempts,
126
- ...defined({
127
- errorCode: classification.errorCode,
128
- friction: classification.friction,
129
- }),
121
+ invoke(address, args, context) {
122
+ return runEdge(this.pipeline(address, args, context));
123
+ }
124
+ /**
125
+ * `invoke` without the Promise, for a caller already running a fiber —
126
+ * code mode's host calls yield it rather than crossing a second edge. It
127
+ * never fails: every outcome, refusals included, is its success value.
128
+ */
129
+ pipeline(address, args, context) {
130
+ return Effect.gen({ self: this }, function* () {
131
+ const started = Date.now();
132
+ let catalogMs = 0;
133
+ let admissionMs = 0;
134
+ let connectorMs = 0;
135
+ let resultProcessingMs = 0;
136
+ let attempts = 0;
137
+ let resolved;
138
+ let activityTarget;
139
+ // The address as written, used for activity when resolution never reached
140
+ // a connector. Only its two halves are recorded — the same fields activity
141
+ // has always carried — so no new class of payload enters the log.
142
+ const attempted = splitAddress(address);
143
+ const timing = () => ({
144
+ catalogMs,
145
+ admissionMs,
146
+ connectorMs,
147
+ resultProcessingMs,
148
+ totalMs: Date.now() - started,
130
149
  });
131
- };
132
- const enrich = (error, target) => {
133
- if (!target)
134
- return error;
135
- switch (error.code) {
136
- case "destructive_tool_requires_approval": {
137
- const echoed = echoedCallArgs(args);
138
- return {
139
- ...error,
140
- nextAction: {
141
- tool: "call_destructive_tool",
142
- arguments: {
143
- address: `${target.connector.id}.${target.toolName}`,
144
- ...echoed,
145
- },
146
- purpose: "Ask the MCP host to approve this consequential call. " +
147
- ("args" in echoed
148
- ? "Re-send these arguments and add a short reason for the human reviewer."
149
- : "Re-send the arguments you just sent — they are too large to echo back — and add a short reason for the human reviewer."),
150
- },
151
- };
152
- }
153
- case "auth_required":
154
- return {
155
- ...error,
156
- connector: target.connector.id,
157
- operation: `${target.connector.id}.${target.toolName}`,
158
- recovery: recoveryMode(this.registry, target.connector, this.catalog.baseUrl),
159
- nextAction: {
160
- tool: "authorize_connector",
161
- arguments: { connector: target.connector.id },
162
- operatorHandoff: "Give the URL and instructions it returns to the operator.",
163
- },
164
- retry: `Retry ${target.connector.id}.${target.toolName} after ` +
165
- "the operator completes recovery.",
166
- };
167
- case "invalid_args":
168
- if (!error.validation)
169
- return error;
170
- return {
171
- ...error,
172
- connector: target.connector.id,
173
- operation: `${target.connector.id}.${target.toolName}`,
174
- nextAction: this.catalog.searchRecovery({
175
- query: target.toolName,
176
- connector: target.connector.id,
177
- }, "Inspect the current input shape if the validation findings are not sufficient."),
178
- retry: `Correct the listed arguments and retry ` +
179
- `${target.connector.id}.${target.toolName}.`,
180
- };
181
- default:
182
- return error;
183
- }
184
- };
185
- const failed = (error) => {
186
- const diagnostics = timing();
187
- const target = resolved ?? activityTarget;
188
- const details = enrich(error, target);
189
- // Activity rows stay payload-free by construction; the operator's log is
190
- // where the downstream reason goes, bounded and without arguments.
191
- if (target && details.code !== "destructive_tool_requires_approval") {
192
- this.registry
193
- .contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope)
194
- .logger.warn("[connecta] call failed", {
195
- connector: target.connector.id,
196
- tool: target.toolName,
150
+ const record = (outcome, classification = {}) => {
151
+ const identity = activityTarget
152
+ ? {
153
+ connectorId: activityTarget.connector.id,
154
+ toolName: activityTarget.toolName,
155
+ }
156
+ : attempted;
157
+ if (!identity)
158
+ return;
159
+ this.activity?.recordTool?.(this.activity, {
160
+ connectorId: identity.connectorId,
161
+ toolName: identity.toolName,
162
+ address: `${identity.connectorId}.${identity.toolName}`,
197
163
  source: context.source,
198
- code: details.code,
199
- // Sanitized transport diagnostics (origin and errno only, #539).
200
- ...(details.details ? { details: details.details } : {}),
201
- attempts,
164
+ outcome,
202
165
  durationMs: Date.now() - started,
203
- message: String(details.message ?? "").slice(0, 300),
166
+ attempts,
167
+ ...defined({
168
+ errorCode: classification.errorCode,
169
+ friction: classification.friction,
170
+ }),
204
171
  });
205
- }
206
- record(details.code === "timeout"
207
- ? "timeout"
208
- : details.code === "cancelled"
209
- ? "cancelled"
210
- : "error", { errorCode: details.code });
211
- return {
212
- ok: false,
213
- durationMs: Date.now() - started,
214
- attempts,
215
- timing: diagnostics,
216
- ...defined({ resolved }),
217
- error: details,
218
172
  };
219
- };
220
- if (context.requestSignal?.aborted) {
221
- // Preserve the cancelled admission-attempt count without starting discovery.
222
- attempts = 1;
223
- return failed(callerCancelledDetails());
224
- }
225
- let result;
226
- let observedResult;
227
- // One deadline owns discovery, queue admission, and provider dispatch.
228
- // Only the caller of this function records the final outcome, so a late
229
- // cancellation cannot append a second activity event after the deadline.
230
- const dispatch = async (callSignal) => {
231
- const resolution = await this.catalog.resolveTool(address, defined({ signal: callSignal }));
232
- catalogMs += resolution.catalogMs;
233
- if (callSignal?.aborted)
234
- throw callSignal.reason;
235
- if (!resolution.ok) {
236
- if (resolution.connector && resolution.toolName) {
237
- activityTarget = {
238
- connector: resolution.connector,
239
- toolName: resolution.toolName,
240
- };
173
+ const enrich = (error, target) => {
174
+ if (!target)
175
+ return error;
176
+ switch (error.code) {
177
+ case "destructive_tool_requires_approval": {
178
+ const echoed = echoedCallArgs(args);
179
+ return {
180
+ ...error,
181
+ nextAction: {
182
+ tool: "call_destructive_tool",
183
+ arguments: {
184
+ address: `${target.connector.id}.${target.toolName}`,
185
+ ...echoed,
186
+ },
187
+ purpose: "Ask the MCP host to approve this consequential call. " +
188
+ ("args" in echoed
189
+ ? "Re-send these arguments and add a short reason for the human reviewer."
190
+ : "Re-send the arguments you just sent — they are too large to echo back — and add a short reason for the human reviewer."),
191
+ },
192
+ };
193
+ }
194
+ case "auth_required":
195
+ return {
196
+ ...error,
197
+ connector: target.connector.id,
198
+ operation: `${target.connector.id}.${target.toolName}`,
199
+ recovery: recoveryMode(this.registry, target.connector, this.catalog.baseUrl),
200
+ nextAction: {
201
+ tool: "authorize_connector",
202
+ arguments: { connector: target.connector.id },
203
+ operatorHandoff: "Give the URL and instructions it returns to the operator.",
204
+ },
205
+ retry: `Retry ${target.connector.id}.${target.toolName} after ` +
206
+ "the operator completes recovery.",
207
+ };
208
+ case "invalid_args":
209
+ if (!error.validation)
210
+ return error;
211
+ return {
212
+ ...error,
213
+ connector: target.connector.id,
214
+ operation: `${target.connector.id}.${target.toolName}`,
215
+ nextAction: this.catalog.searchRecovery({
216
+ query: target.toolName,
217
+ connector: target.connector.id,
218
+ }, "Inspect the current input shape if the validation findings are not sufficient."),
219
+ retry: `Correct the listed arguments and retry ` +
220
+ `${target.connector.id}.${target.toolName}.`,
221
+ };
222
+ default:
223
+ return error;
241
224
  }
242
- if (resolution.cause && context.requestSignal?.aborted) {
243
- return callerCancelledDetails();
225
+ };
226
+ const failed = (error) => {
227
+ const diagnostics = timing();
228
+ const target = resolved ?? activityTarget;
229
+ const details = enrich(error, target);
230
+ // Activity rows stay payload-free by construction; the operator's log is
231
+ // where the downstream reason goes, bounded and without arguments.
232
+ if (target && details.code !== "destructive_tool_requires_approval") {
233
+ this.registry
234
+ .contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope)
235
+ .logger.warn("[connecta] call failed", {
236
+ connector: target.connector.id,
237
+ tool: target.toolName,
238
+ source: context.source,
239
+ code: details.code,
240
+ // Sanitized transport diagnostics (origin and errno only, #539).
241
+ ...(details.details ? { details: details.details } : {}),
242
+ attempts,
243
+ durationMs: Date.now() - started,
244
+ message: String(details.message ?? "").slice(0, 300),
245
+ });
244
246
  }
245
- return resolution.error;
246
- }
247
- const target = resolution.resolved;
248
- resolved = target;
249
- activityTarget = target;
250
- if (!isExplicitlyReadOnly(target.definition) && !context.allowDestructive) {
251
- const canonicalAddress = `${target.connector.id}.${target.toolName}`;
252
- return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`);
253
- }
254
- // Remote MCP tools advertise their input schema in the catalog. Validate
255
- // against that same request-local definition before admission or provider
256
- // dispatch, so a predictable mismatch stays structured instead of being
257
- // flattened into provider-specific error prose. Unsupported schemas retain
258
- // validateToolInput's fail-open behavior and reach the downstream normally.
259
- if (target.connector.kind === "mcp" &&
260
- target.definition.inputSchema) {
261
- const invalid = validateToolInput(target.definition.inputSchema, args ?? {}, {
262
- address: `${target.connector.id}.${target.toolName}`,
263
- logger: this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope).logger,
264
- });
265
- if (invalid)
266
- return classifyCallError(invalid);
267
- }
268
- try {
269
- context.beforeDispatch?.();
270
- }
271
- catch (error) {
272
- return error instanceof InvocationFailure
273
- ? error.details
274
- : classifyCallError(error);
275
- }
276
- if (callSignal?.aborted)
277
- throw callSignal.reason;
278
- attempts = 1;
279
- let permit;
280
- let attemptError;
281
- let attemptFailed = false;
282
- try {
283
- permit = await timed((elapsed) => { admissionMs += elapsed; }, () => this.registry.admitCall(target.connector.id, {
284
- toolName: target.toolName,
285
- args: args ?? {},
286
- ...defined({ signal: callSignal }),
287
- }));
288
- const raw = await timed((elapsed) => { connectorMs += elapsed; }, () => {
289
- const call = () => {
290
- const connectorContext = this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs }));
291
- if (target.connector.credential &&
292
- !connectorContext.credential) {
293
- throw new ConnectorCallError("auth_required", "Operator-managed credential storage is not configured. Call " +
294
- `authorize_connector({ connector: "${target.connector.id}" }).`);
295
- }
296
- // Cancellation can arrive during admission or context construction.
297
- if (callSignal?.aborted)
298
- throw callSignal.reason;
299
- return target.connector.callTool(target.toolName, args ?? {}, connectorContext);
300
- };
301
- // Race cancellation here too, so an uncooperative connector cannot
302
- // retain its admission permit after the enclosing deadline expires.
303
- return callSignal
304
- ? withDeadline(call, {
305
- signal: callSignal,
306
- timeoutError: new ConnectorCallError("timeout", "Tool call timed out"),
307
- })
308
- : call();
309
- });
310
- // isError is checked here for BOTH result shapes so every adapter
311
- // reports the same downstream-failure wording, and the throw lands
312
- // inside the attempt where it feeds health.
313
- assertRawMcpSuccess(target.connector.kind, raw);
314
- observedResult = unwrapMcpResult(target.connector.kind, raw);
315
- result = context.unwrapResult ? observedResult : raw;
316
- }
317
- catch (error) {
318
- attemptFailed = true;
319
- attemptError = error;
320
- }
321
- finally {
322
- permit?.release();
247
+ record(details.code === "timeout"
248
+ ? "timeout"
249
+ : details.code === "cancelled"
250
+ ? "cancelled"
251
+ : "error", { errorCode: details.code });
252
+ return {
253
+ ok: false,
254
+ durationMs: Date.now() - started,
255
+ attempts,
256
+ timing: diagnostics,
257
+ ...defined({ resolved }),
258
+ error: details,
259
+ };
260
+ };
261
+ if (context.requestSignal?.aborted) {
262
+ // Preserve the cancelled admission-attempt count without starting discovery.
263
+ attempts = 1;
264
+ return failed(callerCancelledDetails());
323
265
  }
324
- if (attemptFailed) {
266
+ let result;
267
+ let observedResult;
268
+ // Everything up to the downstream result: resolution, the safety and
269
+ // schema refusals, admission, and the one attempt. A refusal is its
270
+ // success value. Its failure is an abort reason or something nobody
271
+ // expected, and a throw anywhere in it — a defect, to Effect — is
272
+ // classified like a failure, as the async body this replaced did.
273
+ const dispatch = (callSignal) => Effect.gen({ self: this }, function* () {
274
+ const resolution = yield* this.catalog.resolve(address, defined({ signal: callSignal }));
275
+ catalogMs += resolution.catalogMs;
325
276
  if (callSignal?.aborted)
326
- throw callSignal.reason;
327
- const callerCancelled = isCallerCancellation(attemptError, context.requestSignal);
328
- const details = callerCancelled
329
- ? callerCancelledDetails()
330
- : classifyCallError(attemptError);
331
- return details;
332
- }
333
- return undefined;
334
- };
335
- try {
336
- const error = context.timeoutMs || context.requestSignal
337
- ? await withDeadline(dispatch, {
277
+ return yield* Effect.fail(callSignal.reason);
278
+ if (!resolution.ok) {
279
+ if (resolution.connector && resolution.toolName) {
280
+ activityTarget = {
281
+ connector: resolution.connector,
282
+ toolName: resolution.toolName,
283
+ };
284
+ }
285
+ if (resolution.cause && context.requestSignal?.aborted) {
286
+ return callerCancelledDetails();
287
+ }
288
+ return resolution.error;
289
+ }
290
+ const target = resolution.resolved;
291
+ resolved = target;
292
+ activityTarget = target;
293
+ if (!isExplicitlyReadOnly(target.definition) && !context.allowDestructive) {
294
+ const canonicalAddress = `${target.connector.id}.${target.toolName}`;
295
+ return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`);
296
+ }
297
+ // Remote MCP tools advertise their input schema in the catalog. Validate
298
+ // against that same request-local definition before admission or provider
299
+ // dispatch, so a predictable mismatch stays structured instead of being
300
+ // flattened into provider-specific error prose. Unsupported schemas retain
301
+ // validateToolInput's fail-open behavior and reach the downstream normally.
302
+ if (target.connector.kind === "mcp" &&
303
+ target.definition.inputSchema) {
304
+ const invalid = validateToolInput(target.definition.inputSchema, args ?? {}, {
305
+ address: `${target.connector.id}.${target.toolName}`,
306
+ logger: this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope).logger,
307
+ });
308
+ if (invalid)
309
+ return classifyCallError(invalid);
310
+ }
311
+ try {
312
+ context.beforeDispatch?.();
313
+ }
314
+ catch (error) {
315
+ return error instanceof InvocationFailure
316
+ ? error.details
317
+ : classifyCallError(error);
318
+ }
319
+ if (callSignal?.aborted)
320
+ return yield* Effect.fail(callSignal.reason);
321
+ attempts = 1;
322
+ const call = () => {
323
+ const connectorContext = this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs }));
324
+ if (target.connector.credential &&
325
+ !connectorContext.credential) {
326
+ throw new ConnectorCallError("auth_required", "Operator-managed credential storage is not configured. Call " +
327
+ `authorize_connector({ connector: "${target.connector.id}" }).`);
328
+ }
329
+ // Cancellation can arrive during admission or context construction.
330
+ if (callSignal?.aborted)
331
+ throw callSignal.reason;
332
+ return target.connector.callTool(target.toolName, args ?? {}, connectorContext);
333
+ };
334
+ // The permit belongs to this scope, so success, failure, and the
335
+ // deadline's interruption all release it. Interruption stops the
336
+ // wait instead of waiting for the connector, so an uncooperative one
337
+ // cannot hold its permit past the deadline.
338
+ const attempt = yield* Effect.exit(Effect.scoped(Effect.gen({ self: this }, function* () {
339
+ yield* timed((elapsed) => { admissionMs += elapsed; }, admitted(this.registry, target, args, callSignal));
340
+ const raw = yield* timed((elapsed) => { connectorMs += elapsed; }, Effect.tryPromise({
341
+ try: () => Promise.resolve(call()),
342
+ catch: (error) => error,
343
+ }));
344
+ // isError is checked here for BOTH result shapes so every adapter
345
+ // reports the same downstream-failure wording, and the throw lands
346
+ // inside the attempt where it feeds health.
347
+ assertRawMcpSuccess(target.connector.kind, raw);
348
+ return { raw, observed: unwrapMcpResult(target.connector.kind, raw) };
349
+ })));
350
+ if (Exit.isFailure(attempt)) {
351
+ if (callSignal?.aborted)
352
+ return yield* Effect.fail(callSignal.reason);
353
+ const attemptError = Cause.squash(attempt.cause);
354
+ return isCallerCancellation(attemptError, context.requestSignal)
355
+ ? callerCancelledDetails()
356
+ : classifyCallError(attemptError);
357
+ }
358
+ observedResult = attempt.value.observed;
359
+ result = context.unwrapResult ? observedResult : attempt.value.raw;
360
+ return undefined;
361
+ });
362
+ // One deadline owns discovery, queue admission, and provider dispatch.
363
+ // Expiry interrupts dispatch wherever it is, so nothing it started runs
364
+ // on afterwards, and only this fiber records the outcome: a late
365
+ // cancellation cannot append a second activity event.
366
+ const dispatched = yield* Effect.exit(context.timeoutMs || context.requestSignal
367
+ ? withDeadlineEffect(dispatch, {
338
368
  ...defined({
339
369
  timeoutMs: context.timeoutMs,
340
370
  signal: context.requestSignal,
341
371
  }),
342
372
  timeoutError: new ConnectorCallError("timeout", `Tool call timed out after ${context.timeoutMs}ms`),
343
373
  })
344
- : await dispatch();
345
- if (error)
346
- return failed(error);
347
- }
348
- catch (error) {
349
- return failed(context.requestSignal?.aborted
350
- ? callerCancelledDetails()
351
- : classifyCallError(error));
352
- }
353
- // A dispatch that returned no refusal resolved a concrete tool.
354
- const completed = resolved;
355
- if (!completed)
356
- throw new Error("Invocation completed without a resolved tool");
357
- try {
358
- const value = await timed((elapsed) => { resultProcessingMs += elapsed; }, async () => {
359
- const processed = context.processResult
360
- ? await context.processResult(result, completed)
361
- : result;
374
+ : dispatch());
375
+ if (Exit.isFailure(dispatched)) {
376
+ return failed(context.requestSignal?.aborted
377
+ ? callerCancelledDetails()
378
+ : classifyCallError(Cause.squash(dispatched.cause)));
379
+ }
380
+ if (dispatched.value)
381
+ return failed(dispatched.value);
382
+ // A dispatch that returned no refusal resolved a concrete tool.
383
+ const completed = resolved;
384
+ if (!completed) {
385
+ return yield* Effect.die(new Error("Invocation completed without a resolved tool"));
386
+ }
387
+ const processResult = context.processResult;
388
+ const processing = processResult
389
+ ? Effect.tryPromise({
390
+ try: () => Promise.resolve(processResult(result, completed)),
391
+ catch: (error) => error,
392
+ })
393
+ : Effect.succeed(result);
394
+ const processed = yield* Effect.exit(timed((elapsed) => { resultProcessingMs += elapsed; }, Effect.tap(processing, () => Effect.sync(() => {
362
395
  try {
363
396
  this.registry.observeOutputShape(completed.connector.id, completed.definition, observedResult);
364
397
  }
365
398
  catch {
366
399
  // Shape learning is advisory. It cannot change a completed call.
367
400
  }
368
- return processed;
369
- });
370
- const diagnostics = timing();
371
- const friction = context.activityFriction?.(value);
372
- record("success", friction ? { friction } : {});
373
- return {
374
- ok: true,
375
- value,
376
- resolved: completed,
377
- durationMs: Date.now() - started,
378
- attempts,
379
- timing: diagnostics,
380
- };
381
- }
382
- catch {
383
- return failed(framingError("result_processing_failed", "The downstream call completed, but its result could not be processed. Do not repeat the call to recover its result."));
384
- }
401
+ }))));
402
+ // Past this point the call has happened, so nothing may invite a retry.
403
+ const unprocessable = () => failed(framingError("result_processing_failed", "The downstream call completed, but its result could not be processed. Do not repeat the call to recover its result."));
404
+ if (Exit.isFailure(processed))
405
+ return unprocessable();
406
+ try {
407
+ const value = processed.value;
408
+ const diagnostics = timing();
409
+ const friction = context.activityFriction?.(value);
410
+ record("success", friction ? { friction } : {});
411
+ return {
412
+ ok: true,
413
+ value,
414
+ resolved: completed,
415
+ durationMs: Date.now() - started,
416
+ attempts,
417
+ timing: diagnostics,
418
+ };
419
+ }
420
+ catch {
421
+ return unprocessable();
422
+ }
423
+ });
385
424
  }
386
425
  }