@stigmer/runner 3.8.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.
Files changed (64) hide show
  1. package/README.md +2 -2
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/execute-cursor/attachment-resolver.d.ts +16 -0
  4. package/dist/activities/execute-cursor/attachment-resolver.js +63 -4
  5. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  6. package/dist/activities/execute-cursor/index.d.ts +15 -0
  7. package/dist/activities/execute-cursor/index.js +73 -11
  8. package/dist/activities/execute-cursor/index.js.map +1 -1
  9. package/dist/activities/execute-cursor/prompt-builder.d.ts +14 -1
  10. package/dist/activities/execute-cursor/prompt-builder.js +11 -2
  11. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  12. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +17 -0
  13. package/dist/activities/execute-deep-agent/attachment-injector.js +34 -4
  14. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  15. package/dist/activities/execute-deep-agent/hitl.d.ts +15 -7
  16. package/dist/activities/execute-deep-agent/hitl.js +6 -15
  17. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  18. package/dist/activities/execute-deep-agent/index.js +4 -1
  19. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  20. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  21. package/dist/activities/execute-deep-agent/prompt-builder.js +13 -2
  22. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/setup.js +49 -3
  24. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  25. package/dist/runner-manager.js +14 -0
  26. package/dist/runner-manager.js.map +1 -1
  27. package/dist/runner.js +14 -0
  28. package/dist/runner.js.map +1 -1
  29. package/dist/shared/artifact-storage.d.ts +10 -0
  30. package/dist/shared/artifact-storage.js +49 -8
  31. package/dist/shared/artifact-storage.js.map +1 -1
  32. package/dist/shared/attachment-vision.d.ts +244 -0
  33. package/dist/shared/attachment-vision.js +330 -0
  34. package/dist/shared/attachment-vision.js.map +1 -0
  35. package/dist/shared/mcp-manager.d.ts +14 -1
  36. package/dist/shared/mcp-manager.js +20 -21
  37. package/dist/shared/mcp-manager.js.map +1 -1
  38. package/dist/shared/model-registry.d.ts +20 -2
  39. package/dist/shared/model-registry.js +37 -2
  40. package/dist/shared/model-registry.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +218 -0
  43. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +105 -3
  44. package/src/activities/execute-cursor/attachment-resolver.ts +97 -4
  45. package/src/activities/execute-cursor/index.ts +94 -13
  46. package/src/activities/execute-cursor/prompt-builder.ts +27 -2
  47. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +207 -0
  48. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +13 -13
  49. package/src/activities/execute-deep-agent/__tests__/vision-input.test.ts +152 -0
  50. package/src/activities/execute-deep-agent/attachment-injector.ts +65 -4
  51. package/src/activities/execute-deep-agent/hitl.ts +14 -19
  52. package/src/activities/execute-deep-agent/index.ts +4 -5
  53. package/src/activities/execute-deep-agent/prompt-builder.ts +37 -2
  54. package/src/activities/execute-deep-agent/setup.ts +58 -3
  55. package/src/runner-manager.ts +19 -0
  56. package/src/runner.ts +19 -0
  57. package/src/shared/__tests__/artifact-storage.test.ts +76 -1
  58. package/src/shared/__tests__/attachment-vision.test.ts +420 -0
  59. package/src/shared/__tests__/mcp-manager.test.ts +87 -1
  60. package/src/shared/__tests__/model-registry.test.ts +71 -0
  61. package/src/shared/artifact-storage.ts +55 -8
  62. package/src/shared/attachment-vision.ts +456 -0
  63. package/src/shared/mcp-manager.ts +22 -22
  64. package/src/shared/model-registry.ts +50 -2
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Proves the T04 vision input path end-to-end through the REAL framework
3
+ * stack: the exact `{ role: "user", content: [...] }` shape that setup.ts
4
+ * builds is driven through `createDeepAgent` (the production graph factory,
5
+ * default middleware included) and the test asserts what the chat model's
6
+ * `_generate` actually received.
7
+ *
8
+ * This is the offline stand-in for the deferred live provider probes (T01
9
+ * A4/A5): it cannot prove Anthropic renders the pixels, but it proves the
10
+ * image blocks survive the LangGraph message reducer and the full deepagents
11
+ * middleware stack byte-identically — the part of the path we own.
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
15
+ import { mkdtemp, rm } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { tmpdir } from "node:os";
18
+ import type { BaseMessage } from "@langchain/core/messages";
19
+ import { HumanMessage } from "@langchain/core/messages";
20
+ import type { ChatResult } from "@langchain/core/outputs";
21
+ import { MemorySaver } from "@langchain/langgraph";
22
+ import { createDeepAgent } from "deepagents";
23
+ import { createCasCaptureBackend } from "../cas-capture-backend.js";
24
+ import { CasCaptureObserver } from "../cas-capture-observer.js";
25
+ import {
26
+ ScriptedModel,
27
+ type ScriptSelector,
28
+ } from "../__test-utils__/scripted-model.js";
29
+ import {
30
+ DEEP_AGENT_VISION_PROFILE,
31
+ VisionBudget,
32
+ toLangChainImageBlocks,
33
+ type LangChainContentBlock,
34
+ type VisionImage,
35
+ } from "../../../shared/attachment-vision.js";
36
+
37
+ /**
38
+ * A ScriptedModel that records every `_generate` input. `bindTools` must
39
+ * return a NEW capturing instance sharing the same capture array — the base
40
+ * class re-instantiates itself on bind, which would silently drop the spy.
41
+ */
42
+ class CapturingModel extends ScriptedModel {
43
+ constructor(
44
+ private readonly selectFn: ScriptSelector,
45
+ private readonly captured: BaseMessage[][],
46
+ ) {
47
+ super(selectFn);
48
+ }
49
+
50
+ // Reports as ChatAnthropic so createDeepAgent engages the same
51
+ // anthropic-prompt-caching middleware a production run gets — the stack the
52
+ // image blocks must survive.
53
+ override getName(): string {
54
+ return "ChatAnthropic";
55
+ }
56
+
57
+ override bindTools(tools: unknown[]): this {
58
+ const next = new CapturingModel(this.selectFn, this.captured);
59
+ next.toolNames = (tools as Array<{ name?: string }>).map((t) => t?.name ?? "");
60
+ return next as unknown as this;
61
+ }
62
+
63
+ override async _generate(messages: BaseMessage[]): Promise<ChatResult> {
64
+ this.captured.push(messages);
65
+ return super._generate(messages);
66
+ }
67
+ }
68
+
69
+ function makeVisionImage(filename: string): VisionImage {
70
+ const pngBytes = Buffer.concat([
71
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
72
+ Buffer.alloc(56, 0xab),
73
+ ]);
74
+ const outcome = new VisionBudget(DEEP_AGENT_VISION_PROFILE).offer(
75
+ filename,
76
+ "image/png",
77
+ pngBytes,
78
+ );
79
+ if (outcome.kind !== "accepted") throw new Error("fixture PNG must be accepted");
80
+ return outcome.image;
81
+ }
82
+
83
+ describe("deep-agent vision input through the real graph", () => {
84
+ let root: string;
85
+
86
+ beforeEach(async () => {
87
+ root = await mkdtemp(join(tmpdir(), "vision-input-"));
88
+ });
89
+
90
+ afterEach(async () => {
91
+ await rm(root, { recursive: true, force: true });
92
+ });
93
+
94
+ /**
95
+ * `content` is exactly what setup.ts puts on the user message: a plain
96
+ * string, or the content-block array built by toLangChainImageBlocks plus
97
+ * the composed text block.
98
+ */
99
+ async function invokeGraphWith(
100
+ content: string | LangChainContentBlock[],
101
+ ): Promise<BaseMessage[][]> {
102
+ const captured: BaseMessage[][] = [];
103
+ const script: ScriptSelector = () => ({ toolCalls: [], done: "seen" });
104
+
105
+ const observer = new CasCaptureObserver({ rootDir: root, isIgnored: async () => false });
106
+ const backend = await createCasCaptureBackend({ rootDir: root, observer, shellEnv: {} });
107
+
108
+ const agent = await createDeepAgent({
109
+ model: new CapturingModel(script, captured),
110
+ checkpointer: new MemorySaver() as never,
111
+ backend,
112
+ } as Parameters<typeof createDeepAgent>[0]);
113
+
114
+ // The EXACT input shape setup.ts constructs (plain role/content dict, not
115
+ // a HumanMessage instance — the reducer does the coercion in production).
116
+ await agent.invoke(
117
+ { messages: [{ role: "user", content }] },
118
+ { configurable: { thread_id: "vision-thread" }, recursionLimit: 10 },
119
+ );
120
+ return captured;
121
+ }
122
+
123
+ it("delivers image_url blocks (labels first, text last) to the model byte-identically", async () => {
124
+ const image = makeVisionImage("photo.png");
125
+ const content: LangChainContentBlock[] = [
126
+ ...toLangChainImageBlocks([image]),
127
+ { type: "text", text: "what is in this image?" },
128
+ ];
129
+
130
+ const captured = await invokeGraphWith(content);
131
+
132
+ expect(captured.length).toBeGreaterThan(0);
133
+ const human = captured[0].find((m): m is HumanMessage => m instanceof HumanMessage);
134
+ expect(human).toBeDefined();
135
+ expect(human!.content).toEqual([
136
+ { type: "text", text: "Image 1: photo.png" },
137
+ {
138
+ type: "image_url",
139
+ image_url: { url: `data:image/png;base64,${image.base64}` },
140
+ },
141
+ { type: "text", text: "what is in this image?" },
142
+ ]);
143
+ });
144
+
145
+ it("keeps a plain-string user message untouched (the no-attachment path)", async () => {
146
+ const captured = await invokeGraphWith("just text, no images");
147
+
148
+ const human = captured[0].find((m): m is HumanMessage => m instanceof HumanMessage);
149
+ expect(human).toBeDefined();
150
+ expect(human!.content).toBe("just text, no images");
151
+ });
152
+ });
@@ -20,6 +20,11 @@ import { basename, posix } from "node:path";
20
20
  import type { Attachment } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
