@zackbart/connecta 0.7.8 → 0.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/dist/activity.d.ts +1 -1
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js.map +1 -1
- package/dist/call-admission.d.ts +81 -0
- package/dist/call-admission.d.ts.map +1 -0
- package/dist/call-admission.js +339 -0
- package/dist/call-admission.js.map +1 -0
- package/dist/connectors/api.d.ts +3 -1
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +1 -0
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +3 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +1 -0
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +45 -15
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +3 -0
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +127 -29
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +25 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +28 -0
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +5 -0
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +8 -4
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/activity.ts +5 -1
- package/src/call-admission.ts +519 -0
- package/src/connectors/api.ts +4 -0
- package/src/connectors/remote-mcp.ts +4 -0
- package/src/execute.ts +54 -22
- package/src/index.ts +5 -0
- package/src/meta-tools.ts +157 -45
- package/src/registry.ts +55 -0
- package/src/server.ts +5 -0
- package/src/types.ts +61 -0
- package/src/ui.ts +8 -4
- package/src/version.ts +1 -1
package/src/activity.ts
CHANGED
|
@@ -6,7 +6,11 @@ export type ActivityCallSource =
|
|
|
6
6
|
| "batch_call"
|
|
7
7
|
| "execute_code";
|
|
8
8
|
|
|
9
|
-
export type ActivityOutcome =
|
|
9
|
+
export type ActivityOutcome =
|
|
10
|
+
| "success"
|
|
11
|
+
| "error"
|
|
12
|
+
| "timeout"
|
|
13
|
+
| "cancelled";
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* Authenticated identity attached to an activity event. `id` is intentionally
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { ConnectorCallError } from "./errors.js";
|
|
2
|
+
import type {
|
|
3
|
+
ConnectorCallAdmissionInput,
|
|
4
|
+
ConnectorCallAdmissionPolicy,
|
|
5
|
+
ConnectorCallAdmissionRule,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_QUEUE_SIZE = 32;
|
|
9
|
+
const DEFAULT_QUEUE_TIMEOUT_MS = 5_000;
|
|
10
|
+
const DEFAULT_RETRY_AFTER_MS = 1_000;
|
|
11
|
+
const DEFAULT_MAX_PARTITIONS = 1_024;
|
|
12
|
+
const MAX_PARTITION_KEY_BYTES = 128;
|
|
13
|
+
const DEFAULT_PARTITION_KEY = "";
|
|
14
|
+
const enc = new TextEncoder();
|
|
15
|
+
|
|
16
|
+
export type CallAdmissionFailureKind =
|
|
17
|
+
| "concurrency"
|
|
18
|
+
| "budget"
|
|
19
|
+
| "cancelled"
|
|
20
|
+
| "closed"
|
|
21
|
+
| "partition";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A locally-produced connector-call failure. Extending ConnectorCallError
|
|
25
|
+
* preserves the public error envelope while letting call paths avoid recording
|
|
26
|
+
* a refusal as evidence that the downstream provider is unhealthy.
|
|
27
|
+
*/
|
|
28
|
+
export class CallAdmissionError extends ConnectorCallError {
|
|
29
|
+
constructor(
|
|
30
|
+
readonly admissionKind: CallAdmissionFailureKind,
|
|
31
|
+
code: ConstructorParameters<typeof ConnectorCallError>[0],
|
|
32
|
+
message: string,
|
|
33
|
+
opts: ConstructorParameters<typeof ConnectorCallError>[2] = {},
|
|
34
|
+
) {
|
|
35
|
+
super(code, message, opts);
|
|
36
|
+
this.name = "CallAdmissionError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isCallAdmissionError(
|
|
41
|
+
error: unknown,
|
|
42
|
+
): error is CallAdmissionError {
|
|
43
|
+
return error instanceof CallAdmissionError;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CallAdmissionPermit {
|
|
47
|
+
/** Time spent in the concurrency queue. Zero for immediate admission. */
|
|
48
|
+
readonly waitMs: number;
|
|
49
|
+
/** Idempotent. */
|
|
50
|
+
release(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Waiter {
|
|
54
|
+
queuedAt: number;
|
|
55
|
+
resolve: (permit: CallAdmissionPermit) => void;
|
|
56
|
+
reject: (error: CallAdmissionError) => void;
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
onAbort?: () => void;
|
|
59
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface PartitionState {
|
|
63
|
+
active: number;
|
|
64
|
+
waiters: Waiter[];
|
|
65
|
+
admittedAt: number[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ConnectorCallAdmissionSnapshot {
|
|
69
|
+
rules: number;
|
|
70
|
+
partitions: number;
|
|
71
|
+
active: number;
|
|
72
|
+
queued: number;
|
|
73
|
+
closed: boolean;
|
|
74
|
+
totals: {
|
|
75
|
+
admitted: number;
|
|
76
|
+
queued: number;
|
|
77
|
+
rejected: number;
|
|
78
|
+
rateLimited: number;
|
|
79
|
+
cancelled: number;
|
|
80
|
+
};
|
|
81
|
+
queueWaitMs: {
|
|
82
|
+
count: number;
|
|
83
|
+
total: number;
|
|
84
|
+
max: number;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function positiveWhole(value: number, name: string): number {
|
|
89
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
|
90
|
+
throw new TypeError(`${name} must be a positive whole number.`);
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function nonNegativeWhole(value: number, name: string): number {
|
|
96
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
97
|
+
throw new TypeError(`${name} must be a non-negative whole number.`);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Per-runtime, per-connector call admission. State contains only bounded
|
|
104
|
+
* partition keys, counters, timestamps, signals, and promise continuations;
|
|
105
|
+
* tool arguments never enter the controller.
|
|
106
|
+
*/
|
|
107
|
+
export class ConnectorCallAdmissionController {
|
|
108
|
+
private readonly maxConcurrency: number | undefined;
|
|
109
|
+
private readonly maxQueueSize: number;
|
|
110
|
+
private readonly queueTimeoutMs: number;
|
|
111
|
+
private readonly retryAfterMs: number;
|
|
112
|
+
private readonly maxPartitions: number;
|
|
113
|
+
private readonly budget:
|
|
114
|
+
| { maxCalls: number; windowMs: number }
|
|
115
|
+
| undefined;
|
|
116
|
+
private readonly partitionKey:
|
|
117
|
+
| ConnectorCallAdmissionRule["partitionKey"]
|
|
118
|
+
| undefined;
|
|
119
|
+
private readonly partitions = new Map<string, PartitionState>();
|
|
120
|
+
private closed = false;
|
|
121
|
+
private admittedTotal = 0;
|
|
122
|
+
private queuedTotal = 0;
|
|
123
|
+
private rejectedTotal = 0;
|
|
124
|
+
private rateLimitedTotal = 0;
|
|
125
|
+
private cancelledTotal = 0;
|
|
126
|
+
private queueWaitCount = 0;
|
|
127
|
+
private queueWaitTotalMs = 0;
|
|
128
|
+
private queueWaitMaxMs = 0;
|
|
129
|
+
|
|
130
|
+
constructor(
|
|
131
|
+
readonly connectorId: string,
|
|
132
|
+
policy: ConnectorCallAdmissionPolicy,
|
|
133
|
+
) {
|
|
134
|
+
if (!Array.isArray(policy.rules) || policy.rules.length !== 1) {
|
|
135
|
+
throw new TypeError(
|
|
136
|
+
`connector "${connectorId}" callAdmission.rules must contain exactly one rule in this release.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const rule = policy.rules[0];
|
|
140
|
+
if (
|
|
141
|
+
rule.maxConcurrency === undefined &&
|
|
142
|
+
rule.budget === undefined
|
|
143
|
+
) {
|
|
144
|
+
throw new TypeError(
|
|
145
|
+
`connector "${connectorId}" callAdmission rule must declare maxConcurrency or budget.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
this.maxConcurrency =
|
|
149
|
+
rule.maxConcurrency === undefined
|
|
150
|
+
? undefined
|
|
151
|
+
: positiveWhole(
|
|
152
|
+
rule.maxConcurrency,
|
|
153
|
+
`connector "${connectorId}" callAdmission maxConcurrency`,
|
|
154
|
+
);
|
|
155
|
+
if (
|
|
156
|
+
this.maxConcurrency === undefined &&
|
|
157
|
+
(rule.maxQueueSize !== undefined ||
|
|
158
|
+
rule.queueTimeoutMs !== undefined ||
|
|
159
|
+
rule.retryAfterMs !== undefined)
|
|
160
|
+
) {
|
|
161
|
+
throw new TypeError(
|
|
162
|
+
`connector "${connectorId}" callAdmission queue settings require maxConcurrency.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
this.maxQueueSize = nonNegativeWhole(
|
|
166
|
+
rule.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
|
|
167
|
+
`connector "${connectorId}" callAdmission maxQueueSize`,
|
|
168
|
+
);
|
|
169
|
+
this.queueTimeoutMs = positiveWhole(
|
|
170
|
+
rule.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS,
|
|
171
|
+
`connector "${connectorId}" callAdmission queueTimeoutMs`,
|
|
172
|
+
);
|
|
173
|
+
this.retryAfterMs = nonNegativeWhole(
|
|
174
|
+
rule.retryAfterMs ?? DEFAULT_RETRY_AFTER_MS,
|
|
175
|
+
`connector "${connectorId}" callAdmission retryAfterMs`,
|
|
176
|
+
);
|
|
177
|
+
this.maxPartitions = positiveWhole(
|
|
178
|
+
policy.maxPartitions ?? DEFAULT_MAX_PARTITIONS,
|
|
179
|
+
`connector "${connectorId}" callAdmission maxPartitions`,
|
|
180
|
+
);
|
|
181
|
+
if (rule.budget) {
|
|
182
|
+
if (rule.budget.kind !== "rolling-window") {
|
|
183
|
+
throw new TypeError(
|
|
184
|
+
`connector "${connectorId}" callAdmission budget kind must be "rolling-window".`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const maxCalls = positiveWhole(
|
|
188
|
+
rule.budget.maxCalls,
|
|
189
|
+
`connector "${connectorId}" callAdmission budget.maxCalls`,
|
|
190
|
+
);
|
|
191
|
+
const windowMs = positiveWhole(
|
|
192
|
+
rule.budget.windowMs,
|
|
193
|
+
`connector "${connectorId}" callAdmission budget.windowMs`,
|
|
194
|
+
);
|
|
195
|
+
this.budget = { maxCalls, windowMs };
|
|
196
|
+
} else {
|
|
197
|
+
this.budget = undefined;
|
|
198
|
+
}
|
|
199
|
+
this.partitionKey = rule.partitionKey;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
acquire(
|
|
203
|
+
input: Readonly<ConnectorCallAdmissionInput> & { signal?: AbortSignal },
|
|
204
|
+
): Promise<CallAdmissionPermit> {
|
|
205
|
+
// Copy the signal out before constructing any waiter closure. Referencing
|
|
206
|
+
// `input` from a queued callback would retain its `args`, defeating the
|
|
207
|
+
// limiter's payload-free state contract.
|
|
208
|
+
const signal = input.signal;
|
|
209
|
+
if (this.closed) {
|
|
210
|
+
return Promise.reject(
|
|
211
|
+
new CallAdmissionError(
|
|
212
|
+
"closed",
|
|
213
|
+
"unavailable",
|
|
214
|
+
`Connector "${this.connectorId}" call admission is closed.`,
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (signal?.aborted) {
|
|
219
|
+
this.cancelledTotal++;
|
|
220
|
+
return Promise.reject(this.cancelled(signal));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let key: string;
|
|
224
|
+
try {
|
|
225
|
+
key = this.partitionKey
|
|
226
|
+
? this.partitionKey({
|
|
227
|
+
toolName: input.toolName,
|
|
228
|
+
args: input.args,
|
|
229
|
+
})
|
|
230
|
+
: DEFAULT_PARTITION_KEY;
|
|
231
|
+
} catch (cause) {
|
|
232
|
+
this.rejectedTotal++;
|
|
233
|
+
return Promise.reject(
|
|
234
|
+
new CallAdmissionError(
|
|
235
|
+
"partition",
|
|
236
|
+
"connector_call_failed",
|
|
237
|
+
`Connector "${this.connectorId}" call-admission partitionKey threw.`,
|
|
238
|
+
{ cause },
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
typeof key !== "string" ||
|
|
244
|
+
enc.encode(key).length > MAX_PARTITION_KEY_BYTES
|
|
245
|
+
) {
|
|
246
|
+
this.rejectedTotal++;
|
|
247
|
+
return Promise.reject(
|
|
248
|
+
new CallAdmissionError(
|
|
249
|
+
"partition",
|
|
250
|
+
"connector_call_failed",
|
|
251
|
+
`Connector "${this.connectorId}" call-admission partitionKey must return a string of at most ${MAX_PARTITION_KEY_BYTES} UTF-8 bytes.`,
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
// A partition callback is operator code and may synchronously abort the
|
|
256
|
+
// caller. Recheck after it returns so that cancellation cannot consume a
|
|
257
|
+
// budget entry or concurrency slot.
|
|
258
|
+
if (signal?.aborted) {
|
|
259
|
+
this.cancelledTotal++;
|
|
260
|
+
return Promise.reject(this.cancelled(signal));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
let state = this.partitions.get(key);
|
|
265
|
+
if (!state) {
|
|
266
|
+
this.evictIdlePartitions(now);
|
|
267
|
+
if (this.partitions.size >= this.maxPartitions) {
|
|
268
|
+
this.rejectedTotal++;
|
|
269
|
+
return Promise.reject(
|
|
270
|
+
new CallAdmissionError(
|
|
271
|
+
"partition",
|
|
272
|
+
"rate_limited",
|
|
273
|
+
`Connector "${this.connectorId}" call-admission partition capacity is exhausted.`,
|
|
274
|
+
{ retryAfterMs: this.retryAfterMs },
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
state = {
|
|
279
|
+
active: 0,
|
|
280
|
+
waiters: [],
|
|
281
|
+
admittedAt: [],
|
|
282
|
+
};
|
|
283
|
+
this.partitions.set(key, state);
|
|
284
|
+
}
|
|
285
|
+
this.pruneBudget(state, now);
|
|
286
|
+
const budgetRetryAfterMs = this.budgetRetryAfterMs(state, now);
|
|
287
|
+
if (budgetRetryAfterMs !== undefined) {
|
|
288
|
+
this.rateLimitedTotal++;
|
|
289
|
+
return Promise.reject(this.budgetLimited(budgetRetryAfterMs));
|
|
290
|
+
}
|
|
291
|
+
if (
|
|
292
|
+
this.maxConcurrency === undefined ||
|
|
293
|
+
state.active < this.maxConcurrency
|
|
294
|
+
) {
|
|
295
|
+
return Promise.resolve(this.admit(state, now, 0));
|
|
296
|
+
}
|
|
297
|
+
if (state.waiters.length >= this.maxQueueSize) {
|
|
298
|
+
this.rejectedTotal++;
|
|
299
|
+
return Promise.reject(this.concurrencyLimited("queue is full"));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return new Promise<CallAdmissionPermit>((resolve, reject) => {
|
|
303
|
+
const waiter: Waiter = {
|
|
304
|
+
queuedAt: now,
|
|
305
|
+
resolve,
|
|
306
|
+
reject,
|
|
307
|
+
...(signal ? { signal } : {}),
|
|
308
|
+
};
|
|
309
|
+
waiter.onAbort = () => {
|
|
310
|
+
if (!this.removeWaiter(state!, waiter)) return;
|
|
311
|
+
this.cleanupWaiter(waiter);
|
|
312
|
+
this.cancelledTotal++;
|
|
313
|
+
reject(this.cancelled(signal!));
|
|
314
|
+
this.maybeDeletePartition(key, state!, Date.now());
|
|
315
|
+
};
|
|
316
|
+
waiter.timer = setTimeout(() => {
|
|
317
|
+
if (!this.removeWaiter(state!, waiter)) return;
|
|
318
|
+
this.cleanupWaiter(waiter);
|
|
319
|
+
this.rejectedTotal++;
|
|
320
|
+
reject(
|
|
321
|
+
this.concurrencyLimited(
|
|
322
|
+
`queue wait exceeded ${this.queueTimeoutMs}ms`,
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
this.maybeDeletePartition(key, state!, Date.now());
|
|
326
|
+
}, this.queueTimeoutMs);
|
|
327
|
+
state!.waiters.push(waiter);
|
|
328
|
+
this.queuedTotal++;
|
|
329
|
+
signal?.addEventListener("abort", waiter.onAbort, { once: true });
|
|
330
|
+
// Close the check-to-listener race: an abort before registration is not
|
|
331
|
+
// replayed by AbortSignal, so inspect it once after the waiter is fully
|
|
332
|
+
// removable and its timer is installed.
|
|
333
|
+
if (signal?.aborted) waiter.onAbort();
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
snapshot(): ConnectorCallAdmissionSnapshot {
|
|
338
|
+
let active = 0;
|
|
339
|
+
let queued = 0;
|
|
340
|
+
for (const state of this.partitions.values()) {
|
|
341
|
+
active += state.active;
|
|
342
|
+
queued += state.waiters.length;
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
rules: 1,
|
|
346
|
+
partitions: this.partitions.size,
|
|
347
|
+
active,
|
|
348
|
+
queued,
|
|
349
|
+
closed: this.closed,
|
|
350
|
+
totals: {
|
|
351
|
+
admitted: this.admittedTotal,
|
|
352
|
+
queued: this.queuedTotal,
|
|
353
|
+
rejected: this.rejectedTotal,
|
|
354
|
+
rateLimited: this.rateLimitedTotal,
|
|
355
|
+
cancelled: this.cancelledTotal,
|
|
356
|
+
},
|
|
357
|
+
queueWaitMs: {
|
|
358
|
+
count: this.queueWaitCount,
|
|
359
|
+
total: this.queueWaitTotalMs,
|
|
360
|
+
max: this.queueWaitMaxMs,
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
close(): void {
|
|
366
|
+
if (this.closed) return;
|
|
367
|
+
this.closed = true;
|
|
368
|
+
for (const state of this.partitions.values()) {
|
|
369
|
+
for (const waiter of state.waiters.splice(0)) {
|
|
370
|
+
this.cleanupWaiter(waiter);
|
|
371
|
+
waiter.reject(
|
|
372
|
+
new CallAdmissionError(
|
|
373
|
+
"closed",
|
|
374
|
+
"unavailable",
|
|
375
|
+
`Connector "${this.connectorId}" call admission is closed.`,
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private admit(
|
|
383
|
+
state: PartitionState,
|
|
384
|
+
now: number,
|
|
385
|
+
waitMs: number,
|
|
386
|
+
): CallAdmissionPermit {
|
|
387
|
+
state.active++;
|
|
388
|
+
if (this.budget) state.admittedAt.push(now);
|
|
389
|
+
this.admittedTotal++;
|
|
390
|
+
if (waitMs > 0) {
|
|
391
|
+
this.queueWaitCount++;
|
|
392
|
+
this.queueWaitTotalMs += waitMs;
|
|
393
|
+
this.queueWaitMaxMs = Math.max(this.queueWaitMaxMs, waitMs);
|
|
394
|
+
}
|
|
395
|
+
let released = false;
|
|
396
|
+
return {
|
|
397
|
+
waitMs,
|
|
398
|
+
release: () => {
|
|
399
|
+
if (released) return;
|
|
400
|
+
released = true;
|
|
401
|
+
if (state.active > 0) state.active--;
|
|
402
|
+
this.pump(state);
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private pump(state: PartitionState): void {
|
|
408
|
+
if (this.closed || this.maxConcurrency === undefined) return;
|
|
409
|
+
while (
|
|
410
|
+
state.active < this.maxConcurrency &&
|
|
411
|
+
state.waiters.length > 0
|
|
412
|
+
) {
|
|
413
|
+
const waiter = state.waiters.shift()!;
|
|
414
|
+
this.cleanupWaiter(waiter);
|
|
415
|
+
if (waiter.signal?.aborted) {
|
|
416
|
+
this.cancelledTotal++;
|
|
417
|
+
waiter.reject(this.cancelled(waiter.signal));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
this.pruneBudget(state, now);
|
|
422
|
+
const retryAfterMs = this.budgetRetryAfterMs(state, now);
|
|
423
|
+
if (retryAfterMs !== undefined) {
|
|
424
|
+
this.rateLimitedTotal++;
|
|
425
|
+
waiter.reject(this.budgetLimited(retryAfterMs));
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const waitMs = Math.max(0, now - waiter.queuedAt);
|
|
429
|
+
waiter.resolve(this.admit(state, now, waitMs));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private pruneBudget(state: PartitionState, now: number): void {
|
|
434
|
+
const budget = this.budget;
|
|
435
|
+
if (!budget || state.admittedAt.length === 0) return;
|
|
436
|
+
let expired = 0;
|
|
437
|
+
while (
|
|
438
|
+
expired < state.admittedAt.length &&
|
|
439
|
+
state.admittedAt[expired] + budget.windowMs <= now
|
|
440
|
+
) {
|
|
441
|
+
expired++;
|
|
442
|
+
}
|
|
443
|
+
if (expired > 0) state.admittedAt.splice(0, expired);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private budgetRetryAfterMs(
|
|
447
|
+
state: PartitionState,
|
|
448
|
+
now: number,
|
|
449
|
+
): number | undefined {
|
|
450
|
+
const budget = this.budget;
|
|
451
|
+
if (!budget || state.admittedAt.length < budget.maxCalls) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
return Math.max(0, state.admittedAt[0] + budget.windowMs - now);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private evictIdlePartitions(now: number): void {
|
|
458
|
+
for (const [key, state] of this.partitions) {
|
|
459
|
+
this.pruneBudget(state, now);
|
|
460
|
+
this.maybeDeletePartition(key, state, now);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private maybeDeletePartition(
|
|
465
|
+
key: string,
|
|
466
|
+
state: PartitionState,
|
|
467
|
+
now: number,
|
|
468
|
+
): void {
|
|
469
|
+
this.pruneBudget(state, now);
|
|
470
|
+
if (
|
|
471
|
+
state.active === 0 &&
|
|
472
|
+
state.waiters.length === 0 &&
|
|
473
|
+
state.admittedAt.length === 0
|
|
474
|
+
) {
|
|
475
|
+
this.partitions.delete(key);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private removeWaiter(state: PartitionState, waiter: Waiter): boolean {
|
|
480
|
+
const index = state.waiters.indexOf(waiter);
|
|
481
|
+
if (index < 0) return false;
|
|
482
|
+
state.waiters.splice(index, 1);
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private cleanupWaiter(waiter: Waiter): void {
|
|
487
|
+
if (waiter.timer !== undefined) clearTimeout(waiter.timer);
|
|
488
|
+
if (waiter.onAbort) {
|
|
489
|
+
waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private concurrencyLimited(reason: string): CallAdmissionError {
|
|
494
|
+
return new CallAdmissionError(
|
|
495
|
+
"concurrency",
|
|
496
|
+
"rate_limited",
|
|
497
|
+
`Connector "${this.connectorId}" call concurrency ${reason}.`,
|
|
498
|
+
{ retryAfterMs: this.retryAfterMs },
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private budgetLimited(retryAfterMs: number): CallAdmissionError {
|
|
503
|
+
return new CallAdmissionError(
|
|
504
|
+
"budget",
|
|
505
|
+
"rate_limited",
|
|
506
|
+
`Connector "${this.connectorId}" rolling call budget is exhausted.`,
|
|
507
|
+
{ retryAfterMs },
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private cancelled(signal: AbortSignal): CallAdmissionError {
|
|
512
|
+
return new CallAdmissionError(
|
|
513
|
+
"cancelled",
|
|
514
|
+
"timeout",
|
|
515
|
+
`Connector "${this.connectorId}" call was cancelled before admission.`,
|
|
516
|
+
{ cause: signal.reason },
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
package/src/connectors/api.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { precompileValidator, validateToolInput } from "../validate.js";
|
|
2
2
|
import type {
|
|
3
3
|
Connector,
|
|
4
|
+
ConnectorCallAdmissionPolicy,
|
|
4
5
|
ConnectorCredentialConfig,
|
|
5
6
|
ConnectorCredentialValues,
|
|
6
7
|
ConnectorContext,
|
|
@@ -37,6 +38,8 @@ export interface ApiOptions {
|
|
|
37
38
|
* and is ignored.
|
|
38
39
|
*/
|
|
39
40
|
maxResultBytes?: number;
|
|
41
|
+
/** Optional per-runtime downstream call-admission policy. */
|
|
42
|
+
callAdmission?: ConnectorCallAdmissionPolicy;
|
|
40
43
|
/**
|
|
41
44
|
* Optional agent-facing usage guide (markdown) served by the `skills`
|
|
42
45
|
* meta-tool as `connector:<id>`. See `Connector.usageGuide`.
|
|
@@ -111,6 +114,7 @@ export function api(id: string, opts: ApiOptions): Connector {
|
|
|
111
114
|
kind: "api",
|
|
112
115
|
description: opts.description,
|
|
113
116
|
maxResultBytes: opts.maxResultBytes,
|
|
117
|
+
callAdmission: opts.callAdmission,
|
|
114
118
|
usageGuide: opts.usageGuide,
|
|
115
119
|
credential: opts.credential,
|
|
116
120
|
testCredential: opts.testCredential,
|
|
@@ -13,6 +13,7 @@ import { ConnectorCallError } from "../errors.js";
|
|
|
13
13
|
import { CONNECTA_VERSION } from "../version.js";
|
|
14
14
|
import type {
|
|
15
15
|
Connector,
|
|
16
|
+
ConnectorCallAdmissionPolicy,
|
|
16
17
|
ConnectorContext,
|
|
17
18
|
ConnectorStatus,
|
|
18
19
|
Logger,
|
|
@@ -38,6 +39,8 @@ export interface RemoteMcpOptions {
|
|
|
38
39
|
* and is ignored.
|
|
39
40
|
*/
|
|
40
41
|
maxResultBytes?: number;
|
|
42
|
+
/** Optional per-runtime downstream call-admission policy. */
|
|
43
|
+
callAdmission?: ConnectorCallAdmissionPolicy;
|
|
41
44
|
/**
|
|
42
45
|
* Optional agent-facing usage guide (markdown) served by the `skills`
|
|
43
46
|
* meta-tool as `connector:<id>`. See `Connector.usageGuide`.
|
|
@@ -743,6 +746,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
743
746
|
kind: "mcp",
|
|
744
747
|
description: opts.description,
|
|
745
748
|
maxResultBytes: opts.maxResultBytes,
|
|
749
|
+
callAdmission: opts.callAdmission,
|
|
746
750
|
usageGuide: opts.usageGuide,
|
|
747
751
|
|
|
748
752
|
// `tools/list` is cursor-paginated: the server chooses the page size and
|
package/src/execute.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
truncateExecuteText,
|
|
17
17
|
} from "./executor-result.js";
|
|
18
18
|
import { classifyCallError, ConnectorCallError } from "./errors.js";
|
|
19
|
+
import { isCallAdmissionError } from "./call-admission.js";
|
|
19
20
|
import {
|
|
20
21
|
ExecutorAdmissionError,
|
|
21
22
|
isAdmittingExecutor,
|
|
@@ -192,27 +193,37 @@ export async function buildSandboxProviders(
|
|
|
192
193
|
const controller = new AbortController();
|
|
193
194
|
const cancel = () => controller.abort(limits.signal?.reason);
|
|
194
195
|
limits.signal?.addEventListener("abort", cancel, { once: true });
|
|
196
|
+
if (limits.signal?.aborted) cancel();
|
|
195
197
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
196
|
-
let
|
|
197
|
-
const cancelled = new Promise<never>((_, reject) => {
|
|
198
|
-
rejectCancelled = reject;
|
|
199
|
-
});
|
|
200
|
-
const onAbort = () => {
|
|
201
|
-
rejectCancelled(
|
|
202
|
-
controller.signal.reason instanceof Error
|
|
203
|
-
? controller.signal.reason
|
|
204
|
-
: new Error("execute_code host call cancelled"),
|
|
205
|
-
);
|
|
206
|
-
};
|
|
207
|
-
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
208
|
-
const ctx = registry.contextFor(
|
|
209
|
-
resolved.connector.id,
|
|
210
|
-
baseUrl,
|
|
211
|
-
requestScope,
|
|
212
|
-
{ signal: controller.signal, timeoutMs: hostCallTimeoutMs },
|
|
213
|
-
);
|
|
198
|
+
let onAbort: (() => void) | undefined;
|
|
214
199
|
const started = Date.now();
|
|
200
|
+
let permit: Awaited<ReturnType<RegistryView["admitCall"]>> | undefined;
|
|
215
201
|
try {
|
|
202
|
+
permit = await registry.admitCall(resolved.connector.id, {
|
|
203
|
+
toolName: resolved.toolName,
|
|
204
|
+
args: args ?? {},
|
|
205
|
+
signal: controller.signal,
|
|
206
|
+
});
|
|
207
|
+
let rejectCancelled!: (reason: Error) => void;
|
|
208
|
+
const cancelled = new Promise<never>((_, reject) => {
|
|
209
|
+
rejectCancelled = reject;
|
|
210
|
+
});
|
|
211
|
+
onAbort = () => {
|
|
212
|
+
rejectCancelled(
|
|
213
|
+
controller.signal.reason instanceof Error
|
|
214
|
+
? controller.signal.reason
|
|
215
|
+
: new Error("execute_code host call cancelled"),
|
|
216
|
+
);
|
|
217
|
+
};
|
|
218
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
219
|
+
if (controller.signal.aborted) onAbort();
|
|
220
|
+
if (controller.signal.aborted) await cancelled;
|
|
221
|
+
const ctx = registry.contextFor(
|
|
222
|
+
resolved.connector.id,
|
|
223
|
+
baseUrl,
|
|
224
|
+
requestScope,
|
|
225
|
+
{ signal: controller.signal, timeoutMs: hostCallTimeoutMs },
|
|
226
|
+
);
|
|
216
227
|
timer = setTimeout(() => {
|
|
217
228
|
controller.abort(
|
|
218
229
|
new ConnectorCallError(
|
|
@@ -242,14 +253,34 @@ export async function buildSandboxProviders(
|
|
|
242
253
|
});
|
|
243
254
|
return value;
|
|
244
255
|
} catch (err) {
|
|
245
|
-
|
|
246
|
-
|
|
256
|
+
const callerCancelled =
|
|
257
|
+
limits.signal?.aborted === true ||
|
|
258
|
+
(isCallAdmissionError(err) && err.admissionKind === "cancelled");
|
|
259
|
+
if (!callerCancelled && !isCallAdmissionError(err)) {
|
|
260
|
+
registry.recordFailure(
|
|
261
|
+
resolved.connector.id,
|
|
262
|
+
Date.now() - started,
|
|
263
|
+
err,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const details = callerCancelled
|
|
267
|
+
? {
|
|
268
|
+
code: "cancelled",
|
|
269
|
+
message: "Tool call was cancelled by the caller.",
|
|
270
|
+
retryable: false,
|
|
271
|
+
}
|
|
272
|
+
: classifyCallError(err);
|
|
247
273
|
recordToolActivity(activity, {
|
|
248
274
|
connectorId: resolved.connector.id,
|
|
249
275
|
toolName: resolved.toolName,
|
|
250
276
|
address: `${resolved.connector.id}.${resolved.toolName}`,
|
|
251
277
|
source: "execute_code",
|
|
252
|
-
outcome:
|
|
278
|
+
outcome:
|
|
279
|
+
details.code === "timeout"
|
|
280
|
+
? "timeout"
|
|
281
|
+
: details.code === "cancelled"
|
|
282
|
+
? "cancelled"
|
|
283
|
+
: "error",
|
|
253
284
|
durationMs: Date.now() - started,
|
|
254
285
|
attempts: 1,
|
|
255
286
|
errorCode: details.code,
|
|
@@ -257,8 +288,9 @@ export async function buildSandboxProviders(
|
|
|
257
288
|
throw err;
|
|
258
289
|
} finally {
|
|
259
290
|
if (timer) clearTimeout(timer);
|
|
260
|
-
controller.signal.removeEventListener("abort", onAbort);
|
|
291
|
+
if (onAbort) controller.signal.removeEventListener("abort", onAbort);
|
|
261
292
|
limits.signal?.removeEventListener("abort", cancel);
|
|
293
|
+
permit?.release();
|
|
262
294
|
}
|
|
263
295
|
};
|
|
264
296
|
for (let i = 0; i < connectors.length; i++) {
|