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