@stigmer/runner 3.8.0 → 3.9.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 (54) 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 +56 -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 +66 -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 +41 -2
  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 +203 -0
  33. package/dist/shared/attachment-vision.js +264 -0
  34. package/dist/shared/attachment-vision.js.map +1 -0
  35. package/package.json +3 -3
  36. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +179 -0
  37. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +105 -3
  38. package/src/activities/execute-cursor/attachment-resolver.ts +90 -4
  39. package/src/activities/execute-cursor/index.ts +87 -13
  40. package/src/activities/execute-cursor/prompt-builder.ts +27 -2
  41. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +185 -0
  42. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +13 -13
  43. package/src/activities/execute-deep-agent/__tests__/vision-input.test.ts +152 -0
  44. package/src/activities/execute-deep-agent/attachment-injector.ts +65 -4
  45. package/src/activities/execute-deep-agent/hitl.ts +14 -19
  46. package/src/activities/execute-deep-agent/index.ts +4 -5
  47. package/src/activities/execute-deep-agent/prompt-builder.ts +37 -2
  48. package/src/activities/execute-deep-agent/setup.ts +50 -2
  49. package/src/runner-manager.ts +19 -0
  50. package/src/runner.ts +19 -0
  51. package/src/shared/__tests__/artifact-storage.test.ts +76 -1
  52. package/src/shared/__tests__/attachment-vision.test.ts +323 -0
  53. package/src/shared/artifact-storage.ts +55 -8
  54. package/src/shared/attachment-vision.ts +373 -0
@@ -29,10 +29,17 @@
29
29
  * the execution with an actionable error.
30
30
  */
31
31
 
32
- import { mkdir, copyFile, writeFile } from "node:fs/promises";
32
+ import { mkdir, copyFile, readFile, stat, writeFile } from "node:fs/promises";
33
33
  import { join, basename } from "node:path";
34
34
  import type { Attachment } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
35
35
  import type { ArtifactStorage } from "../../shared/artifact-storage.js";
36
+ import {
37
+ isVisionCandidate,
38
+ type VisionBudget,
39
+ type VisionDegradedReason,
40
+ type VisionImage,
41
+ type VisionOutcome,
42
+ } from "../../shared/attachment-vision.js";
36
43
  import { getPlatformDir } from "../../shared/workspace/platform-dir.js";
37
44
  import { ensureStigmerSymlink, STIGMER_LOCAL_STATE_DIR } from "../../shared/workspace/stigmer-link.js";
38
45
 
@@ -42,6 +49,15 @@ export interface ResolvedAttachment {
42
49
  filename: string;
43
50
  /** Workspace-relative path the agent reads (`.stigmer/inputs/{filename}`). */
44
51
  relativePath: string;
52
+ /** Present when the attachment was accepted into the turn's vision payload. */
53
+ vision?: VisionImage;
54
+ /**
55
+ * Present when the attachment was plausibly an image but could not ride
56
+ * inline (see {@link VisionDegradedReason}) — disclosed in the prompt so the
57
+ * agent never silently ignores a photo the user believes it can see.
58
+ * Attachments that were never image-shaped carry neither field.
59
+ */
60
+ visionDegraded?: VisionDegradedReason;
45
61
  }
46
62
 
