@zackbart/connecta 0.7.7 → 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 +88 -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/catalog.d.ts +5 -3
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +13 -4
- package/dist/catalog.js.map +1 -1
- 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 +72 -30
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +7 -4
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +212 -54
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +37 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +45 -2
- 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/catalog.ts +24 -4
- package/src/connectors/api.ts +4 -0
- package/src/connectors/remote-mcp.ts +4 -0
- package/src/execute.ts +83 -35
- package/src/index.ts +14 -1
- package/src/meta-tools.ts +246 -70
- package/src/registry.ts +91 -1
- 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/meta-tools.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
messageLooksRetryable,
|
|
18
18
|
type CallErrorDetails,
|
|
19
19
|
} from "./errors.js";
|
|
20
|
+
import { isCallAdmissionError } from "./call-admission.js";
|
|
20
21
|
import {
|
|
21
22
|
isValidMaxResultBytes,
|
|
22
23
|
MIN_MAX_RESULT_BYTES,
|
|
@@ -228,6 +229,24 @@ function errorDetails(code: string, message: string): ErrorDetails {
|
|
|
228
229
|
return { code, message, retryable: messageLooksRetryable(message) };
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
function callerCancelledDetails(): ErrorDetails {
|
|
233
|
+
return {
|
|
234
|
+
code: "cancelled",
|
|
235
|
+
message: "Tool call was cancelled by the caller.",
|
|
236
|
+
retryable: false,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function isCallerCancellation(
|
|
241
|
+
error: unknown,
|
|
242
|
+
signal: AbortSignal | undefined,
|
|
243
|
+
): boolean {
|
|
244
|
+
return (
|
|
245
|
+
signal?.aborted === true ||
|
|
246
|
+
(isCallAdmissionError(error) && error.admissionKind === "cancelled")
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
231
250
|
/** True if `b` is a UTF-8 continuation byte (0b10xxxxxx). */
|
|
232
251
|
function isContinuationByte(b: number): boolean {
|
|
233
252
|
return (b & 0xc0) === 0x80;
|
|
@@ -406,6 +425,15 @@ async function stashResult(
|
|
|
406
425
|
};
|
|
407
426
|
}
|
|
408
427
|
|
|
428
|
+
/** Keep an oversized batch's inline outcome summary at fixed string overhead. */
|
|
429
|
+
function batchSummaryString(value: string): string {
|
|
430
|
+
const bytes = enc.encode(value);
|
|
431
|
+
const maxBytes = 512;
|
|
432
|
+
if (bytes.length <= maxBytes) return value;
|
|
433
|
+
const end = alignEndToCharBoundary(bytes, 0, maxBytes, bytes.length);
|
|
434
|
+
return `${dec.decode(bytes.slice(0, end))}…`;
|
|
435
|
+
}
|
|
436
|
+
|
|
409
437
|
/**
|
|
410
438
|
* Return `text` as a single content block; if it exceeds `cap` bytes, stash the
|
|
411
439
|
* full text and return the first `cap` bytes followed by a JSON truncation
|
|
@@ -575,10 +603,10 @@ export interface SkillArgs {
|
|
|
575
603
|
* supplies a deadline for calls that don't carry one. (execute_code, the
|
|
576
604
|
* optional tenth tool, is registered separately by registerExecuteTool.)
|
|
577
605
|
*
|
|
578
|
-
*
|
|
579
|
-
* passed in: `ConnectaConfig.calls.maxResultBytes
|
|
580
|
-
*
|
|
581
|
-
*
|
|
606
|
+
* Deployment-wide result-size caps are read off the registry view rather than
|
|
607
|
+
* passed in: `ConnectaConfig.calls.maxResultBytes`, its per-connector override,
|
|
608
|
+
* and the independent `calls.maxBatchResultBytes` final-envelope boundary each
|
|
609
|
+
* have one runtime source of truth.
|
|
582
610
|
*/
|
|
583
611
|
export function createMetaTools(
|
|
584
612
|
registry: RegistryView,
|
|
@@ -589,12 +617,15 @@ export function createMetaTools(
|
|
|
589
617
|
/** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
|
|
590
618
|
probeTimeoutMs?: number;
|
|
591
619
|
activity?: ActivityRequestContext;
|
|
620
|
+
/** Inbound request cancellation shared by direct and batch child calls. */
|
|
621
|
+
requestSignal?: AbortSignal;
|
|
592
622
|
/** Runtime continuation for the bounded tail of probe-owned teardown. */
|
|
593
623
|
defer?: DeferredWork;
|
|
594
624
|
} = {},
|
|
595
625
|
) {
|
|
596
626
|
// Already normalized and warned about at registry construction.
|
|
597
627
|
const globalCap = registry.maxResultBytes;
|
|
628
|
+
const batchCap = registry.maxBatchResultBytes;
|
|
598
629
|
const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
|
|
599
630
|
const probeTimeoutMs =
|
|
600
631
|
normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
@@ -621,6 +652,7 @@ export function createMetaTools(
|
|
|
621
652
|
attempts: number;
|
|
622
653
|
timing: {
|
|
623
654
|
catalogMs: number;
|
|
655
|
+
admissionMs: number;
|
|
624
656
|
connectorMs: number;
|
|
625
657
|
backoffMs: number;
|
|
626
658
|
resultProcessingMs: number;
|
|
@@ -638,12 +670,14 @@ export function createMetaTools(
|
|
|
638
670
|
): Promise<RunCallOutcome> {
|
|
639
671
|
const started = Date.now();
|
|
640
672
|
let catalogMs = 0;
|
|
673
|
+
let admissionMs = 0;
|
|
641
674
|
let connectorMs = 0;
|
|
642
675
|
let backoffMs = 0;
|
|
643
676
|
let resultProcessingMs = 0;
|
|
644
677
|
let attempts = 0;
|
|
645
678
|
const timing = () => ({
|
|
646
679
|
catalogMs,
|
|
680
|
+
admissionMs,
|
|
647
681
|
connectorMs,
|
|
648
682
|
backoffMs,
|
|
649
683
|
resultProcessingMs,
|
|
@@ -651,7 +685,7 @@ export function createMetaTools(
|
|
|
651
685
|
});
|
|
652
686
|
const resolved = registry.resolveAddress(call.address);
|
|
653
687
|
const record = (
|
|
654
|
-
outcome: "success" | "error" | "timeout",
|
|
688
|
+
outcome: "success" | "error" | "timeout" | "cancelled",
|
|
655
689
|
errorCode?: string,
|
|
656
690
|
) => {
|
|
657
691
|
if (!resolved) return;
|
|
@@ -669,7 +703,14 @@ export function createMetaTools(
|
|
|
669
703
|
const failed = (error: ErrorDetails): RunCallOutcome => {
|
|
670
704
|
const durationMs = Date.now() - started;
|
|
671
705
|
const diagnostics = timing();
|
|
672
|
-
record(
|
|
706
|
+
record(
|
|
707
|
+
error.code === "timeout"
|
|
708
|
+
? "timeout"
|
|
709
|
+
: error.code === "cancelled"
|
|
710
|
+
? "cancelled"
|
|
711
|
+
: "error",
|
|
712
|
+
error.code,
|
|
713
|
+
);
|
|
673
714
|
return {
|
|
674
715
|
toolResult:
|
|
675
716
|
call.resultMode === "value"
|
|
@@ -719,6 +760,9 @@ export function createMetaTools(
|
|
|
719
760
|
).find((tool) => tool.name === resolved.toolName);
|
|
720
761
|
} catch (err) {
|
|
721
762
|
catalogMs += Date.now() - catalogStarted;
|
|
763
|
+
if (opts.requestSignal?.aborted) {
|
|
764
|
+
return failed(callerCancelledDetails());
|
|
765
|
+
}
|
|
722
766
|
// A connector whose catalog cannot be fetched is as unusable as one whose
|
|
723
767
|
// execution fails, so it feeds health accounting the same way the
|
|
724
768
|
// execution catch below does — otherwise a connector every call_tool
|
|
@@ -764,38 +808,76 @@ export function createMetaTools(
|
|
|
764
808
|
let result: unknown;
|
|
765
809
|
while (true) {
|
|
766
810
|
attempts++;
|
|
767
|
-
|
|
811
|
+
let permit: Awaited<ReturnType<RegistryView["admitCall"]>> | undefined;
|
|
812
|
+
const controller =
|
|
813
|
+
timeoutMs || opts.requestSignal ? new AbortController() : undefined;
|
|
814
|
+
const forwardAbort = () =>
|
|
815
|
+
controller?.abort(opts.requestSignal?.reason);
|
|
816
|
+
if (opts.requestSignal?.aborted) forwardAbort();
|
|
817
|
+
else {
|
|
818
|
+
opts.requestSignal?.addEventListener("abort", forwardAbort, {
|
|
819
|
+
once: true,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
768
822
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
requestScope,
|
|
773
|
-
{ signal: controller?.signal, timeoutMs },
|
|
774
|
-
);
|
|
775
|
-
const connectorStarted = Date.now();
|
|
823
|
+
let onAbort: (() => void) | undefined;
|
|
824
|
+
let attemptFailed = false;
|
|
825
|
+
let attemptError: unknown;
|
|
776
826
|
try {
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
827
|
+
const admissionStarted = Date.now();
|
|
828
|
+
try {
|
|
829
|
+
permit = await registry.admitCall(resolved.connector.id, {
|
|
830
|
+
toolName: resolved.toolName,
|
|
831
|
+
args: call.args ?? {},
|
|
832
|
+
signal: opts.requestSignal,
|
|
833
|
+
});
|
|
834
|
+
} finally {
|
|
835
|
+
admissionMs += Date.now() - admissionStarted;
|
|
836
|
+
}
|
|
837
|
+
const ctx = registry.contextFor(
|
|
838
|
+
resolved.connector.id,
|
|
839
|
+
baseUrl,
|
|
840
|
+
requestScope,
|
|
841
|
+
{ signal: controller?.signal, timeoutMs },
|
|
781
842
|
);
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
843
|
+
let rejectCancelled!: (reason: unknown) => void;
|
|
844
|
+
const cancelled = controller
|
|
845
|
+
? new Promise<never>((_, reject) => {
|
|
846
|
+
rejectCancelled = reject;
|
|
847
|
+
})
|
|
848
|
+
: undefined;
|
|
849
|
+
onAbort = () => {
|
|
850
|
+
rejectCancelled(
|
|
851
|
+
controller?.signal.reason ??
|
|
852
|
+
new ConnectorCallError("timeout", "Tool call was cancelled"),
|
|
853
|
+
);
|
|
854
|
+
};
|
|
855
|
+
controller?.signal.addEventListener("abort", onAbort, { once: true });
|
|
856
|
+
if (controller?.signal.aborted) onAbort();
|
|
857
|
+
if (controller?.signal.aborted) await cancelled;
|
|
858
|
+
if (timeoutMs) {
|
|
859
|
+
timer = setTimeout(() => {
|
|
860
|
+
controller?.abort(
|
|
861
|
+
new ConnectorCallError(
|
|
862
|
+
"timeout",
|
|
863
|
+
`Tool call timed out after ${timeoutMs}ms`,
|
|
864
|
+
),
|
|
865
|
+
);
|
|
866
|
+
}, timeoutMs);
|
|
867
|
+
}
|
|
868
|
+
const connectorStarted = Date.now();
|
|
869
|
+
try {
|
|
870
|
+
const pending = resolved.connector.callTool(
|
|
871
|
+
resolved.toolName,
|
|
872
|
+
call.args ?? {},
|
|
873
|
+
ctx,
|
|
874
|
+
);
|
|
875
|
+
result = cancelled
|
|
876
|
+
? await Promise.race([pending, cancelled])
|
|
877
|
+
: await pending;
|
|
878
|
+
} finally {
|
|
879
|
+
connectorMs += Date.now() - connectorStarted;
|
|
880
|
+
}
|
|
799
881
|
const mcpResult = result as {
|
|
800
882
|
content?: TextContent[];
|
|
801
883
|
isError?: boolean;
|
|
@@ -806,34 +888,72 @@ export function createMetaTools(
|
|
|
806
888
|
"Downstream tool call failed",
|
|
807
889
|
);
|
|
808
890
|
}
|
|
809
|
-
break;
|
|
810
891
|
} catch (err) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
if (
|
|
892
|
+
attemptFailed = true;
|
|
893
|
+
attemptError = err;
|
|
894
|
+
} finally {
|
|
895
|
+
if (timer) clearTimeout(timer);
|
|
896
|
+
if (onAbort) {
|
|
897
|
+
controller?.signal.removeEventListener("abort", onAbort);
|
|
898
|
+
}
|
|
899
|
+
opts.requestSignal?.removeEventListener("abort", forwardAbort);
|
|
900
|
+
permit?.release();
|
|
901
|
+
}
|
|
902
|
+
if (attemptFailed) {
|
|
903
|
+
const callerCancelled = isCallerCancellation(
|
|
904
|
+
attemptError,
|
|
905
|
+
opts.requestSignal,
|
|
906
|
+
);
|
|
907
|
+
const details = callerCancelled
|
|
908
|
+
? callerCancelledDetails()
|
|
909
|
+
: classifyCallError(attemptError);
|
|
910
|
+
if (
|
|
911
|
+
!callerCancelled &&
|
|
912
|
+
attempts <= maxRetries &&
|
|
913
|
+
retrySafe &&
|
|
914
|
+
details.retryable
|
|
915
|
+
) {
|
|
815
916
|
const wait = retryBackoffMs(attempts, details.retryAfterMs);
|
|
816
917
|
if (wait !== undefined) {
|
|
817
918
|
const backoffStarted = Date.now();
|
|
818
919
|
if (wait > 0) {
|
|
819
|
-
await new Promise((resolve) =>
|
|
920
|
+
const completed = await new Promise<boolean>((resolve) => {
|
|
921
|
+
let settled = false;
|
|
922
|
+
const finish = (value: boolean) => {
|
|
923
|
+
if (settled) return;
|
|
924
|
+
settled = true;
|
|
925
|
+
clearTimeout(timer);
|
|
926
|
+
opts.requestSignal?.removeEventListener("abort", cancel);
|
|
927
|
+
resolve(value);
|
|
928
|
+
};
|
|
929
|
+
const timer = setTimeout(() => finish(true), wait);
|
|
930
|
+
const cancel = () => finish(false);
|
|
931
|
+
opts.requestSignal?.addEventListener("abort", cancel, {
|
|
932
|
+
once: true,
|
|
933
|
+
});
|
|
934
|
+
if (opts.requestSignal?.aborted) cancel();
|
|
935
|
+
});
|
|
936
|
+
backoffMs += Date.now() - backoffStarted;
|
|
937
|
+
if (!completed) return failed(callerCancelledDetails());
|
|
938
|
+
} else {
|
|
939
|
+
backoffMs += Date.now() - backoffStarted;
|
|
820
940
|
}
|
|
821
|
-
backoffMs += Date.now() - backoffStarted;
|
|
822
941
|
continue;
|
|
823
942
|
}
|
|
824
943
|
// The reported window is longer than the engine will park a
|
|
825
944
|
// synchronous request for. Fall through to failure with
|
|
826
945
|
// retryAfterMs reported verbatim so the agent can re-issue.
|
|
827
946
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
947
|
+
if (!callerCancelled && !isCallAdmissionError(attemptError)) {
|
|
948
|
+
registry.recordFailure(
|
|
949
|
+
resolved.connector.id,
|
|
950
|
+
Date.now() - started,
|
|
951
|
+
attemptError,
|
|
952
|
+
);
|
|
953
|
+
}
|
|
833
954
|
return failed(details);
|
|
834
|
-
} finally {
|
|
835
|
-
if (timer) clearTimeout(timer);
|
|
836
955
|
}
|
|
956
|
+
break;
|
|
837
957
|
}
|
|
838
958
|
|
|
839
959
|
registry.recordSuccess(resolved.connector.id, Date.now() - started);
|
|
@@ -1141,26 +1261,36 @@ export function createMetaTools(
|
|
|
1141
1261
|
),
|
|
1142
1262
|
),
|
|
1143
1263
|
);
|
|
1144
|
-
let
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
:
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1264
|
+
let matchMode: "all" | "partial" = "all";
|
|
1265
|
+
const collectMatches = (mode: "all" | "partial") => {
|
|
1266
|
+
matches.length = 0;
|
|
1267
|
+
let orderBase = 0;
|
|
1268
|
+
catalogs.forEach((catalog, connectorIndex) => {
|
|
1269
|
+
const c = conns[connectorIndex];
|
|
1270
|
+
if (catalog.status === "fulfilled") {
|
|
1271
|
+
for (const ranked of rankTools(catalog.value, q, mode)) {
|
|
1272
|
+
matches.push({
|
|
1273
|
+
connectorId: c.id,
|
|
1274
|
+
connectorTitle: c.title,
|
|
1275
|
+
connectorDescription: c.description,
|
|
1276
|
+
...(connectorGuide(c)
|
|
1277
|
+
? { connectorGuideSkill: connectorSkillName(c.id) }
|
|
1278
|
+
: {}),
|
|
1279
|
+
tool: ranked.tool,
|
|
1280
|
+
score: ranked.score,
|
|
1281
|
+
order: orderBase + ranked.order,
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1160
1284
|
}
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1285
|
+
orderBase +=
|
|
1286
|
+
catalog.status === "fulfilled" ? catalog.value.length : 1;
|
|
1287
|
+
});
|
|
1288
|
+
};
|
|
1289
|
+
collectMatches("all");
|
|
1290
|
+
if (q.trim() && matches.length === 0) {
|
|
1291
|
+
matchMode = "partial";
|
|
1292
|
+
collectMatches(matchMode);
|
|
1293
|
+
}
|
|
1164
1294
|
matches.sort((a, b) => b.score - a.score || a.order - b.order);
|
|
1165
1295
|
const page = matches.slice(offset, offset + limit);
|
|
1166
1296
|
const groups: {
|
|
@@ -1235,6 +1365,9 @@ export function createMetaTools(
|
|
|
1235
1365
|
limit,
|
|
1236
1366
|
hasMore: nextOffset !== undefined,
|
|
1237
1367
|
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
1368
|
+
...(matchMode === "partial" && matches.length > 0
|
|
1369
|
+
? { matchMode }
|
|
1370
|
+
: {}),
|
|
1238
1371
|
},
|
|
1239
1372
|
"Request a smaller limit, omit fullDescriptions, or use compact schemas.",
|
|
1240
1373
|
);
|
|
@@ -1457,9 +1590,50 @@ export function createMetaTools(
|
|
|
1457
1590
|
: {}),
|
|
1458
1591
|
};
|
|
1459
1592
|
});
|
|
1460
|
-
|
|
1593
|
+
const envelope = {
|
|
1461
1594
|
results,
|
|
1462
1595
|
durationMs: Date.now() - batchStarted,
|
|
1596
|
+
};
|
|
1597
|
+
const text = serializeResultText(envelope);
|
|
1598
|
+
const bytes = enc.encode(text);
|
|
1599
|
+
if (bytes.length <= batchCap) return jsonResult(envelope);
|
|
1600
|
+
|
|
1601
|
+
const notice = await stashResult(
|
|
1602
|
+
text,
|
|
1603
|
+
registry.resultsStorage(),
|
|
1604
|
+
bytes.length,
|
|
1605
|
+
);
|
|
1606
|
+
return jsonResult({
|
|
1607
|
+
results: results.map((result) => {
|
|
1608
|
+
const common = {
|
|
1609
|
+
address: batchSummaryString(result.address),
|
|
1610
|
+
ok: !("error" in result),
|
|
1611
|
+
...("durationMs" in result
|
|
1612
|
+
? { durationMs: result.durationMs }
|
|
1613
|
+
: {}),
|
|
1614
|
+
...("attempts" in result ? { attempts: result.attempts } : {}),
|
|
1615
|
+
...("timing" in result ? { timing: result.timing } : {}),
|
|
1616
|
+
};
|
|
1617
|
+
if (!("error" in result)) return common;
|
|
1618
|
+
const error = result.error ?? "Batch call failed";
|
|
1619
|
+
const details =
|
|
1620
|
+
result.errorDetails ??
|
|
1621
|
+
errorDetails("batch_call_failed", error);
|
|
1622
|
+
return {
|
|
1623
|
+
...common,
|
|
1624
|
+
error: batchSummaryString(error),
|
|
1625
|
+
errorDetails: {
|
|
1626
|
+
code: batchSummaryString(details.code),
|
|
1627
|
+
message: batchSummaryString(details.message),
|
|
1628
|
+
retryable: details.retryable,
|
|
1629
|
+
...(details.retryAfterMs !== undefined
|
|
1630
|
+
? { retryAfterMs: details.retryAfterMs }
|
|
1631
|
+
: {}),
|
|
1632
|
+
},
|
|
1633
|
+
};
|
|
1634
|
+
}),
|
|
1635
|
+
durationMs: envelope.durationMs,
|
|
1636
|
+
...notice,
|
|
1463
1637
|
});
|
|
1464
1638
|
},
|
|
1465
1639
|
|
|
@@ -1527,7 +1701,7 @@ const CALL_DESTRUCTIVE_DESC =
|
|
|
1527
1701
|
const GET_RESULT_DESC =
|
|
1528
1702
|
"Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. maxBytes is a whole number of bytes >= 1 (omit for the deployment default) and offset a whole number of bytes >= 0; an offset inside a multi-byte character is moved back to that character's first byte and the offset served is returned. Unknown/expired id is an error.";
|
|
1529
1703
|
const BATCH_DESC =
|
|
1530
|
-
"Use for 2–10 independent tools explicitly annotated readOnlyHint: true. Calls run in parallel with shared request-scoped clients; use execute_code when available instead for dependencies or in-sandbox reduction. Unannotated, write-capable, and destructive tools are refused. Batch timeout, safe retry, result mode, and diagnostics defaults may be overridden per call.";
|
|
1704
|
+
"Use for 2–10 independent tools explicitly annotated readOnlyHint: true. Calls run in parallel with shared request-scoped clients; use execute_code when available instead for dependencies or in-sandbox reduction. Unannotated, write-capable, and destructive tools are refused. Batch timeout, safe retry, result mode, and diagnostics defaults may be overridden per call. An oversized final envelope returns ordered outcome summaries plus a get_result page handle.";
|
|
1531
1705
|
const AUTHORIZE_DESC =
|
|
1532
1706
|
"Use after a connector reports auth_required. Starts downstream OAuth and returns an authorizationUrl for the operator to open. force=true wipes stored credentials first and restarts consent.";
|
|
1533
1707
|
const SKILLS_DESC =
|
|
@@ -1593,6 +1767,7 @@ export function registerMetaTools(
|
|
|
1593
1767
|
defaultToolTimeoutMs?: number;
|
|
1594
1768
|
probeTimeoutMs?: number;
|
|
1595
1769
|
activity?: ActivityRequestContext;
|
|
1770
|
+
requestSignal?: AbortSignal;
|
|
1596
1771
|
defer?: DeferredWork;
|
|
1597
1772
|
},
|
|
1598
1773
|
): void {
|
|
@@ -1600,6 +1775,7 @@ export function registerMetaTools(
|
|
|
1600
1775
|
defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
|
|
1601
1776
|
probeTimeoutMs: ctx.probeTimeoutMs,
|
|
1602
1777
|
activity: ctx.activity,
|
|
1778
|
+
requestSignal: ctx.requestSignal,
|
|
1603
1779
|
defer: ctx.defer,
|
|
1604
1780
|
});
|
|
1605
1781
|
|
package/src/registry.ts
CHANGED
|
@@ -18,6 +18,11 @@ import {
|
|
|
18
18
|
type CredentialHealthConfig,
|
|
19
19
|
type CredentialHealthRecord,
|
|
20
20
|
} from "./credential-health.js";
|
|
21
|
+
import {
|
|
22
|
+
ConnectorCallAdmissionController,
|
|
23
|
+
type CallAdmissionPermit,
|
|
24
|
+
type ConnectorCallAdmissionSnapshot,
|
|
25
|
+
} from "./call-admission.js";
|
|
21
26
|
import type { DeferredWork } from "./connector-scope.js";
|
|
22
27
|
import { splitAddress, type Toolkit } from "./toolkits.js";
|
|
23
28
|
|
|
@@ -25,6 +30,8 @@ const ID_RE = /^[a-z0-9_-]+$/;
|
|
|
25
30
|
const DEFAULT_TTL_SECONDS = 300;
|
|
26
31
|
const DEFAULT_STALE_SECONDS = 3600;
|
|
27
32
|
export const DEFAULT_MAX_RESULT_BYTES = 50_000;
|
|
33
|
+
/** Independent final-envelope boundary for `batch_call`. */
|
|
34
|
+
export const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
|
|
28
35
|
|
|
29
36
|
/**
|
|
30
37
|
* Smallest accepted inline-result cap. One byte is pathological but harmless:
|
|
@@ -143,6 +150,11 @@ export interface RegistryOptions {
|
|
|
143
150
|
* to the default 50_000.
|
|
144
151
|
*/
|
|
145
152
|
maxResultBytes?: number;
|
|
153
|
+
/**
|
|
154
|
+
* Cap on the complete serialized batch_call envelope. Must be a whole number
|
|
155
|
+
* of bytes >= 1; anything else warns and falls back to 100_000.
|
|
156
|
+
*/
|
|
157
|
+
maxBatchResultBytes?: number;
|
|
146
158
|
/** Tuning for the credential liveness checks (issue #24). */
|
|
147
159
|
credentialHealth?: CredentialHealthConfig;
|
|
148
160
|
}
|
|
@@ -179,6 +191,8 @@ type ConnectorOperationOptions = Pick<
|
|
|
179
191
|
export interface RegistryView {
|
|
180
192
|
/** Deployment-wide result-size cap threaded to the meta-tools. */
|
|
181
193
|
readonly maxResultBytes: number;
|
|
194
|
+
/** Independent cap for the complete serialized batch_call envelope. */
|
|
195
|
+
readonly maxBatchResultBytes: number;
|
|
182
196
|
listConnectors(): Connector[];
|
|
183
197
|
getConnector(id: string): Connector | undefined;
|
|
184
198
|
resolveAddress(
|
|
@@ -203,6 +217,14 @@ export interface RegistryView {
|
|
|
203
217
|
requestScope?: object,
|
|
204
218
|
callOptions?: ConnectorOperationOptions,
|
|
205
219
|
): ConnectorContext;
|
|
220
|
+
/**
|
|
221
|
+
* Acquire the connector's shared downstream-call permit. Scoped views
|
|
222
|
+
* delegate to the base registry so every toolkit contends on the same pool.
|
|
223
|
+
*/
|
|
224
|
+
admitCall(
|
|
225
|
+
id: string,
|
|
226
|
+
input: { toolName: string; args: unknown; signal?: AbortSignal },
|
|
227
|
+
): Promise<CallAdmissionPermit>;
|
|
206
228
|
resultsStorage(): KVStorage;
|
|
207
229
|
recordSuccess(id: string, latencyMs: number): void;
|
|
208
230
|
recordFailure(id: string, latencyMs: number, error: unknown): void;
|
|
@@ -233,6 +255,10 @@ export interface RegistryView {
|
|
|
233
255
|
*/
|
|
234
256
|
export class Registry implements RegistryView {
|
|
235
257
|
private readonly connectors = new Map<string, Connector>();
|
|
258
|
+
private readonly callAdmission = new Map<
|
|
259
|
+
string,
|
|
260
|
+
ConnectorCallAdmissionController
|
|
261
|
+
>();
|
|
236
262
|
private readonly cache = new Map<string, CacheEntry>();
|
|
237
263
|
private readonly invalidated = new Set<string>();
|
|
238
264
|
/** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
|
|
@@ -246,6 +272,8 @@ export class Registry implements RegistryView {
|
|
|
246
272
|
private readonly persistToolCatalog: boolean;
|
|
247
273
|
/** Result-size guard cap threaded to the meta-tools. */
|
|
248
274
|
readonly maxResultBytes: number;
|
|
275
|
+
/** Final batch envelope cap threaded to the meta-tools. */
|
|
276
|
+
readonly maxBatchResultBytes: number;
|
|
249
277
|
/** Proactive liveness checks over stored downstream credentials (issue #24). */
|
|
250
278
|
private readonly credentialHealth: CredentialHealthChecker;
|
|
251
279
|
|
|
@@ -262,6 +290,10 @@ export class Registry implements RegistryView {
|
|
|
262
290
|
opts.maxResultBytes,
|
|
263
291
|
DEFAULT_MAX_RESULT_BYTES,
|
|
264
292
|
);
|
|
293
|
+
this.maxBatchResultBytes = resolveMaxResultBytes(
|
|
294
|
+
opts.maxBatchResultBytes,
|
|
295
|
+
DEFAULT_MAX_BATCH_RESULT_BYTES,
|
|
296
|
+
);
|
|
265
297
|
for (const c of connectors) {
|
|
266
298
|
if (!ID_RE.test(c.id)) {
|
|
267
299
|
throw new Error(
|
|
@@ -272,9 +304,19 @@ export class Registry implements RegistryView {
|
|
|
272
304
|
throw new Error(`Duplicate connector id "${c.id}"`);
|
|
273
305
|
}
|
|
274
306
|
this.connectors.set(c.id, c);
|
|
307
|
+
if (c.callAdmission) {
|
|
308
|
+
this.callAdmission.set(
|
|
309
|
+
c.id,
|
|
310
|
+
new ConnectorCallAdmissionController(c.id, c.callAdmission),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
275
313
|
}
|
|
276
314
|
this.checkConventions(opts.logger);
|
|
277
|
-
this.checkResultCaps(
|
|
315
|
+
this.checkResultCaps(
|
|
316
|
+
opts.logger,
|
|
317
|
+
opts.maxResultBytes,
|
|
318
|
+
opts.maxBatchResultBytes,
|
|
319
|
+
);
|
|
278
320
|
this.credentialHealth = new CredentialHealthChecker(
|
|
279
321
|
{
|
|
280
322
|
listConnectors: () => this.listConnectors(),
|
|
@@ -300,6 +342,7 @@ export class Registry implements RegistryView {
|
|
|
300
342
|
private checkResultCaps(
|
|
301
343
|
logger: Logger,
|
|
302
344
|
configured: number | undefined,
|
|
345
|
+
configuredBatch: number | undefined,
|
|
303
346
|
): void {
|
|
304
347
|
if (configured !== undefined && !isValidMaxResultBytes(configured)) {
|
|
305
348
|
logger.warn(
|
|
@@ -309,6 +352,17 @@ export class Registry implements RegistryView {
|
|
|
309
352
|
`default ${DEFAULT_MAX_RESULT_BYTES} instead.`,
|
|
310
353
|
);
|
|
311
354
|
}
|
|
355
|
+
if (
|
|
356
|
+
configuredBatch !== undefined &&
|
|
357
|
+
!isValidMaxResultBytes(configuredBatch)
|
|
358
|
+
) {
|
|
359
|
+
logger.warn(
|
|
360
|
+
`[connecta] calls.maxBatchResultBytes ${configuredBatch} is not a whole ` +
|
|
361
|
+
`number of bytes >= ${MIN_MAX_RESULT_BYTES}: it would leave the final ` +
|
|
362
|
+
"batch envelope unbounded or serve an unusable page. Using the " +
|
|
363
|
+
`default ${DEFAULT_MAX_BATCH_RESULT_BYTES} instead.`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
312
366
|
for (const c of this.connectors.values()) {
|
|
313
367
|
if (
|
|
314
368
|
c.maxResultBytes !== undefined &&
|
|
@@ -399,6 +453,30 @@ export class Registry implements RegistryView {
|
|
|
399
453
|
};
|
|
400
454
|
}
|
|
401
455
|
|
|
456
|
+
admitCall(
|
|
457
|
+
id: string,
|
|
458
|
+
input: { toolName: string; args: unknown; signal?: AbortSignal },
|
|
459
|
+
): Promise<CallAdmissionPermit> {
|
|
460
|
+
const admission = this.callAdmission.get(id);
|
|
461
|
+
if (admission) return admission.acquire(input);
|
|
462
|
+
return Promise.resolve({ waitMs: 0, release() {} });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Payload-free aggregate state for the open health endpoint. */
|
|
466
|
+
callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot> {
|
|
467
|
+
return Object.fromEntries(
|
|
468
|
+
[...this.callAdmission].map(([id, admission]) => [
|
|
469
|
+
id,
|
|
470
|
+
admission.snapshot(),
|
|
471
|
+
]),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Reject queued/future downstream admission; active permits release safely. */
|
|
476
|
+
closeCallAdmission(): void {
|
|
477
|
+
for (const admission of this.callAdmission.values()) admission.close();
|
|
478
|
+
}
|
|
479
|
+
|
|
402
480
|
/**
|
|
403
481
|
* Storage namespaced to the meta-tool result store (`results:` prefix), kept
|
|
404
482
|
* separate from any connector's `conn:<id>:` namespace. Backs get_result.
|
|
@@ -831,6 +909,10 @@ export class ScopedRegistry implements RegistryView {
|
|
|
831
909
|
return this.base.maxResultBytes;
|
|
832
910
|
}
|
|
833
911
|
|
|
912
|
+
get maxBatchResultBytes(): number {
|
|
913
|
+
return this.base.maxBatchResultBytes;
|
|
914
|
+
}
|
|
915
|
+
|
|
834
916
|
/** In scope AND actually registered. */
|
|
835
917
|
private visible(id: string): boolean {
|
|
836
918
|
return (
|
|
@@ -911,6 +993,14 @@ export class ScopedRegistry implements RegistryView {
|
|
|
911
993
|
return this.base.contextFor(id, baseUrl, requestScope, callOptions);
|
|
912
994
|
}
|
|
913
995
|
|
|
996
|
+
admitCall(
|
|
997
|
+
id: string,
|
|
998
|
+
input: { toolName: string; args: unknown; signal?: AbortSignal },
|
|
999
|
+
): Promise<CallAdmissionPermit> {
|
|
1000
|
+
if (!this.visible(id)) return Promise.reject(this.unknownConnector(id));
|
|
1001
|
+
return this.base.admitCall(id, input);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
914
1004
|
/**
|
|
915
1005
|
* Stashed oversized results are bound to the scope that produced them: a
|
|
916
1006
|
* scoped session cannot page a result it could not have produced, and an id
|
package/src/server.ts
CHANGED
|
@@ -1273,6 +1273,7 @@ async function serveMcp(
|
|
|
1273
1273
|
activity,
|
|
1274
1274
|
defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
|
|
1275
1275
|
probeTimeoutMs: opts.probeTimeoutMs,
|
|
1276
|
+
requestSignal: request.signal,
|
|
1276
1277
|
...(runtimeContext
|
|
1277
1278
|
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
1278
1279
|
: {}),
|
|
@@ -1577,6 +1578,10 @@ export function createFetchHandler(
|
|
|
1577
1578
|
code: opts.executor
|
|
1578
1579
|
? (codeAdmission ?? { managedByExecutor: true })
|
|
1579
1580
|
: null,
|
|
1581
|
+
downstreamCalls: {
|
|
1582
|
+
policy: "connector-partitioned-per-runtime",
|
|
1583
|
+
connectors: registry.callAdmissionSnapshot(),
|
|
1584
|
+
},
|
|
1580
1585
|
reservedRoutes: [
|
|
1581
1586
|
"/health",
|
|
1582
1587
|
"/",
|