@stigmer/runner 3.12.9 → 3.14.0-dev.20260910084630
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/LICENSE +190 -0
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/call-agent.d.ts +3 -2
- package/dist/activities/call-agent.js +13 -4
- package/dist/activities/call-agent.js.map +1 -1
- package/dist/activities/discover-mcp-server.js +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +67 -0
- package/dist/activities/execute-cursor/message-translator.js +103 -0
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.js +13 -1
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.d.ts +16 -1
- package/dist/activities/execute-cursor/turn-boundary.js +44 -2
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +1 -1
- package/dist/activities/execute-deep-agent/attachment-injector.js +2 -2
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/client/stigmer-client.js +7 -2
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/ipc-protocol-fixtures.js +3 -4
- package/dist/ipc-protocol-fixtures.js.map +1 -1
- package/dist/ipc-protocol.js +5 -4
- package/dist/ipc-protocol.js.map +1 -1
- package/dist/runner-manager.js +1 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +1 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/zip-extract.js +1 -1
- package/dist/shared/zip-extract.js.map +1 -1
- package/package.json +15 -4
- package/src/__tests__/ipc-protocol-fixtures.test.ts +3 -4
- package/src/__tests__/ipc-protocol.test.ts +3 -2
- package/src/activities/call-agent.ts +14 -4
- package/src/activities/discover-mcp-server.ts +1 -1
- package/src/activities/execute-cursor/__tests__/cursor-generate-image-live.test.ts +147 -0
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +1 -1
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +153 -1
- package/src/activities/execute-cursor/message-translator.ts +115 -0
- package/src/activities/execute-cursor/prompt-builder.ts +13 -1
- package/src/activities/execute-cursor/turn-boundary.ts +65 -1
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +3 -2
- package/src/activities/execute-deep-agent/attachment-injector.ts +2 -2
- package/src/client/stigmer-client.ts +7 -2
- package/src/ipc-protocol-fixtures.ts +3 -4
- package/src/ipc-protocol.ts +5 -4
- package/src/runner-manager.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/shared/__tests__/skill-mount.test.ts +1 -1
- package/src/shared/__tests__/skill-writer.test.ts +1 -1
- package/src/shared/__tests__/zip-extract.test.ts +1 -1
- package/src/shared/zip-extract.ts +1 -1
- package/dist/shared/zip-structure.d.ts +0 -61
- package/dist/shared/zip-structure.js +0 -128
- package/dist/shared/zip-structure.js.map +0 -1
- package/src/__test-utils__/zip-fixtures.ts +0 -206
- package/src/shared/zip-structure.ts +0 -181
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
33
33
|
import { captureBaselineToLedger } from "../capture-flow.js";
|
|
34
34
|
import { denialLedgerPath } from "../approval-state.js";
|
|
35
|
-
import { toolCallIdentityToken } from "../message-translator.js";
|
|
35
|
+
import { toolCallIdentityToken, UNRESOLVED_TOOL_CALL_ERROR } from "../message-translator.js";
|
|
36
36
|
import { runTurnBoundary, type TurnBoundaryOptions } from "../turn-boundary.js";
|
|
37
37
|
|
|
38
38
|
const execFileAsync = promisify(execFile);
|
|
@@ -405,3 +405,155 @@ describe("runTurnBoundary", () => {
|
|
|
405
405
|
expect(shellCall.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
406
406
|
});
|
|
407
407
|
});
|
|
408
|
+
|
|
409
|
+
// The issue #965 invariant: an unresolved tool must never silently complete.
|
|
410
|
+
// The production fixture is aex_01m1a6ww3nmp4952ar5v0g4g85 — Cursor's native
|
|
411
|
+
// `generateImage` (an interaction-channel tool no Stigmer seam touches) hung
|
|
412
|
+
// with no result event and no ledger entry, the turn completed, and the model's
|
|
413
|
+
// last words promised a write approval the platform never held. The boundary
|
|
414
|
+
// must settle such rows to an honest INTERRUPTED and put the platform's own
|
|
415
|
+
// disclosure on the transcript so the model's claim is never the last word.
|
|
416
|
+
describe("runTurnBoundary — unresolved tool calls on a completing turn (issue #965)", () => {
|
|
417
|
+
/** The incident's exact row shape: a streamed call that never resolved. */
|
|
418
|
+
function hangingGenerateImage(id: string): AgentMessage {
|
|
419
|
+
return create(AgentMessageSchema, {
|
|
420
|
+
type: MessageType.MESSAGE_AI,
|
|
421
|
+
toolCalls: [
|
|
422
|
+
create(ToolCallSchema, {
|
|
423
|
+
id,
|
|
424
|
+
name: "generateImage",
|
|
425
|
+
status: ToolCallStatus.TOOL_CALL_RUNNING,
|
|
426
|
+
args: { description: "a red circle", filePath: "red-circle.png" },
|
|
427
|
+
}),
|
|
428
|
+
],
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
it("settles the incident shape to INTERRUPTED and discloses it (regression: aex_01m1a6ww)", async () => {
|
|
433
|
+
const status = newStatus();
|
|
434
|
+
const baseline = await captureBaselineToLedger({
|
|
435
|
+
status,
|
|
436
|
+
gitRoot: repo,
|
|
437
|
+
executionId: EXEC_ID,
|
|
438
|
+
changeSetId: CHANGE_SET_ID,
|
|
439
|
+
});
|
|
440
|
+
status.messages.push(hangingGenerateImage("tc-genimage-1"));
|
|
441
|
+
|
|
442
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
443
|
+
|
|
444
|
+
// The turn completes (no pause) — but not silently.
|
|
445
|
+
expect(result.waiting).toBe(false);
|
|
446
|
+
expect(result.settledUnresolvedCount).toBe(1);
|
|
447
|
+
|
|
448
|
+
const row = status.messages[0].toolCalls[0];
|
|
449
|
+
// INTERRUPTED, never FAILED: the one settled status a recovery replay may
|
|
450
|
+
// supersede (the #207 contract) — a FAILED stamp would freeze the row.
|
|
451
|
+
expect(row.status).toBe(ToolCallStatus.TOOL_CALL_INTERRUPTED);
|
|
452
|
+
expect(row.error).toBe(UNRESOLVED_TOOL_CALL_ERROR);
|
|
453
|
+
expect(row.isStreaming).toBe(false);
|
|
454
|
+
expect(row.completedAt).not.toBe("");
|
|
455
|
+
|
|
456
|
+
// The platform's disclosure is the transcript's last word — it names the
|
|
457
|
+
// tool and explicitly denies the phantom approval.
|
|
458
|
+
const last = status.messages[status.messages.length - 1];
|
|
459
|
+
expect(last.type).toBe(MessageType.MESSAGE_SYSTEM);
|
|
460
|
+
expect(last.content).toContain("generateImage");
|
|
461
|
+
expect(last.content).toContain("No approval is pending");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("leaves non-terminal rows to the pause machinery on a PAUSING turn", async () => {
|
|
465
|
+
const status = newStatus();
|
|
466
|
+
const baseline = await captureBaselineToLedger({
|
|
467
|
+
status,
|
|
468
|
+
gitRoot: repo,
|
|
469
|
+
executionId: EXEC_ID,
|
|
470
|
+
changeSetId: CHANGE_SET_ID,
|
|
471
|
+
});
|
|
472
|
+
// A captured file change makes the turn pause…
|
|
473
|
+
await write("notes.md", "original notes\npaused-turn edit\n");
|
|
474
|
+
status.messages.push(
|
|
475
|
+
streamedEdit("tc-edit-1", "notes.md", "original notes\npaused-turn edit\n"),
|
|
476
|
+
);
|
|
477
|
+
// …while a hanging row rides the same turn.
|
|
478
|
+
status.messages.push(hangingGenerateImage("tc-genimage-2"));
|
|
479
|
+
|
|
480
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
481
|
+
|
|
482
|
+
expect(result.waiting).toBe(true);
|
|
483
|
+
expect(result.settledUnresolvedCount).toBe(0);
|
|
484
|
+
// Untouched by THIS sweep (a pausing turn's rows belong to the reconcile /
|
|
485
|
+
// collapse machinery, which has its own treatment for orphaned attempts).
|
|
486
|
+
expect(status.messages[1].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("never settles a ledger-attributed row — that is the kinded machinery's call", async () => {
|
|
490
|
+
const status = newStatus();
|
|
491
|
+
const baseline = await captureBaselineToLedger({
|
|
492
|
+
status,
|
|
493
|
+
gitRoot: repo,
|
|
494
|
+
executionId: EXEC_ID,
|
|
495
|
+
changeSetId: CHANGE_SET_ID,
|
|
496
|
+
});
|
|
497
|
+
const secretWrite = create(ToolCallSchema, {
|
|
498
|
+
id: "tc-secret-1",
|
|
499
|
+
name: "edit",
|
|
500
|
+
status: ToolCallStatus.TOOL_CALL_PENDING,
|
|
501
|
+
args: { path: ".env", content: "API_KEY=x" },
|
|
502
|
+
});
|
|
503
|
+
status.messages.push(
|
|
504
|
+
create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls: [secretWrite] }),
|
|
505
|
+
);
|
|
506
|
+
// A secret-kind hard-block entry: attributable (kind non-approval), so the
|
|
507
|
+
// row is accounted for and must NOT be swept as "unresolved".
|
|
508
|
+
await writeFile(
|
|
509
|
+
denialLedgerPath(hitlDir),
|
|
510
|
+
JSON.stringify({
|
|
511
|
+
toolName: "Write",
|
|
512
|
+
token: toolCallIdentityToken(secretWrite),
|
|
513
|
+
kind: "secret",
|
|
514
|
+
}) + "\n",
|
|
515
|
+
"utf-8",
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const result = await runTurnBoundary(boundaryOpts(status, baseline));
|
|
519
|
+
|
|
520
|
+
expect(result.settledUnresolvedCount).toBe(0);
|
|
521
|
+
expect(secretWrite.status).toBe(ToolCallStatus.TOOL_CALL_PENDING);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("scopes to THIS turn: seeded prior-turn rows and terminal rows are untouched", async () => {
|
|
525
|
+
const status = newStatus();
|
|
526
|
+
const baseline = await captureBaselineToLedger({
|
|
527
|
+
status,
|
|
528
|
+
gitRoot: repo,
|
|
529
|
+
executionId: EXEC_ID,
|
|
530
|
+
changeSetId: CHANGE_SET_ID,
|
|
531
|
+
});
|
|
532
|
+
// Message 0 is seeded prior-turn context (already adjudicated elsewhere).
|
|
533
|
+
status.messages.push(hangingGenerateImage("tc-prior-turn"));
|
|
534
|
+
// Message 1 opens this turn: one real terminal row.
|
|
535
|
+
status.messages.push(
|
|
536
|
+
create(AgentMessageSchema, {
|
|
537
|
+
type: MessageType.MESSAGE_AI,
|
|
538
|
+
toolCalls: [
|
|
539
|
+
create(ToolCallSchema, {
|
|
540
|
+
id: "tc-done",
|
|
541
|
+
name: "Read",
|
|
542
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
543
|
+
result: "file contents",
|
|
544
|
+
}),
|
|
545
|
+
],
|
|
546
|
+
}),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const result = await runTurnBoundary(
|
|
550
|
+
boundaryOpts(status, baseline, { turnStartMessageIndex: 1 }),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
expect(result.settledUnresolvedCount).toBe(0);
|
|
554
|
+
expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
|
|
555
|
+
expect(status.messages[1].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
556
|
+
// No disclosure was appended.
|
|
557
|
+
expect(status.messages.at(-1)?.type).toBe(MessageType.MESSAGE_AI);
|
|
558
|
+
});
|
|
559
|
+
});
|
|
@@ -1923,6 +1923,121 @@ export function detectUnattributedHookBlocks(
|
|
|
1923
1923
|
return blocks;
|
|
1924
1924
|
}
|
|
1925
1925
|
|
|
1926
|
+
/**
|
|
1927
|
+
* The honest terminal error stamped on a tool call that never resolved (issue
|
|
1928
|
+
* #965). Deliberately states all three negatives — not executed, not approved,
|
|
1929
|
+
* not denied — because the incident's harm was the model claiming an approval
|
|
1930
|
+
* was pending: this text is what the transcript shows INSTEAD of a spinner or
|
|
1931
|
+
* a silent after-the-fact interruption, and it must leave no room for an
|
|
1932
|
+
* approval-is-coming reading.
|
|
1933
|
+
*/
|
|
1934
|
+
export const UNRESOLVED_TOOL_CALL_ERROR =
|
|
1935
|
+
"The tool did not return a result before the turn ended. It was never " +
|
|
1936
|
+
"executed, approved, or denied — no approval is pending for it.";
|
|
1937
|
+
|
|
1938
|
+
/** One never-resolved tool call the turn boundary settled (issue #965). */
|
|
1939
|
+
export interface UnresolvedToolCall {
|
|
1940
|
+
toolCallId: string;
|
|
1941
|
+
toolName: string;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* Settle this-turn tool calls that are still NON-TERMINAL when a turn
|
|
1946
|
+
* completes without pausing — the issue #965 invariant, the sibling of
|
|
1947
|
+
* {@link detectUnattributedHookBlocks}' #205 invariant ("a blocked tool must
|
|
1948
|
+
* never silently complete" → "an unresolved tool must never silently
|
|
1949
|
+
* complete").
|
|
1950
|
+
*
|
|
1951
|
+
* THE SHAPE THIS CATCHES. A tool that hangs INSIDE the Cursor agent runtime —
|
|
1952
|
+
* the production case is `generateImage`, which rides the SDK's
|
|
1953
|
+
* interaction-query channel rather than the ordinary tool path — streams a
|
|
1954
|
+
* tool_call start, never streams a result, and is invisible to every Stigmer
|
|
1955
|
+
* seam: the hook never denied it (no ledger entry), so the reconcile never
|
|
1956
|
+
* gated it, and the turn completes with the row still PENDING/RUNNING. Before
|
|
1957
|
+
* this sweep, the server's terminal settle (issue #207) stamped such rows
|
|
1958
|
+
* TOOL_CALL_INTERRUPTED silently AFTER the runner reported completion — the
|
|
1959
|
+
* transcript's last word stayed whatever the model claimed, which in
|
|
1960
|
+
* aex_01m1a6ww3nmp4952ar5v0g4g85 was a promise that an approval was pending
|
|
1961
|
+
* when none existed.
|
|
1962
|
+
*
|
|
1963
|
+
* Settling here instead makes the runner the author of the honest record: the
|
|
1964
|
+
* row gets TOOL_CALL_INTERRUPTED with {@link UNRESOLVED_TOOL_CALL_ERROR}, and
|
|
1965
|
+
* the caller (turn-boundary.ts) appends a system disclosure naming what never
|
|
1966
|
+
* ran.
|
|
1967
|
+
*
|
|
1968
|
+
* WHY INTERRUPTED AND NEVER FAILED. TOOL_CALL_INTERRUPTED is deliberately the
|
|
1969
|
+
* one settled status the monotonic merge guard lets live execution evidence
|
|
1970
|
+
* supersede (see the guard's note in this file, ~line 331): if this FAILED
|
|
1971
|
+
* execution is later RECOVERED, the harness checkpoint may re-execute the call
|
|
1972
|
+
* under its original id, and the replayed events must be able to advance the
|
|
1973
|
+
* row to its true outcome. A boundary-stamped FAILED would freeze it forever.
|
|
1974
|
+
*
|
|
1975
|
+
* WHY IT NEVER FAILS THE RUN (unlike #205). A foreign hook block is provably
|
|
1976
|
+
* adversarial — approval semantics are permanently broken, so completing would
|
|
1977
|
+
* always be a lie. An unresolved row can also be benign stream event-loss
|
|
1978
|
+
* where the tool actually ran; failing the run would convert those into
|
|
1979
|
+
* regressions. Disclosure restores honesty at zero regression risk.
|
|
1980
|
+
*
|
|
1981
|
+
* Scope and exclusions, in order:
|
|
1982
|
+
* - THIS turn's parent-transcript rows only (from `turnStartMessageIndex`):
|
|
1983
|
+
* seeded prior-turn rows were adjudicated by their own execution's settle,
|
|
1984
|
+
* and sub-agent inner rows are the server settle's concern — the parent row
|
|
1985
|
+
* (e.g. the Task call) is what the user sees. Mirrors #205's scoping.
|
|
1986
|
+
* - Only PENDING/RUNNING rows: every terminal row was adjudicated, and
|
|
1987
|
+
* WAITING_APPROVAL rows belong to the pause machinery (the caller only runs
|
|
1988
|
+
* this sweep on a NON-pausing turn, so none should exist here anyway).
|
|
1989
|
+
* - Only rows with NO denial-ledger entry of ANY kind (exact token, then the
|
|
1990
|
+
* normalized-path fallback — the same two identities every sweep in this
|
|
1991
|
+
* file uses): a ledger-attributed row is the unattended/secret/fail-closed
|
|
1992
|
+
* machinery's to settle, not ours.
|
|
1993
|
+
*
|
|
1994
|
+
* Returns the settled calls so the boundary can disclose and log them.
|
|
1995
|
+
*/
|
|
1996
|
+
export function settleUnresolvedToolCalls(
|
|
1997
|
+
messages: readonly AgentMessage[],
|
|
1998
|
+
turnStartMessageIndex: number,
|
|
1999
|
+
ledger: readonly DeniedLedgerEntry[],
|
|
2000
|
+
workspaceRoot?: string,
|
|
2001
|
+
): UnresolvedToolCall[] {
|
|
2002
|
+
const ledgerTokens = new Set(ledger.map((e) => e.token));
|
|
2003
|
+
const ledgerNormalizedSalients = new Set<string>();
|
|
2004
|
+
if (workspaceRoot) {
|
|
2005
|
+
for (const entry of ledger) {
|
|
2006
|
+
const decoded = decodeIdentityToken(entry.token);
|
|
2007
|
+
if (!decoded) continue;
|
|
2008
|
+
const normalized = normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot);
|
|
2009
|
+
if (normalized) ledgerNormalizedSalients.add(normalized);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
const matchesLedger = (tc: ToolCall): boolean => {
|
|
2014
|
+
if (ledgerTokens.has(toolCallIdentityToken(tc))) return true;
|
|
2015
|
+
if (!workspaceRoot) return false;
|
|
2016
|
+
const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
|
|
2017
|
+
const normalized = normalizedFileSalient(id.key, id.salient, workspaceRoot);
|
|
2018
|
+
return !!normalized && ledgerNormalizedSalients.has(normalized);
|
|
2019
|
+
};
|
|
2020
|
+
|
|
2021
|
+
const settled: UnresolvedToolCall[] = [];
|
|
2022
|
+
for (const msg of messages.slice(Math.max(0, turnStartMessageIndex))) {
|
|
2023
|
+
for (const tc of msg.toolCalls) {
|
|
2024
|
+
if (
|
|
2025
|
+
tc.status !== ToolCallStatus.TOOL_CALL_PENDING &&
|
|
2026
|
+
tc.status !== ToolCallStatus.TOOL_CALL_RUNNING
|
|
2027
|
+
) {
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
if (matchesLedger(tc)) continue;
|
|
2031
|
+
tc.status = ToolCallStatus.TOOL_CALL_INTERRUPTED;
|
|
2032
|
+
tc.error = UNRESOLVED_TOOL_CALL_ERROR;
|
|
2033
|
+
tc.isStreaming = false;
|
|
2034
|
+
if (!tc.completedAt) tc.completedAt = utcTimestamp();
|
|
2035
|
+
settled.push({ toolCallId: tc.id, toolName: tc.name });
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
return settled;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
1926
2041
|
/**
|
|
1927
2042
|
* Stamp the tool calls the hook denied under UNATTENDED approval mode
|
|
1928
2043
|
* (DD-014) as terminal TOOL_CALL_SKIPPED rows with UNATTENDED_SKIP
|
|
@@ -751,12 +751,24 @@ const TOOL_APPROVAL_PROTOCOL_INTRO =
|
|
|
751
751
|
* well-behaved model otherwise concludes the environment is broken and tells the
|
|
752
752
|
* user to "enable hooks in your Cursor settings", contradicting the approval
|
|
753
753
|
* card. This rule reframes that signal as the gate working as designed.
|
|
754
|
+
*
|
|
755
|
+
* The fifth rule is the fourth's honesty boundary (issue #965): the gate
|
|
756
|
+
* recognition must be scoped to the PLATFORM'S OWN message texts ("blocked by
|
|
757
|
+
* a hook"; "submitted to the user for approval"), because a failure inside the
|
|
758
|
+
* harness itself can ALSO speak in permission vocabulary — the production case
|
|
759
|
+
* was Cursor's native generateImage hanging with a write-permission-flavored
|
|
760
|
+
* error, which the old any-"requires approval"-text reading turned into the
|
|
761
|
+
* model promising the user an approval card the platform never held. A failure
|
|
762
|
+
* without the platform's notice is an ordinary tool failure and must be
|
|
763
|
+
* reported as one; the platform's approval surface is never narrated into
|
|
764
|
+
* existence.
|
|
754
765
|
*/
|
|
755
766
|
const TOOL_APPROVAL_PROTOCOL_RULES: readonly string[] = [
|
|
756
767
|
"Carry out every action by calling the appropriate tool directly. Never describe an action you intend to take and then stop, and never ask the user for permission in prose.",
|
|
757
768
|
"When an action needs approval, the platform pauses it, asks the user, and resumes you automatically after they decide. You do not request approval yourself — invoking the tool is how you request it.",
|
|
758
769
|
"Even if a tool or MCP server instructs you to confirm with the user before acting (for example before sending, deleting, or purchasing), do NOT ask in prose. Invoke the tool and let the platform's approval step handle it.",
|
|
759
|
-
"A tool result that says
|
|
770
|
+
"A tool result that says it was \"blocked by a hook\" or that the action was \"submitted to the user for approval\" is the platform's approval gate doing its job — it is NOT an error and NOT a Cursor misconfiguration. Never tell the user to change Cursor settings, enable hooks, or fix their configuration; the gate is intentional, and for THESE results the platform will resume you automatically once the user decides.",
|
|
771
|
+
"Any other tool failure — including one that mentions permissions or approval but does not carry the platform's approval notice above — is an ordinary failure, not the approval gate. Report it to the user honestly as something that did not run. NEVER tell the user an approval is pending or that you will be resumed automatically unless the tool result carried the platform's approval notice; the platform shows its own approval prompts, and you must not invent one.",
|
|
760
772
|
"If an action is declined, do not retry it or attempt a workaround for it; continue with the rest of the task.",
|
|
761
773
|
];
|
|
762
774
|
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
* and redact the model's provisional post-denial narration;
|
|
13
13
|
* 5. detect UNATTRIBUTED hook blocks (issue #205) — a tool blocked by a hook
|
|
14
14
|
* with no ledger entry of any kind was denied by a FOREIGN hook the merge
|
|
15
|
-
* preserved, and the caller fails the run rather than completing silently
|
|
15
|
+
* preserved, and the caller fails the run rather than completing silently;
|
|
16
|
+
* 6. settle UNRESOLVED tool calls (issue #965) — a this-turn row still
|
|
17
|
+
* non-terminal on a completing turn with no ledger attribution hung inside
|
|
18
|
+
* the harness and can never complete; it is settled to an honest
|
|
19
|
+
* TOOL_CALL_INTERRUPTED and disclosed on the transcript instead of being
|
|
20
|
+
* silently stamped by the server's terminal settle after the fact.
|
|
16
21
|
*
|
|
17
22
|
* Extracted from the activity entry point (index.ts Phase 12) so it is directly
|
|
18
23
|
* unit-testable AND re-enterable: the poisoned-handle / transport-timeout
|
|
@@ -51,9 +56,14 @@ import {
|
|
|
51
56
|
clearProvisionalPostDenialNarration,
|
|
52
57
|
detectUnattributedHookBlocks,
|
|
53
58
|
reconcileDeniedToolCalls,
|
|
59
|
+
settleUnresolvedToolCalls,
|
|
54
60
|
stampUnattendedSkippedToolCalls,
|
|
61
|
+
utcTimestamp,
|
|
55
62
|
type UnattributedHookBlock,
|
|
56
63
|
} from "./message-translator.js";
|
|
64
|
+
import { create } from "@bufbuild/protobuf";
|
|
65
|
+
import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
66
|
+
import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
57
67
|
|
|
58
68
|
// How long the boundary waits for the first-denial-stop's run.cancel() to
|
|
59
69
|
// settle before reading the final denial ledger and capturing the turn's tree.
|
|
@@ -140,6 +150,16 @@ export interface TurnBoundaryResult {
|
|
|
140
150
|
* undone (a pausing turn is not silent — the caller logs and pauses as usual).
|
|
141
151
|
*/
|
|
142
152
|
readonly unattributedHookBlocks: readonly UnattributedHookBlock[];
|
|
153
|
+
/**
|
|
154
|
+
* This-turn tool calls settled to TOOL_CALL_INTERRUPTED because they were
|
|
155
|
+
* still non-terminal on a completing turn with no ledger attribution (issue
|
|
156
|
+
* #965) — the harness returned no result for them and the platform holds no
|
|
157
|
+
* approval for them. Informational: the boundary already settled the rows
|
|
158
|
+
* and appended the transcript disclosure; the run is NOT failed for these
|
|
159
|
+
* (unlike #205's unattributed blocks, an unresolved row can be benign
|
|
160
|
+
* stream event-loss, so failing would over-punish).
|
|
161
|
+
*/
|
|
162
|
+
readonly settledUnresolvedCount: number;
|
|
143
163
|
}
|
|
144
164
|
|
|
145
165
|
/**
|
|
@@ -338,6 +358,49 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
|
|
|
338
358
|
primaryWorkspaceDir,
|
|
339
359
|
);
|
|
340
360
|
const waiting = deniedToolCalls.length > 0 || capturedChangeCount > 0;
|
|
361
|
+
|
|
362
|
+
// Issue #965 invariant: an unresolved tool must never silently complete.
|
|
363
|
+
// On a COMPLETING (non-pausing) turn, any this-turn row still PENDING /
|
|
364
|
+
// RUNNING with no ledger attribution hung inside the harness (the production
|
|
365
|
+
// case: `generateImage`, which rides the SDK's interaction-query channel and
|
|
366
|
+
// is invisible to the hook). Settle it to an honest TOOL_CALL_INTERRUPTED —
|
|
367
|
+
// never FAILED, so a recovery replay can still supersede it (#207) — and
|
|
368
|
+
// disclose it on the transcript, so the model's own narration (which may
|
|
369
|
+
// have promised an approval the platform does not hold) is never the last
|
|
370
|
+
// word. A PAUSING turn is skipped: its non-terminal rows belong to the
|
|
371
|
+
// reconcile/collapse machinery above. Runs AFTER the unattended stamp so a
|
|
372
|
+
// ledger-attributed row is already SKIPPED and cannot double-settle, and
|
|
373
|
+
// AFTER the #205 detection so a hook-block FAILED row keeps its distinct,
|
|
374
|
+
// run-failing treatment. Deliberately does NOT fail the run (unlike #205):
|
|
375
|
+
// an unresolved row can also be benign stream event-loss where the tool
|
|
376
|
+
// actually ran, and converting those into failures would be a regression.
|
|
377
|
+
let settledUnresolved: readonly { toolCallId: string; toolName: string }[] = [];
|
|
378
|
+
if (!waiting) {
|
|
379
|
+
settledUnresolved = settleUnresolvedToolCalls(
|
|
380
|
+
status.messages,
|
|
381
|
+
turnStartMessageIndex,
|
|
382
|
+
deniedLedger,
|
|
383
|
+
primaryWorkspaceDir,
|
|
384
|
+
);
|
|
385
|
+
if (settledUnresolved.length > 0) {
|
|
386
|
+
const names = [...new Set(settledUnresolved.map((s) => s.toolName))].join(", ");
|
|
387
|
+
status.messages.push(create(AgentMessageSchema, {
|
|
388
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
389
|
+
content:
|
|
390
|
+
`Note: the following tool call(s) never completed and were not executed: ${names}. ` +
|
|
391
|
+
`No approval is pending for them — if the agent said otherwise, disregard that. ` +
|
|
392
|
+
`You can ask the agent to try again.`,
|
|
393
|
+
timestamp: utcTimestamp(),
|
|
394
|
+
}));
|
|
395
|
+
console.warn(
|
|
396
|
+
`ExecuteCursor turn boundary: settled ${settledUnresolved.length} unresolved ` +
|
|
397
|
+
`tool call(s) to INTERRUPTED with disclosure [${names}] — the harness returned ` +
|
|
398
|
+
`no result for them and no ledger entry accounts for them (issue #965; ` +
|
|
399
|
+
`execution=${executionId})`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
341
404
|
if (unattributedHookBlocks.length > 0) {
|
|
342
405
|
const culprits = (foreignGatingHooks?.length ?? 0) > 0
|
|
343
406
|
? ` — likely foreign workspace hook(s): ${foreignGatingHooks!.join(", ")}`
|
|
@@ -366,5 +429,6 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
|
|
|
366
429
|
capturedChangeCount,
|
|
367
430
|
deniedToolCallCount: deniedToolCalls.length,
|
|
368
431
|
unattributedHookBlocks,
|
|
432
|
+
settledUnresolvedCount: settledUnresolved.length,
|
|
369
433
|
};
|
|
370
434
|
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "../attachment-injector.js";
|
|
15
15
|
import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
|
|
16
16
|
import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
|
|
17
|
-
import { buildZip, type ZipFixtureFile } from "
|
|
17
|
+
import { buildZip, type ZipFixtureFile } from "@stigmer/zip-structure/testing";
|
|
18
18
|
import {
|
|
19
19
|
DEEP_AGENT_VISION_PROFILE,
|
|
20
20
|
VisionBudget,
|
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
|
|
23
23
|
// ── ZIP Construction Helpers ─────────────────────────────────────────
|
|
24
24
|
//
|
|
25
|
-
// All archives come from the shared real-shape builder
|
|
25
|
+
// All archives come from the shared real-shape builder
|
|
26
|
+
// (@stigmer/zip-structure/testing):
|
|
26
27
|
// local headers, payloads, central directory, EOCD — the only shape real
|
|
27
28
|
// ZIP writers produce and the shape central-directory parsing requires.
|
|
28
29
|
// The record-form helpers below keep ordinary call sites terse; tests that
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Security model: attachments are untrusted user uploads. ZIP archives are
|
|
9
9
|
* parsed from the central directory — the format's authoritative index —
|
|
10
|
-
* via the shared structural layer (
|
|
10
|
+
* via the shared structural layer (@stigmer/zip-structure; issue #567,
|
|
11
11
|
* which killed this module's local-header walk: it silently truncated
|
|
12
12
|
* stored streaming entries and rejected Go-default archives outright).
|
|
13
13
|
* Every entry is validated for path traversal, zip bombs, and format
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
EOCD_MIN_SIZE,
|
|
49
49
|
parseZipStructure,
|
|
50
50
|
type ZipStructuralEntry,
|
|
51
|
-
} from "
|
|
51
|
+
} from "@stigmer/zip-structure";
|
|
52
52
|
|
|
53
53
|
// ── Constants ────────────────────────────────────────────────────────
|
|
54
54
|
|
|
@@ -234,7 +234,10 @@ export class StigmerClient {
|
|
|
234
234
|
// scoped-token exchange itself requires the embedded_runner
|
|
235
235
|
// bootstrap credential (a desktop runner's control-plane token is
|
|
236
236
|
// the user's own Auth0 token, which the server correctly treats as
|
|
237
|
-
// a browsing user)
|
|
237
|
+
// a browsing user); and the workflow child-execution create stamps
|
|
238
|
+
// the platform's workflow lineage labels, which cloud's
|
|
239
|
+
// reserved-label guard and environment composer accept only from
|
|
240
|
+
// runner-class callers — a user-token create is rejected outright.
|
|
238
241
|
//
|
|
239
242
|
// 3. Everything else uses the control-plane token. Falls through
|
|
240
243
|
// unchanged when no runner token exists (OSS/local, where the
|
|
@@ -246,7 +249,9 @@ export class StigmerClient {
|
|
|
246
249
|
const usesRunnerCredential =
|
|
247
250
|
req.service.typeName === ExecutionContextQueryController.typeName ||
|
|
248
251
|
(req.service.typeName === PlatformQueryController.typeName &&
|
|
249
|
-
req.method.name === PlatformQueryController.method.getRunnerScopedToken.name)
|
|
252
|
+
req.method.name === PlatformQueryController.method.getRunnerScopedToken.name) ||
|
|
253
|
+
(req.service.typeName === AgentExecutionCommandController.typeName &&
|
|
254
|
+
req.method.name === AgentExecutionCommandController.method.create.name);
|
|
250
255
|
const token =
|
|
251
256
|
(usesRunnerCredential ? this.runnerTokenRef?.current : null)
|
|
252
257
|
?? this.tokenRef?.current
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// Golden wire-shape examples for the manager-mode IPC contract — one representative
|
|
2
|
-
// instance per message. This is the single source the cross-language
|
|
3
|
-
// against (Rust `protocol.rs
|
|
4
|
-
// `
|
|
5
|
-
// read that artifact. Because every sample is typed against an `Ipc*` interface from
|
|
2
|
+
// instance per message. This is the single source the cross-language mirror asserts
|
|
3
|
+
// against (Rust `protocol.rs`): the generator script serializes `buildFixtures()` to
|
|
4
|
+
// `fixtures/ipc-protocol.generated.json`, and the mirror's tests read that artifact. Because every sample is typed against an `Ipc*` interface from
|
|
6
5
|
// `ipc-protocol.ts`, renaming or retyping a field there fails `tsc` here — that compile
|
|
7
6
|
// error is what binds the fixtures to the contract. Full rules: docs/ipc-protocol.md.
|
|
8
7
|
|
package/src/ipc-protocol.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Canonical machine-readable definition of the manager-mode IPC contract.
|
|
2
2
|
// The runner emits these messages; the Rust host crate (crates/stigmer-runner-host/src/
|
|
3
|
-
// protocol.rs)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// protocol.rs) hand-mirrors them, kept honest by golden fixtures generated from this file
|
|
4
|
+
// via ipc-protocol-fixtures.ts (run `make gen-ipc-fixtures`). The conformance harness's
|
|
5
|
+
// manager-mode spawner (test/conformance/src/harness/runner-manager-process.ts) imports
|
|
6
|
+
// these types directly rather than mirroring them. Full spec and the rule for keeping all
|
|
7
|
+
// definitions in sync: docs/ipc-protocol.md.
|
|
7
8
|
|
|
8
9
|
// Integer protocol version advertised in the `ready` handshake. Bump ONLY on a
|
|
9
10
|
// breaking change (removed/renamed message, changed field type, changed lifecycle
|
package/src/runner-manager.ts
CHANGED
|
@@ -789,7 +789,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
789
789
|
...createCallGrpcActivities(),
|
|
790
790
|
...createCallFunctionActivities(),
|
|
791
791
|
...createCallLlmActivities(),
|
|
792
|
-
...createCallAgentActivities(),
|
|
792
|
+
...createCallAgentActivities(config),
|
|
793
793
|
...createCallAgentStatusActivities(),
|
|
794
794
|
...createRunCommandActivities(),
|
|
795
795
|
...createHydrateWorkflowActivities(config),
|
package/src/runner.ts
CHANGED
|
@@ -524,7 +524,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
524
524
|
...createCallGrpcActivities(),
|
|
525
525
|
...createCallFunctionActivities(),
|
|
526
526
|
...createCallLlmActivities(),
|
|
527
|
-
...createCallAgentActivities(),
|
|
527
|
+
...createCallAgentActivities(config),
|
|
528
528
|
...createCallAgentStatusActivities(),
|
|
529
529
|
...createRunCommandActivities(),
|
|
530
530
|
...createHydrateWorkflowActivities(config),
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
downloadArtifact,
|
|
10
10
|
writeSkillMount,
|
|
11
11
|
} from "../skill-mount.js";
|
|
12
|
-
import { buildZip } from "
|
|
12
|
+
import { buildZip } from "@stigmer/zip-structure/testing";
|
|
13
13
|
|
|
14
14
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
15
15
|
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "../skill-writer.js";
|
|
13
13
|
import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
|
|
14
14
|
import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
15
|
-
import { buildZip } from "
|
|
15
|
+
import { buildZip } from "@stigmer/zip-structure/testing";
|
|
16
16
|
|
|
17
17
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
18
18
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { extractZipFileEntries, type ZipFileEntry } from "../zip-extract.js";
|
|
3
|
-
import { buildZip } from "
|
|
3
|
+
import { buildZip } from "@stigmer/zip-structure/testing";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Decode entries for text-content assertions. The production contract is
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { createInflateRaw } from "node:zlib";
|
|
36
|
-
import { EOCD_MIN_SIZE, parseZipStructure, type ZipStructuralEntry } from "
|
|
36
|
+
import { EOCD_MIN_SIZE, parseZipStructure, type ZipStructuralEntry } from "@stigmer/zip-structure";
|
|
37
37
|
|
|
38
38
|
// ─── Public API ──────────────────────────────────────────────────────────
|
|
39
39
|
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Policy-free structural ZIP parsing: end-of-central-directory (EOCD)
|
|
3
|
-
* location plus central-directory walk, producing one record per entry.
|
|
4
|
-
*
|
|
5
|
-
* Parsing is *central-directory-based* — never a front-to-back walk of
|
|
6
|
-
* local file headers. This is a correctness decision, not a style choice
|
|
7
|
-
* (issues #450 and #567): local headers of streaming entries
|
|
8
|
-
* (general-purpose flag bit 3) carry zeroed sizes, and the only
|
|
9
|
-
* local-only way to recover them is scanning the payload for the
|
|
10
|
-
* data-descriptor signature — which silently truncates any stored entry
|
|
11
|
-
* whose *content* happens to contain those four bytes, and
|
|
12
|
-
* desynchronizes every entry after it. The central directory always
|
|
13
|
-
* carries the real sizes, and every consumer here holds the complete
|
|
14
|
-
* archive bytes, so nothing a local-header walk could offer is needed.
|
|
15
|
-
*
|
|
16
|
-
* This layer maps bytes to entry records and nothing else. Policy — what
|
|
17
|
-
* a structural failure means, which entries are acceptable, how payloads
|
|
18
|
-
* are decoded — belongs to the consumers, and they differ on purpose:
|
|
19
|
-
*
|
|
20
|
-
* - shared/zip-extract.ts (skill artifacts): structural failure is
|
|
21
|
-
* NON-FATAL — both editions' push gates validated every artifact
|
|
22
|
-
* with a central-directory-based reader before storage, so a defect
|
|
23
|
-
* here can only be a truncated or corrupted download.
|
|
24
|
-
* - execute-deep-agent/attachment-injector.ts (user attachments):
|
|
25
|
-
* structural failure is FAIL-HARD — the input is an untrusted
|
|
26
|
-
* upload and nothing upstream vouched for it.
|
|
27
|
-
*
|
|
28
|
-
* ZIP64 is deliberately unsupported: both consumers cap input far below
|
|
29
|
-
* every ZIP64 threshold (skill push gates: 100MB / 10,000 files;
|
|
30
|
-
* attachment uploads: 10MB).
|
|
31
|
-
*
|
|
32
|
-
* Throws plain `Error`s on structural defects (no valid EOCD, a central
|
|
33
|
-
* directory or local header record that does not parse, a payload slice
|
|
34
|
-
* that runs past the end of the buffer); consumers translate those into
|
|
35
|
-
* their own error models.
|
|
36
|
-
*/
|
|
37
|
-
export interface ZipStructuralEntry {
|
|
38
|
-
/** Entry path exactly as recorded in the central directory. */
|
|
39
|
-
readonly name: string;
|
|
40
|
-
readonly isDirectory: boolean;
|
|
41
|
-
readonly compressionMethod: number;
|
|
42
|
-
/**
|
|
43
|
-
* The central directory's declared uncompressed size — authoritative for
|
|
44
|
-
* pre-extraction accounting (e.g. ZIP-bomb budgeting), but still a
|
|
45
|
-
* *declaration*: a consumer that distrusts its input must enforce it
|
|
46
|
-
* against the actual decompressed output.
|
|
47
|
-
*/
|
|
48
|
-
readonly uncompressedSize: number;
|
|
49
|
-
/** The entry's raw payload slice (a view, not a copy) of the archive buffer. */
|
|
50
|
-
readonly compressedData: Uint8Array;
|
|
51
|
-
}
|
|
52
|
-
/** Fixed size of the EOCD record, excluding the variable-length comment. */
|
|
53
|
-
export declare const EOCD_MIN_SIZE = 22;
|
|
54
|
-
/**
|
|
55
|
-
* Enumerate the archive's entries from its central directory.
|
|
56
|
-
*
|
|
57
|
-
* Throws on any structural defect: no valid EOCD, a central directory or
|
|
58
|
-
* local header record that doesn't parse, or a payload slice that runs
|
|
59
|
-
* past the end of the buffer.
|
|
60
|
-
*/
|
|
61
|
-
export declare function parseZipStructure(data: Uint8Array): ZipStructuralEntry[];
|