@stigmer/runner 3.9.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.js +7 -0
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +8 -1
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +10 -3
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/shared/attachment-vision.d.ts +43 -2
- package/dist/shared/attachment-vision.js +72 -6
- package/dist/shared/attachment-vision.js.map +1 -1
- package/dist/shared/mcp-manager.d.ts +14 -1
- package/dist/shared/mcp-manager.js +20 -21
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/model-registry.d.ts +20 -2
- package/dist/shared/model-registry.js +37 -2
- package/dist/shared/model-registry.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +39 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +7 -0
- package/src/activities/execute-cursor/index.ts +8 -1
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +22 -0
- package/src/activities/execute-deep-agent/setup.ts +10 -3
- package/src/shared/__tests__/attachment-vision.test.ts +97 -0
- package/src/shared/__tests__/mcp-manager.test.ts +87 -1
- package/src/shared/__tests__/model-registry.test.ts +71 -0
- package/src/shared/attachment-vision.ts +92 -9
- package/src/shared/mcp-manager.ts +22 -22
- package/src/shared/model-registry.ts +50 -2
|
@@ -143,6 +143,71 @@ describe("VisionBudget.offer — eligibility", () => {
|
|
|
143
143
|
});
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
describe("VisionBudget — model vision capability gate", () => {
|
|
147
|
+
const blind = () => new VisionBudget(DEEP_AGENT_VISION_PROFILE, { modelVision: false });
|
|
148
|
+
|
|
149
|
+
it("degrades every recognizable image with model_no_vision when the model is flagged blind", () => {
|
|
150
|
+
expect(blind().offer("photo.png", "image/png", imageBytes("png"))).toEqual({
|
|
151
|
+
kind: "degraded",
|
|
152
|
+
reason: "model_no_vision",
|
|
153
|
+
});
|
|
154
|
+
// Sniff-authoritative acceptance is gated too: real image bytes with a
|
|
155
|
+
// non-image declared type are still image-shaped.
|
|
156
|
+
expect(blind().offer("blob.bin", "application/octet-stream", imageBytes("jpeg"))).toEqual({
|
|
157
|
+
kind: "degraded",
|
|
158
|
+
reason: "model_no_vision",
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("reports model_no_vision (not type_mismatch) for declared-but-unsniffable images", () => {
|
|
163
|
+
// A HEIC renamed .jpg on a blind model: "unreadable format" would invite
|
|
164
|
+
// a re-encode that cannot help — blindness is the whole story.
|
|
165
|
+
expect(blind().offer("photo.jpg", "image/jpeg", Buffer.from("not an image"))).toEqual({
|
|
166
|
+
kind: "degraded",
|
|
167
|
+
reason: "model_no_vision",
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("gates before format and size rules so their resend advice never leaks", () => {
|
|
172
|
+
const cursorBlind = new VisionBudget(CURSOR_VISION_PROFILE, {
|
|
173
|
+
maxImageBytes: 100,
|
|
174
|
+
modelVision: false,
|
|
175
|
+
});
|
|
176
|
+
// WebP on the Cursor profile would be unsupported_format; oversized would
|
|
177
|
+
// be too_large. Blindness pre-empts both.
|
|
178
|
+
expect(cursorBlind.offer("sticker.webp", "image/webp", imageBytes("webp"))).toEqual({
|
|
179
|
+
kind: "degraded",
|
|
180
|
+
reason: "model_no_vision",
|
|
181
|
+
});
|
|
182
|
+
expect(cursorBlind.offer("big.png", "image/png", imageBytes("png", 101))).toEqual({
|
|
183
|
+
kind: "degraded",
|
|
184
|
+
reason: "model_no_vision",
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("still skips non-image input silently on a blind model", () => {
|
|
189
|
+
expect(blind().offer("doc.pdf", "application/pdf", Buffer.from("%PDF-1.7"))).toEqual({
|
|
190
|
+
kind: "skipped",
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("treats explicit true and unknown identically: fail-open", () => {
|
|
195
|
+
const sighted = new VisionBudget(DEEP_AGENT_VISION_PROFILE, { modelVision: true });
|
|
196
|
+
const unknown = new VisionBudget(DEEP_AGENT_VISION_PROFILE, { modelVision: undefined });
|
|
197
|
+
expect(sighted.offer("a.png", "image/png", imageBytes("png")).kind).toBe("accepted");
|
|
198
|
+
expect(unknown.offer("a.png", "image/png", imageBytes("png")).kind).toBe("accepted");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("modelCannotSee + offerBlind settle a candidate without reading bytes", () => {
|
|
202
|
+
const budget = blind();
|
|
203
|
+
expect(budget.modelCannotSee()).toBe(true);
|
|
204
|
+
expect(budget.offerBlind()).toEqual({ kind: "degraded", reason: "model_no_vision" });
|
|
205
|
+
|
|
206
|
+
const sighted = new VisionBudget(DEEP_AGENT_VISION_PROFILE);
|
|
207
|
+
expect(sighted.modelCannotSee()).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
146
211
|
describe("VisionBudget — size and count budgets", () => {
|
|
147
212
|
it("accepts exactly at the per-image cap and degrades one byte over it", () => {
|
|
148
213
|
const budget = new VisionBudget(DEEP_AGENT_VISION_PROFILE, {
|
|
@@ -317,6 +382,38 @@ describe("visionDisclosureLines", () => {
|
|
|
317
382
|
expect(lines).toHaveLength(2);
|
|
318
383
|
});
|
|
319
384
|
|
|
385
|
+
it("gives blind-model entries honest advice instead of the resend suggestion", () => {
|
|
386
|
+
const lines = visionDisclosureLines(
|
|
387
|
+
[],
|
|
388
|
+
[
|
|
389
|
+
{ path: ".stigmer/inputs/a.png", reason: "model_no_vision" },
|
|
390
|
+
{ path: ".stigmer/inputs/b.jpg", reason: "model_no_vision" },
|
|
391
|
+
],
|
|
392
|
+
);
|
|
393
|
+
expect(lines[0]).toBe(
|
|
394
|
+
"NOT VIEWABLE INLINE: `.stigmer/inputs/a.png` (model cannot view images), " +
|
|
395
|
+
"`.stigmer/inputs/b.jpg` (model cannot view images).",
|
|
396
|
+
);
|
|
397
|
+
expect(lines[1]).toContain("does not support image input");
|
|
398
|
+
expect(lines[1]).toContain("no resend will help");
|
|
399
|
+
// The resend-smaller suggestion must never appear for a blind model.
|
|
400
|
+
expect(lines.join("\n")).not.toContain("resend it as a smaller PNG or JPEG");
|
|
401
|
+
expect(lines).toHaveLength(2);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("keeps both advice lines, each scoped to its reasons, when reasons mix", () => {
|
|
405
|
+
const lines = visionDisclosureLines(
|
|
406
|
+
[],
|
|
407
|
+
[
|
|
408
|
+
{ path: ".stigmer/inputs/big.png", reason: "too_large" },
|
|
409
|
+
{ path: ".stigmer/inputs/a.png", reason: "model_no_vision" },
|
|
410
|
+
],
|
|
411
|
+
);
|
|
412
|
+
expect(lines[1]).toContain("ask the user to resend");
|
|
413
|
+
expect(lines[2]).toContain("no resend will help");
|
|
414
|
+
expect(lines).toHaveLength(3);
|
|
415
|
+
});
|
|
416
|
+
|
|
320
417
|
it("returns nothing when there is nothing to disclose", () => {
|
|
321
418
|
expect(visionDisclosureLines([], [])).toEqual([]);
|
|
322
419
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import type { Connection } from "@langchain/mcp-adapters";
|
|
2
3
|
import { toMcpClientConfig } from "../mcp-manager.js";
|
|
3
4
|
import type { ResolvedMcpServer } from "../mcp-resolver.js";
|
|
4
5
|
|
|
@@ -13,6 +14,18 @@ function makeServer(overrides: Partial<ResolvedMcpServer>): ResolvedMcpServer {
|
|
|
13
14
|
};
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
/** Narrow a Connection to its stdio variant and return its env. */
|
|
18
|
+
function stdioEnv(
|
|
19
|
+
config: Record<string, Connection>,
|
|
20
|
+
slug: string,
|
|
21
|
+
): Record<string, string> | undefined {
|
|
22
|
+
const conn = config[slug];
|
|
23
|
+
if (!conn || !("command" in conn)) {
|
|
24
|
+
throw new Error(`expected a stdio connection for '${slug}'`);
|
|
25
|
+
}
|
|
26
|
+
return conn.env;
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
describe("toMcpClientConfig", () => {
|
|
17
30
|
it("maps stdio servers to the client config format", () => {
|
|
18
31
|
const servers = [
|
|
@@ -81,3 +94,76 @@ describe("toMcpClientConfig", () => {
|
|
|
81
94
|
expect(toMcpClientConfig([])).toEqual({});
|
|
82
95
|
});
|
|
83
96
|
});
|
|
97
|
+
|
|
98
|
+
// Runner-internal credentials that must never reach an MCP stdio subprocess
|
|
99
|
+
// (oss#256). Names mirror what the runner actually reads: config.ts,
|
|
100
|
+
// fingerprint-secret.ts, model-client.ts, registry-endpoint.ts.
|
|
101
|
+
const RUNNER_CREDENTIAL_KEYS = [
|
|
102
|
+
"STIGMER_RUNNER_HITL_SECRET",
|
|
103
|
+
"STIGMER_TOKEN",
|
|
104
|
+
"STIGMER_AUTH_TOKEN",
|
|
105
|
+
"CURSOR_API_KEY",
|
|
106
|
+
"ANTHROPIC_API_KEY",
|
|
107
|
+
"OPENAI_API_KEY",
|
|
108
|
+
] as const;
|
|
109
|
+
|
|
110
|
+
describe("toMcpClientConfig — stdio env isolation (oss#256)", () => {
|
|
111
|
+
afterEach(() => {
|
|
112
|
+
vi.unstubAllEnvs();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("gives a stdio server with no declared env none of the runner's variables", () => {
|
|
116
|
+
for (const key of RUNNER_CREDENTIAL_KEYS) {
|
|
117
|
+
vi.stubEnv(key, `leaked-${key}`);
|
|
118
|
+
}
|
|
119
|
+
vi.stubEnv("STIGMER_TEST_LEAK_SENTINEL", "canary");
|
|
120
|
+
|
|
121
|
+
const config = toMcpClientConfig([
|
|
122
|
+
makeServer({ slug: "bare", command: "some-mcp-server", env: undefined }),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
// Passing no env means the MCP SDK supplies its minimal base environment
|
|
126
|
+
// (HOME, LOGNAME, PATH, SHELL, TERM, USER) — the runner must add nothing.
|
|
127
|
+
expect(stdioEnv(config, "bare")).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("never copies process.env into a stdio config (fallback-reintroduction tripwire)", () => {
|
|
131
|
+
vi.stubEnv("STIGMER_TEST_LEAK_SENTINEL", "canary");
|
|
132
|
+
|
|
133
|
+
const config = toMcpClientConfig([
|
|
134
|
+
makeServer({ slug: "bare", command: "some-mcp-server", env: undefined }),
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const env = stdioEnv(config, "bare") ?? {};
|
|
138
|
+
for (const key of Object.keys(process.env)) {
|
|
139
|
+
expect(
|
|
140
|
+
env,
|
|
141
|
+
`process.env key '${key}' must not leak into an MCP stdio env`,
|
|
142
|
+
).not.toHaveProperty(key);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("passes a declared env through exactly, without merging process.env", () => {
|
|
147
|
+
vi.stubEnv("STIGMER_TOKEN", "runner-secret");
|
|
148
|
+
|
|
149
|
+
const config = toMcpClientConfig([
|
|
150
|
+
makeServer({
|
|
151
|
+
slug: "declared",
|
|
152
|
+
command: "some-mcp-server",
|
|
153
|
+
env: { API_KEY: "user-value" },
|
|
154
|
+
}),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
expect(stdioEnv(config, "declared")).toEqual({ API_KEY: "user-value" });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("passes an empty declared env through unchanged (same child env as undeclared)", () => {
|
|
161
|
+
vi.stubEnv("STIGMER_TOKEN", "runner-secret");
|
|
162
|
+
|
|
163
|
+
const config = toMcpClientConfig([
|
|
164
|
+
makeServer({ slug: "empty", command: "some-mcp-server", env: {} }),
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
expect(stdioEnv(config, "empty")).toEqual({});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
getSummarizationModel,
|
|
4
4
|
getEconomyModel,
|
|
5
5
|
getDefaultModel,
|
|
6
|
+
getModelVisionCapability,
|
|
6
7
|
resolveToApiModelId,
|
|
7
8
|
_resetRegistryCache,
|
|
8
9
|
} from "../model-registry.js";
|
|
@@ -253,6 +254,75 @@ describe("resolveToApiModelId", () => {
|
|
|
253
254
|
});
|
|
254
255
|
});
|
|
255
256
|
|
|
257
|
+
describe("getModelVisionCapability", () => {
|
|
258
|
+
beforeEach(() => {
|
|
259
|
+
_resetRegistryCache();
|
|
260
|
+
vi.restoreAllMocks();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterEach(() => {
|
|
264
|
+
_resetRegistryCache();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("returns the explicit vision flag when the capabilities block is present", async () => {
|
|
268
|
+
mockRegistryResponse([
|
|
269
|
+
{ id: "claude-sonnet-4.6", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { vision: true, toolUse: true } },
|
|
270
|
+
{ id: "llama3.1", provider: "ollama", costTier: "economy", harness: "native", capabilities: { vision: false, toolUse: true } },
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
expect(await getModelVisionCapability("claude-sonnet-4.6")).toBe(true);
|
|
274
|
+
_resetRegistryCache();
|
|
275
|
+
mockRegistryResponse([
|
|
276
|
+
{ id: "llama3.1", provider: "ollama", costTier: "economy", harness: "native", capabilities: { vision: false } },
|
|
277
|
+
]);
|
|
278
|
+
expect(await getModelVisionCapability("llama3.1")).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("returns undefined when the capabilities block is absent (never assessed ≠ blind)", async () => {
|
|
282
|
+
// The cursor-harness convention today: pricing + UI fields, no
|
|
283
|
+
// capabilities block. Must stay undefined, never coerce to false.
|
|
284
|
+
mockRegistryResponse([
|
|
285
|
+
{ id: "composer-2.5", provider: "cursor", costTier: "economy", harness: "cursor" },
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
expect(await getModelVisionCapability("composer-2.5")).toBeUndefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("matches by apiModelId as well as registry id", async () => {
|
|
292
|
+
// getDefaultModel() hands the deep-agent harness the provider API id, so
|
|
293
|
+
// both identifier forms must resolve to the same capability.
|
|
294
|
+
mockRegistryResponse([
|
|
295
|
+
{ id: "claude-sonnet-4.6", apiModelId: "claude-sonnet-4-6", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { vision: true } },
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
expect(await getModelVisionCapability("claude-sonnet-4-6")).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("returns undefined for unknown names, the empty string, and the Auto pool", async () => {
|
|
302
|
+
mockRegistryResponse([
|
|
303
|
+
{ id: "claude-sonnet-4.6", provider: "anthropic", costTier: "standard", harness: "native", capabilities: { vision: true } },
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
expect(await getModelVisionCapability("never-heard-of-it")).toBeUndefined();
|
|
307
|
+
expect(await getModelVisionCapability("")).toBeUndefined();
|
|
308
|
+
expect(await getModelVisionCapability("default")).toBeUndefined();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("returns undefined when the registry fetch fails (fail-open)", async () => {
|
|
312
|
+
vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("network error"));
|
|
313
|
+
|
|
314
|
+
expect(await getModelVisionCapability("claude-sonnet-4.6")).toBeUndefined();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("treats a malformed capabilities value as undefined", async () => {
|
|
318
|
+
mockRegistryResponse([
|
|
319
|
+
{ id: "weird-model", provider: "anthropic", costTier: "standard", harness: "native", capabilities: "yes" as unknown as { vision?: boolean } },
|
|
320
|
+
]);
|
|
321
|
+
|
|
322
|
+
expect(await getModelVisionCapability("weird-model")).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
256
326
|
interface MockModel {
|
|
257
327
|
id: string;
|
|
258
328
|
apiModelId?: string;
|
|
@@ -260,6 +330,7 @@ interface MockModel {
|
|
|
260
330
|
costTier: string;
|
|
261
331
|
harness: string;
|
|
262
332
|
featured?: boolean;
|
|
333
|
+
capabilities?: { vision?: boolean; toolUse?: boolean };
|
|
263
334
|
}
|
|
264
335
|
|
|
265
336
|
function mockRegistryResponse(models: MockModel[]) {
|
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
* authoritative. A declared image whose bytes are not a recognizable image
|
|
17
17
|
* degrades to the file-pointer story instead of shipping a mislabeled payload.
|
|
18
18
|
*
|
|
19
|
+
* Model capability gate: eligibility also consults the execution model's
|
|
20
|
+
* vision capability, sourced from the model registry's `capabilities.vision`
|
|
21
|
+
* flag (model-registry.ts, `getModelVisionCapability`) and passed in at
|
|
22
|
+
* budget construction. The gate fails OPEN on unknown — only an explicit
|
|
23
|
+
* `vision: false` degrades — because most registry entries have never been
|
|
24
|
+
* capability-assessed, and blocking images on missing data would regress
|
|
25
|
+
* behavior that works today (issue #370 has the full evidence trail).
|
|
26
|
+
*
|
|
19
27
|
* Degradation is always non-fatal and always disclosed: an image the model
|
|
20
28
|
* cannot see is announced in the prompt (see {@link visionDisclosureLines}) so
|
|
21
29
|
* the agent can tell the user instead of silently ignoring a photo the user
|
|
@@ -83,7 +91,14 @@ export type VisionDegradedReason =
|
|
|
83
91
|
/** A real image type the current harness cannot display (e.g. WebP on Cursor). */
|
|
84
92
|
| "unsupported_format"
|
|
85
93
|
/** Declared as an image but the bytes are not a recognizable image (HEIC named .jpg, corrupt file). */
|
|
86
|
-
| "type_mismatch"
|
|
94
|
+
| "type_mismatch"
|
|
95
|
+
/**
|
|
96
|
+
* The model registry explicitly flags the execution's model as unable to
|
|
97
|
+
* see images (`capabilities.vision: false`). Unlike every other reason,
|
|
98
|
+
* this one is not resend-fixable — no smaller or re-encoded image can help
|
|
99
|
+
* — so the disclosure gives it its own honest wording.
|
|
100
|
+
*/
|
|
101
|
+
| "model_no_vision";
|
|
87
102
|
|
|
88
103
|
export type VisionOutcome =
|
|
89
104
|
| { readonly kind: "accepted"; readonly image: VisionImage }
|
|
@@ -187,17 +202,32 @@ export class VisionBudget {
|
|
|
187
202
|
private readonly maxImageBytes: number;
|
|
188
203
|
private readonly maxTotalBytes: number;
|
|
189
204
|
private readonly maxImages: number;
|
|
205
|
+
/**
|
|
206
|
+
* Tri-state model capability from the registry (model-registry.ts,
|
|
207
|
+
* `getModelVisionCapability`). Only an explicit `false` gates: `undefined`
|
|
208
|
+
* means the capability was never assessed (or the registry was
|
|
209
|
+
* unreachable, or the model is the Cursor "default" Auto pool), and the
|
|
210
|
+
* policy fails OPEN on unknown — degrading every image because a flag is
|
|
211
|
+
* missing would regress behavior that works today.
|
|
212
|
+
*/
|
|
213
|
+
private readonly modelVision?: boolean;
|
|
190
214
|
private totalBytes = 0;
|
|
191
215
|
private imageCount = 0;
|
|
192
216
|
|
|
193
217
|
constructor(
|
|
194
218
|
profile: VisionProfile,
|
|
195
|
-
|
|
219
|
+
options?: {
|
|
220
|
+
maxImageBytes?: number;
|
|
221
|
+
maxTotalBytes?: number;
|
|
222
|
+
maxImages?: number;
|
|
223
|
+
modelVision?: boolean;
|
|
224
|
+
},
|
|
196
225
|
) {
|
|
197
226
|
this.profile = profile;
|
|
198
|
-
this.maxImageBytes =
|
|
199
|
-
this.maxTotalBytes =
|
|
200
|
-
this.maxImages =
|
|
227
|
+
this.maxImageBytes = options?.maxImageBytes ?? MAX_VISION_IMAGE_BYTES;
|
|
228
|
+
this.maxTotalBytes = options?.maxTotalBytes ?? MAX_VISION_TOTAL_BYTES;
|
|
229
|
+
this.maxImages = options?.maxImages ?? MAX_VISION_IMAGES;
|
|
230
|
+
this.modelVision = options?.modelVision;
|
|
201
231
|
}
|
|
202
232
|
|
|
203
233
|
/** Evaluate one attachment's bytes against every eligibility and budget rule. */
|
|
@@ -205,6 +235,17 @@ export class VisionBudget {
|
|
|
205
235
|
const sniffed = sniffImageMime(bytes);
|
|
206
236
|
const declaredIsImage = declaredType.toLowerCase().startsWith("image/");
|
|
207
237
|
|
|
238
|
+
// The blind-model gate comes before every other rule: for a model that
|
|
239
|
+
// cannot see images, format/size/budget reasons would be irrelevant and
|
|
240
|
+
// their "resend smaller" advice actively misleading. Anything
|
|
241
|
+
// image-shaped (recognizable bytes OR a declared image type) is
|
|
242
|
+
// disclosed; everything else stays on the silent file story.
|
|
243
|
+
if (this.modelVision === false) {
|
|
244
|
+
return sniffed !== undefined || declaredIsImage
|
|
245
|
+
? { kind: "degraded", reason: "model_no_vision" }
|
|
246
|
+
: { kind: "skipped" };
|
|
247
|
+
}
|
|
248
|
+
|
|
208
249
|
if (sniffed === undefined) {
|
|
209
250
|
// Declared an image but isn't one we can recognize — the user plausibly
|
|
210
251
|
// expects it to be seen (iPhone HEIC renamed .jpg is the common case),
|
|
@@ -250,6 +291,28 @@ export class VisionBudget {
|
|
|
250
291
|
offerOversized(): VisionOutcome {
|
|
251
292
|
return { kind: "degraded", reason: "too_large" };
|
|
252
293
|
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* True when the model is explicitly flagged as unable to see images, so no
|
|
297
|
+
* candidate can ever be accepted. Callers on a no-read path (the Cursor
|
|
298
|
+
* local-file fast branch) check this BEFORE stat/size logic — a blind
|
|
299
|
+
* model's oversized image must report {@link offerBlind}'s honest reason,
|
|
300
|
+
* never `too_large`'s "resend smaller" advice — then record the outcome
|
|
301
|
+
* via {@link offerBlind}, mirroring the exceedsImageCap/offerOversized
|
|
302
|
+
* pattern.
|
|
303
|
+
*/
|
|
304
|
+
modelCannotSee(): boolean {
|
|
305
|
+
return this.modelVision === false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Record a vision candidate the caller chose not to read because
|
|
310
|
+
* {@link modelCannotSee} was true — the model's blindness alone settles
|
|
311
|
+
* the outcome.
|
|
312
|
+
*/
|
|
313
|
+
offerBlind(): VisionOutcome {
|
|
314
|
+
return { kind: "degraded", reason: "model_no_vision" };
|
|
315
|
+
}
|
|
253
316
|
}
|
|
254
317
|
|
|
255
318
|
// ---------------------------------------------------------------------------
|
|
@@ -331,6 +394,8 @@ function reasonLabel(reason: VisionDegradedReason): string {
|
|
|
331
394
|
return "unsupported format";
|
|
332
395
|
case "type_mismatch":
|
|
333
396
|
return "unreadable image format";
|
|
397
|
+
case "model_no_vision":
|
|
398
|
+
return "model cannot view images";
|
|
334
399
|
}
|
|
335
400
|
}
|
|
336
401
|
|
|
@@ -358,10 +423,28 @@ export function visionDisclosureLines(
|
|
|
358
423
|
.map((e) => `\`${e.path}\` (${reasonLabel(e.reason)})`)
|
|
359
424
|
.join(", ");
|
|
360
425
|
lines.push(`NOT VIEWABLE INLINE: ${entries}.`);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
426
|
+
// The advice must match the reason. Resending helps only when the image
|
|
427
|
+
// itself was the problem; for a blind model that advice would send the
|
|
428
|
+
// user on a pointless resize-and-resend errand, so that arm gets its own
|
|
429
|
+
// honest wording. (In practice a blind model degrades EVERY image, so
|
|
430
|
+
// the two lines rarely co-occur — but the wording stays reason-accurate
|
|
431
|
+
// either way.)
|
|
432
|
+
const resendFixable = notViewable.filter((e) => e.reason !== "model_no_vision");
|
|
433
|
+
const modelBlind = notViewable.filter((e) => e.reason === "model_no_vision");
|
|
434
|
+
if (resendFixable.length > 0) {
|
|
435
|
+
lines.push(
|
|
436
|
+
"You cannot see these files; if you need one, ask the user to resend it " +
|
|
437
|
+
"as a smaller PNG or JPEG.",
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
if (modelBlind.length > 0) {
|
|
441
|
+
lines.push(
|
|
442
|
+
"The current model does not support image input, so no resend will " +
|
|
443
|
+
"help. The files are saved on disk at the paths above; if the user " +
|
|
444
|
+
"needs an image understood, suggest switching to a vision-capable " +
|
|
445
|
+
"model.",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
365
448
|
}
|
|
366
449
|
if (inlineFilenames.length > 0) {
|
|
367
450
|
lines.push(
|
|
@@ -10,9 +10,22 @@
|
|
|
10
10
|
* resolution time by shared/mcp-transport-guard.ts — servers reaching
|
|
11
11
|
* this manager have already passed it.
|
|
12
12
|
*
|
|
13
|
+
* Stdio env contract (oss#256): a subprocess receives exactly the
|
|
14
|
+
* variables declared in the server's spec.env (resolved upstream by
|
|
15
|
+
* filterEnvToDeclaredKeys) plus the MCP SDK's minimal base environment
|
|
16
|
+
* (HOME, LOGNAME, PATH, SHELL, TERM, USER — getDefaultEnvironment in
|
|
17
|
+
* @modelcontextprotocol/sdk, merged under whatever we pass). The
|
|
18
|
+
* runner's own process env is never passed: it carries runner-internal
|
|
19
|
+
* credentials (STIGMER_TOKEN, STIGMER_RUNNER_HITL_SECRET,
|
|
20
|
+
* CURSOR_API_KEY, LLM provider keys) that no third-party MCP subprocess
|
|
21
|
+
* may see. A declared-empty env and an undeclared env are deliberately
|
|
22
|
+
* equivalent — both yield the SDK base environment.
|
|
23
|
+
*
|
|
13
24
|
* The Cursor execution path does NOT use this manager — it passes MCP
|
|
14
25
|
* configs directly to the Cursor SDK via toCursorMcpConfig(). This
|
|
15
|
-
* manager
|
|
26
|
+
* manager serves LangGraph-based deep agent executions, and discovery
|
|
27
|
+
* (activities/discover-mcp-server.ts) builds its spawn config through
|
|
28
|
+
* toMcpClientConfig too.
|
|
16
29
|
*/
|
|
17
30
|
|
|
18
31
|
import { MultiServerMCPClient } from "@langchain/mcp-adapters";
|
|
@@ -32,11 +45,18 @@ export function toMcpClientConfig(
|
|
|
32
45
|
for (const server of servers) {
|
|
33
46
|
if (server.connectionType === "stdio") {
|
|
34
47
|
if (!server.command) continue;
|
|
48
|
+
if (!server.env || Object.keys(server.env).length === 0) {
|
|
49
|
+
console.log(
|
|
50
|
+
`[MCP] Server '${server.slug}' declares no env — its subprocess ` +
|
|
51
|
+
`starts with the minimal base environment only. Declare variables ` +
|
|
52
|
+
`in the McpServer's spec.env to pass them.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
35
55
|
config[server.slug] = {
|
|
36
56
|
transport: "stdio",
|
|
37
57
|
command: server.command,
|
|
38
58
|
args: server.args ?? [],
|
|
39
|
-
env: server.env
|
|
59
|
+
env: server.env,
|
|
40
60
|
cwd: server.cwd,
|
|
41
61
|
};
|
|
42
62
|
} else if (server.connectionType === "http" || server.connectionType === "sse") {
|
|
@@ -91,23 +111,3 @@ export async function connectMcpServers(
|
|
|
91
111
|
|
|
92
112
|
return { client, tools, serverToolMap };
|
|
93
113
|
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Snapshot of process.env as a string-only record (no undefined values).
|
|
97
|
-
* Used as a fallback when an MCP server has no declared env — the
|
|
98
|
-
* subprocess inherits the runner's full environment, matching standard
|
|
99
|
-
* Unix child-process behavior.
|
|
100
|
-
*
|
|
101
|
-
* Without this, @modelcontextprotocol/sdk only passes a restricted
|
|
102
|
-
* whitelist (HOME, PATH, USER, etc.) which drops platform variables
|
|
103
|
-
* like STIGMER_SERVER_ADDRESS.
|
|
104
|
-
*/
|
|
105
|
-
function processEnvAsStrings(): Record<string, string> {
|
|
106
|
-
const env: Record<string, string> = {};
|
|
107
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
108
|
-
if (value !== undefined) {
|
|
109
|
-
env[key] = value;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return env;
|
|
113
|
-
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Model registry — provider lookup
|
|
2
|
+
* Model registry — provider lookup, economy-tier model derivation, and
|
|
3
|
+
* model capability resolution.
|
|
3
4
|
*
|
|
4
5
|
* Fetches the model registry from the runner's control plane (see
|
|
5
6
|
* registry-endpoint.ts for endpoint resolution — same endpoint as
|
|
6
7
|
* model-pricing-data.ts) and uses `costTier` + `harness` fields to
|
|
7
|
-
* dynamically resolve economy-tier models for extraction/summarization
|
|
8
|
+
* dynamically resolve economy-tier models for extraction/summarization,
|
|
9
|
+
* plus `capabilities` catalog metadata for per-model capability lookups
|
|
10
|
+
* (getModelVisionCapability).
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import { resolveModelRegistryUrl, buildRegistryHeaders } from "./registry-endpoint.js";
|
|
@@ -22,6 +25,14 @@ interface RegistryModel {
|
|
|
22
25
|
costTier: string;
|
|
23
26
|
harness: string;
|
|
24
27
|
featured: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Tri-state vision capability from the registry's `capabilities` block.
|
|
30
|
+
* The registry serializes `capabilities` only for models whose capabilities
|
|
31
|
+
* have actually been assessed, so `undefined` means "never assessed" —
|
|
32
|
+
* deliberately distinct from an explicit `false` ("assessed as blind").
|
|
33
|
+
* Consumers gate only on the explicit `false` (see attachment-vision.ts).
|
|
34
|
+
*/
|
|
35
|
+
visionCapability?: boolean;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
let cache: { models: readonly RegistryModel[]; expiresAt: number } | null = null;
|
|
@@ -41,9 +52,20 @@ function parseRegistry(json: unknown): RegistryModel[] {
|
|
|
41
52
|
costTier: (m.costTier as string) ?? "standard",
|
|
42
53
|
harness: (m.harness as string) ?? "native",
|
|
43
54
|
featured: !!m.featured,
|
|
55
|
+
visionCapability: parseVisionCapability(m.capabilities),
|
|
44
56
|
}));
|
|
45
57
|
}
|
|
46
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Extract `capabilities.vision` preserving the tri-state: a missing or
|
|
61
|
+
* malformed `capabilities` block stays `undefined` (never coerced to false).
|
|
62
|
+
*/
|
|
63
|
+
function parseVisionCapability(capabilities: unknown): boolean | undefined {
|
|
64
|
+
if (!capabilities || typeof capabilities !== "object") return undefined;
|
|
65
|
+
const vision = (capabilities as Record<string, unknown>).vision;
|
|
66
|
+
return typeof vision === "boolean" ? vision : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
47
69
|
async function fetchRegistry(): Promise<readonly RegistryModel[]> {
|
|
48
70
|
const url = resolveModelRegistryUrl();
|
|
49
71
|
const res = await fetch(url, { headers: buildRegistryHeaders() });
|
|
@@ -211,6 +233,32 @@ export async function resolveToApiModelId(registryId: string): Promise<string> {
|
|
|
211
233
|
return entry.apiModelId ?? registryId;
|
|
212
234
|
}
|
|
213
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Look up a model's vision capability from the registry's `capabilities`
|
|
238
|
+
* catalog metadata. Returns the tri-state the vision policy expects
|
|
239
|
+
* (attachment-vision.ts): `false` only when the registry explicitly says the
|
|
240
|
+
* model cannot see images; `undefined` whenever the answer is unknown —
|
|
241
|
+
* capability never assessed, model not in the registry, registry
|
|
242
|
+
* unreachable, or no concrete model name (the Cursor harness's ""/"default"
|
|
243
|
+
* Auto pool). Callers gate on the explicit `false` only, so every unknown
|
|
244
|
+
* degrades to today's behavior instead of blocking images.
|
|
245
|
+
*
|
|
246
|
+
* Matches by registry `id` OR `apiModelId`: getDefaultModel() hands the
|
|
247
|
+
* deep-agent harness the provider API id, while executionConfig.modelName
|
|
248
|
+
* carries the registry id, so both forms arrive here.
|
|
249
|
+
*/
|
|
250
|
+
export async function getModelVisionCapability(
|
|
251
|
+
modelName: string,
|
|
252
|
+
): Promise<boolean | undefined> {
|
|
253
|
+
if (!modelName || modelName === "default") return undefined;
|
|
254
|
+
|
|
255
|
+
const registry = await getRegistry();
|
|
256
|
+
const entry = registry.find(
|
|
257
|
+
(m) => m.id === modelName || m.apiModelId === modelName,
|
|
258
|
+
);
|
|
259
|
+
return entry?.visionCapability;
|
|
260
|
+
}
|
|
261
|
+
|
|
214
262
|
/** Exposed for testing — resets the in-memory cache. */
|
|
215
263
|
export function _resetRegistryCache(): void {
|
|
216
264
|
cache = null;
|