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