@stigmer/runner 3.7.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 (76) 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 +27 -0
  7. package/dist/activities/execute-cursor/index.js +101 -10
  8. package/dist/activities/execute-cursor/index.js.map +1 -1
  9. package/dist/activities/execute-cursor/prompt-builder.d.ts +25 -1
  10. package/dist/activities/execute-cursor/prompt-builder.js +22 -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/mcp-gate.d.ts +28 -0
  21. package/dist/activities/execute-deep-agent/mcp-gate.js +22 -0
  22. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -0
  23. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +28 -0
  24. package/dist/activities/execute-deep-agent/prompt-builder.js +29 -2
  25. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/setup.js +71 -6
  27. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  28. package/dist/runner-manager.js +14 -0
  29. package/dist/runner-manager.js.map +1 -1
  30. package/dist/runner.js +14 -0
  31. package/dist/runner.js.map +1 -1
  32. package/dist/shared/artifact-storage.d.ts +10 -0
  33. package/dist/shared/artifact-storage.js +49 -8
  34. package/dist/shared/artifact-storage.js.map +1 -1
  35. package/dist/shared/attachment-vision.d.ts +203 -0
  36. package/dist/shared/attachment-vision.js +264 -0
  37. package/dist/shared/attachment-vision.js.map +1 -0
  38. package/dist/shared/channel-attachment.d.ts +3 -1
  39. package/dist/shared/channel-attachment.js +3 -1
  40. package/dist/shared/channel-attachment.js.map +1 -1
  41. package/dist/shared/conversation-attachment.d.ts +81 -0
  42. package/dist/shared/conversation-attachment.js +102 -0
  43. package/dist/shared/conversation-attachment.js.map +1 -0
  44. package/dist/shared/conversation-catchup.d.ts +33 -0
  45. package/dist/shared/conversation-catchup.js +53 -0
  46. package/dist/shared/conversation-catchup.js.map +1 -0
  47. package/package.json +3 -3
  48. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +179 -0
  49. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +183 -2
  50. package/src/activities/execute-cursor/attachment-resolver.ts +90 -4
  51. package/src/activities/execute-cursor/index.ts +143 -11
  52. package/src/activities/execute-cursor/prompt-builder.ts +50 -2
  53. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +185 -0
  54. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +13 -13
  55. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +42 -0
  56. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +39 -1
  57. package/src/activities/execute-deep-agent/__tests__/vision-input.test.ts +152 -0
  58. package/src/activities/execute-deep-agent/attachment-injector.ts +65 -4
  59. package/src/activities/execute-deep-agent/hitl.ts +14 -19
  60. package/src/activities/execute-deep-agent/index.ts +4 -5
  61. package/src/activities/execute-deep-agent/mcp-gate.ts +37 -0
  62. package/src/activities/execute-deep-agent/prompt-builder.ts +59 -4
  63. package/src/activities/execute-deep-agent/setup.ts +90 -6
  64. package/src/runner-manager.ts +19 -0
  65. package/src/runner.ts +19 -0
  66. package/src/shared/__tests__/artifact-storage.test.ts +76 -1
  67. package/src/shared/__tests__/attachment-vision.test.ts +323 -0
  68. package/src/shared/__tests__/channel-attachment.test.ts +3 -3
  69. package/src/shared/__tests__/conversation-attachment.test.ts +138 -0
  70. package/src/shared/__tests__/conversation-catchup.test.ts +70 -0
  71. package/src/shared/__tests__/synthesized-attachment.test.ts +120 -0
  72. package/src/shared/artifact-storage.ts +55 -8
  73. package/src/shared/attachment-vision.ts +373 -0
  74. package/src/shared/channel-attachment.ts +3 -1
  75. package/src/shared/conversation-attachment.ts +115 -0
  76. package/src/shared/conversation-catchup.ts +60 -0
