@stigmer/runner 3.11.0 → 3.11.1-dev.20260812192248
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/README.md +2 -0
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/discover-mcp-server.d.ts +7 -0
- package/dist/activities/discover-mcp-server.js +6 -4
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/emit-event.d.ts +14 -2
- package/dist/activities/emit-event.js +52 -17
- package/dist/activities/emit-event.js.map +1 -1
- package/dist/activities/execute-cursor/error-classifier.d.ts +19 -0
- package/dist/activities/execute-cursor/error-classifier.js +34 -3
- package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +46 -10
- package/dist/activities/execute-cursor/index.js +126 -36
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/model-pricing-data.d.ts +2 -0
- package/dist/activities/execute-cursor/model-pricing-data.js +13 -3
- package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +33 -0
- package/dist/activities/execute-cursor/prompt-builder.js +37 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/turn-recovery.d.ts +52 -0
- package/dist/activities/execute-cursor/turn-recovery.js +193 -0
- package/dist/activities/execute-cursor/turn-recovery.js.map +1 -0
- package/dist/activities/execute-deep-agent/setup.js +16 -7
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/shell-env.d.ts +5 -1
- package/dist/activities/execute-deep-agent/shell-env.js +7 -9
- package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +7 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +17 -3
- package/dist/activities/execute-deep-agent/subagent-wiring.js +14 -2
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/hydrate-workflow-execution.js +8 -5
- package/dist/activities/hydrate-workflow-execution.js.map +1 -1
- package/dist/activities/run-command.d.ts +5 -2
- package/dist/activities/run-command.js +22 -10
- package/dist/activities/run-command.js.map +1 -1
- package/dist/activities/run-env.d.ts +40 -0
- package/dist/activities/run-env.js +66 -0
- package/dist/activities/run-env.js.map +1 -0
- package/dist/bootstrap.js +5 -0
- package/dist/bootstrap.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +83 -26
- package/dist/client/stigmer-client.js +88 -24
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/encryption/config.d.ts +41 -9
- package/dist/encryption/config.js +63 -23
- package/dist/encryption/config.js.map +1 -1
- package/dist/middleware/index.d.ts +3 -0
- package/dist/middleware/index.js +7 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/path-normalization.d.ts +57 -0
- package/dist/middleware/path-normalization.js +109 -0
- package/dist/middleware/path-normalization.js.map +1 -0
- package/dist/middleware/types.d.ts +8 -0
- package/dist/payload-codecs.d.ts +2 -1
- package/dist/payload-codecs.js +7 -3
- package/dist/payload-codecs.js.map +1 -1
- package/dist/runner-manager.js +17 -3
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +7 -3
- package/dist/runner.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +23 -1
- package/dist/shared/artifact-storage.js +51 -10
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/checkpointer/http-saver.d.ts +4 -20
- package/dist/shared/checkpointer/http-saver.js +15 -57
- package/dist/shared/checkpointer/http-saver.js.map +1 -1
- package/dist/shared/conversation-catchup.d.ts +9 -2
- package/dist/shared/conversation-catchup.js +39 -6
- package/dist/shared/conversation-catchup.js.map +1 -1
- package/dist/shared/grpc-retry.d.ts +5 -1
- package/dist/shared/grpc-retry.js +5 -1
- package/dist/shared/grpc-retry.js.map +1 -1
- package/dist/shared/http-retry.d.ts +51 -9
- package/dist/shared/http-retry.js +60 -9
- package/dist/shared/http-retry.js.map +1 -1
- package/dist/shared/mcp-manager.d.ts +8 -4
- package/dist/shared/mcp-manager.js +30 -4
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/mcp-schema-sanitizer.d.ts +70 -0
- package/dist/shared/mcp-schema-sanitizer.js +197 -0
- package/dist/shared/mcp-schema-sanitizer.js.map +1 -0
- package/dist/shared/model-client.d.ts +6 -0
- package/dist/shared/model-client.js +14 -3
- package/dist/shared/model-client.js.map +1 -1
- package/dist/shared/model-pricing-data.d.ts +2 -0
- package/dist/shared/model-pricing-data.js +13 -3
- package/dist/shared/model-pricing-data.js.map +1 -1
- package/dist/shared/model-registry.js +3 -2
- package/dist/shared/model-registry.js.map +1 -1
- package/dist/shared/plan-mode-permissions.d.ts +7 -0
- package/dist/shared/plan-mode-permissions.js +7 -0
- package/dist/shared/plan-mode-permissions.js.map +1 -1
- package/dist/shared/registry-endpoint.d.ts +20 -0
- package/dist/shared/registry-endpoint.js +25 -0
- package/dist/shared/registry-endpoint.js.map +1 -1
- package/dist/shared/runner-credential-keys.d.ts +24 -0
- package/dist/shared/runner-credential-keys.js +47 -0
- package/dist/shared/runner-credential-keys.js.map +1 -0
- package/dist/workflow-engine/resolve.d.ts +19 -1
- package/dist/workflow-engine/resolve.js +37 -2
- package/dist/workflow-engine/resolve.js.map +1 -1
- package/dist/workflows/connect-mcp-server.js +2 -0
- package/dist/workflows/connect-mcp-server.js.map +1 -1
- package/dist/workflows/types.d.ts +8 -0
- package/package.json +3 -4
- package/src/__test-utils__/__tests__/vitest-global-setup.test.ts +37 -0
- package/src/__test-utils__/vitest-global-setup.ts +50 -0
- package/src/__tests__/bootstrap.test.ts +43 -0
- package/src/__tests__/encryption-codec.test.ts +53 -0
- package/src/__tests__/preflight.test.ts +15 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +39 -1
- package/src/activities/__tests__/error-classifier.test.ts +94 -2
- package/src/activities/__tests__/run-command.test.ts +221 -0
- package/src/activities/discover-mcp-server.ts +16 -2
- package/src/activities/emit-event.ts +65 -21
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +111 -2
- package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +62 -1
- package/src/activities/execute-cursor/__tests__/turn-recovery.test.ts +160 -0
- package/src/activities/execute-cursor/error-classifier.ts +40 -3
- package/src/activities/execute-cursor/index.ts +154 -38
- package/src/activities/execute-cursor/model-pricing-data.ts +22 -3
- package/src/activities/execute-cursor/prompt-builder.ts +51 -0
- package/src/activities/execute-cursor/turn-recovery.ts +208 -0
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +174 -0
- package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +24 -10
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +22 -17
- package/src/activities/execute-deep-agent/setup.ts +16 -8
- package/src/activities/execute-deep-agent/shell-env.ts +8 -9
- package/src/activities/execute-deep-agent/subagent-transformer.ts +7 -1
- package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -3
- package/src/activities/hydrate-workflow-execution.ts +8 -5
- package/src/activities/run-command.ts +25 -12
- package/src/activities/run-env.ts +79 -0
- package/src/bootstrap.ts +5 -0
- package/src/client/__tests__/stigmer-client.test.ts +104 -5
- package/src/client/stigmer-client.ts +128 -29
- package/src/encryption/config.ts +87 -23
- package/src/middleware/__tests__/path-normalization.test.ts +140 -0
- package/src/middleware/index.ts +8 -0
- package/src/middleware/path-normalization.ts +125 -0
- package/src/middleware/types.ts +11 -0
- package/src/payload-codecs.ts +8 -2
- package/src/runner-manager.ts +21 -3
- package/src/runner.ts +7 -3
- package/src/shared/__tests__/artifact-storage.test.ts +145 -2
- package/src/shared/__tests__/conversation-catchup.test.ts +24 -0
- package/src/shared/__tests__/http-retry.test.ts +5 -4
- package/src/shared/__tests__/mcp-manager.test.ts +53 -13
- package/src/shared/__tests__/mcp-schema-sanitizer.test.ts +267 -0
- package/src/shared/__tests__/model-client.test.ts +46 -0
- package/src/shared/__tests__/model-pricing.test.ts +62 -2
- package/src/shared/__tests__/model-registry.test.ts +22 -0
- package/src/shared/artifact-storage.ts +75 -10
- package/src/shared/checkpointer/http-saver.ts +15 -60
- package/src/shared/conversation-catchup.ts +39 -6
- package/src/shared/grpc-retry.ts +5 -1
- package/src/shared/http-retry.ts +98 -9
- package/src/shared/mcp-manager.ts +35 -4
- package/src/shared/mcp-schema-sanitizer.ts +224 -0
- package/src/shared/model-client.ts +21 -3
- package/src/shared/model-pricing-data.ts +22 -3
- package/src/shared/model-registry.ts +7 -2
- package/src/shared/plan-mode-permissions.ts +7 -0
- package/src/shared/registry-endpoint.ts +27 -0
- package/src/shared/runner-credential-keys.ts +46 -0
- package/src/workflow-engine/__tests__/tasks/emit-event.test.ts +127 -49
- package/src/workflow-engine/resolve.ts +48 -2
- package/src/workflows/__tests__/connect-mcp-server.test.ts +25 -0
- package/src/workflows/connect-mcp-server.ts +2 -0
- package/src/workflows/types.ts +8 -0
- package/dist/activities/execute-cursor/mcp-config.d.ts +0 -30
- package/dist/activities/execute-cursor/mcp-config.js +0 -39
- package/dist/activities/execute-cursor/mcp-config.js.map +0 -1
- package/src/activities/execute-cursor/mcp-config.ts +0 -66
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { computeLlmCostMicros, computeTurnCost, getModelPricing, resolveModelId, ensureLoaded } from "../model-pricing.js";
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
getPricingTable,
|
|
5
|
+
_resetPricingCache,
|
|
6
|
+
DEFAULT_PRICING,
|
|
7
|
+
type ModelPricing,
|
|
8
|
+
} from "../model-pricing-data.js";
|
|
4
9
|
|
|
5
10
|
describe("computeLlmCostMicros", () => {
|
|
6
11
|
it("falls back to DEFAULT_PRICING when registry is not loaded", () => {
|
|
@@ -83,3 +88,58 @@ describe("getModelPricing", () => {
|
|
|
83
88
|
expect(pricing.model).toBe("custom-model");
|
|
84
89
|
});
|
|
85
90
|
});
|
|
91
|
+
|
|
92
|
+
describe("getPricingTable failure caching (#468)", () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
_resetPricingCache();
|
|
95
|
+
vi.restoreAllMocks();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
_resetPricingCache();
|
|
100
|
+
vi.useRealTimers();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const registryResponse = () =>
|
|
104
|
+
new Response(
|
|
105
|
+
JSON.stringify({
|
|
106
|
+
models: [
|
|
107
|
+
{
|
|
108
|
+
id: "claude-sonnet-4-6",
|
|
109
|
+
displayName: "Claude Sonnet",
|
|
110
|
+
costTier: "standard",
|
|
111
|
+
pricing: {
|
|
112
|
+
inputPricePerMillion: 3.0,
|
|
113
|
+
outputPricePerMillion: 15.0,
|
|
114
|
+
cacheWritePricePerMillion: 3.75,
|
|
115
|
+
cacheReadPricePerMillion: 0.3,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
}),
|
|
120
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
it("retries after the short failure TTL instead of pinning DEFAULT_PRICING for an hour", async () => {
|
|
124
|
+
// The model-registry.ts failure-cache policy, applied here: wrong default
|
|
125
|
+
// rates for cost tracking must not persist a full success TTL.
|
|
126
|
+
vi.useFakeTimers();
|
|
127
|
+
const fetchSpy = vi
|
|
128
|
+
.spyOn(globalThis, "fetch")
|
|
129
|
+
.mockRejectedValueOnce(new Error("network error"))
|
|
130
|
+
.mockResolvedValueOnce(registryResponse());
|
|
131
|
+
|
|
132
|
+
// First call fails and degrades to the default table.
|
|
133
|
+
expect((await getPricingTable())[0]).toBe(DEFAULT_PRICING);
|
|
134
|
+
|
|
135
|
+
// Within the failure TTL the fallback stays cached (no refetch).
|
|
136
|
+
vi.advanceTimersByTime(30_000);
|
|
137
|
+
expect((await getPricingTable())[0]).toBe(DEFAULT_PRICING);
|
|
138
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
139
|
+
|
|
140
|
+
// Past the failure TTL the registry is refetched and real rates recover.
|
|
141
|
+
vi.advanceTimersByTime(31_000);
|
|
142
|
+
expect((await getPricingTable())[0]?.model).toBe("claude-sonnet-4-6");
|
|
143
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -223,6 +223,28 @@ describe("resolveToApiModelId", () => {
|
|
|
223
223
|
expect(result).toBe("claude-haiku-4.5");
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
+
it("recovers from a transient network failure without engaging the failure cache (#468)", async () => {
|
|
227
|
+
// undici's retryable shape (TypeError) — the shared bounded-backoff loop
|
|
228
|
+
// absorbs it, so resolution succeeds on the SAME call and the 60 s
|
|
229
|
+
// degraded window never opens. Pre-#468 code failed the fetch outright.
|
|
230
|
+
const fetchSpy = vi
|
|
231
|
+
.spyOn(globalThis, "fetch")
|
|
232
|
+
.mockRejectedValueOnce(new TypeError("fetch failed"))
|
|
233
|
+
.mockResolvedValueOnce(
|
|
234
|
+
new Response(
|
|
235
|
+
JSON.stringify({
|
|
236
|
+
models: [
|
|
237
|
+
{ id: "claude-haiku-4.5", apiModelId: "claude-haiku-4-5-20251001", provider: "anthropic", costTier: "economy", harness: "native" },
|
|
238
|
+
],
|
|
239
|
+
}),
|
|
240
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(await resolveToApiModelId("claude-haiku-4.5")).toBe("claude-haiku-4-5-20251001");
|
|
245
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
246
|
+
});
|
|
247
|
+
|
|
226
248
|
it("retries after the short failure TTL instead of caching the failure for an hour", async () => {
|
|
227
249
|
vi.useFakeTimers();
|
|
228
250
|
const fetchSpy = vi
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* The runner never holds R2/S3 credentials — in cloud mode it calls the proxy
|
|
14
14
|
* to obtain a presigned upload URL, then PUTs content over plain HTTPS.
|
|
15
15
|
*
|
|
16
|
+
* Every proxy-backend request rides the shared bounded-backoff loop
|
|
17
|
+
* (http-retry.ts) with a per-request abort timeout, so a momentary
|
|
18
|
+
* stigmer-service interruption is retried and a hung connection cannot
|
|
19
|
+
* stall an agent turn unboundedly (stigmer/stigmer#468).
|
|
20
|
+
*
|
|
16
21
|
* DD-6: No direct R2 backend. Local + Proxy only.
|
|
17
22
|
*/
|
|
18
23
|
|
|
@@ -20,6 +25,8 @@ import { mkdir, writeFile, readFile, access, rm } from "node:fs/promises";
|
|
|
20
25
|
import { dirname, join, resolve, sep } from "node:path";
|
|
21
26
|
import { homedir } from "node:os";
|
|
22
27
|
import type { Config } from "../config.js";
|
|
28
|
+
import type { RetryOptions } from "./grpc-retry.js";
|
|
29
|
+
import { fetchWithRetry, type FetchRetryPolicy } from "./http-retry.js";
|
|
23
30
|
|
|
24
31
|
// ── Interface ────────────────────────────────────────────────────────
|
|
25
32
|
|
|
@@ -117,13 +124,64 @@ export class LocalArtifactStorage implements ArtifactStorage {
|
|
|
117
124
|
|
|
118
125
|
// ── Proxy Backend ────────────────────────────────────────────────────
|
|
119
126
|
|
|
127
|
+
/** Retry policy plus the two per-request bounds; every field is defaulted. */
|
|
128
|
+
export interface ProxyArtifactStorageOptions extends RetryOptions {
|
|
129
|
+
/** Bound for proxy presign calls and the 1-byte exists probe. Default 30 s. */
|
|
130
|
+
readonly requestTimeoutMs?: number;
|
|
131
|
+
/**
|
|
132
|
+
* Bound for the presigned R2 PUT/GET, which move whole artifact payloads
|
|
133
|
+
* (claim-check offloads, tool outputs, file-review captures — MB scale).
|
|
134
|
+
* Deliberately wider than requestTimeoutMs so a legitimate slow transfer
|
|
135
|
+
* is never aborted by a bound sized for JSON round-trips. Default 120 s.
|
|
136
|
+
*/
|
|
137
|
+
readonly transferTimeoutMs?: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Default transient-retry policy (stigmer/stigmer#468): the checkpointer's
|
|
142
|
+
* budget — sized to span a pod-restart reroute, ~7.75 s of delays across
|
|
143
|
+
* 5 retries. Retrying is safe at every site: the presign POSTs are
|
|
144
|
+
* idempotent mints, the PUT re-sends identical bytes to the same key (a
|
|
145
|
+
* keyed overwrite), and the GETs are reads.
|
|
146
|
+
*/
|
|
147
|
+
const DEFAULT_RETRY = {
|
|
148
|
+
baseDelayMs: 250,
|
|
149
|
+
backoffFactor: 2,
|
|
150
|
+
maxRetries: 5,
|
|
151
|
+
requestTimeoutMs: 30_000,
|
|
152
|
+
transferTimeoutMs: 120_000,
|
|
153
|
+
} as const;
|
|
154
|
+
|
|
120
155
|
export class ProxyArtifactStorage implements ArtifactStorage {
|
|
121
156
|
private readonly baseUrl: string;
|
|
122
157
|
private readonly authTokenSource: ProxyAuthTokenSource;
|
|
123
|
-
|
|
124
|
-
|
|
158
|
+
/** Governs the proxy presign calls and the exists probe. */
|
|
159
|
+
private readonly requestPolicy: FetchRetryPolicy;
|
|
160
|
+
/** Governs the presigned R2 transfers; same budget, wider timeout. */
|
|
161
|
+
private readonly transferPolicy: FetchRetryPolicy;
|
|
162
|
+
|
|
163
|
+
constructor(
|
|
164
|
+
proxyEndpoint: string,
|
|
165
|
+
authToken: ProxyAuthTokenSource,
|
|
166
|
+
options: ProxyArtifactStorageOptions = {},
|
|
167
|
+
) {
|
|
125
168
|
this.baseUrl = `${proxyEndpoint.replace(/\/+$/, "")}/v1/proxy/artifacts`;
|
|
126
169
|
this.authTokenSource = authToken;
|
|
170
|
+
const shared = {
|
|
171
|
+
label: "ProxyArtifactStorage",
|
|
172
|
+
baseDelayMs: options.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
|
|
173
|
+
backoffFactor: options.backoffFactor ?? DEFAULT_RETRY.backoffFactor,
|
|
174
|
+
maxRetries: options.maxRetries ?? DEFAULT_RETRY.maxRetries,
|
|
175
|
+
delayFn: options.delayFn,
|
|
176
|
+
};
|
|
177
|
+
this.requestPolicy = {
|
|
178
|
+
...shared,
|
|
179
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_RETRY.requestTimeoutMs,
|
|
180
|
+
};
|
|
181
|
+
this.transferPolicy = {
|
|
182
|
+
...shared,
|
|
183
|
+
requestTimeoutMs: options.transferTimeoutMs ?? DEFAULT_RETRY.transferTimeoutMs,
|
|
184
|
+
};
|
|
127
185
|
}
|
|
128
186
|
|
|
129
187
|
/**
|
|
@@ -141,14 +199,14 @@ export class ProxyArtifactStorage implements ArtifactStorage {
|
|
|
141
199
|
async upload(key: string, content: Buffer, contentType?: string): Promise<string> {
|
|
142
200
|
const ct = contentType ?? "application/octet-stream";
|
|
143
201
|
|
|
144
|
-
const presignResp = await
|
|
202
|
+
const presignResp = await fetchWithRetry(`${this.baseUrl}/presigned-upload-url`, {
|
|
145
203
|
method: "POST",
|
|
146
204
|
headers: {
|
|
147
205
|
"Authorization": `Bearer ${this.authToken}`,
|
|
148
206
|
"Content-Type": "application/json",
|
|
149
207
|
},
|
|
150
208
|
body: JSON.stringify({ key, content_type: ct }),
|
|
151
|
-
});
|
|
209
|
+
}, this.requestPolicy);
|
|
152
210
|
if (!presignResp.ok) {
|
|
153
211
|
throw new Error(
|
|
154
212
|
`Failed to get presigned upload URL (HTTP ${presignResp.status}): ` +
|
|
@@ -180,11 +238,14 @@ export class ProxyArtifactStorage implements ArtifactStorage {
|
|
|
180
238
|
uploadHeaders["Content-Type"] = ct;
|
|
181
239
|
}
|
|
182
240
|
|
|
183
|
-
|
|
241
|
+
// Retrying the PUT alone (not the presign+PUT pair) is deliberate: the
|
|
242
|
+
// retry budget's few seconds of delays sit far inside presign validity,
|
|
243
|
+
// and re-sending the same Buffer to the same key is a keyed overwrite.
|
|
244
|
+
const putResp = await fetchWithRetry(data.url, {
|
|
184
245
|
method: "PUT",
|
|
185
246
|
headers: uploadHeaders,
|
|
186
247
|
body: content,
|
|
187
|
-
});
|
|
248
|
+
}, this.transferPolicy);
|
|
188
249
|
if (!putResp.ok) {
|
|
189
250
|
throw new Error(
|
|
190
251
|
`Presigned upload failed (HTTP ${putResp.status}): ` +
|
|
@@ -196,14 +257,14 @@ export class ProxyArtifactStorage implements ArtifactStorage {
|
|
|
196
257
|
}
|
|
197
258
|
|
|
198
259
|
async getDownloadUrl(key: string): Promise<string> {
|
|
199
|
-
const resp = await
|
|
260
|
+
const resp = await fetchWithRetry(`${this.baseUrl}/presigned-download-url`, {
|
|
200
261
|
method: "POST",
|
|
201
262
|
headers: {
|
|
202
263
|
"Authorization": `Bearer ${this.authToken}`,
|
|
203
264
|
"Content-Type": "application/json",
|
|
204
265
|
},
|
|
205
266
|
body: JSON.stringify({ key }),
|
|
206
|
-
});
|
|
267
|
+
}, this.requestPolicy);
|
|
207
268
|
if (!resp.ok) {
|
|
208
269
|
throw new Error(
|
|
209
270
|
`Failed to get presigned download URL (HTTP ${resp.status}): ` +
|
|
@@ -216,7 +277,7 @@ export class ProxyArtifactStorage implements ArtifactStorage {
|
|
|
216
277
|
|
|
217
278
|
async download(key: string): Promise<Buffer> {
|
|
218
279
|
const url = await this.getDownloadUrl(key);
|
|
219
|
-
const resp = await
|
|
280
|
+
const resp = await fetchWithRetry(url, undefined, this.transferPolicy);
|
|
220
281
|
if (!resp.ok) {
|
|
221
282
|
throw new Error(
|
|
222
283
|
`Artifact download failed (HTTP ${resp.status}) for key '${key}': ` +
|
|
@@ -242,7 +303,11 @@ export class ProxyArtifactStorage implements ArtifactStorage {
|
|
|
242
303
|
// addressed CAS blob dedup) then re-uploads, which is idempotent.
|
|
243
304
|
return false;
|
|
244
305
|
}
|
|
245
|
-
const resp = await
|
|
306
|
+
const resp = await fetchWithRetry(
|
|
307
|
+
url,
|
|
308
|
+
{ headers: { Range: "bytes=0-0" } },
|
|
309
|
+
this.requestPolicy,
|
|
310
|
+
);
|
|
246
311
|
await resp.arrayBuffer().catch(() => undefined); // drain the <=1-byte body
|
|
247
312
|
if (resp.status === 404) return false;
|
|
248
313
|
if (resp.status === 200 || resp.status === 206 || resp.status === 416) return true;
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
* The Java proxy calls Document.parse(json) which handles $binary
|
|
15
15
|
* natively, and doc.toJson() emits the same format on reads.
|
|
16
16
|
*
|
|
17
|
-
* Every request runs through
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Every request runs through the shared bounded-backoff loop
|
|
18
|
+
* (http-retry.ts's fetchWithRetry, with this saver's policy: transient
|
|
19
|
+
* classification + a per-request abort timeout). That loop is the ONLY
|
|
20
20
|
* retry layer between an agent execution and its checkpoints — the deep
|
|
21
21
|
* agent activity is deliberately non-retryable at the Temporal level
|
|
22
22
|
* (maximumAttempts: 1; replaying a whole agent run is not safe), so before
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
} from "@langchain/langgraph-checkpoint";
|
|
40
40
|
import type { CheckpointMetadata, PendingWrite } from "@langchain/langgraph-checkpoint";
|
|
41
41
|
import type { RetryOptions } from "../grpc-retry.js";
|
|
42
|
-
import {
|
|
42
|
+
import { fetchWithRetry, type FetchRetryPolicy } from "../http-retry.js";
|
|
43
43
|
|
|
44
44
|
function encodeB64(data: Uint8Array): string {
|
|
45
45
|
if (typeof Buffer !== "undefined") {
|
|
@@ -117,18 +117,10 @@ const DEFAULT_RETRY = {
|
|
|
117
117
|
requestTimeoutMs: 30_000,
|
|
118
118
|
} as const;
|
|
119
119
|
|
|
120
|
-
function defaultDelay(ms: number): Promise<void> {
|
|
121
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
120
|
export class HttpCheckpointSaver extends BaseCheckpointSaver {
|
|
125
121
|
private readonly baseUrl: string;
|
|
126
122
|
private readonly headers: Record<string, string>;
|
|
127
|
-
private readonly
|
|
128
|
-
private readonly backoffFactor: number;
|
|
129
|
-
private readonly maxRetries: number;
|
|
130
|
-
private readonly requestTimeoutMs: number;
|
|
131
|
-
private readonly delay: (ms: number) => Promise<void>;
|
|
123
|
+
private readonly retryPolicy: FetchRetryPolicy;
|
|
132
124
|
|
|
133
125
|
constructor(
|
|
134
126
|
proxyEndpoint: string,
|
|
@@ -141,55 +133,18 @@ export class HttpCheckpointSaver extends BaseCheckpointSaver {
|
|
|
141
133
|
Authorization: `Bearer ${authToken}`,
|
|
142
134
|
"Content-Type": "application/json",
|
|
143
135
|
};
|
|
144
|
-
this.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
* `fetch` with bounded exponential backoff on transient failures and a
|
|
153
|
-
* per-request abort timeout.
|
|
154
|
-
*
|
|
155
|
-
* Composition contract: on a non-retryable status — or when the budget is
|
|
156
|
-
* exhausted on a retryable one — the last `Response` is RETURNED, so every
|
|
157
|
-
* call site keeps its own `resp.ok` / 404 handling unchanged; this wrapper
|
|
158
|
-
* changes when a response arrives, never what call sites do with it. It
|
|
159
|
-
* throws only what `fetch` itself throws: a network-level rejection that is
|
|
160
|
-
* non-retryable or has exhausted the budget.
|
|
161
|
-
*/
|
|
162
|
-
private async fetchWithRetry(url: string, init?: RequestInit): Promise<Response> {
|
|
163
|
-
for (let attempt = 0; ; attempt++) {
|
|
164
|
-
const canRetry = attempt < this.maxRetries;
|
|
165
|
-
let resp: Response;
|
|
166
|
-
try {
|
|
167
|
-
resp = await fetch(url, {
|
|
168
|
-
...init,
|
|
169
|
-
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
170
|
-
});
|
|
171
|
-
} catch (err) {
|
|
172
|
-
if (canRetry && isRetryableFetchError(err)) {
|
|
173
|
-
await this.backOff(attempt, String(err));
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
throw err;
|
|
177
|
-
}
|
|
178
|
-
if (canRetry && isRetryableHttpStatus(resp.status)) {
|
|
179
|
-
await this.backOff(attempt, `HTTP ${resp.status} ${resp.statusText}`);
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
return resp;
|
|
183
|
-
}
|
|
136
|
+
this.retryPolicy = {
|
|
137
|
+
label: "HttpCheckpointSaver",
|
|
138
|
+
baseDelayMs: options.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
|
|
139
|
+
backoffFactor: options.backoffFactor ?? DEFAULT_RETRY.backoffFactor,
|
|
140
|
+
maxRetries: options.maxRetries ?? DEFAULT_RETRY.maxRetries,
|
|
141
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_RETRY.requestTimeoutMs,
|
|
142
|
+
delayFn: options.delayFn,
|
|
143
|
+
};
|
|
184
144
|
}
|
|
185
145
|
|
|
186
|
-
private
|
|
187
|
-
|
|
188
|
-
console.warn(
|
|
189
|
-
`[HttpCheckpointSaver] retryable failure ` +
|
|
190
|
-
`(attempt ${attempt + 1}/${this.maxRetries + 1}, retry in ${delayMs}ms): ${reason}`,
|
|
191
|
-
);
|
|
192
|
-
await this.delay(delayMs);
|
|
146
|
+
private fetchWithRetry(url: string, init?: RequestInit): Promise<Response> {
|
|
147
|
+
return fetchWithRetry(url, init, this.retryPolicy);
|
|
193
148
|
}
|
|
194
149
|
|
|
195
150
|
private async serializeTyped(obj: unknown): Promise<[string, BinaryObj]> {
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Conversation catchup (cloud channel-conversations DD-006): what happened on
|
|
3
3
|
* a live channel conversation that the agent has not seen — customer messages
|
|
4
|
-
* handled by a human teammate, the teammate's replies, platform notices
|
|
5
|
-
*
|
|
4
|
+
* handled by a human teammate, the teammate's replies, platform notices,
|
|
5
|
+
* notes, and the agent's own earlier escalations. Since cloud#347 a line
|
|
6
|
+
* whose send is known-undelivered carries a `(not delivered)` speaker
|
|
7
|
+
* annotation and an in-flight one carries `(sending)` — the digest no longer
|
|
8
|
+
* implies every public-lane line reached the customer. Since cloud#352 the
|
|
9
|
+
* digest also closes the feedback loop on send OUTCOMES: the agent's own
|
|
10
|
+
* dead-lettered sends surface as `You (not delivered):` lines, and a send
|
|
11
|
+
* that failed only after an earlier digest already conveyed it surfaces as a
|
|
12
|
+
* `System:` delivery-failure notice at the moment the failure became known.
|
|
6
13
|
*
|
|
7
14
|
* The cloud composes the CONTENT (bare `Customer:` / `Teammate:` / `System:` /
|
|
8
15
|
* `You escalated:` / `Note:` lines, oldest first) on the execution spec's
|
|
@@ -29,16 +36,42 @@ import type { ConversationCatchup } from "@stigmer/protos/ai/stigmer/agentic/age
|
|
|
29
36
|
* drift between them. Deliberately takeover-neutral: a digest can exist with
|
|
30
37
|
* no human handoff at all (a failed turn's re-composed window), so the
|
|
31
38
|
* preamble asserts only what is always true (the A15/A20 honesty bar).
|
|
39
|
+
*
|
|
40
|
+
* The send-status annotations (cloud#347) get an explicit exception from the
|
|
41
|
+
* don't-re-answer contract: a `(not delivered)` teammate reply is words the
|
|
42
|
+
* customer never got, so treating it as settled history would silently
|
|
43
|
+
* abandon whatever it was meant to convey. The preamble defines the
|
|
44
|
+
* annotations — the DD-013 split puts model-facing meaning here, while the
|
|
45
|
+
* cloud composer owns which lines earn them.
|
|
46
|
+
*
|
|
47
|
+
* The send-outcome lines (cloud#352, cloud triage DD-009) extend the same
|
|
48
|
+
* exception to the agent's OWN sends: `You (not delivered):` lines and
|
|
49
|
+
* `System:` delivery-failure notices mean the customer never got those
|
|
50
|
+
* words. The behavioral contract is owner-ruled (DD-009 Q-4): treat the
|
|
51
|
+
* failure as unfinished conversation business — weigh what still needs
|
|
52
|
+
* saying and re-say it naturally in the next turn — but never resend the
|
|
53
|
+
* failed text verbatim: a multi-chunk send can partially land, and a
|
|
54
|
+
* word-for-word repeat risks the customer reading the same message twice.
|
|
32
55
|
*/
|
|
33
56
|
const CONVERSATION_CATCHUP_PREAMBLE =
|
|
34
57
|
"Below is activity from this conversation that you have not seen — " +
|
|
35
58
|
"oldest first. It may include customer messages that were handled by a " +
|
|
36
|
-
"human teammate, the teammate's own replies, notices the
|
|
37
|
-
"
|
|
59
|
+
"human teammate, the teammate's own replies, notices sent to the " +
|
|
60
|
+
"customer, internal notes, and escalations you raised earlier. Treat it " +
|
|
38
61
|
"as conversation history you already know: do not answer or re-answer " +
|
|
39
62
|
"these messages, do not repeat or summarize them back, and do not " +
|
|
40
|
-
"mention any handoff unless asked.
|
|
41
|
-
"
|
|
63
|
+
"mention any handoff unless asked. One exception: lines marked " +
|
|
64
|
+
"(not delivered) never reached the customer, and lines marked (sending) " +
|
|
65
|
+
"were still on their way when this summary was built — the customer may " +
|
|
66
|
+
"not have seen those words, so weigh that when deciding what still needs " +
|
|
67
|
+
"saying. That includes your own words: a line marked " +
|
|
68
|
+
"You (not delivered), or a System line reporting that a message was not " +
|
|
69
|
+
"delivered, means the customer never received it. Treat such a failure " +
|
|
70
|
+
"as unfinished business — if what it said still matters, work it " +
|
|
71
|
+
"naturally into your reply in your own words, but never resend the " +
|
|
72
|
+
"failed text word-for-word (part of it may have reached the customer, " +
|
|
73
|
+
"and an exact repeat reads as a duplicate). " +
|
|
74
|
+
"Continue from the customer's newest message.";
|
|
42
75
|
|
|
43
76
|
/**
|
|
44
77
|
* Read the catchup digest from an execution spec's `conversation_catchup`.
|
package/src/shared/grpc-retry.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
* `updateStatus` should back off and retry (transient transport errors) or
|
|
7
7
|
* fail fast (deterministic errors). This module is intentionally just the
|
|
8
8
|
* classification policy + its options type, kept small and separately tested;
|
|
9
|
-
* the persist loop that consumes it lives with the rest of the persist logic
|
|
9
|
+
* the persist loop that consumes it lives with the rest of the persist logic
|
|
10
|
+
* in status.ts — a single consumer whose loop is interwoven with persist
|
|
11
|
+
* semantics. (The HTTP twin, http-retry.ts, diverged there deliberately: with
|
|
12
|
+
* three fetch clients needing a byte-identical loop it hosts the shared
|
|
13
|
+
* fetchWithRetry too — see its module doc.)
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
import { ConnectError, Code } from "@connectrpc/connect";
|
package/src/shared/http-retry.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP error classification
|
|
2
|
+
* HTTP error classification AND the shared bounded-backoff loop for
|
|
3
|
+
* side-channel proxy retries.
|
|
3
4
|
*
|
|
4
5
|
* The runner reaches stigmer-service's Side-Channel Proxy (`/v1/proxy/...`)
|
|
5
|
-
* over plain `fetch` from several clients
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* over plain `fetch` from several clients: the checkpoint saver, artifact
|
|
7
|
+
* storage, and the model-registry fetches (stigmer/stigmer#468). This module
|
|
8
|
+
* is the one place that answers "is this HTTP failure worth retrying?" — the
|
|
9
|
+
* `fetch` twin of grpc-retry.ts — and, since #468, also owns the
|
|
10
|
+
* {@link fetchWithRetry} loop those clients share. That is a deliberate
|
|
11
|
+
* divergence from grpc-retry.ts's policy-only split: the gRPC side has a
|
|
12
|
+
* single consumer whose loop is interwoven with persist-specific logic
|
|
13
|
+
* (status.ts), while the HTTP side grew three consumers needing a
|
|
14
|
+
* byte-identical loop — per-client copies were the drift risk, not the seam.
|
|
12
15
|
*
|
|
13
16
|
* The policy is grounded in what the proxy actually emits, not HTTP folklore.
|
|
14
17
|
* CheckpointerProxyController returns exactly four deterministic errors —
|
|
@@ -17,7 +20,10 @@
|
|
|
17
20
|
* reach the runner only as 5xx (Spring's error handler, ingress 502-504),
|
|
18
21
|
* 408/429 (infrastructure between runner and service), or as network-level
|
|
19
22
|
* fetch rejections. 401/403 stay terminal deliberately: retrying cannot fix
|
|
20
|
-
* an expired or rejected credential.
|
|
23
|
+
* an expired or rejected credential. The same classification holds for the
|
|
24
|
+
* presigned R2 URLs artifact storage talks to: their deterministic failures
|
|
25
|
+
* (403 expired signature, 404 missing object) are 4xx, their transient ones
|
|
26
|
+
* are 5xx/network.
|
|
21
27
|
*/
|
|
22
28
|
|
|
23
29
|
/**
|
|
@@ -48,3 +54,86 @@ export function isRetryableFetchError(err: unknown): boolean {
|
|
|
48
54
|
if (err instanceof TypeError) return true;
|
|
49
55
|
return err instanceof Error && err.name === "TimeoutError";
|
|
50
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A fully-resolved retry policy for {@link fetchWithRetry}. Every field is
|
|
60
|
+
* required on purpose: defaults differ per client (the checkpointer and the
|
|
61
|
+
* presign endpoints run 30 s requests, artifact transfers 120 s, registry
|
|
62
|
+
* fetches 10 s), so each client resolves its own defaults once and this
|
|
63
|
+
* module never guesses.
|
|
64
|
+
*/
|
|
65
|
+
export interface FetchRetryPolicy {
|
|
66
|
+
/** Client name for retry logs, e.g. "HttpCheckpointSaver". */
|
|
67
|
+
readonly label: string;
|
|
68
|
+
/** Delay before the first retry (ms); doubles per attempt via the factor. */
|
|
69
|
+
readonly baseDelayMs: number;
|
|
70
|
+
readonly backoffFactor: number;
|
|
71
|
+
/** Retry attempts after the initial one. */
|
|
72
|
+
readonly maxRetries: number;
|
|
73
|
+
/**
|
|
74
|
+
* Milliseconds before an in-flight request is aborted and the attempt is
|
|
75
|
+
* classified retryable. Without it a hung connection (the realistic
|
|
76
|
+
* unplanned-pod-kill symptom) stalls forever and the retry never engages.
|
|
77
|
+
*/
|
|
78
|
+
readonly requestTimeoutMs: number;
|
|
79
|
+
/** Injectable delay for tests; defaults to a real setTimeout sleep. */
|
|
80
|
+
readonly delayFn?: (ms: number) => Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function defaultDelay(ms: number): Promise<void> {
|
|
84
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `fetch` with bounded exponential backoff on transient failures and a
|
|
89
|
+
* per-request abort timeout.
|
|
90
|
+
*
|
|
91
|
+
* Composition contract: on a non-retryable status — or when the budget is
|
|
92
|
+
* exhausted on a retryable one — the last `Response` is RETURNED, so every
|
|
93
|
+
* call site keeps its own `resp.ok` / 404 handling unchanged; this wrapper
|
|
94
|
+
* changes when a response arrives, never what call sites do with it. It
|
|
95
|
+
* throws only what `fetch` itself throws: a network-level rejection that is
|
|
96
|
+
* non-retryable or has exhausted the budget.
|
|
97
|
+
*/
|
|
98
|
+
export async function fetchWithRetry(
|
|
99
|
+
url: string,
|
|
100
|
+
init: RequestInit | undefined,
|
|
101
|
+
policy: FetchRetryPolicy,
|
|
102
|
+
): Promise<Response> {
|
|
103
|
+
const delay = policy.delayFn ?? defaultDelay;
|
|
104
|
+
for (let attempt = 0; ; attempt++) {
|
|
105
|
+
const canRetry = attempt < policy.maxRetries;
|
|
106
|
+
let resp: Response;
|
|
107
|
+
try {
|
|
108
|
+
resp = await fetch(url, {
|
|
109
|
+
...init,
|
|
110
|
+
signal: AbortSignal.timeout(policy.requestTimeoutMs),
|
|
111
|
+
});
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (canRetry && isRetryableFetchError(err)) {
|
|
114
|
+
await backOff(policy, delay, attempt, String(err));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
if (canRetry && isRetryableHttpStatus(resp.status)) {
|
|
120
|
+
await backOff(policy, delay, attempt, `HTTP ${resp.status} ${resp.statusText}`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
return resp;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function backOff(
|
|
128
|
+
policy: FetchRetryPolicy,
|
|
129
|
+
delay: (ms: number) => Promise<void>,
|
|
130
|
+
attempt: number,
|
|
131
|
+
reason: string,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const delayMs = policy.baseDelayMs * Math.pow(policy.backoffFactor, attempt);
|
|
134
|
+
console.warn(
|
|
135
|
+
`[${policy.label}] retryable failure ` +
|
|
136
|
+
`(attempt ${attempt + 1}/${policy.maxRetries + 1}, retry in ${delayMs}ms): ${reason}`,
|
|
137
|
+
);
|
|
138
|
+
await delay(delayMs);
|
|
139
|
+
}
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* Wraps @langchain/mcp-adapters MultiServerMCPClient with:
|
|
5
5
|
* - Conversion from ResolvedMcpServer[] to the SDK's Connection config
|
|
6
6
|
* - Proper async cleanup via close()
|
|
7
|
+
* - Input-schema sanitization of unicode-invalid regex patterns
|
|
8
|
+
* (issue #420; semantics in shared/mcp-schema-sanitizer.ts) — a vendor
|
|
9
|
+
* pattern that cannot compile under the /u flag must not leave its tool
|
|
10
|
+
* permanently uncallable
|
|
7
11
|
* - Tool filtering by each server's effective enabled_tools allow-list
|
|
8
12
|
* (issue #350; semantics in shared/mcp-enabled-tools.ts) — the model only
|
|
9
13
|
* ever sees the tools the agent's manifest enables
|
|
@@ -18,10 +22,10 @@
|
|
|
18
22
|
* (HOME, LOGNAME, PATH, SHELL, TERM, USER — getDefaultEnvironment in
|
|
19
23
|
* @modelcontextprotocol/sdk, merged under whatever we pass). The
|
|
20
24
|
* runner's own process env is never passed: it carries runner-internal
|
|
21
|
-
* credentials (
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
+
* credentials (enumerated in runner-credential-keys.ts) that no
|
|
26
|
+
* third-party MCP subprocess may see. A declared-empty env and an
|
|
27
|
+
* undeclared env are deliberately equivalent — both yield the SDK base
|
|
28
|
+
* environment.
|
|
25
29
|
*
|
|
26
30
|
* The Cursor execution path does NOT use this manager — it passes MCP
|
|
27
31
|
* configs directly to the Cursor SDK via toCursorMcpConfig(). This
|
|
@@ -35,6 +39,7 @@ import type { Connection } from "@langchain/mcp-adapters";
|
|
|
35
39
|
import type { DynamicStructuredTool } from "@langchain/core/tools";
|
|
36
40
|
import type { ResolvedMcpServer } from "./mcp-resolver.js";
|
|
37
41
|
import { filterToolsByEnabledTools } from "./mcp-enabled-tools.js";
|
|
42
|
+
import { sanitizeSchemaPatterns } from "./mcp-schema-sanitizer.js";
|
|
38
43
|
|
|
39
44
|
/**
|
|
40
45
|
* Convert harness-agnostic ResolvedMcpServer[] into the
|
|
@@ -100,6 +105,32 @@ export async function connectMcpServers(
|
|
|
100
105
|
const client = new MultiServerMCPClient(connectionConfig);
|
|
101
106
|
const discoveredToolMap = await client.initializeConnections();
|
|
102
107
|
|
|
108
|
+
// Drop unicode-invalid regex patterns from every discovered tool's input
|
|
109
|
+
// schema BEFORE anything downstream holds a reference (issue #420):
|
|
110
|
+
// @langchain/core re-validates against tool.schema on every invocation,
|
|
111
|
+
// so one vendor pattern that cannot compile under the /u flag would
|
|
112
|
+
// otherwise make its tool permanently uncallable. Semantics and the
|
|
113
|
+
// loosen-never-tighten invariant live in shared/mcp-schema-sanitizer.ts.
|
|
114
|
+
for (const [slug, discovered] of Object.entries(discoveredToolMap)) {
|
|
115
|
+
for (const tool of discovered) {
|
|
116
|
+
const droppedPatterns = sanitizeSchemaPatterns(tool.schema);
|
|
117
|
+
if (droppedPatterns.length > 0) {
|
|
118
|
+
console.warn(
|
|
119
|
+
`[MCP] Server '${slug}' tool '${tool.name}': dropped ` +
|
|
120
|
+
`${droppedPatterns.length} regex pattern(s) that cannot compile ` +
|
|
121
|
+
`under the unicode flag — the tool stays callable, the server ` +
|
|
122
|
+
`still validates its own inputs (issue #420): ` +
|
|
123
|
+
droppedPatterns
|
|
124
|
+
.map((d) =>
|
|
125
|
+
`${d.location} ${JSON.stringify(d.pattern)}` +
|
|
126
|
+
(d.compilesWithoutUnicodeFlag ? "" : " (invalid without /u too)"),
|
|
127
|
+
)
|
|
128
|
+
.join(", "),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
103
134
|
// Enforce each server's effective enabled_tools allow-list (issue #350)
|
|
104
135
|
// HERE, before anything downstream sees the tools: the parent tool list,
|
|
105
136
|
// the approval gate's toolServerMap, and the sub-agent McpAccess filter
|