@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
@@ -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
+ });
@@ -70,23 +70,23 @@ function makeGraphState(
70
70
  }
71
71
 
72
72
  describe("resolveResumeInput", () => {
73
- it("returns fresh input when no interrupts exist", () => {
73
+ it("reports not-a-resume when no interrupts exist (caller falls back to setup.langgraphInput)", () => {
74
74
  const execution = makeExecution([]);
75
75
  const state: GraphStateSnapshot = { values: {}, tasks: [] };
76
76
 
77
- const result = resolveResumeInput(execution, state, "hello");
77
+ const result = resolveResumeInput(execution, state);
78
78
 
79
79
  expect(result.isResumeFromApproval).toBe(false);
80
- expect(result.graphInput).toEqual({
81
- messages: [{ role: "user", content: "hello" }],
82
- });
80
+ // No graphInput on this branch — the turn's user message has exactly ONE
81
+ // construction site (setup.langgraphInput), never a second copy here.
82
+ expect("graphInput" in result).toBe(false);
83
83
  });
84
84
 
85
- it("returns fresh input when interrupts exist but no decisions", () => {
85
+ it("reports not-a-resume when interrupts exist but no decisions", () => {
86
86
  const execution = makeExecution([]);
87
87
  const state = makeGraphState([{ taskId: "task-1", toolCallId: "call-1" }]);
88
88
 
89
- const result = resolveResumeInput(execution, state, "hello");
89
+ const result = resolveResumeInput(execution, state);
90
90
 
91
91
  expect(result.isResumeFromApproval).toBe(false);
92
92
  });
@@ -100,9 +100,9 @@ describe("resolveResumeInput", () => {
100
100
 
101
101
  const state = makeGraphState([{ taskId: "task-1", toolCallId: "call-1" }]);
102
102
 
103
- const result = resolveResumeInput(execution, state, "hello");
103
+ const result = resolveResumeInput(execution, state);
104
104
 
105
- expect(result.isResumeFromApproval).toBe(true);
105
+ if (!result.isResumeFromApproval) throw new Error("expected an approval resume");
106
106
  expect(result.graphInput).toBeInstanceOf(Command);
107
107
  });
108
108
 
@@ -115,13 +115,13 @@ describe("resolveResumeInput", () => {
115
115
 
116
116
  const state = makeGraphState([{ taskId: "task-1", toolCallId: "call-1" }]);
117
117
 
118
- const result = resolveResumeInput(execution, state, "hello");
118
+ const result = resolveResumeInput(execution, state);
119
119
 
120
120
  // REJECT resumes the gate like any other decision — the gate returns a
121
121
  // denial ToolMessage and the run continues. There is no execution-level
122
122
  // "rejection" flag any more; the terminal tool status is set by
123
123
  // reconcileNonExecutingDecisions.
124
- expect(result.isResumeFromApproval).toBe(true);
124
+ if (!result.isResumeFromApproval) throw new Error("expected an approval resume");
125
125
  expect(result.graphInput).toBeInstanceOf(Command);
126
126
  });
127
127
 
@@ -144,7 +144,7 @@ describe("resolveResumeInput", () => {
144
144
  { taskId: "task-2", toolCallId: "call-2" },
145
145
  ]);
146
146
 
147
- const result = resolveResumeInput(execution, state, "hello");
147
+ const result = resolveResumeInput(execution, state);
148
148
 
149
149
  expect(result.isResumeFromApproval).toBe(true);
150
150
  });
@@ -160,7 +160,7 @@ describe("resolveResumeInput", () => {
160
160
  { taskId: "task-1", toolCallId: "call-1", hasResume: true },
161
161
  ]);
162
162
 
163
- const result = resolveResumeInput(execution, state, "hello");
163
+ const result = resolveResumeInput(execution, state);
164
164
 
165
165
  expect(result.isResumeFromApproval).toBe(false);
166
166
  });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The deep-agent MCP gate, arm by arm. A missing arm here means a tool
3
+ * source silently dropped for exactly the agents whose only source it
4
+ * is — the failure DD-006 D7 documented for channel messaging and the
5
+ * reason the predicate lives in its own testable module instead of
6
+ * inline in setup.ts (whose import graph forbids a setup.test.ts).
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { shouldConnectMcp } from "../mcp-gate.js";
12
+
13
+ const nothing = {
14
+ mcpServerUsageCount: 0,
15
+ datastoreUsageCount: 0,
16
+ channelMessagingCount: 0,
17
+ conversationChannelId: undefined,
18
+ };
19
+
20
+ describe("shouldConnectMcp", () => {
21
+ it("skips the MCP block when no tool source exists", () => {
22
+ expect(shouldConnectMcp(nothing)).toBe(false);
23
+ });
24
+
25
+ it("enters on declared MCP server usages alone", () => {
26
+ expect(shouldConnectMcp({ ...nothing, mcpServerUsageCount: 1 })).toBe(true);
27
+ });
28
+
29
+ it("enters on datastore usages alone (the records attachment)", () => {
30
+ expect(shouldConnectMcp({ ...nothing, datastoreUsageCount: 1 })).toBe(true);
31
+ });
32
+
33
+ it("enters on a serving proactive channel alone (the channels attachment)", () => {
34
+ expect(shouldConnectMcp({ ...nothing, channelMessagingCount: 1 })).toBe(true);
35
+ });
36
+
37
+ it("enters on a channel conversation alone (the conversation attachment)", () => {
38
+ // The reply-only pilot shape: no declared servers, no datastores,
39
+ // no proactive channel — the escalation tool is the ONLY source.
40
+ expect(shouldConnectMcp({ ...nothing, conversationChannelId: "agch_1" })).toBe(true);
41
+ });
42
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
3
- import { buildEnhancedSystemPrompt } from "../prompt-builder.js";
3
+ import { buildEnhancedSystemPrompt, composeUserMessage } from "../prompt-builder.js";
4
4
  import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
5
5
  import { SourceType } from "../../../shared/workspace/types.js";
6
6
  import type { ProvisionResult } from "../../../shared/workspace/types.js";
@@ -408,3 +408,41 @@ describe("buildEnhancedSystemPrompt", () => {
408
408
  });
409
409
  });
410
410
  });
