@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
|
@@ -35,7 +35,7 @@ import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agent
|
|
|
35
35
|
import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
|
|
36
36
|
import type { AgentExecution, AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
37
37
|
import { ExecutionControlSignal, ExecutionPhase, FileChangeSetStatus, InteractionMode, MessageType, ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
38
|
-
import type { Run, ConversationTurn } from "@cursor/sdk";
|
|
38
|
+
import type { Run, ConversationTurn, SDKUserMessage } from "@cursor/sdk";
|
|
39
39
|
|
|
40
40
|
import type { Config } from "../../config.js";
|
|
41
41
|
import { StigmerClient } from "../../client/stigmer-client.js";
|
|
@@ -58,6 +58,13 @@ import { readSessionContext } from "../../shared/session-context.js";
|
|
|
58
58
|
import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
|
|
59
59
|
import { StallTimeoutError, formatStallFailure } from "../../shared/stall-watchdog.js";
|
|
60
60
|
import { resolveUsableArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
|
|
61
|
+
import {
|
|
62
|
+
CURSOR_VISION_PROFILE,
|
|
63
|
+
VisionBudget,
|
|
64
|
+
toCursorImages,
|
|
65
|
+
type NotViewableEntry,
|
|
66
|
+
} from "../../shared/attachment-vision.js";
|
|
67
|
+
import { getModelVisionCapability } from "../../shared/model-registry.js";
|
|
61
68
|
import { publishPlanArtifact } from "../../shared/plan-artifact.js";
|
|
62
69
|
import { DeltaEnricher } from "./delta-enricher.js";
|
|
63
70
|
import { TodoTracker } from "./todo-tracker.js";
|
|
@@ -84,7 +91,7 @@ import { buildCursorSubAgentDefinitions } from "./subagent-config.js";
|
|
|
84
91
|
import { resolveSkills } from "./skill-resolver.js";
|
|
85
92
|
import { removeStigmerSymlink } from "../../shared/workspace/stigmer-link.js";
|
|
86
93
|
import { resolveAttachments } from "./attachment-resolver.js";
|
|
87
|
-
import { buildEnhancedPrompt, buildReinvocationPrompt, formatConversationCatchupSection, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
|
|
94
|
+
import { buildEnhancedPrompt, buildReinvocationPrompt, formatConversationCatchupSection, formatInputFiles, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
|
|
88
95
|
import { installHitlGate, removeHitlGate } from "./workspace-setup.js";
|
|
89
96
|
import { ensureHitlDir } from "../../shared/workspace/platform-dir.js";
|
|
90
97
|
import {
|
|
@@ -761,14 +768,44 @@ async function executeCursorInner(
|
|
|
761
768
|
|
|
762
769
|
// Phase 5b: Resolve attachments (fail-hard — explicit user inputs; see
|
|
763
770
|
// attachment-resolver.ts). Downloads by storage key through the same
|
|
764
|
-
// artifactStorage resolved for status offload above.
|
|
771
|
+
// artifactStorage resolved for status offload above. The vision budget
|
|
772
|
+
// rides along so image attachments are selected for inline delivery while
|
|
773
|
+
// their bytes are already in hand (attachment-vision.ts owns all policy).
|
|
774
|
+
// The budget also carries the requested model's registry vision
|
|
775
|
+
// capability, looked up from the raw executionConfig name — full model
|
|
776
|
+
// validation (Phase 6) isn't needed for this, and ""/"default" (the Auto
|
|
777
|
+
// pool) resolves to unknown, which the policy treats as sighted.
|
|
778
|
+
const visionBudget = new VisionBudget(CURSOR_VISION_PROFILE, {
|
|
779
|
+
modelVision: await getModelVisionCapability(spec.executionConfig?.modelName ?? ""),
|
|
780
|
+
});
|
|
765
781
|
const attachmentResults = await resolveAttachments(spec.attachments, {
|
|
766
782
|
sessionId,
|
|
767
783
|
primaryWorkspaceDir,
|
|
768
784
|
mode: config.mode,
|
|
769
785
|
storage: artifactStorage,
|
|
786
|
+
visionBudget,
|
|
770
787
|
});
|
|
771
788
|
const attachmentPaths = attachmentResults.map((a) => a.relativePath);
|
|
789
|
+
// Vision facts, derived once from the single resolution result: the
|
|
790
|
+
// images the model will see inline (in attachment order) and the ones
|
|
791
|
+
// that degraded to path-only, disclosed in the prompt.
|
|
792
|
+
const visionImages = attachmentResults.flatMap((a) => (a.vision ? [a.vision] : []));
|
|
793
|
+
const visionNotViewable: NotViewableEntry[] = attachmentResults.flatMap((a) =>
|
|
794
|
+
a.visionDegraded ? [{ path: a.relativePath, reason: a.visionDegraded }] : [],
|
|
795
|
+
);
|
|
796
|
+
const visionPromptInfo = visionImages.length > 0 || visionNotViewable.length > 0
|
|
797
|
+
? {
|
|
798
|
+
inlineFilenames: visionImages.map((v) => v.filename),
|
|
799
|
+
notViewable: visionNotViewable,
|
|
800
|
+
}
|
|
801
|
+
: undefined;
|
|
802
|
+
if (visionPromptInfo) {
|
|
803
|
+
console.log(
|
|
804
|
+
`[attachment-vision] execution=${executionId} inline=${visionImages.length} ` +
|
|
805
|
+
`(${visionImages.reduce((n, v) => n + v.byteSize, 0)} bytes) ` +
|
|
806
|
+
`degraded=${JSON.stringify(visionNotViewable.map((d) => `${d.path}:${d.reason}`))}`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
772
809
|
setupTiming.mark("resolve_attachments");
|
|
773
810
|
|
|
774
811
|
// Phase 5b3: Exact-apply approved whole-file writes (HITL "what you approve
|
|
@@ -1094,6 +1131,7 @@ async function executeCursorInner(
|
|
|
1094
1131
|
workspaceDirs: blueprint.workspaceDirs,
|
|
1095
1132
|
workspaceFileRefs: spec.workspaceFileRefs ?? [],
|
|
1096
1133
|
attachmentPaths,
|
|
1134
|
+
vision: visionPromptInfo,
|
|
1097
1135
|
pendingApprovals: adjudicatedApprovals,
|
|
1098
1136
|
appliedToolCallIds,
|
|
1099
1137
|
interactionMode,
|
|
@@ -1111,6 +1149,20 @@ async function executeCursorInner(
|
|
|
1111
1149
|
effectivePrompt += `\n\n---\nCRITICAL OUTPUT REQUIREMENT:\nYour final response MUST be a single valid JSON object (no markdown, no commentary, no code fences) that matches this schema:\n${schemaStr}\n\nRespond with ONLY the JSON object. Nothing else.`;
|
|
1112
1150
|
}
|
|
1113
1151
|
|
|
1152
|
+
// Phase 10a1: The turn's vision payload. The invariant is "images
|
|
1153
|
+
// accompany the user's turn message — where the message goes, they go":
|
|
1154
|
+
// every send that delivers this turn's message carries them (the primary
|
|
1155
|
+
// send and both fresh-agent recovery retries, whose empty conversations
|
|
1156
|
+
// genuinely need the re-send), while a HITL re-invocation — whose prompt
|
|
1157
|
+
// carries no user message and whose resumed agent already holds the
|
|
1158
|
+
// images in its native conversation — carries none. Computed ONCE here so
|
|
1159
|
+
// all send sites agree by construction.
|
|
1160
|
+
const turnImages = isHitlReinvocation(approvalDecisions)
|
|
1161
|
+
? []
|
|
1162
|
+
: toCursorImages(visionImages);
|
|
1163
|
+
const toSendMessage = (sendPrompt: string): string | SDKUserMessage =>
|
|
1164
|
+
turnImages.length > 0 ? { text: sendPrompt, images: turnImages } : sendPrompt;
|
|
1165
|
+
|
|
1114
1166
|
// Phase 10a2: Log Stigmer preamble size for context trimming diagnostics
|
|
1115
1167
|
const promptChars = effectivePrompt.length;
|
|
1116
1168
|
const promptEstimatedTokens = Math.ceil(promptChars / 4);
|
|
@@ -1225,7 +1277,7 @@ async function executeCursorInner(
|
|
|
1225
1277
|
// The stall watchdog is armed inside consumeCursorTurnStream (it needs the
|
|
1226
1278
|
// run to cancel), stored on turnState.stallWatchdog so this shared onDelta can
|
|
1227
1279
|
// reset it and the activity's finally can stop it as a backstop.
|
|
1228
|
-
const run = await resolution.agent.send(effectivePrompt, {
|
|
1280
|
+
const run = await resolution.agent.send(toSendMessage(effectivePrompt), {
|
|
1229
1281
|
onDelta: (event) => {
|
|
1230
1282
|
if (!turnFirstEventEmitted) {
|
|
1231
1283
|
turnFirstEventEmitted = true;
|
|
@@ -1596,7 +1648,10 @@ async function executeCursorInner(
|
|
|
1596
1648
|
// poisoned-handle path leaked the fresh agent — it closed the stale one.)
|
|
1597
1649
|
resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
|
|
1598
1650
|
turnState.streamErrorMessage = undefined;
|
|
1599
|
-
|
|
1651
|
+
// The retry carries the turn's images too (same toSendMessage): the
|
|
1652
|
+
// fresh agent's conversation is empty, so skipping them here would
|
|
1653
|
+
// silently lose the user's photo on a recovered turn.
|
|
1654
|
+
const retryRun = await freshAgent.send(toSendMessage(retryPrompt), {
|
|
1600
1655
|
onDelta: makeCursorTurnOnDelta(onDeltaDeps),
|
|
1601
1656
|
});
|
|
1602
1657
|
await consumeCursorTurnStream(retryRun, streamDeps);
|
|
@@ -1735,6 +1790,7 @@ async function executeCursorInner(
|
|
|
1735
1790
|
workspaceDirs: blueprint.workspaceDirs,
|
|
1736
1791
|
workspaceFileRefs: spec.workspaceFileRefs ?? [],
|
|
1737
1792
|
attachmentPaths,
|
|
1793
|
+
vision: visionPromptInfo,
|
|
1738
1794
|
pendingApprovals: adjudicatedApprovals,
|
|
1739
1795
|
interactionMode,
|
|
1740
1796
|
// buildFromPlan was silently dropped here until T03 Sitting 3 —
|
|
@@ -2310,6 +2366,12 @@ export interface BuildPromptInput {
|
|
|
2310
2366
|
workspaceDirs: string[];
|
|
2311
2367
|
workspaceFileRefs: string[];
|
|
2312
2368
|
attachmentPaths: string[];
|
|
2369
|
+
/**
|
|
2370
|
+
* Vision facts for the input-files section (T04): which attachments the
|
|
2371
|
+
* model sees inline and which degraded to path-only. PER-TURN like the
|
|
2372
|
+
* catchup — it rides both the enhanced prompt and a resumed turn's prefix.
|
|
2373
|
+
*/
|
|
2374
|
+
vision?: import("./prompt-builder.js").VisionPromptInfo;
|
|
2313
2375
|
pendingApprovals: import("@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb").PendingApproval[];
|
|
2314
2376
|
/**
|
|
2315
2377
|
* Approved whole-file writes the runner already applied itself (exact-apply).
|
|
@@ -2372,6 +2434,20 @@ export interface BuildPromptInput {
|
|
|
2372
2434
|
* 3. first execution / fresh -> buildEnhancedPrompt (full instructions +
|
|
2373
2435
|
* agent after resume failure skills; no prior conversation to inherit)
|
|
2374
2436
|
*/
|
|
2437
|
+
/**
|
|
2438
|
+
* Whether this activity invocation is a HITL re-invocation — the turn resumes
|
|
2439
|
+
* an agent purely to convey approval decisions, carrying NO user message.
|
|
2440
|
+
* The single discriminator for everything that must ride with the user's
|
|
2441
|
+
* message and nothing else: the reinvocation prompt shape (below) and the
|
|
2442
|
+
* vision payload (images accompany the message; a resumed agent already holds
|
|
2443
|
+
* them in its native conversation).
|
|
2444
|
+
*/
|
|
2445
|
+
export function isHitlReinvocation(
|
|
2446
|
+
approvalDecisions: Map<string, ApprovalAction> | undefined,
|
|
2447
|
+
): approvalDecisions is Map<string, ApprovalAction> {
|
|
2448
|
+
return approvalDecisions !== undefined && approvalDecisions.size > 0;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2375
2451
|
export function buildPrompt(input: BuildPromptInput): string {
|
|
2376
2452
|
const {
|
|
2377
2453
|
resolution,
|
|
@@ -2388,12 +2464,10 @@ export function buildPrompt(input: BuildPromptInput): string {
|
|
|
2388
2464
|
conversationCatchup,
|
|
2389
2465
|
} = input;
|
|
2390
2466
|
|
|
2391
|
-
const isHitlReinvocation = approvalDecisions !== undefined && approvalDecisions.size > 0;
|
|
2392
|
-
|
|
2393
2467
|
// HITL reinvocation: the agent is resumed, so its native context carries the
|
|
2394
2468
|
// prior conversation; the reinvocation prompt conveys the approval decisions
|
|
2395
2469
|
// (and which approved writes the runner already exact-applied).
|
|
2396
|
-
if (isHitlReinvocation) {
|
|
2470
|
+
if (isHitlReinvocation(approvalDecisions)) {
|
|
2397
2471
|
return buildReinvocationPrompt(
|
|
2398
2472
|
input.pendingApprovals,
|
|
2399
2473
|
approvalDecisions,
|
|
@@ -2407,15 +2481,21 @@ export function buildPrompt(input: BuildPromptInput): string {
|
|
|
2407
2481
|
// session's first turn: the interaction-mode prefix (a follow-up can switch
|
|
2408
2482
|
// Agent→Plan mid-session, and for Cursor the prompt is the only plan-mode
|
|
2409
2483
|
// enforcement), the implement-plan directive (the build turn is usually a
|
|
2410
|
-
// follow-up on a resumed agent),
|
|
2411
|
-
//
|
|
2412
|
-
//
|
|
2413
|
-
//
|
|
2414
|
-
//
|
|
2484
|
+
// follow-up on a resumed agent), THIS turn's attachments (spec.attachments
|
|
2485
|
+
// is per-execution — a file sent on a follow-up turn materializes for this
|
|
2486
|
+
// turn and would otherwise never be announced at all), and the conversation
|
|
2487
|
+
// catchup (handback ALWAYS lands mid-session on a resumed agent — this
|
|
2488
|
+
// prefix is the property the metadata lane structurally cannot deliver,
|
|
2489
|
+
// cloud DD-006). Catchup last: it is context, and context sits closest to
|
|
2490
|
+
// the task (the enhanced prompt's own ordering doctrine); the input files
|
|
2491
|
+
// precede it because they are this turn's payload, not background.
|
|
2415
2492
|
if (resolution.reason === "resumed_successfully") {
|
|
2416
2493
|
const prefixes = [
|
|
2417
2494
|
formatInteractionModePrefix(interactionMode),
|
|
2418
2495
|
formatImplementPlanSection(buildFromPlan, attachmentPaths),
|
|
2496
|
+
attachmentPaths.length > 0
|
|
2497
|
+
? formatInputFiles(attachmentPaths, input.vision)
|
|
2498
|
+
: undefined,
|
|
2419
2499
|
conversationCatchup !== undefined
|
|
2420
2500
|
? formatConversationCatchupSection(conversationCatchup)
|
|
2421
2501
|
: undefined,
|
|
@@ -2438,6 +2518,7 @@ export function buildPrompt(input: BuildPromptInput): string {
|
|
|
2438
2518
|
workspaceDirs,
|
|
2439
2519
|
workspaceFileRefs,
|
|
2440
2520
|
attachmentPaths,
|
|
2521
|
+
vision: input.vision,
|
|
2441
2522
|
interactionMode,
|
|
2442
2523
|
buildFromPlan,
|
|
2443
2524
|
contextBridge: input.contextBridge,
|
|
@@ -32,6 +32,10 @@ import {
|
|
|
32
32
|
type SenderIdentity,
|
|
33
33
|
} from "../../shared/sender-identity.js";
|
|
34
34
|
import { formatSessionContextText } from "../../shared/session-context.js";
|
|
35
|
+
import {
|
|
36
|
+
visionDisclosureLines,
|
|
37
|
+
type NotViewableEntry,
|
|
38
|
+
} from "../../shared/attachment-vision.js";
|
|
35
39
|
import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
|
|
36
40
|
import {
|
|
37
41
|
buildImplementPlanDirective,
|
|
@@ -56,6 +60,17 @@ export interface SkillMetadata {
|
|
|
56
60
|
path: string;
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Vision facts about this turn's attachments, rendered inside the
|
|
65
|
+
* input-files section: which images the model can see inline (in send
|
|
66
|
+
* order) and which degraded to the file-pointer story. Derived per turn
|
|
67
|
+
* from the resolved attachments — never carried across turns.
|
|
68
|
+
*/
|
|
69
|
+
export interface VisionPromptInfo {
|
|
70
|
+
inlineFilenames: string[];
|
|
71
|
+
notViewable: NotViewableEntry[];
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
export interface EnhancedPromptOptions {
|
|
60
75
|
instructions: string;
|
|
61
76
|
userMessage: string;
|
|
@@ -76,6 +91,8 @@ export interface EnhancedPromptOptions {
|
|
|
76
91
|
workspaceDirs: string[];
|
|
77
92
|
workspaceFileRefs: string[];
|
|
78
93
|
attachmentPaths: string[];
|
|
94
|
+
/** Inline/degraded image facts for the input-files section (T04 vision). */
|
|
95
|
+
vision?: VisionPromptInfo;
|
|
79
96
|
interactionMode?: InteractionMode;
|
|
80
97
|
/**
|
|
81
98
|
* The execution is a Build-from-plan turn (spec.execution_config
|
|
@@ -179,7 +196,7 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
|
|
|
179
196
|
}
|
|
180
197
|
|
|
181
198
|
if (options.attachmentPaths.length > 0) {
|
|
182
|
-
sections.push(formatInputFiles(options.attachmentPaths));
|
|
199
|
+
sections.push(formatInputFiles(options.attachmentPaths, options.vision));
|
|
183
200
|
}
|
|
184
201
|
|
|
185
202
|
if (options.workspaceFileRefs.length > 0) {
|
|
@@ -462,12 +479,20 @@ export function formatWorkspaceContext(dirs: string[]): string {
|
|
|
462
479
|
].join("\n");
|
|
463
480
|
}
|
|
464
481
|
|
|
465
|
-
export function formatInputFiles(paths: string[]): string {
|
|
482
|
+
export function formatInputFiles(paths: string[], vision?: VisionPromptInfo): string {
|
|
466
483
|
const entries = paths.map((p) => `- \`${p}\``);
|
|
484
|
+
// The vision lines (shared wording, attachment-vision.ts) tell the model
|
|
485
|
+
// which of these files it can already SEE inline versus which degraded to
|
|
486
|
+
// path-only — without them an agent silently ignores a photo the user
|
|
487
|
+
// believes it can see.
|
|
488
|
+
const disclosure = vision
|
|
489
|
+
? visionDisclosureLines(vision.inlineFilenames, vision.notViewable)
|
|
490
|
+
: [];
|
|
467
491
|
return [
|
|
468
492
|
"<input_files>",
|
|
469
493
|
"The following files have been provided as inputs. Read them when relevant to the task:",
|
|
470
494
|
...entries,
|
|
495
|
+
...disclosure,
|
|
471
496
|
"</input_files>",
|
|
472
497
|
].join("\n");
|
|
473
498
|
}
|
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
} from "../attachment-injector.js";
|
|
16
16
|
import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
|
|
17
17
|
import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
|
|
18
|
+
import {
|
|
19
|
+
DEEP_AGENT_VISION_PROFILE,
|
|
20
|
+
VisionBudget,
|
|
21
|
+
} from "../../../shared/attachment-vision.js";
|
|
18
22
|
|
|
19
23
|
// ── ZIP Construction Helpers ─────────────────────────────────────────
|
|
20
24
|
|
|
@@ -552,6 +556,28 @@ describe("injectAttachments", () => {
|
|
|
552
556
|
})).rejects.toThrow(/missing storageKey/);
|
|
553
557
|
});
|
|
554
558
|
|
|
559
|
+
it("rejects a caller-supplied mountPath that escapes the workspace root", async () => {
|
|
560
|
+
// `resolveMountPath` only stripped leading slashes, so `..` segments on a
|
|
561
|
+
// non-`.stigmer/` mount path reached the unchecked join(rootDir, path).
|
|
562
|
+
const storage = makeMockStorage();
|
|
563
|
+
storage.download.mockResolvedValue(Buffer.from("owned"));
|
|
564
|
+
const backend = mockWorkspaceBackend();
|
|
565
|
+
|
|
566
|
+
const escapes = ["../../escape.txt", "../etc/evil", "foo/../../bar"];
|
|
567
|
+
for (const mountPath of escapes) {
|
|
568
|
+
await expect(injectAttachments({
|
|
569
|
+
backend,
|
|
570
|
+
attachments: [makeAttachment({
|
|
571
|
+
filename: "data.txt",
|
|
572
|
+
storageKey: "attachments/xyz/data.txt",
|
|
573
|
+
mountPath,
|
|
574
|
+
})],
|
|
575
|
+
storage,
|
|
576
|
+
isLocalMode: false,
|
|
577
|
+
})).rejects.toThrow(/mount path .* escapes the workspace root|traversal/i);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
555
581
|
it("preserves binary content via writeFileBuffer", async () => {
|
|
556
582
|
const binaryContent = Buffer.from([0x00, 0x01, 0xFF, 0xFE, 0x89, 0x50, 0x4E, 0x47]);
|
|
557
583
|
const localFile = join(tempDir, "image.png");
|
|
@@ -705,3 +731,184 @@ 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("degrades an image with model_no_vision on a blind model, file written intact", async () => {
|
|
780
|
+
const backend = mockWorkspaceBackend();
|
|
781
|
+
const storage = makeMockStorage();
|
|
782
|
+
await storage.upload("attachments/abc/photo.png", PNG_BYTES);
|
|
783
|
+
|
|
784
|
+
const [injected] = await injectAttachments({
|
|
785
|
+
backend,
|
|
786
|
+
attachments: [makeAttachment({
|
|
787
|
+
filename: "photo.png",
|
|
788
|
+
storageKey: "attachments/abc/photo.png",
|
|
789
|
+
contentType: "image/png",
|
|
790
|
+
})],
|
|
791
|
+
storage,
|
|
792
|
+
isLocalMode: false,
|
|
793
|
+
visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE, { modelVision: false }),
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
expect(injected.vision).toBeUndefined();
|
|
797
|
+
expect(injected.visionDegraded).toBe("model_no_vision");
|
|
798
|
+
expect(backend.writeFileBuffer).toHaveBeenCalledWith(".stigmer/inputs/photo.png", PNG_BYTES);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("accepts WebP on the deep-agent profile (unlike the Cursor harness)", async () => {
|
|
802
|
+
const backend = mockWorkspaceBackend();
|
|
803
|
+
const storage = makeMockStorage();
|
|
804
|
+
await storage.upload("attachments/abc/pic.webp", WEBP_BYTES);
|
|
805
|
+
|
|
806
|
+
const [injected] = await injectAttachments({
|
|
807
|
+
backend,
|
|
808
|
+
attachments: [makeAttachment({
|
|
809
|
+
filename: "pic.webp",
|
|
810
|
+
storageKey: "attachments/abc/pic.webp",
|
|
811
|
+
contentType: "image/webp",
|
|
812
|
+
})],
|
|
813
|
+
storage,
|
|
814
|
+
isLocalMode: false,
|
|
815
|
+
visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
expect(injected.vision?.mimeType).toBe("image/webp");
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it("carries no vision fields for a non-image attachment", async () => {
|
|
822
|
+
const backend = mockWorkspaceBackend();
|
|
823
|
+
const storage = makeMockStorage();
|
|
824
|
+
await storage.upload("attachments/abc/doc.pdf", Buffer.from("%PDF-1.7"));
|
|
825
|
+
|
|
826
|
+
const [injected] = await injectAttachments({
|
|
827
|
+
backend,
|
|
828
|
+
attachments: [makeAttachment({
|
|
829
|
+
filename: "doc.pdf",
|
|
830
|
+
storageKey: "attachments/abc/doc.pdf",
|
|
831
|
+
contentType: "application/pdf",
|
|
832
|
+
})],
|
|
833
|
+
storage,
|
|
834
|
+
isLocalMode: false,
|
|
835
|
+
visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
expect(injected.vision).toBeUndefined();
|
|
839
|
+
expect(injected.visionDegraded).toBeUndefined();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("degrades a declared image whose bytes are not one (type_mismatch)", async () => {
|
|
843
|
+
const backend = mockWorkspaceBackend();
|
|
844
|
+
const storage = makeMockStorage();
|
|
845
|
+
await storage.upload("attachments/abc/photo.jpg", Buffer.from("actually HEIC"));
|
|
846
|
+
|
|
847
|
+
const [injected] = await injectAttachments({
|
|
848
|
+
backend,
|
|
849
|
+
attachments: [makeAttachment({
|
|
850
|
+
filename: "photo.jpg",
|
|
851
|
+
storageKey: "attachments/abc/photo.jpg",
|
|
852
|
+
contentType: "image/jpeg",
|
|
853
|
+
})],
|
|
854
|
+
storage,
|
|
855
|
+
isLocalMode: false,
|
|
856
|
+
visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
expect(injected.vision).toBeUndefined();
|
|
860
|
+
expect(injected.visionDegraded).toBe("type_mismatch");
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it("never offers an extract archive to the budget — extracted files carry no vision fields", async () => {
|
|
864
|
+
// A PNG inside a ZIP has no attachment-level bytes; the archive rides the
|
|
865
|
+
// normal extraction story with no vision involvement or disclosure.
|
|
866
|
+
const zip = makeZip({ "inner.png": PNG_BYTES });
|
|
867
|
+
const backend = mockWorkspaceBackend();
|
|
868
|
+
const storage = makeMockStorage();
|
|
869
|
+
await storage.upload("attachments/abc/bundle.zip", zip);
|
|
870
|
+
|
|
871
|
+
const injected = await injectAttachments({
|
|
872
|
+
backend,
|
|
873
|
+
attachments: [makeAttachment({
|
|
874
|
+
filename: "bundle.zip",
|
|
875
|
+
storageKey: "attachments/abc/bundle.zip",
|
|
876
|
+
extract: true,
|
|
877
|
+
})],
|
|
878
|
+
storage,
|
|
879
|
+
isLocalMode: false,
|
|
880
|
+
visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
expect(injected.length).toBeGreaterThan(0);
|
|
884
|
+
for (const file of injected) {
|
|
885
|
+
expect(file.vision).toBeUndefined();
|
|
886
|
+
expect(file.visionDegraded).toBeUndefined();
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("degrades over the total budget in attachment order (budget_exhausted)", async () => {
|
|
891
|
+
const backend = mockWorkspaceBackend();
|
|
892
|
+
const storage = makeMockStorage();
|
|
893
|
+
await storage.upload("attachments/a/a.png", PNG_BYTES);
|
|
894
|
+
await storage.upload("attachments/b/b.png", PNG_BYTES);
|
|
895
|
+
|
|
896
|
+
const injected = await injectAttachments({
|
|
897
|
+
backend,
|
|
898
|
+
attachments: [
|
|
899
|
+
makeAttachment({ filename: "a.png", storageKey: "attachments/a/a.png", contentType: "image/png" }),
|
|
900
|
+
makeAttachment({ filename: "b.png", storageKey: "attachments/b/b.png", contentType: "image/png" }),
|
|
901
|
+
],
|
|
902
|
+
storage,
|
|
903
|
+
isLocalMode: false,
|
|
904
|
+
visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE, {
|
|
905
|
+
maxImageBytes: PNG_BYTES.length,
|
|
906
|
+
maxTotalBytes: PNG_BYTES.length,
|
|
907
|
+
}),
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
expect(injected[0].vision).toBeDefined();
|
|
911
|
+
expect(injected[1].vision).toBeUndefined();
|
|
912
|
+
expect(injected[1].visionDegraded).toBe("budget_exhausted");
|
|
913
|
+
});
|
|
914
|
+
});
|
|
@@ -70,23 +70,23 @@ function makeGraphState(
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
describe("resolveResumeInput", () => {
|
|
73
|
-
it("
|
|
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
|
|
77
|
+
const result = resolveResumeInput(execution, state);
|
|
78
78
|
|
|
79
79
|
expect(result.isResumeFromApproval).toBe(false);
|
|
80
|
-
|
|
81
|
-
|
|
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("
|
|
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
|
|
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
|
|
103
|
+
const result = resolveResumeInput(execution, state);
|
|
104
104
|
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
163
|
+
const result = resolveResumeInput(execution, state);
|
|
164
164
|
|
|
165
165
|
expect(result.isResumeFromApproval).toBe(false);
|
|
166
166
|
});
|