21
21
  import type { WorkspaceBackend } from "../../shared/workspace/types.js";
22
22
  import type { ArtifactStorage } from "../../shared/artifact-storage.js";
23
+ import type {
24
+ VisionBudget,
25
+ VisionDegradedReason,
26
+ VisionImage,
27
+ } from "../../shared/attachment-vision.js";
23
28
 
24
29
  // ── Constants ────────────────────────────────────────────────────────
25
30
 
@@ -36,6 +41,15 @@ export interface InjectedFile {
36
41
  readonly filename: string;
37
42
  readonly path: string;
38
43
  readonly sizeBytes: number;
44
+ /** Present when the attachment was accepted into the turn's vision payload. */
45
+ readonly vision?: VisionImage;
46
+ /**
47
+ * Present when the attachment was plausibly an image but could not ride
48
+ * inline (see {@link VisionDegradedReason}) — disclosed in the system prompt
49
+ * so the agent never silently ignores a photo the user believes it can see.
50
+ * Attachments that were never image-shaped carry neither field.
51
+ */
52
+ readonly visionDegraded?: VisionDegradedReason;
39
53
  }
40
54
 
41
55
  export interface InjectAttachmentsOptions {
@@ -50,6 +64,13 @@ export interface InjectAttachmentsOptions {
50
64
  */
51
65
  readonly storage: ArtifactStorage | undefined;
52
66
  readonly isLocalMode: boolean;
67
+ /**
68
+ * The turn's vision selector (attachment-vision.ts owns all policy).
69
+ * `undefined` disables inline image delivery; file materialization is
70
+ * identical either way — vision is strictly additive. Archives (`extract`)
71
+ * are never offered: an image inside a ZIP has no attachment-level bytes.
72
+ */
73
+ readonly visionBudget?: VisionBudget;
53
74
  }
54
75
 
55
76
  export interface ZipEntryInfo {
@@ -240,7 +261,7 @@ export function validateZipForExtraction(
240
261
  * On any failure, throws AttachmentInjectionError with an actionable message.
241
262
  */
242
263
  export async function injectAttachments(opts: InjectAttachmentsOptions): Promise<InjectedFile[]> {
243
- const { backend, attachments, storage, isLocalMode } = opts;
264
+ const { backend, attachments, storage, isLocalMode, visionBudget } = opts;
244
265
 
245
266
  if (attachments.length === 0) return [];
246
267
 
@@ -262,10 +283,17 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
262
283
  injectedFiles.push(...extracted);
263
284
  } else {
264
285
  await backend.writeFileBuffer(mountPath, content);
286
+ const filename = attachment.filename || basename(mountPath);
287
+ // The bytes are already in hand for the workspace write — offer them to
288
+ // the vision budget before they go out of scope (the sniff decides
289
+ // eligibility; the budget owns every size/count rule).
290
+ const vision = visionBudget?.offer(filename, attachment.contentType, content);
265
291
  injectedFiles.push({
266
- filename: attachment.filename || basename(mountPath),
292
+ filename,
267
293
  path: mountPath,
268
294
  sizeBytes: content.length,
295
+ ...(vision?.kind === "accepted" ? { vision: vision.image } : {}),
296
+ ...(vision?.kind === "degraded" ? { visionDegraded: vision.reason } : {}),
269
297
  });
270
298
  }
271
299
  }
@@ -313,20 +341,53 @@ function resolveMountPath(attachment: Attachment): string {
313
341
  "mountPath resolves to an empty path after removing leading slashes",
314
342
  );
315
343
  }
344
+ // A caller-supplied mount path is untrusted. Stripping leading slashes does
345
+ // not stop `..` segments from climbing out of the workspace root on the
346
+ // non-`.stigmer/` branch (the `.stigmer/`-routed branch is already guarded
347
+ // by LocalWorkspaceBackend.resolvePath). Reject any path that normalizes to
348
+ // an escape before it reaches the backend write.
349
+ if (escapesRoot(cleaned)) {
350
+ throw new AttachmentInjectionError(
351
+ attachment.filename,
352
+ `mount path '${attachment.mountPath}' escapes the workspace root`,
353
+ );
354
+ }
316
355
  return cleaned;
317
356
  }
