@stigmer/runner 3.0.8-dev.20260613074252 → 3.0.8
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/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/hook-script.d.ts +23 -12
- package/dist/activities/execute-cursor/hook-script.js +85 -51
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +210 -79
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +35 -0
- package/dist/activities/execute-cursor/message-translator.js +114 -6
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +25 -0
- package/dist/activities/execute-cursor/prompt-builder.js +54 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +8 -2
- package/dist/activities/execute-cursor/workspace-setup.js +62 -30
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +14 -4
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +0 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.js +32 -8
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +4 -5
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +3 -4
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +8 -0
- package/dist/activities/execute-deep-agent/streaming.js +3 -4
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-tracker.js +4 -5
- package/dist/activities/execute-deep-agent/subagent-tracker.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -5
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/in-flight.d.ts +35 -0
- package/dist/in-flight.js +61 -0
- package/dist/in-flight.js.map +1 -0
- package/dist/main.js +6 -3
- package/dist/main.js.map +1 -1
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +90 -29
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -0
- package/dist/runner.js.map +1 -1
- package/dist/shared/grpc-retry.d.ts +9 -20
- package/dist/shared/grpc-retry.js +9 -52
- package/dist/shared/grpc-retry.js.map +1 -1
- package/dist/shared/stall-watchdog.d.ts +68 -0
- package/dist/shared/stall-watchdog.js +102 -0
- package/dist/shared/stall-watchdog.js.map +1 -0
- package/dist/shared/status-offload.d.ts +84 -0
- package/dist/shared/status-offload.js +292 -0
- package/dist/shared/status-offload.js.map +1 -0
- package/dist/shared/status.d.ts +34 -3
- package/dist/shared/status.js +102 -9
- package/dist/shared/status.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/config.test.ts +8 -0
- package/src/__tests__/in-flight.test.ts +84 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +74 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +90 -15
- package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +244 -0
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +53 -4
- package/src/activities/execute-cursor/hook-script.ts +85 -51
- package/src/activities/execute-cursor/index.ts +170 -35
- package/src/activities/execute-cursor/message-translator.ts +113 -6
- package/src/activities/execute-cursor/prompt-builder.ts +59 -0
- package/src/activities/execute-cursor/workspace-setup.ts +76 -44
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder-shared.test.ts +66 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +6 -3
- package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +70 -0
- package/src/activities/execute-deep-agent/index.ts +16 -4
- package/src/activities/execute-deep-agent/status-builder-shared.ts +27 -5
- package/src/activities/execute-deep-agent/status-builder.ts +3 -5
- package/src/activities/execute-deep-agent/streaming-v3.ts +4 -4
- package/src/activities/execute-deep-agent/streaming.ts +13 -4
- package/src/activities/execute-deep-agent/subagent-tracker.ts +4 -5
- package/src/activities/execute-deep-agent/v3-status-builder.ts +5 -5
- package/src/config.ts +27 -0
- package/src/in-flight.ts +71 -0
- package/src/main.ts +7 -2
- package/src/runner-manager.ts +127 -33
- package/src/runner.ts +6 -0
- package/src/shared/__tests__/artifact-storage.test.ts +1 -0
- package/src/shared/__tests__/grpc-retry-extended.test.ts +6 -144
- package/src/shared/__tests__/grpc-retry.test.ts +5 -134
- package/src/shared/__tests__/stall-watchdog.test.ts +193 -0
- package/src/shared/__tests__/status-offload.test.ts +256 -0
- package/src/shared/__tests__/status.test.ts +199 -0
- package/src/shared/grpc-retry.ts +9 -72
- package/src/shared/stall-watchdog.ts +122 -0
- package/src/shared/status-offload.ts +342 -0
- package/src/shared/status.ts +142 -8
|
@@ -180,7 +180,7 @@ export function buildToolCallProto(
|
|
|
180
180
|
status,
|
|
181
181
|
startedAt: status === ToolCallStatus.TOOL_CALL_RUNNING ? utcTimestamp() : "",
|
|
182
182
|
completedAt: isTerminalToolStatus(status) ? utcTimestamp() : "",
|
|
183
|
-
result:
|
|
183
|
+
result: toResultString(event.result),
|
|
184
184
|
error: status === ToolCallStatus.TOOL_CALL_FAILED
|
|
185
185
|
? (typeof event.result === "string" ? event.result : "Tool call failed")
|
|
186
186
|
: "",
|
|
@@ -317,10 +317,114 @@ function safeString(obj: unknown, key: string): string {
|
|
|
317
317
|
* Normalize a tool_call event result into a string for the ToolCall proto.
|
|
318
318
|
* Returns "" for an absent result so callers can treat "no result yet" and
|
|
319
319
|
* "empty result" uniformly (e.g. to avoid clobbering a captured result).
|
|
320
|
+
*
|
|
321
|
+
* The one non-passthrough case is a multimodal MCP result (e.g. a computer-use
|
|
322
|
+
* screenshot): see {@link canonicalizeImageResult}. Everything else is the
|
|
323
|
+
* string as-is, or a whole-value JSON.stringify — byte-identical to before.
|
|
320
324
|
*/
|
|
321
|
-
function toResultString(result: unknown): string {
|
|
325
|
+
export function toResultString(result: unknown): string {
|
|
322
326
|
if (result == null) return "";
|
|
323
|
-
|
|
327
|
+
if (typeof result === "string") return result;
|
|
328
|
+
const canonical = canonicalizeImageResult(result);
|
|
329
|
+
if (canonical !== undefined) return canonical;
|
|
330
|
+
return JSON.stringify(result);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Re-emit a Cursor MCP result that carries an image block as the canonical
|
|
335
|
+
* top-level content-block array the persist-time offload understands.
|
|
336
|
+
*
|
|
337
|
+
* The Cursor SDK wraps an MCP tool result as `{ status, value: { content: [...] } }`
|
|
338
|
+
* (or a bare `{ content: [...] }`), where an image block is
|
|
339
|
+
* `{ image: { data, mimeType } }` and `data` is a Node Buffer-JSON
|
|
340
|
+
* (`{ type:"Buffer", data:number[] }`). Persisting that envelope verbatim buries
|
|
341
|
+
* the image where `detectImagePayload`/`contentBlocks` (shared/status-offload.ts)
|
|
342
|
+
* cannot see it, so the screenshot lands as `text/plain` instead of a renderable
|
|
343
|
+
* `ToolCallOutputRef`.
|
|
344
|
+
*
|
|
345
|
+
* This mirrors `serializeToolContent` in the deep-agent path
|
|
346
|
+
* (execute-deep-agent/status-builder-shared.ts): the harness adapter normalizes
|
|
347
|
+
* its own wire shape into the canonical array
|
|
348
|
+
* `[{ type:"text", text }, { type:"image", data:<base64>, mimeType }]`
|
|
349
|
+
* so the shared offload stays harness-agnostic (its envelope handling is
|
|
350
|
+
* documented there as insurance, not the primary path). Buffer-JSON is decoded
|
|
351
|
+
* to base64 here so the bloated byte-array never propagates into the status.
|
|
352
|
+
*
|
|
353
|
+
* Returns undefined when there is no content array or no image block, so the
|
|
354
|
+
* caller falls back to its existing serialization (no change for text/error
|
|
355
|
+
* results).
|
|
356
|
+
*/
|
|
357
|
+
export function canonicalizeImageResult(result: unknown): string | undefined {
|
|
358
|
+
if (result == null || typeof result !== "object") return undefined;
|
|
359
|
+
const blocks = resultContentBlocks(result as Record<string, unknown>);
|
|
360
|
+
if (!blocks) return undefined;
|
|
361
|
+
|
|
362
|
+
const canonical: Array<Record<string, unknown>> = [];
|
|
363
|
+
let sawImage = false;
|
|
364
|
+
for (const block of blocks) {
|
|
365
|
+
if (!block || typeof block !== "object") continue;
|
|
366
|
+
const b = block as Record<string, unknown>;
|
|
367
|
+
|
|
368
|
+
if (b.image && typeof b.image === "object") {
|
|
369
|
+
const img = b.image as Record<string, unknown>;
|
|
370
|
+
const base64 = imageDataToBase64(img.data);
|
|
371
|
+
if (base64) {
|
|
372
|
+
const mimeType = typeof img.mimeType === "string" ? img.mimeType : "image/png";
|
|
373
|
+
canonical.push({ type: "image", data: base64, mimeType });
|
|
374
|
+
sawImage = true;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const text = blockText(b);
|
|
380
|
+
if (text !== undefined) canonical.push({ type: "text", text });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return sawImage ? JSON.stringify(canonical) : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Extract the content-block array from a Cursor result envelope or a bare one. */
|
|
387
|
+
function resultContentBlocks(obj: Record<string, unknown>): unknown[] | undefined {
|
|
388
|
+
const value = obj.value;
|
|
389
|
+
if (value && typeof value === "object" && Array.isArray((value as Record<string, unknown>).content)) {
|
|
390
|
+
return (value as Record<string, unknown>).content as unknown[];
|
|
391
|
+
}
|
|
392
|
+
if (Array.isArray(obj.content)) return obj.content;
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Decode an image block's `data` to plain base64. Accepts a Node Buffer-JSON
|
|
398
|
+
* (`{ type:"Buffer", data:number[] }`, how the Cursor SDK serializes bytes), a
|
|
399
|
+
* `data:` URL, or an already-base64 string. Returns undefined for anything else.
|
|
400
|
+
*/
|
|
401
|
+
function imageDataToBase64(data: unknown): string | undefined {
|
|
402
|
+
if (data && typeof data === "object") {
|
|
403
|
+
const d = data as Record<string, unknown>;
|
|
404
|
+
if (d.type === "Buffer" && Array.isArray(d.data)) {
|
|
405
|
+
try {
|
|
406
|
+
return Buffer.from(d.data as number[]).toString("base64");
|
|
407
|
+
} catch {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
if (typeof data === "string" && data) {
|
|
414
|
+
const dataUrl = data.match(/^data:image\/[a-zA-Z0-9.+-]+;base64,([\s\S]+)$/);
|
|
415
|
+
return (dataUrl ? dataUrl[1] : data).replace(/\s+/g, "");
|
|
416
|
+
}
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Extract the text from a Cursor content block: `{ text:{text} }` or `{ text }`. */
|
|
421
|
+
function blockText(b: Record<string, unknown>): string | undefined {
|
|
422
|
+
const t = b.text;
|
|
423
|
+
if (typeof t === "string") return t;
|
|
424
|
+
if (t && typeof t === "object" && typeof (t as Record<string, unknown>).text === "string") {
|
|
425
|
+
return (t as Record<string, unknown>).text as string;
|
|
426
|
+
}
|
|
427
|
+
return undefined;
|
|
324
428
|
}
|
|
325
429
|
|
|
326
430
|
/**
|
|
@@ -389,9 +493,12 @@ export function extractConversationSteps(
|
|
|
389
493
|
if (msg.result != null) {
|
|
390
494
|
const resultObj = msg.result as Record<string, unknown>;
|
|
391
495
|
if (resultObj.status === "success" && resultObj.value != null) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
496
|
+
// Normalize a sub-agent screenshot the same way as a top-level tool
|
|
497
|
+
// result; fall back to the existing value serialization otherwise.
|
|
498
|
+
toolResult = canonicalizeImageResult(resultObj.value)
|
|
499
|
+
?? (typeof resultObj.value === "string"
|
|
500
|
+
? resultObj.value
|
|
501
|
+
: JSON.stringify(resultObj.value));
|
|
395
502
|
} else if (resultObj.status === "error") {
|
|
396
503
|
toolResult = typeof resultObj.error === "string"
|
|
397
504
|
? resultObj.error
|
|
@@ -98,6 +98,11 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
|
|
|
98
98
|
sections.push(formatResponseRules());
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Always last before the task: the platform's tool-approval protocol. Placed
|
|
102
|
+
// here for recency so it outweighs any "ask the user first" guidance Cursor
|
|
103
|
+
// surfaces from a connected MCP server (see formatToolApprovalProtocol).
|
|
104
|
+
sections.push(formatToolApprovalProtocol());
|
|
105
|
+
|
|
101
106
|
sections.push(`<user_request>\n${options.userMessage}\n</user_request>`);
|
|
102
107
|
|
|
103
108
|
return sections.join("\n\n---\n\n");
|
|
@@ -145,6 +150,16 @@ export function buildReinvocationPrompt(
|
|
|
145
150
|
);
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
// The model's "ask the user first" bias recurs every turn, including resumes,
|
|
154
|
+
// so restate the protocol: keep invoking tools directly to finish the task —
|
|
155
|
+
// the platform gates and resumes any further sensitive actions automatically.
|
|
156
|
+
// Never ask for permission in prose. (See formatToolApprovalProtocol.)
|
|
157
|
+
parts.push(
|
|
158
|
+
"Continue the rest of the task by invoking the tools it requires directly. " +
|
|
159
|
+
"The platform automatically requests approval for any further sensitive " +
|
|
160
|
+
"action and resumes you — do not ask the user for permission in prose.",
|
|
161
|
+
);
|
|
162
|
+
|
|
148
163
|
return parts.join("\n\n");
|
|
149
164
|
}
|
|
150
165
|
|
|
@@ -347,3 +362,47 @@ export function formatResponseRules(): string {
|
|
|
347
362
|
"</response_rules>",
|
|
348
363
|
].join("\n");
|
|
349
364
|
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The platform's tool-approval protocol, injected into every Cursor execution.
|
|
368
|
+
*
|
|
369
|
+
* Why this exists ONLY in the Cursor harness (the native LangGraph harness has
|
|
370
|
+
* no such guidance): the two harnesses gate tools differently.
|
|
371
|
+
*
|
|
372
|
+
* - Native gates at the framework level — it calls LangGraph `interrupt()`
|
|
373
|
+
* BEFORE a tool runs, so the model never sees a denial and cannot react to it
|
|
374
|
+
* in prose. It also binds only MCP tool descriptions, never a server's
|
|
375
|
+
* `initialize` `instructions`, so server-authored "ask the user first"
|
|
376
|
+
* guidance never reaches the model.
|
|
377
|
+
* - Cursor gates via hooks (allow/deny; `ask` is unreliable). The model has
|
|
378
|
+
* agency over whether it invokes a tool, and Cursor surfaces a connected MCP
|
|
379
|
+
* server's `instructions` to the model. Servers like open-computer-use ship
|
|
380
|
+
* "Ask the user before externally-visible actions such as sending…", which
|
|
381
|
+
* makes the model describe the action in prose and end its turn instead of
|
|
382
|
+
* invoking the tool. With no tool call, the deny-based gate never fires and
|
|
383
|
+
* the execution wrongly completes.
|
|
384
|
+
*
|
|
385
|
+
* This directive resolves that conflict in the platform's favor: the agent must
|
|
386
|
+
* invoke tools directly and rely on the platform's automatic approval step,
|
|
387
|
+
* rather than asking for permission in prose. It is the only lever Cursor's
|
|
388
|
+
* hook model leaves available.
|
|
389
|
+
*/
|
|
390
|
+
export function formatToolApprovalProtocol(): string {
|
|
391
|
+
return [
|
|
392
|
+
"<tool_approval_protocol>",
|
|
393
|
+
"You run inside a platform that automatically gates sensitive actions for",
|
|
394
|
+
"human approval. Follow these rules without exception:",
|
|
395
|
+
"- Carry out every action by calling the appropriate tool directly. Never",
|
|
396
|
+
" describe an action you intend to take and then stop, and never ask the",
|
|
397
|
+
" user for permission in prose.",
|
|
398
|
+
"- When an action needs approval, the platform pauses it, asks the user, and",
|
|
399
|
+
" resumes you automatically after they decide. You do not request approval",
|
|
400
|
+
" yourself — invoking the tool is how you request it.",
|
|
401
|
+
"- Even if a tool or MCP server instructs you to confirm with the user before",
|
|
402
|
+
" acting (for example before sending, deleting, or purchasing), do NOT ask",
|
|
403
|
+
" in prose. Invoke the tool and let the platform's approval step handle it.",
|
|
404
|
+
"- If an action is declined, do not retry it or attempt a workaround for it;",
|
|
405
|
+
" continue with the rest of the task.",
|
|
406
|
+
"</tool_approval_protocol>",
|
|
407
|
+
].join("\n");
|
|
408
|
+
}
|
|
@@ -40,7 +40,22 @@ const CURSOR_DIR = ".cursor";
|
|
|
40
40
|
const HOOKS_CONFIG_FILE = "hooks.json";
|
|
41
41
|
const HOOK_SCRIPT_FILE = "stigmer-approval.sh";
|
|
42
42
|
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Cursor hook events the gate registers, both pointing at the same script (which
|
|
45
|
+
* branches on `hook_event_name`). `preToolUse` gates built-in tools
|
|
46
|
+
* (Write/Shell/Delete); `beforeMCPExecution` is the only event Cursor enforces
|
|
47
|
+
* for MCP tool calls.
|
|
48
|
+
*/
|
|
49
|
+
const PRE_TOOL_USE_EVENT = "preToolUse";
|
|
50
|
+
const BEFORE_MCP_EVENT = "beforeMCPExecution";
|
|
51
|
+
|
|
52
|
+
/** One (event -> script) registration in `.cursor/hooks.json`. */
|
|
53
|
+
interface HookRegistration {
|
|
54
|
+
event: string;
|
|
55
|
+
scriptPath: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Hook timeout (seconds) — each script is a quick local decision. */
|
|
44
59
|
const HOOK_TIMEOUT_SECONDS = 10;
|
|
45
60
|
|
|
46
61
|
/**
|
|
@@ -73,8 +88,14 @@ export async function installHitlGate(params: {
|
|
|
73
88
|
}): Promise<HitlGateHandle> {
|
|
74
89
|
const { workspaceRoot, hitlDir, approvalState, runnerPid } = params;
|
|
75
90
|
|
|
76
|
-
const
|
|
77
|
-
|
|
91
|
+
const approvalScriptPath = await writeHitlArtifacts(hitlDir, approvalState, runnerPid);
|
|
92
|
+
// One script, two events: preToolUse gates built-ins; beforeMCPExecution is
|
|
93
|
+
// the only event Cursor enforces for MCP tools. The script branches internally
|
|
94
|
+
// on hook_event_name so MCP is gated in exactly one place.
|
|
95
|
+
return installWorkspaceHook(workspaceRoot, [
|
|
96
|
+
{ event: PRE_TOOL_USE_EVENT, scriptPath: approvalScriptPath },
|
|
97
|
+
{ event: BEFORE_MCP_EVENT, scriptPath: approvalScriptPath },
|
|
98
|
+
]);
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
/**
|
|
@@ -117,15 +138,15 @@ async function writeHitlArtifacts(
|
|
|
117
138
|
// HITL directory and Temporal activity retries.
|
|
118
139
|
const ledgerFilePath = await resetDenialLedger(hitlDir);
|
|
119
140
|
|
|
120
|
-
const
|
|
141
|
+
const approvalScriptPath = join(hitlDir, HOOK_SCRIPT_FILE);
|
|
121
142
|
await writeFile(
|
|
122
|
-
|
|
143
|
+
approvalScriptPath,
|
|
123
144
|
generateHookScript(stateFilePath, ledgerFilePath, runnerPid),
|
|
124
145
|
"utf-8",
|
|
125
146
|
);
|
|
126
|
-
await chmod(
|
|
147
|
+
await chmod(approvalScriptPath, 0o755);
|
|
127
148
|
|
|
128
|
-
return
|
|
149
|
+
return approvalScriptPath;
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
/**
|
|
@@ -135,7 +156,7 @@ async function writeHitlArtifacts(
|
|
|
135
156
|
*/
|
|
136
157
|
async function installWorkspaceHook(
|
|
137
158
|
workspaceRoot: string,
|
|
138
|
-
|
|
159
|
+
registrations: HookRegistration[],
|
|
139
160
|
): Promise<HitlGateHandle> {
|
|
140
161
|
const cursorDir = join(workspaceRoot, CURSOR_DIR);
|
|
141
162
|
const hooksJsonPath = join(cursorDir, HOOKS_CONFIG_FILE);
|
|
@@ -147,7 +168,7 @@ async function installWorkspaceHook(
|
|
|
147
168
|
originalRaw = null;
|
|
148
169
|
}
|
|
149
170
|
|
|
150
|
-
const { merged, restoreTo } = buildMergedConfig(originalRaw,
|
|
171
|
+
const { merged, restoreTo } = buildMergedConfig(originalRaw, registrations);
|
|
151
172
|
|
|
152
173
|
await mkdir(cursorDir, { recursive: true });
|
|
153
174
|
await writeFile(hooksJsonPath, merged, "utf-8");
|
|
@@ -156,17 +177,18 @@ async function installWorkspaceHook(
|
|
|
156
177
|
}
|
|
157
178
|
|
|
158
179
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
180
|
+
* A hook entry the gate installs. Absolute `command` so the hook is found
|
|
181
|
+
* regardless of which workspace root a multi-root IDE resolves against.
|
|
161
182
|
*/
|
|
162
183
|
function buildHookEntry(scriptPath: string): Record<string, unknown> {
|
|
163
184
|
return { command: scriptPath, timeout: HOOK_TIMEOUT_SECONDS, failClosed: true };
|
|
164
185
|
}
|
|
165
186
|
|
|
166
187
|
/**
|
|
167
|
-
* Identify a
|
|
168
|
-
*
|
|
169
|
-
*
|
|
188
|
+
* Identify a hook entry the gate itself wrote (in this or a prior, crash-leftover
|
|
189
|
+
* turn) so a re-install never duplicates it and a restore strips it. Matched by
|
|
190
|
+
* the unmistakable runner-owned HITL script path (`~/.stigmer/sessions/.../*.sh`),
|
|
191
|
+
* never by a user's own hook — covers every event and every gate script.
|
|
170
192
|
*/
|
|
171
193
|
function isStigmerHookEntry(entry: unknown): boolean {
|
|
172
194
|
if (!entry || typeof entry !== "object") return false;
|
|
@@ -174,23 +196,47 @@ function isStigmerHookEntry(entry: unknown): boolean {
|
|
|
174
196
|
return (
|
|
175
197
|
typeof command === "string" &&
|
|
176
198
|
command.includes("/.stigmer/sessions/") &&
|
|
177
|
-
command.endsWith(
|
|
199
|
+
command.endsWith(".sh")
|
|
178
200
|
);
|
|
179
201
|
}
|
|
180
202
|
|
|
181
|
-
const STANDALONE_CONFIG = (
|
|
182
|
-
JSON.stringify(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
203
|
+
const STANDALONE_CONFIG = (registrations: HookRegistration[]): string =>
|
|
204
|
+
JSON.stringify({ version: 1, hooks: mergeHooks({}, registrations).hooks }, null, 2);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Merge our registrations into a hooks object: for each event, drop any stale
|
|
208
|
+
* Stigmer entry, then append our fresh one. Returns the merged hooks object, the
|
|
209
|
+
* cleaned (Stigmer-free) hooks for restore, and whether anything was stripped.
|
|
210
|
+
*/
|
|
211
|
+
function mergeHooks(
|
|
212
|
+
existingHooks: Record<string, unknown>,
|
|
213
|
+
registrations: HookRegistration[],
|
|
214
|
+
): { hooks: Record<string, unknown>; cleaned: Record<string, unknown>; strippedStale: boolean } {
|
|
215
|
+
const hooks: Record<string, unknown> = { ...existingHooks };
|
|
216
|
+
const cleaned: Record<string, unknown> = { ...existingHooks };
|
|
217
|
+
let strippedStale = false;
|
|
218
|
+
|
|
219
|
+
for (const { event, scriptPath } of registrations) {
|
|
220
|
+
const hadEvent = Array.isArray(existingHooks[event]);
|
|
221
|
+
const existing = hadEvent ? (existingHooks[event] as unknown[]) : [];
|
|
222
|
+
const userEntries = existing.filter((e) => !isStigmerHookEntry(e));
|
|
223
|
+
if (userEntries.length !== existing.length) strippedStale = true;
|
|
224
|
+
|
|
225
|
+
hooks[event] = [...userEntries, buildHookEntry(scriptPath)];
|
|
226
|
+
// Restore target keeps the event key only if the user originally had it, so
|
|
227
|
+
// we never leave behind an empty array the user never wrote.
|
|
228
|
+
if (hadEvent) cleaned[event] = userEntries;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { hooks, cleaned, strippedStale };
|
|
232
|
+
}
|
|
187
233
|
|
|
188
234
|
/**
|
|
189
235
|
* Compute the merged hooks.json to write for this turn and the content to
|
|
190
236
|
* restore afterward.
|
|
191
237
|
*
|
|
192
238
|
* - No existing file → write our standalone config; restore by deleting (null).
|
|
193
|
-
* - Existing, parseable file → append our entry to
|
|
239
|
+
* - Existing, parseable file → append our entry to each registered event array,
|
|
194
240
|
* preserving every other hook type and field; restore the user's original
|
|
195
241
|
* bytes. Any stale Stigmer entry from a prior crashed turn is stripped from
|
|
196
242
|
* BOTH the merged config (no duplicate) and the restore target (self-healing).
|
|
@@ -201,20 +247,20 @@ const STANDALONE_CONFIG = (scriptPath: string): string =>
|
|
|
201
247
|
*/
|
|
202
248
|
export function buildMergedConfig(
|
|
203
249
|
originalRaw: string | null,
|
|
204
|
-
|
|
250
|
+
registrations: HookRegistration[],
|
|
205
251
|
): { merged: string; restoreTo: string | null } {
|
|
206
252
|
if (originalRaw === null) {
|
|
207
|
-
return { merged: STANDALONE_CONFIG(
|
|
253
|
+
return { merged: STANDALONE_CONFIG(registrations), restoreTo: null };
|
|
208
254
|
}
|
|
209
255
|
|
|
210
256
|
let parsed: unknown;
|
|
211
257
|
try {
|
|
212
258
|
parsed = JSON.parse(originalRaw);
|
|
213
259
|
} catch {
|
|
214
|
-
return { merged: STANDALONE_CONFIG(
|
|
260
|
+
return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw };
|
|
215
261
|
}
|
|
216
262
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
217
|
-
return { merged: STANDALONE_CONFIG(
|
|
263
|
+
return { merged: STANDALONE_CONFIG(registrations), restoreTo: originalRaw };
|
|
218
264
|
}
|
|
219
265
|
|
|
220
266
|
const root = parsed as Record<string, unknown>;
|
|
@@ -222,30 +268,16 @@ export function buildMergedConfig(
|
|
|
222
268
|
root.hooks && typeof root.hooks === "object" && !Array.isArray(root.hooks)
|
|
223
269
|
? (root.hooks as Record<string, unknown>)
|
|
224
270
|
: {};
|
|
225
|
-
const existingPreToolUse = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
|
|
226
|
-
const userEntries = existingPreToolUse.filter((e) => !isStigmerHookEntry(e));
|
|
227
|
-
const strippedStale = userEntries.length !== existingPreToolUse.length;
|
|
228
|
-
|
|
229
271
|
const version = typeof root.version === "number" ? root.version : 1;
|
|
230
272
|
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
version,
|
|
235
|
-
hooks: { ...hooks, preToolUse: [...userEntries, buildHookEntry(scriptPath)] },
|
|
236
|
-
},
|
|
237
|
-
null,
|
|
238
|
-
2,
|
|
239
|
-
);
|
|
273
|
+
const { hooks: mergedHooks, cleaned, strippedStale } = mergeHooks(hooks, registrations);
|
|
274
|
+
|
|
275
|
+
const merged = JSON.stringify({ ...root, version, hooks: mergedHooks }, null, 2);
|
|
240
276
|
|
|
241
277
|
// Restore the user's exact original bytes — unless we stripped a stale Stigmer
|
|
242
278
|
// entry, in which case restore the cleaned form so our leftover never lingers.
|
|
243
279
|
const restoreTo = strippedStale
|
|
244
|
-
? JSON.stringify(
|
|
245
|
-
{ ...root, version, hooks: { ...hooks, preToolUse: userEntries } },
|
|
246
|
-
null,
|
|
247
|
-
2,
|
|
248
|
-
)
|
|
280
|
+
? JSON.stringify({ ...root, version, hooks: cleaned }, null, 2)
|
|
249
281
|
: originalRaw;
|
|
250
282
|
|
|
251
283
|
return { merged, restoreTo };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { extractToolResult, extractToolResultV3 } from "../status-builder-shared.js";
|
|
3
|
+
|
|
4
|
+
// Image/mixed content blocks (e.g. a computer-use screenshot). The extractor
|
|
5
|
+
// must serialize the BLOCKS ARRAY — not the LangChain envelope around it — so
|
|
6
|
+
// the persist-time offload can detect the image and lift it into a renderable
|
|
7
|
+
// ToolCallOutputRef.
|
|
8
|
+
const imageBlock = { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } };
|
|
9
|
+
|
|
10
|
+
describe("extractToolResultV3", () => {
|
|
11
|
+
it("passes a plain string output through unchanged", () => {
|
|
12
|
+
expect(extractToolResultV3("just text")).toBe("just text");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns kwargs.content when it is a text string (serialized envelope)", () => {
|
|
16
|
+
const envelope = {
|
|
17
|
+
lc: 1,
|
|
18
|
+
type: "constructor",
|
|
19
|
+
id: ["langchain_core", "messages", "ToolMessage"],
|
|
20
|
+
kwargs: { status: "success", content: "hello" },
|
|
21
|
+
};
|
|
22
|
+
expect(extractToolResultV3(envelope)).toBe("hello");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("serializes the blocks array (not the envelope) when kwargs.content is an array", () => {
|
|
26
|
+
const envelope = {
|
|
27
|
+
lc: 1,
|
|
28
|
+
type: "constructor",
|
|
29
|
+
id: ["langchain_core", "messages", "ToolMessage"],
|
|
30
|
+
kwargs: { content: [{ type: "text", text: "shot" }, imageBlock] },
|
|
31
|
+
};
|
|
32
|
+
const result = extractToolResultV3(envelope);
|
|
33
|
+
expect(JSON.parse(result)).toEqual([{ type: "text", text: "shot" }, imageBlock]);
|
|
34
|
+
// The surrounding envelope keys must NOT appear in the serialized result.
|
|
35
|
+
expect(result).not.toContain("constructor");
|
|
36
|
+
expect(result).not.toContain("langchain_core");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("serializes the blocks array on a live ToolMessage-shaped object (obj.content)", () => {
|
|
40
|
+
const live = { content: [imageBlock] };
|
|
41
|
+
expect(JSON.parse(extractToolResultV3(live))).toEqual([imageBlock]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("falls back to JSON.stringify for unrecognized shapes", () => {
|
|
45
|
+
expect(extractToolResultV3({ foo: "bar" })).toBe(JSON.stringify({ foo: "bar" }));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("extractToolResult (v2)", () => {
|
|
50
|
+
it("passes a string output through unchanged", () => {
|
|
51
|
+
expect(extractToolResult({ output: "text" })).toBe("text");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns output.content when it is a text string", () => {
|
|
55
|
+
expect(extractToolResult({ output: { content: "hello" } })).toBe("hello");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("serializes the blocks array when output.content is an array", () => {
|
|
59
|
+
const data = { output: { content: [imageBlock] } };
|
|
60
|
+
expect(JSON.parse(extractToolResult(data))).toEqual([imageBlock]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to JSON.stringify when there is no usable content", () => {
|
|
64
|
+
expect(extractToolResult({ output: { other: 1 } })).toBe(JSON.stringify({ other: 1 }));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -300,7 +300,10 @@ describe("StatusBuilder", () => {
|
|
|
300
300
|
expect(tc.error).toBe("permission denied");
|
|
301
301
|
});
|
|
302
302
|
|
|
303
|
-
it("
|
|
303
|
+
it("stores the full result faithfully (no builder-level truncation)", () => {
|
|
304
|
+
// Size-bounding is owned by the persist chokepoint (offload + enforce),
|
|
305
|
+
// not the builder. The builder must reflect the stream verbatim so binary
|
|
306
|
+
// content (e.g. a screenshot's base64) survives intact for offload.
|
|
304
307
|
const sb = makeBuilder();
|
|
305
308
|
sb.processEvent(chatStreamEvent("run-1", "text"));
|
|
306
309
|
sb.processEvent(toolStartEvent("tool-run-1", "read"));
|
|
@@ -309,8 +312,8 @@ describe("StatusBuilder", () => {
|
|
|
309
312
|
sb.processEvent(toolEndEvent("tool-run-1", longResult));
|
|
310
313
|
|
|
311
314
|
const tc = sb.currentStatus.messages[0].toolCalls[0];
|
|
312
|
-
expect(tc.result
|
|
313
|
-
expect(tc.result).toContain("[truncated:");
|
|
315
|
+
expect(tc.result).toBe(longResult);
|
|
316
|
+
expect(tc.result).not.toContain("[truncated:");
|
|
314
317
|
});
|
|
315
318
|
|
|
316
319
|
it("sets forceNextUpdate", () => {
|
|
@@ -524,4 +524,74 @@ describe("streamExecutionV3", () => {
|
|
|
524
524
|
expect(status.streamingUsage!.inputTokens).toBe(10n);
|
|
525
525
|
});
|
|
526
526
|
});
|
|
527
|
+
|
|
528
|
+
describe("image offload through the persist chokepoint", () => {
|
|
529
|
+
it("offloads an MCP image tool result to a renderable ToolCallOutputRef", async () => {
|
|
530
|
+
// End-to-end: a tool returns image content blocks; the builder stores them
|
|
531
|
+
// faithfully (no truncation), and the persist chokepoint offloads the image
|
|
532
|
+
// to artifact storage so the persisted ToolCall carries outputRef.isImage.
|
|
533
|
+
const uploads: { key: string; size: number; contentType?: string }[] = [];
|
|
534
|
+
const artifactStorage = {
|
|
535
|
+
upload: vi.fn(async (key: string, content: Buffer, contentType?: string) => {
|
|
536
|
+
uploads.push({ key, size: content.length, contentType });
|
|
537
|
+
return key;
|
|
538
|
+
}),
|
|
539
|
+
getDownloadUrl: vi.fn(async (key: string) => `https://artifacts.local/${key}`),
|
|
540
|
+
exists: vi.fn(async () => true),
|
|
541
|
+
} as any;
|
|
542
|
+
|
|
543
|
+
const base64 = Buffer.from("PNGBYTES".repeat(64)).toString("base64");
|
|
544
|
+
const imageEnvelope = {
|
|
545
|
+
lc: 1,
|
|
546
|
+
type: "constructor",
|
|
547
|
+
id: ["langchain_core", "messages", "ToolMessage"],
|
|
548
|
+
kwargs: {
|
|
549
|
+
status: "success",
|
|
550
|
+
content: [{ type: "image", data: base64, mimeType: "image/png" }],
|
|
551
|
+
tool_call_id: "toolu_1",
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const events = [
|
|
556
|
+
makeEvent(0, "messages", { event: "message-start", run_id: "r1" }),
|
|
557
|
+
makeToolStartedEvent(1, "screenshot", {}),
|
|
558
|
+
makeEvent(2, "tools", {
|
|
559
|
+
event: "tool-finished",
|
|
560
|
+
tool_call_id: "toolu_1",
|
|
561
|
+
output: imageEnvelope,
|
|
562
|
+
}, { namespace: ["tools:toolu_1"] }),
|
|
563
|
+
makeEvent(3, "messages", { event: "message-finish", run_id: "r1" }),
|
|
564
|
+
];
|
|
565
|
+
const graph = mockV3Graph(events, { messages: [] });
|
|
566
|
+
|
|
567
|
+
const persisted: any[] = [];
|
|
568
|
+
const updateStatus = vi.fn(async (_id: string, status: any) => {
|
|
569
|
+
persisted.push(status);
|
|
570
|
+
return { signal: 0 };
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const promise = streamExecutionV3(baseDeps({
|
|
574
|
+
agentGraph: graph,
|
|
575
|
+
client: { updateStatus } as any,
|
|
576
|
+
offload: { artifactStorage, executionId: "exec-v3-test" },
|
|
577
|
+
}));
|
|
578
|
+
await vi.runAllTimersAsync();
|
|
579
|
+
await promise;
|
|
580
|
+
|
|
581
|
+
// Find a persisted snapshot whose tool call carries the image ref.
|
|
582
|
+
const refTc = persisted
|
|
583
|
+
.flatMap((s) => s.messages)
|
|
584
|
+
.flatMap((m: any) => m.toolCalls)
|
|
585
|
+
.find((tc: any) => tc?.id === "toolu_1" && tc?.outputRef);
|
|
586
|
+
|
|
587
|
+
expect(refTc).toBeDefined();
|
|
588
|
+
expect(refTc.outputRef.isImage).toBe(true);
|
|
589
|
+
expect(refTc.outputRef.mimeType).toBe("image/png");
|
|
590
|
+
expect(refTc.outputRef.downloadUrl).toContain("artifacts/exec-v3-test/toolcalls/toolu_1.png");
|
|
591
|
+
// The base64 must not survive inline on the persisted result.
|
|
592
|
+
expect(refTc.result).not.toContain(base64);
|
|
593
|
+
expect(uploads).toHaveLength(1);
|
|
594
|
+
expect(uploads[0].contentType).toBe("image/png");
|
|
595
|
+
});
|
|
596
|
+
});
|
|
527
597
|
});
|