@@ -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;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The deep-agent harness's MCP gate: whether this execution enters MCP
3
+ * resolution at all (resolve, backfill, synthesized-attachment
4
+ * injection, connect). Unlike the Cursor harness — which resolves MCP
5
+ * unconditionally — deep-agent skips the whole block when no tool
6
+ * source exists, so EVERY tool source must appear here or its tools are
7
+ * silently dropped for exactly the agents whose only source it is
8
+ * (proactive-messaging DD-006 D7 learned this for channel messaging).
9
+ *
10
+ * Extracted from setup.ts as a pure function because setup.ts is
11
+ * untestable at file load (its import graph is why no setup.test.ts
12
+ * exists); the gate is the one piece whose regression is silent, so it
13
+ * gets its own module and an arm-by-arm test.
14
+ */
15
+
16
+ /** One flag per tool source. Adding a source? It gates here or it is dropped. */
17
+ export interface McpToolSources {
18
+ /** Declared MCP server usages (agent spec + session spec). */
19
+ readonly mcpServerUsageCount: number;
20
+ /** Declared datastore usages (the records attachment, T05). */
21
+ readonly datastoreUsageCount: number;
22
+ /** Serving proactive-messaging channels (the channels attachment, DD-006). */
23
+ readonly channelMessagingCount: number;
24
+ /** The serving channel id when this session IS a live channel
25
+ * conversation (the conversation attachment, DD-008). */
26
+ readonly conversationChannelId: string | undefined;
27
+ }
28
+
29
+ /** True when any tool source demands MCP resolution and connect. */
30
+ export function shouldConnectMcp(sources: McpToolSources): boolean {
31
+ return (
32
+ sources.mcpServerUsageCount > 0 ||
33
+ sources.datastoreUsageCount > 0 ||
34
+ sources.channelMessagingCount > 0 ||
35
+ sources.conversationChannelId !== undefined
36
+ );
37
+ }
@@ -11,11 +11,16 @@ import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecuti
11
11
  import type { ProvisionResult, GitMetadata } from "../../shared/workspace/types.js";
12
12
  import { SourceType } from "../../shared/workspace/types.js";
13
13
  import { formatContextBridgeText } from "../../shared/context-bridge.js";
14
+ import { formatConversationCatchupText } from "../../shared/conversation-catchup.js";
14
15
  import {
15
16
  formatSenderIdentityText,
16
17
  type SenderIdentity,
17
18
  } from "../../shared/sender-identity.js";
18
19
  import { formatSessionContextText } from "../../shared/session-context.js";
20
+ import {
21
+ visionDisclosureLines,
22
+ type NotViewableEntry,
23
+ } from "../../shared/attachment-vision.js";
19
24
  import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