318
357
 
319
- const filename = attachment.filename || deriveFilename(attachment.storageKey);
320
- if (!filename) {
358
+ const rawName = attachment.filename || deriveFilename(attachment.storageKey);
359
+ if (!rawName) {
321
360
  throw new AttachmentInjectionError(
322
361
  "(unknown)",
323
362
  "attachment has neither filename nor storageKey — cannot determine mount path",
324
363
  );
325
364
  }
326
365
 
366
+ // The filename is untrusted; reduce it to a single path component so it lands
367
+ // directly under the inputs prefix rather than steering the write elsewhere.
368
+ const filename = posix.basename(rawName);
369
+ if (filename === "" || filename === "." || filename === "..") {
370
+ throw new AttachmentInjectionError(
371
+ rawName,
372
+ `filename '${rawName}' does not yield a usable name for materialization`,
373
+ );
374
+ }
375
+
327
376
  return `${DEFAULT_INPUTS_PREFIX}/${filename}`;
328
377
  }
329
378
 
379
+ // escapesRoot reports whether a workspace-relative mount path would climb out
380
+ // of the workspace root once normalized. Mount paths are forward-slash,
381
+ // workspace-relative strings, so posix normalization is the correct semantics.
382
+ function escapesRoot(relPath: string): boolean {
383
+ const normalized = posix.normalize(relPath);
384
+ return (
385
+ normalized === ".." ||
386
+ normalized.startsWith("../") ||
387
+ posix.isAbsolute(normalized)
388
+ );
389
+ }
390
+
330
391
  function deriveFilename(storageKey: string): string {
331
392
  if (!storageKey) return "";
332
393
  const parts = storageKey.split("/");
@@ -37,10 +37,15 @@ const ACTION_MAP: ReadonlyMap<ApprovalAction, string> = new Map([
37
37
  [ApprovalAction.REJECT, "reject"],
38
38
  ]);
39
39
 
40
- export interface ResumeResult {
41
- readonly graphInput: Command | Record<string, unknown>;
42
- readonly isResumeFromApproval: boolean;
43
- }
40
+ /**
41
+ * Either a genuine approval resume carrying the `Command(resume)` payload, or
42
+ * not one — in which case the caller uses `setup.langgraphInput` (the single
43
+ * construction site of the turn's user message; this module deliberately does
44
+ * NOT build a second copy that could drift from it).
45
+ */
46
+ export type ResumeResult =
47
+ | { readonly isResumeFromApproval: true; readonly graphInput: Command }
48
+ | { readonly isResumeFromApproval: false };
44
49
 
45
50
  export interface GraphStateSnapshot {
46
51
  readonly values: Record<string, unknown>;
@@ -71,8 +76,8 @@ export interface InterruptValue {
71
76
  * Resolve the resume input for a reinvocation after approval.
72
77
  *
73
78
  * Returns a `Command(resume=...)` if there are pending interrupts with
74
- * matching approval decisions, or a fresh user message input if this is
75
- * not a resume scenario.
79
+ * matching approval decisions; otherwise reports "not a resume" and the
80
+ * caller falls back to `setup.langgraphInput`.
76
81
  *
77
82
  * The graph checkpoint snapshot is read once by the caller and passed in: the
78
83
  * same snapshot also decides whether status must be seeded from the persisted
@@ -82,22 +87,15 @@ export interface InterruptValue {
82
87
  export function resolveResumeInput(
83
88
  execution: AgentExecution,
84
89
  graphState: GraphStateSnapshot,
85
- userMessage: string,
86
90
  ): ResumeResult {
87
91
  const pendingInterrupts = extractPendingInterrupts(graphState);
88
92
  if (pendingInterrupts.length === 0) {
89
- return {
90
- graphInput: { messages: [{ role: "user", content: userMessage }] },
91
- isResumeFromApproval: false,
92
- };
93
+ return { isResumeFromApproval: false };
93
94
  }
94
95
 
95
96
  const decisions = extractApprovalDecisions(execution);
96
97
  if (decisions.size === 0) {
97
- return {
98
- graphInput: { messages: [{ role: "user", content: userMessage }] },
99
- isResumeFromApproval: false,
100
- };
98
+ return { isResumeFromApproval: false };
101
99
  }
102
100
 
103
101
  const resumeDict: Record<string, { action: string; comment?: string }> = {};
@@ -117,10 +115,7 @@ export function resolveResumeInput(
117
115
  }
118
116
 
119
117
  if (Object.keys(resumeDict).length === 0) {
120
- return {
121
- graphInput: { messages: [{ role: "user", content: userMessage }] },
122
- isResumeFromApproval: false,
123
- };
118
+ return { isResumeFromApproval: false };
124
119
  }
125
120
 
126
121
  console.log(
@@ -161,12 +161,11 @@ export function createDeepAgentActivities(config: Config) {
161
161
  unattended: setup.unattended,
162
162
  });
163
163
 
164
- const resume = resolveResumeInput(
165
- setup.execution,
166
- graphState,
167
- setup.execution.spec!.message,
168
- );
164
+ const resume = resolveResumeInput(setup.execution, graphState);
169
165
 
166
+ // Not an approval resume -> setup.langgraphInput, the single
167
+ // construction site of the turn's user message (string or multimodal
168
+ // content blocks when the turn carries inline images).
170
169
  const effectiveInput = resume.isResumeFromApproval
171
170
  ? resume.graphInput
172
171
  : setup.langgraphInput;
@@ -17,6 +17,10 @@ import {
17
17
  type SenderIdentity,
18
18
  } from "../../shared/sender-identity.js";
19
19
  import { formatSessionContextText } from "../../shared/session-context.js";
20
+ import {
21
+ visionDisclosureLines,
22
+ type NotViewableEntry,
23
+ } from "../../shared/attachment-vision.js";
20
24
  import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
21
25
  import {
22
26
  buildImplementPlanDirective,
@@ -108,6 +112,12 @@ export interface PromptBuilderInput {
108
112
  workspaceFileRefs: string[];
109
113
  workspaceRoot: string;
110
114
  injectedFiles: InjectedFile[];
115
+ /**
116
+ * Vision facts about this turn's attachments (T04): which images the model
117
+ * sees inline in the user message and which degraded to path-only.
118
+ * Rendered inside the Input Files section.
119
+ */
120
+ vision?: VisionPromptInfo;
111
121
  /**
112
122
  * The execution's interaction mode. PLAN appends the shared plan-mode
113
123
  * directive so the model knows the turn's deliverable is a plan document.
@@ -158,6 +168,17 @@ export interface InjectedFile {
158
168
  size?: number | null;
159
169
  }
160
170
 
171
+ /**
172
+ * Which images ride the user message inline (in send order) and which
173
+ * degraded to the file-pointer story — the shared vision wording
174
+ * (attachment-vision.ts) keeps this prompt and the Cursor harness's
175
+ * input-files section telling the agent the same thing.
176
+ */
177
+ export interface VisionPromptInfo {
178
+ inlineFilenames: string[];
179
+ notViewable: NotViewableEntry[];
180
+ }
181
+
161
182
  /**
162
183
  * Assemble the full system prompt from base instructions and contextual
163
184
  * sections. Pure function with no I/O.
@@ -196,7 +217,7 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
196
217
  }
197
218
 
198
219
  if (input.injectedFiles.length > 0) {
199
- prompt += buildInjectedFilesSection(input.injectedFiles);
220
+ prompt += buildInjectedFilesSection(input.injectedFiles, input.vision);
200
221
  }
201
222
 
202
223
  if (input.senderIdentity) {
@@ -374,7 +395,10 @@ function buildReferencedFilesSection(
374
395
  return section;
375
396
  }
376
397
 
377
- function buildInjectedFilesSection(files: InjectedFile[]): string {
398
+ function buildInjectedFilesSection(
399
+ files: InjectedFile[],
400
+ vision?: VisionPromptInfo,
401
+ ): string {
378
402
  let section = "\n\n## Input Files\n\n";
379
403
  section +=
380
404
  "The following files have been provided as read-only reference " +
@@ -390,5 +414,16 @@ function buildInjectedFilesSection(files: InjectedFile[]): string {
390
414
  section += `- \`${f.path}\`${sizeInfo}\n`;
391
415
  }
392
416
 
417
+ // The vision lines (shared wording, attachment-vision.ts) tell the model
418
+ // which of these files it can already SEE inline in the user message versus
419
+ // which degraded to path-only — without them an agent silently ignores a
420
+ // photo the user believes it can see.
421
+ if (vision) {
422
+ const lines = visionDisclosureLines(vision.inlineFilenames, vision.notViewable);
423
+ if (lines.length > 0) {
424
+ section += "\n" + lines.join("\n") + "\n";
425
+ }
426
+ }
427
+
393
428
  return section;
394
429
  }
@@ -73,7 +73,7 @@ import type { ApprovalGateConfig } from "../../middleware/approval-gate.js";
73
73
  import { deriveExecutionFingerprintKey } from "../../shared/approval-fingerprint.js";
74
74
  import { getRunnerHitlMasterSecret } from "../../shared/fingerprint-secret.js";
75
75
  import { getModelPricing, ensureLoaded as ensurePricingLoaded } from "../../shared/model-pricing.js";
76
- import { getDefaultModel } from "../../shared/model-registry.js";
76
+ import { getDefaultModel, getModelVisionCapability } from "../../shared/model-registry.js";
77
77
  import { buildChatModel } from "../../shared/model-client.js";
78
78
  import {
79
79
  loadArtifactStorageConfig,
@@ -99,6 +99,12 @@ import {
99
99
  } from "../../shared/skill-writer.js";
100
100
  import { filterSkills, SKILL_COUNT_THRESHOLD } from "../../shared/skill-relevance.js";
101
101
  import { injectAttachments } from "./attachment-injector.js";
102
+ import {
103
+ DEEP_AGENT_VISION_PROFILE,
104
+ VisionBudget,
105
+ toLangChainImageBlocks,
106
+ type NotViewableEntry,
107
+ } from "../../shared/attachment-vision.js";
102
108
  import { transformAndCompileSubagents } from "./subagent-transformer.js";
103
109
  import {
104
110
  resolveRecursionLimit,
@@ -508,14 +514,46 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
508
514
  timing.mark("resolve_skills");
509
515
  }
510
516
 
511
- // Step 7c: Inject attachments
517
+ // Step 7c: Inject attachments. The vision budget rides along so image
518
+ // attachments are selected for inline delivery while their bytes are
519
+ // already in hand (attachment-vision.ts owns all policy). The budget
520
+ // also carries the root model's registry vision capability — the root
521
+ // model is the only one that ever receives inline image blocks
522
+ // (sub-agents get a fresh task description, summarization reuses this
523
+ // same model instance) — so a model flagged `vision: false` degrades
524
+ // images honestly instead of shipping a payload its provider rejects.
512
525
  const attachments = execution.spec!.attachments || [];
526
+ const visionBudget = new VisionBudget(DEEP_AGENT_VISION_PROFILE, {
527
+ modelVision: await getModelVisionCapability(modelName),
528
+ });
513
529
  const injectedFiles = await injectAttachments({
514
530
  backend: workspaceBackend,
515
531
  attachments,
516
532
  storage: artifactStorage,
517
533
  isLocalMode: config.mode === "local",
534
+ visionBudget,
518
535
  });
536
+ // Vision facts, derived once from the single injection result: the images
537
+ // the model will see inline (in attachment order) and the ones that
538
+ // degraded to path-only, disclosed in the system prompt's Input Files
539
+ // section.
540
+ const visionImages = injectedFiles.flatMap((f) => (f.vision ? [f.vision] : []));
541
+ const visionNotViewable: NotViewableEntry[] = injectedFiles.flatMap((f) =>
542
+ f.visionDegraded ? [{ path: f.path, reason: f.visionDegraded }] : [],
543
+ );
544
+ const visionPromptInfo = visionImages.length > 0 || visionNotViewable.length > 0
545
+ ? {
546
+ inlineFilenames: visionImages.map((v) => v.filename),
547
+ notViewable: visionNotViewable,
548
+ }
549
+ : undefined;
550
+ if (visionPromptInfo) {
551
+ console.log(
552
+ `[attachment-vision] execution=${executionId} inline=${visionImages.length} ` +
553
+ `(${visionImages.reduce((n, v) => n + v.byteSize, 0)} bytes) ` +
554
+ `degraded=${JSON.stringify(visionNotViewable.map((d) => `${d.path}:${d.reason}`))}`,
555
+ );
556
+ }
519
557
  timing.mark("inject_attachments");
520
558
 
521
559
  // Step 8: Build enhanced system prompt
@@ -535,6 +573,7 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
535
573
  workspaceFileRefs: execution.spec!.workspaceFileRefs || [],
536
574
  workspaceRoot: workspaceBackend.rootDir,
537
575
  injectedFiles,
576
+ vision: visionPromptInfo,
538
577
  interactionMode: execution.spec!.executionConfig?.interactionMode,
539
578
  buildFromPlan: execution.spec!.executionConfig?.buildFromPlan,
540
579
  contextBridge: readContextBridge(session.spec!.metadata),
@@ -784,8 +823,24 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
784
823
  if (outputSchema) {
785
824
  userMessage += `\n\n---\nIMPORTANT: When your analysis is complete, provide your findings as structured output matching the required schema. The system will capture your structured response automatically.`;
786
825
  }
826
+ // With inline images the user message becomes a multimodal content-block
827
+ // array — image blocks (with filename labels) FIRST, the composed text
828
+ // last, per Anthropic's images-before-text guidance. Without images the
829
+ // content stays a plain string, byte-identical to the pre-vision shape.
830
+ // The LangGraph messages reducer coerces either form into a HumanMessage
831
+ // untouched (verified against the installed @langchain/langgraph).
787
832
  const langgraphInput = {
788
- messages: [{ role: "user", content: userMessage }],
833
+ messages: [
834
+ {
835
+ role: "user",
836
+ content: visionImages.length > 0
837
+ ? [
838
+ ...toLangChainImageBlocks(visionImages),
839
+ { type: "text", text: userMessage },
840
+ ]
841
+ : userMessage,
842
+ },
843
+ ],
789
844
  };
790
845
 
791
846
  const langgraphConfig: Record<string, unknown> = {
@@ -332,6 +332,25 @@ export async function createStigmerRunnerManager(
332
332
 
333
333
  const activities = await createAllActivities(config);
334
334
  markBoot("activities_imported");
335
+ // Surface the resolved artifact store at boot (see runner.ts for rationale):
336
+ // a path/type misconfiguration is the #285 failure class, and it should be
337
+ // visible here rather than only when a read fails mid-execution.
338
+ try {
339
+ const { loadArtifactStorageConfig } = await import(
340
+ "./shared/artifact-storage.js"
341
+ );
342
+ const artifactCfg = loadArtifactStorageConfig(config);
343
+ console.log(
344
+ `[runner-manager] Artifact store: type=${artifactCfg.type}` +
345
+ (artifactCfg.type === "local"
346
+ ? ` | root=${artifactCfg.localPath}`
347
+ : ` | proxy=${artifactCfg.proxyEndpoint ?? "(unset)"}`),
348
+ );
349
+ } catch (err) {
350
+ console.warn(
351
+ `[runner-manager] Artifact store: could not resolve config for boot log: ${err}`,
352
+ );
353
+ }
335
354
  const payloadCodec = await createPayloadCodec(config);
336
355
 
337
356
  const connection = await NativeConnection.connect({
package/src/runner.ts CHANGED
@@ -282,6 +282,25 @@ export async function createStigmerRunner(
282
282
  `Mode: ${config.mode} | ` +
283
283
  `Max concurrency: ${config.maxConcurrentActivities}`,
284
284
  );
285
+ // Surface the resolved artifact store at boot so a path/type misconfiguration
286
+ // (the #285 class of failure) is visible immediately instead of only when an
287
+ // attachment or offload read fails minutes into an execution.
288
+ try {
289
+ const { loadArtifactStorageConfig } = await import(
290
+ "./shared/artifact-storage.js"
291
+ );
292
+ const artifactCfg = loadArtifactStorageConfig(config);
293
+ console.log(
294
+ `[runner] Artifact store: type=${artifactCfg.type}` +
295
+ (artifactCfg.type === "local"
296
+ ? ` | root=${artifactCfg.localPath}`
297
+ : ` | proxy=${artifactCfg.proxyEndpoint ?? "(unset)"}`),
298
+ );
299
+ } catch (err) {
300
+ console.warn(
301
+ `[runner] Artifact store: could not resolve config for boot log: ${err}`,
302
+ );
303
+ }
285
304
 
286
305
  const payloadCodec = await createPayloadCodec(config);
287
306