47
63
  export interface AttachmentResolverOptions {
@@ -54,6 +70,12 @@ export interface AttachmentResolverOptions {
54
70
  * then fails with an actionable error rather than a silent skip.
55
71
  */
56
72
  storage: ArtifactStorage | undefined;
73
+ /**
74
+ * The turn's vision selector (attachment-vision.ts owns all policy).
75
+ * `undefined` disables inline image delivery; file materialization is
76
+ * identical either way — vision is strictly additive.
77
+ */
78
+ visionBudget?: VisionBudget;
57
79
  }
58
80
 
59
81
  export class AttachmentResolutionError extends Error {
@@ -109,9 +131,10 @@ async function resolveAttachment(
109
131
  ): Promise<ResolvedAttachment> {
110
132
  // Local-mode fast path: the file is already on this machine's disk.
111
133
  if (options.mode === "local" && attachment.localPath) {
112
- const filename = attachment.filename || basename(attachment.localPath);
134
+ const filename = safeInputName(attachment.filename || attachment.localPath);
135
+ let vision: VisionOutcome | undefined;
113
136
  try {
114
- await copyFile(attachment.localPath, join(inputsDir, filename));
137
+ vision = await materializeLocalFile(attachment, filename, inputsDir, options.visionBudget);
115
138
  } catch (err) {
116
139
  throw new AttachmentResolutionError(
117
140
  attachment.filename,
@@ -122,6 +145,7 @@ async function resolveAttachment(
122
145
  return {
123
146
  filename,
124
147
  relativePath: join(STIGMER_LOCAL_STATE_DIR, INPUTS_SUBDIR, filename),
148
+ ...visionOutcomeFields(vision),
125
149
  };
126
150
  }
127
151
 
@@ -140,7 +164,7 @@ async function resolveAttachment(
140
164
  );
141
165
  }
142
166
 
143
- const filename = attachment.filename || basename(attachment.storageKey);
167
+ const filename = safeInputName(attachment.filename || attachment.storageKey);
144
168
  let content: Buffer;
145
169
  try {
146
170
  content = await options.storage.download(attachment.storageKey);
@@ -153,8 +177,70 @@ async function resolveAttachment(
153
177
  }
154
178
  await writeFile(join(inputsDir, filename), content);
155
179
 
180
+ // The bytes are already in hand for the file write — offer them to the
181
+ // vision budget before they go out of scope (the sniff decides eligibility;
182
+ // no pre-filter needed on this branch).
183
+ const vision = options.visionBudget?.offer(filename, attachment.contentType, content);
184
+
156
185
  return {
157
186
  filename,
158
187
  relativePath: join(STIGMER_LOCAL_STATE_DIR, INPUTS_SUBDIR, filename),
188
+ ...visionOutcomeFields(vision),
159
189
  };
160
190
  }
191
+
192
+ /**
193
+ * Materialize a local-path attachment, reading the bytes only when they are
194
+ * plausibly a vision candidate within the per-image cap — a 25 MB PDF (or an
195
+ * oversized image, detected by stat) keeps the plain `copyFile` and never
196
+ * enters memory. Returns the vision outcome, or `undefined` when vision is
197
+ * disabled or the file is not a candidate.
198
+ */
199
+ async function materializeLocalFile(
200
+ attachment: Attachment,
201
+ filename: string,
202
+ inputsDir: string,
203
+ visionBudget: VisionBudget | undefined,
204
+ ): Promise<VisionOutcome | undefined> {
205
+ const dest = join(inputsDir, filename);
206
+ if (!visionBudget || !isVisionCandidate(attachment.contentType, filename)) {
207
+ await copyFile(attachment.localPath, dest);
208
+ return undefined;
209
+ }
210
+ const info = await stat(attachment.localPath);
211
+ if (visionBudget.exceedsImageCap(info.size)) {
212
+ await copyFile(attachment.localPath, dest);
213
+ return visionBudget.offerOversized();
214
+ }
215
+ const content = await readFile(attachment.localPath);
216
+ await writeFile(dest, content);
217
+ return visionBudget.offer(filename, attachment.contentType, content);
218
+ }
219
+
220
+ function visionOutcomeFields(
221
+ outcome: VisionOutcome | undefined,
222
+ ): Pick<ResolvedAttachment, "vision" | "visionDegraded"> {
223
+ if (outcome === undefined || outcome.kind === "skipped") return {};
224
+ return outcome.kind === "accepted"
225
+ ? { vision: outcome.image }
226
+ : { visionDegraded: outcome.reason };
227
+ }
228
+
229
+ /**
230
+ * Reduce a caller-influenced name to a single, safe path component for writing
231
+ * under the inputs dir. The name (an attachment's original filename, or a
232
+ * storage key's tail) is untrusted — a value like `../../evil.md` would steer
233
+ * the write outside `.stigmer/inputs/`. Taking the basename strips any path
234
+ * structure; the residual `.`/`..`/empty cases (which basename does not strip)
235
+ * are rejected loudly so the write target is always a real file inside inputs.
236
+ */
237
+ function safeInputName(raw: string): string {
238
+ const name = basename(raw);
239
+ if (name === "" || name === "." || name === "..") {
240
+ throw new AttachmentResolutionError(
241
+ raw,
242
+ `'${raw}' does not yield a usable filename for materialization`,
243
+ );
244
+ }
245
+ return name;
246
+ }
@@ -35,7 +35,7 @@ import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agent
35
35
  import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
36
36
  import type { AgentExecution, AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
37
37
  import { ExecutionControlSignal, ExecutionPhase, FileChangeSetStatus, InteractionMode, MessageType, ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
38
- import type { Run, ConversationTurn } from "@cursor/sdk";
38
+ import type { Run, ConversationTurn, SDKUserMessage } from "@cursor/sdk";
39
39
 
40
40
  import type { Config } from "../../config.js";
41
41
  import { StigmerClient } from "../../client/stigmer-client.js";
@@ -58,6 +58,12 @@ import { readSessionContext } from "../../shared/session-context.js";
58
58
  import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
59
59
  import { StallTimeoutError, formatStallFailure } from "../../shared/stall-watchdog.js";
60
60
  import { resolveUsableArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
61
+ import {
62
+ CURSOR_VISION_PROFILE,
63
+ VisionBudget,
64
+ toCursorImages,
65
+ type NotViewableEntry,
66
+ } from "../../shared/attachment-vision.js";
61
67
  import { publishPlanArtifact } from "../../shared/plan-artifact.js";
62
68
  import { DeltaEnricher } from "./delta-enricher.js";
63
69
  import { TodoTracker } from "./todo-tracker.js";
@@ -84,7 +90,7 @@ import { buildCursorSubAgentDefinitions } from "./subagent-config.js";
84
90
  import { resolveSkills } from "./skill-resolver.js";
85
91
  import { removeStigmerSymlink } from "../../shared/workspace/stigmer-link.js";
86
92
  import { resolveAttachments } from "./attachment-resolver.js";
87
- import { buildEnhancedPrompt, buildReinvocationPrompt, formatConversationCatchupSection, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
93
+ import { buildEnhancedPrompt, buildReinvocationPrompt, formatConversationCatchupSection, formatInputFiles, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
88
94
  import { installHitlGate, removeHitlGate } from "./workspace-setup.js";
89
95
  import { ensureHitlDir } from "../../shared/workspace/platform-dir.js";
90
96
  import {
@@ -761,14 +767,38 @@ async function executeCursorInner(
761
767
 
762
768
  // Phase 5b: Resolve attachments (fail-hard — explicit user inputs; see
763
769
  // attachment-resolver.ts). Downloads by storage key through the same
764
- // artifactStorage resolved for status offload above.
770
+ // artifactStorage resolved for status offload above. The vision budget
771
+ // rides along so image attachments are selected for inline delivery while
772
+ // their bytes are already in hand (attachment-vision.ts owns all policy).
773
+ const visionBudget = new VisionBudget(CURSOR_VISION_PROFILE);
765
774
  const attachmentResults = await resolveAttachments(spec.attachments, {
766
775
  sessionId,
767
776
  primaryWorkspaceDir,
768
777
  mode: config.mode,
769
778
  storage: artifactStorage,
779
+ visionBudget,
770
780
  });
771
781
  const attachmentPaths = attachmentResults.map((a) => a.relativePath);
782
+ // Vision facts, derived once from the single resolution result: the
783
+ // images the model will see inline (in attachment order) and the ones
784
+ // that degraded to path-only, disclosed in the prompt.
785
+ const visionImages = attachmentResults.flatMap((a) => (a.vision ? [a.vision] : []));
786
+ const visionNotViewable: NotViewableEntry[] = attachmentResults.flatMap((a) =>
787
+ a.visionDegraded ? [{ path: a.relativePath, reason: a.visionDegraded }] : [],
788
+ );
789
+ const visionPromptInfo = visionImages.length > 0 || visionNotViewable.length > 0
790
+ ? {
791
+ inlineFilenames: visionImages.map((v) => v.filename),
792
+ notViewable: visionNotViewable,
793
+ }
794
+ : undefined;
795
+ if (visionPromptInfo) {
796
+ console.log(
797
+ `[attachment-vision] execution=${executionId} inline=${visionImages.length} ` +
798
+ `(${visionImages.reduce((n, v) => n + v.byteSize, 0)} bytes) ` +
799
+ `degraded=${JSON.stringify(visionNotViewable.map((d) => `${d.path}:${d.reason}`))}`,
800
+ );
801
+ }
772
802
  setupTiming.mark("resolve_attachments");
773
803
 
774
804
  // Phase 5b3: Exact-apply approved whole-file writes (HITL "what you approve
@@ -1094,6 +1124,7 @@ async function executeCursorInner(
1094
1124
  workspaceDirs: blueprint.workspaceDirs,
1095
1125
  workspaceFileRefs: spec.workspaceFileRefs ?? [],
1096
1126
  attachmentPaths,
1127
+ vision: visionPromptInfo,
1097
1128
  pendingApprovals: adjudicatedApprovals,
1098
1129
  appliedToolCallIds,
1099
1130
  interactionMode,
@@ -1111,6 +1142,20 @@ async function executeCursorInner(
1111
1142
  effectivePrompt += `\n\n---\nCRITICAL OUTPUT REQUIREMENT:\nYour final response MUST be a single valid JSON object (no markdown, no commentary, no code fences) that matches this schema:\n${schemaStr}\n\nRespond with ONLY the JSON object. Nothing else.`;
1112
1143
  }
1113
1144
 
1145
+ // Phase 10a1: The turn's vision payload. The invariant is "images
1146
+ // accompany the user's turn message — where the message goes, they go":
1147
+ // every send that delivers this turn's message carries them (the primary
1148
+ // send and both fresh-agent recovery retries, whose empty conversations
1149
+ // genuinely need the re-send), while a HITL re-invocation — whose prompt
1150
+ // carries no user message and whose resumed agent already holds the
1151
+ // images in its native conversation — carries none. Computed ONCE here so
1152
+ // all send sites agree by construction.
1153
+ const turnImages = isHitlReinvocation(approvalDecisions)
1154
+ ? []
1155
+ : toCursorImages(visionImages);
1156
+ const toSendMessage = (sendPrompt: string): string | SDKUserMessage =>
1157
+ turnImages.length > 0 ? { text: sendPrompt, images: turnImages } : sendPrompt;
1158
+
1114
1159
  // Phase 10a2: Log Stigmer preamble size for context trimming diagnostics
1115
1160
  const promptChars = effectivePrompt.length;
1116
1161
  const promptEstimatedTokens = Math.ceil(promptChars / 4);
@@ -1225,7 +1270,7 @@ async function executeCursorInner(
1225
1270
  // The stall watchdog is armed inside consumeCursorTurnStream (it needs the
1226
1271
  // run to cancel), stored on turnState.stallWatchdog so this shared onDelta can
1227
1272
  // reset it and the activity's finally can stop it as a backstop.
1228
- const run = await resolution.agent.send(effectivePrompt, {
1273
+ const run = await resolution.agent.send(toSendMessage(effectivePrompt), {
1229
1274
  onDelta: (event) => {
1230
1275
  if (!turnFirstEventEmitted) {
1231
1276
  turnFirstEventEmitted = true;
@@ -1596,7 +1641,10 @@ async function executeCursorInner(
1596
1641
  // poisoned-handle path leaked the fresh agent — it closed the stale one.)
1597
1642
  resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
1598
1643
  turnState.streamErrorMessage = undefined;
1599
- const retryRun = await freshAgent.send(retryPrompt, {
1644
+ // The retry carries the turn's images too (same toSendMessage): the
1645
+ // fresh agent's conversation is empty, so skipping them here would
1646
+ // silently lose the user's photo on a recovered turn.
1647
+ const retryRun = await freshAgent.send(toSendMessage(retryPrompt), {
1600
1648
  onDelta: makeCursorTurnOnDelta(onDeltaDeps),
1601
1649
  });
1602
1650
  await consumeCursorTurnStream(retryRun, streamDeps);
@@ -1735,6 +1783,7 @@ async function executeCursorInner(
1735
1783
  workspaceDirs: blueprint.workspaceDirs,
1736
1784
  workspaceFileRefs: spec.workspaceFileRefs ?? [],
1737
1785
  attachmentPaths,
1786
+ vision: visionPromptInfo,
1738
1787
  pendingApprovals: adjudicatedApprovals,
1739
1788
  interactionMode,
1740
1789
  // buildFromPlan was silently dropped here until T03 Sitting 3 —
@@ -2310,6 +2359,12 @@ export interface BuildPromptInput {
2310
2359
  workspaceDirs: string[];
2311
2360
  workspaceFileRefs: string[];
2312
2361
  attachmentPaths: string[];
2362
+ /**
2363
+ * Vision facts for the input-files section (T04): which attachments the
2364
+ * model sees inline and which degraded to path-only. PER-TURN like the
2365
+ * catchup — it rides both the enhanced prompt and a resumed turn's prefix.
2366
+ */
2367
+ vision?: import("./prompt-builder.js").VisionPromptInfo;
2313
2368
  pendingApprovals: import("@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb").PendingApproval[];
2314
2369
  /**
2315
2370
  * Approved whole-file writes the runner already applied itself (exact-apply).
@@ -2372,6 +2427,20 @@ export interface BuildPromptInput {
2372
2427
  * 3. first execution / fresh -> buildEnhancedPrompt (full instructions +
2373
2428
  * agent after resume failure skills; no prior conversation to inherit)
2374
2429
  */
2430
+ /**
2431
+ * Whether this activity invocation is a HITL re-invocation — the turn resumes
2432
+ * an agent purely to convey approval decisions, carrying NO user message.
2433
+ * The single discriminator for everything that must ride with the user's
2434
+ * message and nothing else: the reinvocation prompt shape (below) and the
2435
+ * vision payload (images accompany the message; a resumed agent already holds
2436
+ * them in its native conversation).
2437
+ */
2438
+ export function isHitlReinvocation(
2439
+ approvalDecisions: Map<string, ApprovalAction> | undefined,
2440
+ ): approvalDecisions is Map<string, ApprovalAction> {
2441
+ return approvalDecisions !== undefined && approvalDecisions.size > 0;
2442
+ }
2443
+
2375
2444
  export function buildPrompt(input: BuildPromptInput): string {
2376
2445
  const {
2377
2446
  resolution,
@@ -2388,12 +2457,10 @@ export function buildPrompt(input: BuildPromptInput): string {
2388
2457
  conversationCatchup,
2389
2458
  } = input;
2390
2459
 
2391
- const isHitlReinvocation = approvalDecisions !== undefined && approvalDecisions.size > 0;
2392
-
2393
2460
  // HITL reinvocation: the agent is resumed, so its native context carries the
2394
2461
  // prior conversation; the reinvocation prompt conveys the approval decisions
2395
2462
  // (and which approved writes the runner already exact-applied).
2396
- if (isHitlReinvocation) {
2463
+ if (isHitlReinvocation(approvalDecisions)) {
2397
2464
  return buildReinvocationPrompt(
2398
2465
  input.pendingApprovals,
2399
2466
  approvalDecisions,
@@ -2407,15 +2474,21 @@ export function buildPrompt(input: BuildPromptInput): string {
2407
2474
  // session's first turn: the interaction-mode prefix (a follow-up can switch
2408
2475
  // Agent→Plan mid-session, and for Cursor the prompt is the only plan-mode
2409
2476
  // enforcement), the implement-plan directive (the build turn is usually a
2410
- // follow-up on a resumed agent), and the conversation catchup (handback
2411
- // ALWAYS lands mid-session on a resumed agent this prefix is the property
2412
- // the metadata lane structurally cannot deliver, cloud DD-006). Catchup
2413
- // last: it is context, and context sits closest to the task (the enhanced
2414
- // prompt's own ordering doctrine).
2477
+ // follow-up on a resumed agent), THIS turn's attachments (spec.attachments
2478
+ // is per-execution a file sent on a follow-up turn materializes for this
2479
+ // turn and would otherwise never be announced at all), and the conversation
2480
+ // catchup (handback ALWAYS lands mid-session on a resumed agent this
2481
+ // prefix is the property the metadata lane structurally cannot deliver,
2482
+ // cloud DD-006). Catchup last: it is context, and context sits closest to
2483
+ // the task (the enhanced prompt's own ordering doctrine); the input files
2484
+ // precede it because they are this turn's payload, not background.
2415
2485
  if (resolution.reason === "resumed_successfully") {
2416
2486
  const prefixes = [
2417
2487
  formatInteractionModePrefix(interactionMode),
2418
2488
  formatImplementPlanSection(buildFromPlan, attachmentPaths),
2489
+ attachmentPaths.length > 0
2490
+ ? formatInputFiles(attachmentPaths, input.vision)
2491
+ : undefined,
2419
2492
  conversationCatchup !== undefined
2420
2493
  ? formatConversationCatchupSection(conversationCatchup)
2421
2494
  : undefined,
@@ -2438,6 +2511,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2438
2511
  workspaceDirs,
2439
2512
  workspaceFileRefs,
2440
2513
  attachmentPaths,
2514
+ vision: input.vision,
2441
2515
  interactionMode,
2442
2516
  buildFromPlan,
2443
2517
  contextBridge: input.contextBridge,
@@ -32,6 +32,10 @@ import {
32
32
  type SenderIdentity,
33
33
  } from "../../shared/sender-identity.js";
34
34
  import { formatSessionContextText } from "../../shared/session-context.js";
35
+ import {
36
+ visionDisclosureLines,
37
+ type NotViewableEntry,
38
+ } from "../../shared/attachment-vision.js";
35
39
  import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
36
40
  import {
37
41
  buildImplementPlanDirective,
@@ -56,6 +60,17 @@ export interface SkillMetadata {
56
60
  path: string;
57
61
  }
58
62
 
63
+ /**
64
+ * Vision facts about this turn's attachments, rendered inside the
65
+ * input-files section: which images the model can see inline (in send
66
+ * order) and which degraded to the file-pointer story. Derived per turn
67
+ * from the resolved attachments — never carried across turns.
68
+ */
69
+ export interface VisionPromptInfo {
70
+ inlineFilenames: string[];
71
+ notViewable: NotViewableEntry[];
72
+ }
73
+
59
74
  export interface EnhancedPromptOptions {
60
75
  instructions: string;
61
76
  userMessage: string;
@@ -76,6 +91,8 @@ export interface EnhancedPromptOptions {
76
91
  workspaceDirs: string[];
77
92
  workspaceFileRefs: string[];
78
93
  attachmentPaths: string[];
94
+ /** Inline/degraded image facts for the input-files section (T04 vision). */
95
+ vision?: VisionPromptInfo;
79
96
  interactionMode?: InteractionMode;
80
97
  /**
81
98
  * The execution is a Build-from-plan turn (spec.execution_config
@@ -179,7 +196,7 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
179
196
  }
180
197
 
181
198
  if (options.attachmentPaths.length > 0) {
182
- sections.push(formatInputFiles(options.attachmentPaths));
199
+ sections.push(formatInputFiles(options.attachmentPaths, options.vision));
183
200
  }
184
201
 
185
202
  if (options.workspaceFileRefs.length > 0) {
@@ -462,12 +479,20 @@ export function formatWorkspaceContext(dirs: string[]): string {
462
479
  ].join("\n");
463
480
  }
464
481
 
465
- export function formatInputFiles(paths: string[]): string {
482
+ export function formatInputFiles(paths: string[], vision?: VisionPromptInfo): string {
466
483
  const entries = paths.map((p) => `- \`${p}\``);
484
+ // The vision lines (shared wording, attachment-vision.ts) tell the model
485
+ // which of these files it can already SEE inline versus which degraded to
486
+ // path-only — without them an agent silently ignores a photo the user
487
+ // believes it can see.
488
+ const disclosure = vision
489
+ ? visionDisclosureLines(vision.inlineFilenames, vision.notViewable)
490
+ : [];
467
491
  return [
468
492
  "<input_files>",
469
493
  "The following files have been provided as inputs. Read them when relevant to the task:",
470
494
  ...entries,
495
+ ...disclosure,
471
496
  "</input_files>",
472
497
  ].join("\n");
473
498
  }
@@ -15,6 +15,10 @@ import {
15
15
  } from "../attachment-injector.js";
16
16
  import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
17
17
  import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
18
+ import {
19
+ DEEP_AGENT_VISION_PROFILE,
20
+ VisionBudget,
21
+ } from "../../../shared/attachment-vision.js";
18
22
 
19
23
  // ── ZIP Construction Helpers ─────────────────────────────────────────
20
24
 
@@ -552,6 +556,28 @@ describe("injectAttachments", () => {
552
556
  })).rejects.toThrow(/missing storageKey/);
553
557
  });
554
558
 
559
+ it("rejects a caller-supplied mountPath that escapes the workspace root", async () => {
560
+ // `resolveMountPath` only stripped leading slashes, so `..` segments on a
561
+ // non-`.stigmer/` mount path reached the unchecked join(rootDir, path).
562
+ const storage = makeMockStorage();
563
+ storage.download.mockResolvedValue(Buffer.from("owned"));
564
+ const backend = mockWorkspaceBackend();
565
+
566
+ const escapes = ["../../escape.txt", "../etc/evil", "foo/../../bar"];
567
+ for (const mountPath of escapes) {
568
+ await expect(injectAttachments({
569
+ backend,
570
+ attachments: [makeAttachment({
571
+ filename: "data.txt",
572
+ storageKey: "attachments/xyz/data.txt",
573
+ mountPath,
574
+ })],
575
+ storage,
576
+ isLocalMode: false,
577
+ })).rejects.toThrow(/mount path .* escapes the workspace root|traversal/i);
578
+ }
579
+ });
580
+
555
581
  it("preserves binary content via writeFileBuffer", async () => {
556
582
  const binaryContent = Buffer.from([0x00, 0x01, 0xFF, 0xFE, 0x89, 0x50, 0x4E, 0x47]);
557
583
  const localFile = join(tempDir, "image.png");
@@ -705,3 +731,162 @@ describe("injectAttachments", () => {
705
731
  })).rejects.toThrow(AttachmentValidationError);
706
732
  });
707
733
  });
734
+
735
+ // ═══════════════════════════════════════════════════════════════════════
736
+ // Vision selection during injection (T04)
737
+ // ═══════════════════════════════════════════════════════════════════════
738
+ // Vision is strictly additive: every case also asserts the file was written
739
+ // exactly as it would be without a budget.
740
+
741
+ describe("injectAttachments — vision selection", () => {
742
+ const PNG_BYTES = Buffer.concat([
743
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
744
+ Buffer.alloc(56, 0xab),
745
+ ]);
746
+ const WEBP_BYTES = Buffer.concat([
747
+ Buffer.from("RIFF", "ascii"),
748
+ Buffer.from([0x24, 0x00, 0x00, 0x00]),
749
+ Buffer.from("WEBP", "ascii"),
750
+ Buffer.alloc(52, 0xcd),
751
+ ]);
752
+
753
+ it("accepts a PNG into the vision payload (deep-agent profile) and still writes the file", async () => {
754
+ const backend = mockWorkspaceBackend();
755
+ const storage = makeMockStorage();
756
+ await storage.upload("attachments/abc/photo.png", PNG_BYTES);
757
+
758
+ const [injected] = await injectAttachments({
759
+ backend,
760
+ attachments: [makeAttachment({
761
+ filename: "photo.png",
762
+ storageKey: "attachments/abc/photo.png",
763
+ contentType: "image/png",
764
+ })],
765
+ storage,
766
+ isLocalMode: false,
767
+ visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
768
+ });
769
+
770
+ expect(injected.vision).toMatchObject({
771
+ filename: "photo.png",
772
+ mimeType: "image/png",
773
+ byteSize: PNG_BYTES.length,
774
+ });
775
+ expect(Buffer.from(injected.vision!.base64, "base64").equals(PNG_BYTES)).toBe(true);
776
+ expect(backend.writeFileBuffer).toHaveBeenCalledWith(".stigmer/inputs/photo.png", PNG_BYTES);
777
+ });
778
+
779
+ it("accepts WebP on the deep-agent profile (unlike the Cursor harness)", async () => {
780
+ const backend = mockWorkspaceBackend();
781
+ const storage = makeMockStorage();
782
+ await storage.upload("attachments/abc/pic.webp", WEBP_BYTES);
783
+
784
+ const [injected] = await injectAttachments({
785
+ backend,
786
+ attachments: [makeAttachment({
787
+ filename: "pic.webp",
788
+ storageKey: "attachments/abc/pic.webp",
789
+ contentType: "image/webp",
790
+ })],
791
+ storage,
792
+ isLocalMode: false,
793
+ visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
794
+ });
795
+
796
+ expect(injected.vision?.mimeType).toBe("image/webp");
797
+ });
798
+
799
+ it("carries no vision fields for a non-image attachment", async () => {
800
+ const backend = mockWorkspaceBackend();
801
+ const storage = makeMockStorage();
802
+ await storage.upload("attachments/abc/doc.pdf", Buffer.from("%PDF-1.7"));
803
+
804
+ const [injected] = await injectAttachments({
805
+ backend,
806
+ attachments: [makeAttachment({
807
+ filename: "doc.pdf",
808
+ storageKey: "attachments/abc/doc.pdf",
809
+ contentType: "application/pdf",
810
+ })],
811
+ storage,
812
+ isLocalMode: false,
813
+ visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
814
+ });
815
+
816
+ expect(injected.vision).toBeUndefined();
817
+ expect(injected.visionDegraded).toBeUndefined();
818
+ });
819
+
820
+ it("degrades a declared image whose bytes are not one (type_mismatch)", async () => {
821
+ const backend = mockWorkspaceBackend();
822
+ const storage = makeMockStorage();
823
+ await storage.upload("attachments/abc/photo.jpg", Buffer.from("actually HEIC"));
824
+
825
+ const [injected] = await injectAttachments({
826
+ backend,
827
+ attachments: [makeAttachment({
828
+ filename: "photo.jpg",
829
+ storageKey: "attachments/abc/photo.jpg",
830
+ contentType: "image/jpeg",
831
+ })],
832
+ storage,
833
+ isLocalMode: false,
834
+ visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
835
+ });
836
+
837
+ expect(injected.vision).toBeUndefined();
838
+ expect(injected.visionDegraded).toBe("type_mismatch");
839
+ });
840
+
841
+ it("never offers an extract archive to the budget — extracted files carry no vision fields", async () => {
842
+ // A PNG inside a ZIP has no attachment-level bytes; the archive rides the
843
+ // normal extraction story with no vision involvement or disclosure.
844
+ const zip = makeZip({ "inner.png": PNG_BYTES });
845
+ const backend = mockWorkspaceBackend();
846
+ const storage = makeMockStorage();
847
+ await storage.upload("attachments/abc/bundle.zip", zip);
848
+
849
+ const injected = await injectAttachments({
850
+ backend,
851
+ attachments: [makeAttachment({
852
+ filename: "bundle.zip",
853
+ storageKey: "attachments/abc/bundle.zip",
854
+ extract: true,
855
+ })],
856
+ storage,
857
+ isLocalMode: false,
858
+ visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
859
+ });
860
+
861
+ expect(injected.length).toBeGreaterThan(0);
862
+ for (const file of injected) {
863
+ expect(file.vision).toBeUndefined();
864
+ expect(file.visionDegraded).toBeUndefined();
865
+ }
866
+ });
867
+
868
+ it("degrades over the total budget in attachment order (budget_exhausted)", async () => {
869
+ const backend = mockWorkspaceBackend();
870
+ const storage = makeMockStorage();
871
+ await storage.upload("attachments/a/a.png", PNG_BYTES);
872
+ await storage.upload("attachments/b/b.png", PNG_BYTES);
873
+
874
+ const injected = await injectAttachments({
875
+ backend,
876
+ attachments: [
877
+ makeAttachment({ filename: "a.png", storageKey: "attachments/a/a.png", contentType: "image/png" }),
878
+ makeAttachment({ filename: "b.png", storageKey: "attachments/b/b.png", contentType: "image/png" }),
879
+ ],
880
+ storage,
881
+ isLocalMode: false,
882
+ visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE, {
883
+ maxImageBytes: PNG_BYTES.length,
884
+ maxTotalBytes: PNG_BYTES.length,
885
+ }),
886
+ });
887
+
888
+ expect(injected[0].vision).toBeDefined();
889
+ expect(injected[1].vision).toBeUndefined();
890
+ expect(injected[1].visionDegraded).toBe("budget_exhausted");
891
+ });
892
+ });