20
25
  import {
21
26
  buildImplementPlanDirective,
@@ -107,6 +112,12 @@ export interface PromptBuilderInput {
107
112
  workspaceFileRefs: string[];
108
113
  workspaceRoot: string;
109
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;
110
121
  /**
111
122
  * The execution's interaction mode. PLAN appends the shared plan-mode
112
123
  * directive so the model knows the turn's deliverable is a plan document.
@@ -157,6 +168,17 @@ export interface InjectedFile {
157
168
  size?: number | null;
158
169
  }
159
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
+
160
182
  /**
161
183
  * Assemble the full system prompt from base instructions and contextual
162
184
  * sections. Pure function with no I/O.
@@ -195,7 +217,7 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
195
217
  }
196
218
 
197
219
  if (input.injectedFiles.length > 0) {
198
- prompt += buildInjectedFilesSection(input.injectedFiles);
220
+ prompt += buildInjectedFilesSection(input.injectedFiles, input.vision);
199
221
  }
200
222
 
201
223
  if (input.senderIdentity) {
@@ -241,9 +263,28 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
241
263
  return prompt;
242
264
  }
243
265
 
266
+ /**
267
+ * Compose the turn's USER MESSAGE for the graph invocation: the framed
268
+ * conversation catchup (cloud DD-006), when present, prepended to the
269
+ * customer's message. In the user message and never the system prompt (A27):
270
+ * the system prompt is rebuilt per invocation and would forget the digest one
271
+ * turn later, while a message enters the checkpointer with the turn and
272
+ * persists in history — the same durability the cursor harness gets from its
273
+ * prompt prefix. The caller's `spec.message` is never mutated; the prepend
274
+ * exists only in the graph input.
275
+ */
276
+ export function composeUserMessage(
277
+ message: string,
278
+ conversationCatchup: string | undefined,
279
+ ): string {
280
+ return conversationCatchup
281
+ ? `${formatConversationCatchupText(conversationCatchup)}\n\n---\n\n${message}`
282
+ : message;
283
+ }
284
+
244
285
  function buildWorkspacePromptSection(
245
- provisionResults: ProvisionResult[],
246
- containerRoot: string,
286
+ provisionResults: ProvisionResult[],
287
+ containerRoot: string,
247
288
  ): string {
248
289
  if (provisionResults.length === 0) return "";
249
290
 
@@ -354,7 +395,10 @@ function buildReferencedFilesSection(
354
395
  return section;
355
396
  }
356
397
 
357
- function buildInjectedFilesSection(files: InjectedFile[]): string {
398
+ function buildInjectedFilesSection(
399
+ files: InjectedFile[],
400
+ vision?: VisionPromptInfo,
401
+ ): string {
358
402
  let section = "\n\n## Input Files\n\n";
359
403
  section +=
360
404
  "The following files have been provided as read-only reference " +
@@ -370,5 +414,16 @@ function buildInjectedFilesSection(files: InjectedFile[]): string {
370
414
  section += `- \`${f.path}\`${sizeInfo}\n`;
371
415
  }
372
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
+
373
428
  return section;
374
429
  }
@@ -25,6 +25,7 @@ import type { StigmerClient } from "../../client/stigmer-client.js";
25
25
  import { TimingRecorder, emitTimingLog } from "../../shared/cold-start-timing.js";
26
26
  import { createCheckpointer } from "../../shared/checkpointer/factory.js";
27
27
  import { readContextBridge } from "../../shared/context-bridge.js";
28
+ import { readConversationCatchup } from "../../shared/conversation-catchup.js";
28
29
  import { readSenderIdentity } from "../../shared/sender-identity.js";
29
30
  import {
30
31
  injectCallerIdentityEnv,
@@ -44,7 +45,12 @@ import {
44
45
  formatChannelTemplatesSection,
45
46
  synthesizeChannelAttachment,
46
47
  } from "../../shared/channel-attachment.js";
48
+ import {
49
+ readChannelConversationId,
50
+ synthesizeConversationAttachment,
51
+ } from "../../shared/conversation-attachment.js";
47
52
  import { injectSynthesizedAttachment } from "../../shared/synthesized-attachment.js";
53
+ import { shouldConnectMcp } from "./mcp-gate.js";
48
54
  import { WorkspaceProvisioner } from "../../shared/workspace/provisioner.js";
49
55
  import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
50
56
  import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
@@ -59,7 +65,7 @@ import { resolveSessionWorkspaceRoot } from "../../shared/workspace/session-root
59
65
  import { buildWorkspaceFileTree } from "../../shared/workspace/file-tree.js";
60
66
  import { reportSetupProgress } from "../../shared/status.js";
61
67
  import { resolveEnvironment, type EnvironmentResult } from "./environment.js";
62
- import { buildEnhancedSystemPrompt } from "./prompt-builder.js";
68
+ import { buildEnhancedSystemPrompt, composeUserMessage } from "./prompt-builder.js";
63
69
  import { buildMiddlewareStack } from "../../middleware/index.js";
64
70
  import type { GracefulStopMiddleware } from "../../middleware/index.js";
65
71
  import { createThinkTool, createWebFetchTool, resolveGuardPosture } from "../../tools/index.js";
@@ -93,6 +99,12 @@ import {
93
99
  } from "../../shared/skill-writer.js";
94
100
  import { filterSkills, SKILL_COUNT_THRESHOLD } from "../../shared/skill-relevance.js";
95
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";
96
108
  import { transformAndCompileSubagents } from "./subagent-transformer.js";
97
109
  import {
98
110
  resolveRecursionLimit,
@@ -361,8 +373,18 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
361
373
  // answer — no tool, no section, execution unharmed.
362
374
  const channelMessaging = await discoverChannelMessaging(client, exchangedRunnerToken);
363
375
 
376
+ // The conversation-attachment decision (DD-008 D-c): the channel-id
377
+ // session label, stamped server-side on every channel session — a
378
+ // free, synchronous read, so no hoisted discovery needed.
379
+ const conversationChannelId = readChannelConversationId(session.metadata?.labels);
380
+
364
381
  let resolvedMcpServers: Awaited<ReturnType<typeof resolveMcpServers>> | null = null;
365
- if (mcpServerUsages.length > 0 || datastoreUsages.length > 0 || channelMessaging.length > 0) {
382
+ if (shouldConnectMcp({
383
+ mcpServerUsageCount: mcpServerUsages.length,
384
+ datastoreUsageCount: datastoreUsages.length,
385
+ channelMessagingCount: channelMessaging.length,
386
+ conversationChannelId,
387
+ })) {
366
388
  await reportSetupProgress(client, executionId, "Connecting tools…");
367
389
  const transportPosture = resolveMcpTransportPosture(config.mode);
368
390
  // The MCP-bound env map (and ONLY it) carries the reserved
@@ -424,6 +446,21 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
424
446
  );
425
447
  }
426
448
  }
449
+
450
+ // The conversation participation attachment (DD-008 D-c) — the
451
+ // third sibling, same after-backfill rule. HTTP-only: synthesize
452
+ // answers undefined with no bridge endpoint by design (see
453
+ // shared/conversation-attachment.ts).
454
+ const conversationAttachment = synthesizeConversationAttachment(conversationChannelId, {
455
+ bridgeEndpoint: config.mcpBridgeEndpoint,
456
+ credential: attachmentCredential,
457
+ backendEndpoint: config.stigmerBackendEndpoint,
458
+ });
459
+ if (conversationAttachment) {
460
+ backfilledServers = injectSynthesizedAttachment(
461
+ backfilledServers, conversationAttachment, "conversation participation",
462
+ );
463
+ }
427
464
  resolvedMcpServers = { resolvedServers: backfilledServers };
428
465
  timing.mark("backfill_mcp");
429
466
 
@@ -477,14 +514,39 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
477
514
  timing.mark("resolve_skills");
478
515
  }
479
516
 
480
- // 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).
481
520
  const attachments = execution.spec!.attachments || [];
521
+ const visionBudget = new VisionBudget(DEEP_AGENT_VISION_PROFILE);
482
522
  const injectedFiles = await injectAttachments({
483
523
  backend: workspaceBackend,
484
524
  attachments,
485
525
  storage: artifactStorage,
486
526
  isLocalMode: config.mode === "local",
527
+ visionBudget,
487
528
  });
529
+ // Vision facts, derived once from the single injection result: the images
530
+ // the model will see inline (in attachment order) and the ones that
531
+ // degraded to path-only, disclosed in the system prompt's Input Files
532
+ // section.
533
+ const visionImages = injectedFiles.flatMap((f) => (f.vision ? [f.vision] : []));
534
+ const visionNotViewable: NotViewableEntry[] = injectedFiles.flatMap((f) =>
535
+ f.visionDegraded ? [{ path: f.path, reason: f.visionDegraded }] : [],
536
+ );
537
+ const visionPromptInfo = visionImages.length > 0 || visionNotViewable.length > 0
538
+ ? {
539
+ inlineFilenames: visionImages.map((v) => v.filename),
540
+ notViewable: visionNotViewable,
541
+ }
542
+ : undefined;
543
+ if (visionPromptInfo) {
544
+ console.log(
545
+ `[attachment-vision] execution=${executionId} inline=${visionImages.length} ` +
546
+ `(${visionImages.reduce((n, v) => n + v.byteSize, 0)} bytes) ` +
547
+ `degraded=${JSON.stringify(visionNotViewable.map((d) => `${d.path}:${d.reason}`))}`,
548
+ );
549
+ }
488
550
  timing.mark("inject_attachments");
489
551
 
490
552
  // Step 8: Build enhanced system prompt
@@ -504,6 +566,7 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
504
566
  workspaceFileRefs: execution.spec!.workspaceFileRefs || [],
505
567
  workspaceRoot: workspaceBackend.rootDir,
506
568
  injectedFiles,
569
+ vision: visionPromptInfo,
507
570
  interactionMode: execution.spec!.executionConfig?.interactionMode,
508
571
  buildFromPlan: execution.spec!.executionConfig?.buildFromPlan,
509
572
  contextBridge: readContextBridge(session.spec!.metadata),
@@ -743,13 +806,34 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
743
806
  ...(isPlanMode ? { permissions: planModePermissions } : {}),
744
807
  } as Parameters<typeof createDeepAgent>[0]);
745
808
 
746
- // Step 11: Prepare invocation input and config
747
- let userMessage = execution.spec!.message;
809
+ // Step 11: Prepare invocation input and config. The conversation catchup
810
+ // (cloud DD-006) rides the USER MESSAGE, not the system prompt — see
811
+ // composeUserMessage for the durability rationale (A27).
812
+ let userMessage = composeUserMessage(
813
+ execution.spec!.message,
814
+ readConversationCatchup(execution.spec!.conversationCatchup),
815
+ );
748
816
  if (outputSchema) {
749
817
  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.`;
750
818
  }
819
+ // With inline images the user message becomes a multimodal content-block
820
+ // array — image blocks (with filename labels) FIRST, the composed text
821
+ // last, per Anthropic's images-before-text guidance. Without images the
822
+ // content stays a plain string, byte-identical to the pre-vision shape.
823
+ // The LangGraph messages reducer coerces either form into a HumanMessage
824
+ // untouched (verified against the installed @langchain/langgraph).
751
825
  const langgraphInput = {
752
- messages: [{ role: "user", content: userMessage }],
826
+ messages: [
827
+ {
828
+ role: "user",
829
+ content: visionImages.length > 0
830
+ ? [
831
+ ...toLangChainImageBlocks(visionImages),
832
+ { type: "text", text: userMessage },
833
+ ]
834
+ : userMessage,
835
+ },
836
+ ],
753
837
  };
754
838
 
755
839
  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
 
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import { mkdtemp, rm, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { tmpdir } from "node:os";
4
+ import { tmpdir, homedir } from "node:os";
5
5
  import {
6
6
  LocalArtifactStorage,
7
7
  ProxyArtifactStorage,
@@ -101,6 +101,63 @@ describe("LocalArtifactStorage", () => {
101
101
  });
102
102
  });
103
103
 
104
+ // ── LocalArtifactStorage path containment ────────────────────────────
105
+
106
+ describe("LocalArtifactStorage path containment", () => {
107
+ let tempDir: string;
108
+ let storage: LocalArtifactStorage;
109
+
110
+ // Keys that clean to a location outside the storage root. `join(base, key)`
111
+ // silently resolves `..` segments, so without a containment check these would
112
+ // read or write outside the store — the runner-side mirror of the Go finding.
113
+ const escapingKeys = [
114
+ "../escape.txt",
115
+ "../../escape.txt",
116
+ "attachments/x/../../../../escape.txt",
117
+ "a/b/../../../escape.txt",
118
+ ];
119
+
120
+ beforeEach(async () => {
121
+ tempDir = await mkdtemp(join(tmpdir(), "artifact-contain-"));
122
+ storage = new LocalArtifactStorage(tempDir, "http://localhost:7235");
123
+ });
124
+
125
+ afterEach(async () => {
126
+ await rm(tempDir, { recursive: true, force: true });
127
+ });
128
+
129
+ it("refuses to upload a key that escapes the storage root", async () => {
130
+ for (const key of escapingKeys) {
131
+ await expect(storage.upload(key, Buffer.from("owned"))).rejects.toThrow(
132
+ /outside the artifact storage root/,
133
+ );
134
+ }
135
+ });
136
+
137
+ it("refuses to download a key that escapes the storage root", async () => {
138
+ for (const key of escapingKeys) {
139
+ await expect(storage.download(key)).rejects.toThrow(
140
+ /outside the artifact storage root/,
141
+ );
142
+ }
143
+ });
144
+
145
+ it("refuses exists() for a key that escapes the storage root", async () => {
146
+ for (const key of escapingKeys) {
147
+ await expect(storage.exists(key)).rejects.toThrow(
148
+ /outside the artifact storage root/,
149
+ );
150
+ }
151
+ });
152
+
153
+ it("still allows keys with `..` segments that stay inside the root", async () => {
154
+ // Containment rejects escapes, not the mere presence of a `..` segment.
155
+ const key = "attachments/x/../y/plan.md";
156
+ await storage.upload(key, Buffer.from("ok"));
157
+ expect((await storage.download(key)).toString()).toBe("ok");
158
+ });
159
+ });
160
+
104
161
  // ── ProxyArtifactStorage ─────────────────────────────────────────────
105
162
 
106
163
  describe("ProxyArtifactStorage", () => {
@@ -480,6 +537,24 @@ describe("loadArtifactStorageConfig", () => {
480
537
  });
481
538
  expect(cfg.type).toBe("proxy");
482
539
  });
540
+
541
+ // #285: the local default must be the SAME directory the stigmer-server
542
+ // writes to (~/.stigmer/data/artifacts), not the container-era
543
+ // /var/stigmer/artifacts. Asserting the resolved path — not just the type —
544
+ // is the guard that would have caught the original drift.
545
+ it("defaults localPath to the shared ~/.stigmer/data/artifacts root and serveUrl to :7235", () => {
546
+ const cfg = loadArtifactStorageConfig(baseConfig);
547
+ expect(cfg.localPath).toBe(join(homedir(), ".stigmer", "data", "artifacts"));
548
+ expect(cfg.localServeUrl).toBe("http://localhost:7235");
549
+ });
550
+
551
+ it("respects explicit LOCAL_ARTIFACT_PATH and LOCAL_ARTIFACT_SERVE_URL", () => {
552
+ process.env.LOCAL_ARTIFACT_PATH = "/custom/artifacts";
553
+ process.env.LOCAL_ARTIFACT_SERVE_URL = "http://localhost:9999";
554
+ const cfg = loadArtifactStorageConfig(baseConfig);
555
+ expect(cfg.localPath).toBe("/custom/artifacts");
556
+ expect(cfg.localServeUrl).toBe("http://localhost:9999");
557
+ });
483
558
  });
484
559
 
485
560
  // ── resolveUsableArtifactStorage (DD-26 follow-up #1) ─────────────────