411
+
412
+ describe("composeUserMessage (conversation catchup, cloud DD-006 / A27)", () => {
413
+ const MESSAGE = "where is my order?";
414
+ const DIGEST =
415
+ "Customer: I want a refund\nTeammate: I've refunded you in full.";
416
+
417
+ it("prepends the framed catchup to the turn's user message — history durability rides the checkpointer, not the rebuilt system prompt", () => {
418
+ const composed = composeUserMessage(MESSAGE, DIGEST);
419
+
420
+ expect(composed.endsWith(MESSAGE)).toBe(true);
421
+ expect(composed).toContain(DIGEST);
422
+ expect(composed).toContain("you have not seen");
423
+ expect(composed.indexOf(DIGEST)).toBeLessThan(composed.indexOf(MESSAGE));
424
+ });
425
+
426
+ it("separates the catchup from the customer's message with a horizontal rule", () => {
427
+ expect(composeUserMessage(MESSAGE, DIGEST)).toContain("\n\n---\n\n");
428
+ });
429
+
430
+ it("leaves the message untouched when there is no catchup — most turns carry none", () => {
431
+ expect(composeUserMessage(MESSAGE, undefined)).toBe(MESSAGE);
432
+ });
433
+
434
+ it("never renders in the system prompt — the rebuilt-per-invocation lane would forget the digest one turn later", () => {
435
+ const prompt = buildEnhancedSystemPrompt({
436
+ instructions: "Test",
437
+ provisionResults: [],
438
+ containerRoot: "",
439
+ skillsPromptSection: "",
440
+ workspaceFileRefs: [],
441
+ workspaceRoot: "/workspace",
442
+ injectedFiles: [],
443
+ });
444
+
445
+ expect(prompt).not.toContain("Conversation catchup");
446
+ expect(prompt).not.toContain("you have not seen");
447
+ });
448
+ });
@@ -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("/");