@sema-agent/core 5.49.0 → 5.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/agents/roster-store.js +4 -1
  3. package/dist/agents/send-message-tool.js +5 -5
  4. package/dist/agents/subagent.d.ts +6 -0
  5. package/dist/agents/subagent.js +142 -5
  6. package/dist/agents/teacher.js +4 -1
  7. package/dist/brain/anthropic.js +11 -20
  8. package/dist/brain/open-responses.js +6 -14
  9. package/dist/brain/openai.js +6 -18
  10. package/dist/brain/reasoning.d.ts +100 -8
  11. package/dist/brain/reasoning.js +39 -15
  12. package/dist/brain/request-params.d.ts +37 -1
  13. package/dist/brain/request-params.js +40 -2
  14. package/dist/core/auto-mode-prompt.js +9 -1
  15. package/dist/core/hooks.d.ts +24 -1
  16. package/dist/core/hooks.js +26 -4
  17. package/dist/core/mcp.d.ts +7 -1
  18. package/dist/core/mcp.js +64 -8
  19. package/dist/core/memory-engine/engine.d.ts +30 -1
  20. package/dist/core/memory-engine/engine.js +219 -18
  21. package/dist/core/memory-engine/layout.d.ts +43 -0
  22. package/dist/core/memory-engine/layout.js +59 -0
  23. package/dist/core/memory-engine/memory-backend-contract.js +87 -0
  24. package/dist/core/memory-engine/types.d.ts +13 -1
  25. package/dist/core/runner/assemble-result.d.ts +6 -0
  26. package/dist/core/runner/assemble-result.js +1 -1
  27. package/dist/core/runner/prepare-task.d.ts +15 -0
  28. package/dist/core/runner/prepare-task.js +104 -44
  29. package/dist/core/runner/runtask.d.ts +5 -1
  30. package/dist/core/runner/runtask.js +14 -7
  31. package/dist/core/task-registry-agent.js +9 -3
  32. package/dist/core/task-registry-shared.d.ts +6 -0
  33. package/dist/core/task-registry.js +4 -2
  34. package/dist/core/tool-policy.d.ts +37 -0
  35. package/dist/core/tool-policy.js +36 -3
  36. package/dist/core/tools.js +7 -0
  37. package/dist/core/types.d.ts +53 -1
  38. package/dist/engine/loop/agent-loop.js +95 -30
  39. package/dist/engine/loop/types.d.ts +32 -0
  40. package/dist/orchestration/run-workflow-tool.d.ts +12 -0
  41. package/dist/orchestration/run-workflow-tool.js +1 -1
  42. package/dist/orchestration/workflow-governance.d.ts +27 -0
  43. package/dist/orchestration/workflow-governance.js +13 -0
  44. package/dist/orchestration/workflow-primitives.d.ts +8 -1
  45. package/dist/orchestration/workflow-primitives.js +11 -3
  46. package/package.json +1 -1
@@ -1504,6 +1504,65 @@ export function recordChallengedHistory(controlDir, rows, now) {
1504
1504
  export function readChallengedHistory(controlDir) {
1505
1505
  return coerceChallengedHistory(readSidecarJson(controlDir, CHALLENGED_HISTORY_FILE));
1506
1506
  }
1507
+ export const PROJECTION_DEBTS_FILE = "projection-debts.json";
1508
+ function coerceProjectionDebts(raw) {
1509
+ if (raw === undefined)
1510
+ return { version: 1, rows: [] };
1511
+ const rec = raw;
1512
+ if (typeof rec !== "object" || rec === null || rec.version !== 1 || !Array.isArray(rec.rows)) {
1513
+ throw new ControlPlaneCorruptError("memory projection-debt ledger has the wrong shape — refusing (fail-closed; an unreadable debt account must not read as 'no debts')");
1514
+ }
1515
+ for (const r of rec.rows) {
1516
+ const row = r;
1517
+ if (typeof row !== "object" ||
1518
+ row === null ||
1519
+ typeof row.relPath !== "string" ||
1520
+ row.relPath.length === 0 ||
1521
+ row.relPath.startsWith("/") ||
1522
+ row.relPath.split(/[\\/]/).includes("..") ||
1523
+ typeof row.entryId !== "string" ||
1524
+ row.entryId.length === 0 ||
1525
+ typeof row.rev !== "string" ||
1526
+ row.rev.length === 0 ||
1527
+ typeof row.at !== "number" ||
1528
+ !Number.isFinite(row.at)) {
1529
+ throw new ControlPlaneCorruptError("memory projection-debt ledger: a row is malformed — refusing (fail-closed)");
1530
+ }
1531
+ }
1532
+ return rec;
1533
+ }
1534
+ export function stageProjectionDebts(controlDir, rows, now) {
1535
+ if (rows.length === 0)
1536
+ return { refused: [] };
1537
+ const at = now();
1538
+ return lockedStrictUpdate(controlDir, PROJECTION_DEBTS_FILE, "memory projection-debt ledger", coerceProjectionDebts, (rec) => {
1539
+ const byPath = new Map(rec.rows.map((r) => [r.relPath, r]));
1540
+ const refused = [];
1541
+ for (const r of rows) {
1542
+ const existing = byPath.get(r.relPath);
1543
+ if (existing !== undefined && existing.entryId !== r.entryId && existing.entryId !== r.replaces) {
1544
+ refused.push({ relPath: r.relPath, entryId: r.entryId });
1545
+ continue;
1546
+ }
1547
+ byPath.set(r.relPath, { relPath: r.relPath, entryId: r.entryId, rev: r.rev, at });
1548
+ }
1549
+ return { next: { version: 1, rows: [...byPath.values()] }, result: { refused } };
1550
+ });
1551
+ }
1552
+ export function settleProjectionDebts(controlDir, rows) {
1553
+ if (rows.length === 0)
1554
+ return;
1555
+ const drop = new Set(rows.map((r) => JSON.stringify([r.entryId, r.relPath])));
1556
+ lockedStrictUpdate(controlDir, PROJECTION_DEBTS_FILE, "memory projection-debt ledger", coerceProjectionDebts, (rec) => {
1557
+ const kept = rec.rows.filter((r) => !drop.has(JSON.stringify([r.entryId, r.relPath])));
1558
+ if (kept.length === rec.rows.length)
1559
+ return { result: undefined };
1560
+ return { next: { version: 1, rows: kept }, result: undefined };
1561
+ });
1562
+ }
1563
+ export function readProjectionDebts(controlDir) {
1564
+ return coerceProjectionDebts(readStrictSidecar(controlDir, PROJECTION_DEBTS_FILE, "memory projection-debt ledger")).rows;
1565
+ }
1507
1566
  export function writeAllSync(fd, data) {
1508
1567
  const buf = Buffer.from(data, "utf8");
1509
1568
  let written = 0;
@@ -426,6 +426,93 @@ export async function memoryBackendContract(hooks) {
426
426
  const flat = await b.search("same words", ["s1"], { limit: 3 });
427
427
  assert.deepStrictEqual(flat.map((h) => h.id), ["id-band-a-01", "id-band-b-01", "id-band-c-01"]);
428
428
  });
429
+ defer("design/336 §4 hold protocol (update form): mid-hold writes apply plainly (backend is hold-unaware); the release replay at the capture anchor is a reported conflict carrying currentRev; a clean release applies and the snapshot face answers its committed triple", async () => {
430
+ const b = await hooks.make();
431
+ const e1v0 = entry("id-hold-upd1", "s1", "held-clean", "committed v0");
432
+ assert.deepStrictEqual((await b.applyPatches([{ op: "add", id: e1v0.id, entry: e1v0 }])).conflicts, []);
433
+ const e1rel = entry("id-hold-upd1", "s1", "held-clean", "the released capture");
434
+ const rep1 = await b.applyPatches([{ op: "update", id: e1v0.id, entry: e1rel, baseRev: e1v0.rev }]);
435
+ assert.deepStrictEqual(rep1.conflicts, [], "a release onto an untouched anchor applies");
436
+ const snapFace = b.committedSnapshotOf;
437
+ if (typeof snapFace === "function") {
438
+ const snap = await snapFace.call(b, e1v0.id);
439
+ assert.strictEqual(snap.state, "row");
440
+ if (snap.state === "row") {
441
+ assert.strictEqual(snap.rev, e1rel.rev, "the snapshot tracks the UPDATE — not the add-time state");
442
+ assert.strictEqual(snap.binding.state, "bound");
443
+ if (snap.binding.state === "bound") {
444
+ assert.strictEqual(snap.binding.scope, "s1");
445
+ assert.strictEqual(snap.binding.slug, "held-clean");
446
+ }
447
+ }
448
+ }
449
+ const rep1b = await b.applyPatches([{ op: "update", id: e1v0.id, entry: e1rel, baseRev: e1v0.rev }]);
450
+ assert.strictEqual(rep1b.applied.length, 0);
451
+ assert.strictEqual(rep1b.conflicts.length, 1);
452
+ assert.strictEqual(rep1b.conflicts[0]?.currentRev, e1rel.rev, "currentRev is the retry's idempotency evidence");
453
+ const e2v0 = entry("id-hold-upd2", "s1", "held-raced", "committed v0");
454
+ assert.deepStrictEqual((await b.applyPatches([{ op: "add", id: e2v0.id, entry: e2v0 }])).conflicts, []);
455
+ const mid = entry("id-hold-upd2", "s1", "held-raced", "mid-hold edit by another writer");
456
+ const repMid = await b.applyPatches([{ op: "update", id: e2v0.id, entry: mid, baseRev: e2v0.rev }]);
457
+ assert.deepStrictEqual(repMid.conflicts, [], "a mid-hold write applies plainly — the backend is hold-unaware");
458
+ assert.strictEqual((await b.getByIds([e2v0.id])).length, 1, "the held id keeps serving reads during the pendency window");
459
+ const e2rel = entry("id-hold-upd2", "s1", "held-raced", "the captured bytes");
460
+ const rep2 = await b.applyPatches([{ op: "update", id: e2v0.id, entry: e2rel, baseRev: e2v0.rev }]);
461
+ assert.strictEqual(rep2.applied.length, 0, "the release never blind-writes over a mid-hold winner");
462
+ assert.strictEqual(rep2.conflicts.length, 1);
463
+ assert.strictEqual(rep2.conflicts[0]?.baseRev, e2v0.rev);
464
+ assert.strictEqual(rep2.conflicts[0]?.currentRev, mid.rev, "the conflict names the winner — the dispose('conflict') evidence");
465
+ assert.strictEqual((await b.getByIds([e2v0.id]))[0]?.body.replace(/\s+$/, ""), "mid-hold edit by another writer", "the winner's content survives");
466
+ });
467
+ defer("design/336 §4 hold protocol (add form): a guard-absent release applies onto a free id; the crash retry re-applies idempotently (one row, original slug); a concurrent claim answers the guarded conflict with currentRev and survives", async () => {
468
+ const b = await hooks.make();
469
+ const rel = entry("id-hold-add1", "s1", "held-note", "instruction body");
470
+ assert.deepStrictEqual((await b.applyPatches([{ op: "add", id: rel.id, entry: rel, guard: "absent" }])).conflicts, []);
471
+ const rep2 = await b.applyPatches([{ op: "add", id: rel.id, entry: rel, guard: "absent" }]);
472
+ assert.deepStrictEqual(rep2.conflicts, [], "the crash retry of a committed release re-applies idempotently");
473
+ assert.deepStrictEqual(rep2.applied, [{ op: "add", id: rel.id, slug: "held-note" }]);
474
+ assert.deepStrictEqual((await b.listHeaders(["s1"])).filter((h) => h.id === rel.id).map((h) => h.slug), ["held-note"], "ONE row at the ORIGINAL slug — never a suffixed duplicate");
475
+ const claimed = entry("id-hold-add2", "s1", "claimed-note", "the claimant's content");
476
+ assert.deepStrictEqual((await b.applyPatches([{ op: "add", id: claimed.id, entry: claimed }])).conflicts, []);
477
+ const rel2 = entry("id-hold-add2", "s1", "claimed-note", "the captured bytes");
478
+ const rep3 = await b.applyPatches([{ op: "add", id: rel2.id, entry: rel2, guard: "absent" }]);
479
+ assert.strictEqual(rep3.applied.length, 0);
480
+ assert.strictEqual(rep3.conflicts.length, 1);
481
+ assert.match(rep3.conflicts[0]?.reason ?? "", /add_guard_absent_conflict/);
482
+ assert.strictEqual(rep3.conflicts[0]?.currentRev, claimed.rev, "currentRev distinguishes 'claimed by another writer' from the idempotent retry");
483
+ assert.strictEqual((await b.getByIds([claimed.id]))[0]?.body.replace(/\s+$/, ""), "the claimant's content", "the claimant survives");
484
+ });
485
+ defer("design/336: the whitewash judgment precedes CAS — an origin/trust strip with a STALE baseRev still answers the malformed refusal, never the rev-mismatch conflict", async () => {
486
+ const b = await hooks.make();
487
+ const origin = { taint: "external", cause: "observed", at: 1700000000900 };
488
+ const m0 = { id: "id-prec-0001", scope: "s1", slug: "prec-marked", frontmatter: { name: "prec", origin }, body: "b0", rev: "" };
489
+ m0.rev = computeEntryRev(m0);
490
+ assert.deepStrictEqual((await b.applyPatches([{ op: "add", id: m0.id, entry: m0 }])).conflicts, []);
491
+ const m1 = { ...m0, body: "b1", rev: "" };
492
+ m1.rev = computeEntryRev(m1);
493
+ assert.deepStrictEqual((await b.applyPatches([{ op: "update", id: m0.id, entry: m1, baseRev: m0.rev }])).conflicts, []);
494
+ const stripped = { id: m0.id, scope: "s1", slug: "prec-marked", frontmatter: { name: "prec" }, body: "b2", rev: "" };
495
+ stripped.rev = computeEntryRev(stripped);
496
+ const rep = await b.applyPatches([{ op: "update", id: m0.id, entry: stripped, baseRev: m0.rev }]);
497
+ assert.strictEqual(rep.applied.length, 0);
498
+ assert.strictEqual(rep.conflicts.length, 1);
499
+ assert.match(rep.conflicts[0]?.reason ?? "", /malformed patch refused/, "the stronger refusal answers — a CAS conflict here would hide the whitewash");
500
+ const prov = { kind: "repo_file", path: "AGENTS.md", contentHash: "sha256:cccc3333", ingestedAt: 1700000001000 };
501
+ const p0 = { id: "id-prec-0002", scope: "s1", slug: "prec-repo", frontmatter: { name: "prec-repo", provenance: prov, trust: "untrusted" }, body: "p0", rev: "" };
502
+ p0.rev = computeEntryRev(p0);
503
+ assert.deepStrictEqual((await b.applyPatches([{ op: "add", id: p0.id, entry: p0 }])).conflicts, []);
504
+ const p1 = { ...p0, body: "p1", rev: "" };
505
+ p1.rev = computeEntryRev(p1);
506
+ assert.deepStrictEqual((await b.applyPatches([{ op: "update", id: p0.id, entry: p1, baseRev: p0.rev }])).conflicts, []);
507
+ const strippedTrust = { id: p0.id, scope: "s1", slug: "prec-repo", frontmatter: { name: "prec-repo", provenance: prov }, body: "p2", rev: "" };
508
+ strippedTrust.rev = computeEntryRev(strippedTrust);
509
+ const rep2 = await b.applyPatches([{ op: "update", id: p0.id, entry: strippedTrust, baseRev: p0.rev }]);
510
+ assert.strictEqual(rep2.applied.length, 0);
511
+ assert.strictEqual(rep2.conflicts.length, 1);
512
+ assert.match(rep2.conflicts[0]?.reason ?? "", /malformed patch refused/);
513
+ assert.deepStrictEqual((await b.getByIds([m0.id]))[0]?.frontmatter.origin, origin);
514
+ assert.strictEqual((await b.getByIds([p0.id]))[0]?.frontmatter.trust, "untrusted");
515
+ });
429
516
  defer("consolidation cursor round-trips per scope; unset → undefined", async () => {
430
517
  const b = await hooks.make();
431
518
  assert.strictEqual(await b.getConsolidationCursor("s1"), undefined);
@@ -254,6 +254,18 @@ export interface MemoryBackend {
254
254
  * earlier delete in the same batch does not blank the baseline). The one legal exit is the
255
255
  * COMMITTED tombstone: after a delete commits, the marker's life ends with the id (a fresh id —
256
256
  * or the same id in a LATER batch — starts an unmarked life; the engine re-judges its session);
257
+ * - PRECEDENCE (FAM-1 #5, every whitewash spelling): the malformed judgment answers BEFORE
258
+ * guard/CAS arithmetic — a strip riding a stale `baseRev` (or a guard conflict) still answers
259
+ * /malformed patch refused/, never the ordinary rev-mismatch conflict (a whitewash is illegal
260
+ * at ANY rev, and the weaker conflict would tell the caller's ladder to rebase and retry it);
261
+ * - design/336 §4 hold protocol: the backend is hold-UNAWARE — instruction holds live on the
262
+ * ENGINE's control plane, so a held id keeps serving reads and ordinary add/update patches for
263
+ * it apply plainly (no queueing, no refusal). The settlement-period write interaction resolves
264
+ * at release time through the clauses above: the release replays the capture-time anchor
265
+ * (update+`baseRev` / `guard: "absent"` add), a mid-hold winner surfaces as the REPORTED
266
+ * conflict carrying `currentRev` (the engine's conflict-disposition evidence), and the
267
+ * crash-idempotent retry reads the optional committed-snapshot face (post-update rev + bound
268
+ * projection) — all asserted by the suite's two hold-protocol cases;
257
269
  * - conflicts are per-patch and non-fatal: the rest of the batch still applies.
258
270
  */
259
271
  applyPatches(patches: readonly NotePatch[]): Promise<PatchReport>;
@@ -338,7 +350,7 @@ export interface MemorySessionHandle {
338
350
  adoptionRestricted?: boolean;
339
351
  }
340
352
  /** Stable rejection codes a harvest gate can produce (model-visible gate events — 镜头 I). */
341
- export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "polluted" | "restricted_divergence" | "invalid";
353
+ export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "deferred" | "polluted" | "restricted_divergence" | "invalid";
342
354
  /** One rejected file: path (relative to the memory dir), stable code, and a model-readable reason. */
343
355
  export interface HarvestRejection {
344
356
  path: string;
@@ -112,6 +112,12 @@ export interface ResultFlags {
112
112
  * Pure pass-through — assembly neither adds nor filters (a rewind that FAILED never reaches here; it
113
113
  * throws at prepare and lands in the `threw` slot as a terminal errorCode). */
114
114
  rewindNotes?: TaskResult["rewindNotes"];
115
+ /** The run's final turn was halted by a person's BARE rejection of a tool call (the parent-thread
116
+ * control-flow boundary) — echoed on `TaskResult.haltedOnUserRejection`. Pure pass-through on
117
+ * every terminal: the fact is about the leg that ran, whatever terminal it reached (on the normal
118
+ * path the terminal is `completed`, and this is what tells that completion apart from a natural
119
+ * one — the model did not finish; the person stopped it and the run awaits their direction). */
120
+ haltedOnUserRejection?: boolean;
115
121
  /** design/174 final-round: call ids of answered-but-never-collected questions, echoed on
116
122
  * `TaskResult.strandedHumanAnswers`. Pure pass-through; empty/absent ⇒ the field is omitted. The
117
123
  * optional `onError` alert is NOT the disclosure — this mandatory result face is. */
@@ -165,5 +165,5 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
165
165
  void _internalCompaction;
166
166
  if (flags.unpricedSpend)
167
167
  delete publicStats.costMicroUsd;
168
- return { taskId, sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: flags.remoteEnvFailures } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
168
+ return { taskId, sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: flags.remoteEnvFailures } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
169
169
  }
@@ -200,7 +200,22 @@ export interface Prepared {
200
200
  approvalSettlement: Map<string, {
201
201
  settledBy?: import("../tool-policy.js").ApprovalSettledBy;
202
202
  approver?: string;
203
+ resolution?: import("../tool-policy.js").AskDenyResolution;
203
204
  }>;
205
+ /**
206
+ * The parent-thread human-rejection halt fact (see `maybeHumanRejectionHalt`): present from the
207
+ * moment a bare human rejection halts the turn's batch until the run ends or the NEXT provider
208
+ * request begins (user input continuing the run clears it). Consumers: the runner's stop gate
209
+ * (suppress natural-end pushback / final-verify injection — engine continuations must not restart
210
+ * a run a person just stopped), the turn-boundary engine steers (same reason), and the result
211
+ * stamp (`TaskResult.haltedOnUserRejection` — a human-halted run must not read as an ordinary
212
+ * completion). Engine-owned sideband, same trust reasoning as `approvalSettlement` above.
213
+ */
214
+ batchHaltRef: {
215
+ current?: {
216
+ rejectedToolCallId: string;
217
+ };
218
+ };
204
219
  /** Summed usage of nested sub-runs (sub-agents) spawned by this task's tools. */
205
220
  nestedStats: NestedUsageAccum;
206
221
  /** RB-430-a: prepare-time rewind disclosures (conversation-only branch / no snapshot backend / no file
@@ -20,7 +20,7 @@ import { createSubagentWorktreeHelper, forkGovernanceDenial, resolveDelegationEn
20
20
  import { createAgentTranscriptTool, AGENT_TRANSCRIPT_TOOL_NAME } from "../../agents/agent-transcript-tool.js";
21
21
  import { createSendMessageTool, SEND_MESSAGE_TOOL_NAME } from "../../agents/send-message-tool.js";
22
22
  import { SubagentRetainLedger } from "../../agents/retain-ledger.js";
23
- import { askApproverIdentity, checkToolPolicyProjection, combinePolicies, constraintChainDigest, constraintChainEntryOf, isApprovalSettledBy, screenApproverAttribution, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, describeThrown, refuseOutOfContractDecision, resolveAsk, toolPolicyNameSets, tryCloneArgs } from "../tool-policy.js";
23
+ import { askApproverIdentity, checkToolPolicyProjection, combinePolicies, constraintChainDigest, constraintChainEntryOf, isApprovalSettledBy, isAskDenyResolution, screenApproverAttribution, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, describeThrown, refuseOutOfContractDecision, resolveAsk, toolPolicyNameSets, tryCloneArgs } from "../tool-policy.js";
24
24
  const PERSISTED_RULE_TOOL = "Bash";
25
25
  import { findAdmittingRule, suggestRulesForCommand } from "../permission-rule-model.js";
26
26
  import { ActiveSkillScope, createActiveSkillScopePolicy } from "./active-skill-scope.js";
@@ -277,6 +277,19 @@ function raceAbort(p, signal, onAbort) {
277
277
  p.then(finish, () => finish(onAbort()));
278
278
  });
279
279
  }
280
+ function mcpRevocationWiring(deps) {
281
+ if (deps.mcpRevocations === undefined)
282
+ return undefined;
283
+ const ledger = deps.mcpRevocations;
284
+ return {
285
+ isRevoked: (name) => ledger.isRevoked(name),
286
+ onProbeFailure: (e) => deliverEngineNotice(deps.onNotice, {
287
+ code: "mcp.revocation_probe_failed",
288
+ message: `the mcpRevocations.isRevoked probe threw — MCP dispatch fails OPEN (no server treated as revoked) until the probe recovers: ${e instanceof Error ? e.message : String(e)}`,
289
+ detail: { message: e instanceof Error ? e.message : String(e) },
290
+ }),
291
+ };
292
+ }
280
293
  async function forgetQuietly(sessions, sessionId) {
281
294
  try {
282
295
  if (sessions.forget)
@@ -301,11 +314,72 @@ function screenGateSettlement(result, settling) {
301
314
  if (attribution.defect !== undefined) {
302
315
  defects.push(`a tool-gate settlement reported an attribution this engine refuses: ${attribution.defect}; the frame carries no approver`);
303
316
  }
317
+ const reportedResolution = result.resolution;
318
+ let resolution;
319
+ if (reportedResolution !== undefined) {
320
+ if (!isAskDenyResolution(reportedResolution) || !settling) {
321
+ defects.push(`a tool-gate settlement reported a deny resolution "${String(reportedResolution)}" on ${settling ? "a blocked" : "an executing"} call — ` +
322
+ `it is one of the closed ask-deny vocabulary and only a BLOCKED call can carry one; the frame carries no resolution`);
323
+ }
324
+ else
325
+ resolution = reportedResolution;
326
+ }
304
327
  const record = {
305
328
  ...(settledBy !== undefined ? { settledBy } : {}),
306
329
  ...(attribution.approver !== undefined ? { approver: attribution.approver } : {}),
330
+ ...(resolution !== undefined ? { resolution } : {}),
307
331
  };
308
- return { ...(settledBy !== undefined || attribution.approver !== undefined ? { record } : {}), defects };
332
+ return { ...(settledBy !== undefined || attribution.approver !== undefined || resolution !== undefined ? { record } : {}), defects };
333
+ }
334
+ function maybeHumanRejectionHalt(input) {
335
+ if (!input.bare || input.settledBy !== "human" || input.isDelegatedChild)
336
+ return undefined;
337
+ return {
338
+ reason: `This tool call was NOT executed: the user rejected the "${input.toolName}" tool call in the same ` +
339
+ `assistant message, which stops the rest of the batch. Nothing was run for this call — ` +
340
+ `re-issue it after the user's direction only if it is still needed.`,
341
+ details: {
342
+ error: "gate.batch_halted",
343
+ code: "gate.batch_halted",
344
+ rejectedToolCallId: input.toolCallId,
345
+ rejectedToolName: input.toolName,
346
+ },
347
+ };
348
+ }
349
+ const sanitizePreview = (node, depth = 0) => {
350
+ if (depth > 6)
351
+ return undefined;
352
+ if (typeof node === "string") {
353
+ return node.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "\u2400");
354
+ }
355
+ if (node === null || typeof node !== "object")
356
+ return node;
357
+ if (Array.isArray(node))
358
+ return node.map((v) => sanitizePreview(v, depth + 1));
359
+ const out = {};
360
+ for (const [k, v] of Object.entries(node)) {
361
+ out[sanitizePreview(k, depth + 1)] = sanitizePreview(v, depth + 1);
362
+ }
363
+ return out;
364
+ };
365
+ function resolveApprovalPreview(tools, toolName, args) {
366
+ const t = tools.find((x) => x.name === toolName || (x.aliases?.includes(toolName) ?? false));
367
+ if (t?.approvalPreview === undefined)
368
+ return undefined;
369
+ try {
370
+ const raw = t.approvalPreview(args);
371
+ if (raw === undefined)
372
+ return undefined;
373
+ const bytes = JSON.stringify(raw);
374
+ if (bytes === undefined)
375
+ return undefined;
376
+ if (bytes.length > 16_384)
377
+ return { truncated: true, note: `approval preview exceeded 16KiB (${bytes.length} chars serialized)` };
378
+ return sanitizePreview(raw);
379
+ }
380
+ catch {
381
+ return undefined;
382
+ }
309
383
  }
310
384
  function inheritedAskRuleEvidence(deps) {
311
385
  const org = deps.permissionRuleOrg === undefined ? "not_wired" : "not_adjudicated";
@@ -1030,6 +1104,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1030
1104
  parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
1031
1105
  parentReadFace: () => carrierReadFace(),
1032
1106
  parentReadDenyPatterns: () => (readDenyAdditionsNormalized.length > 0 ? readDenyAdditionsNormalized : undefined),
1107
+ ...(spec.handsReadOnly === true ? { parentHandsReadOnly: true } : {}), ...(spec.interactiveTools === false ? { parentInteractiveTools: false } : {}),
1033
1108
  onNotice: deps.onNotice,
1034
1109
  parentCheckpointStoreDisabled: spec.checkpointStore === null,
1035
1110
  parentCenterArtifactDigest: () => centerAdoption?.artifact.artifactDigest,
@@ -1101,7 +1176,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1101
1176
  tools.push(createOutputTool(outputRef, spec.outputSchema, compiled.strict ? compiled.modelSchema : undefined));
1102
1177
  }
1103
1178
  mcp = lockedPreflight.mcp?.length
1104
- ? await materializeMcpTools(lockedPreflight.mcp, spec.principal, deps.onElicit, deps.mcpImageResizer, { reminderMark, counts: reminderDisclosureCounts })
1179
+ ? await materializeMcpTools(lockedPreflight.mcp, spec.principal, deps.onElicit, deps.mcpImageResizer, { reminderMark, counts: reminderDisclosureCounts }, mcpRevocationWiring(deps))
1105
1180
  : { tools: [], toolAxes: [], warnings: [], serverInstructions: [], instructionsDelta: { pendingAdds: [], pendingRemovals: [] }, droppedTools: [], statuses: [], refresh: async () => [], dispose: async () => { } };
1106
1181
  for (const w of mcp.warnings)
1107
1182
  deps.onError?.(w, { phase: "mcp", sessionId });
@@ -3277,6 +3352,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3277
3352
  const preToolContexts = new Map();
3278
3353
  const blockedToolCalls = new Set();
3279
3354
  const approvalSettlement = new Map();
3355
+ const humanBareRejections = new Set();
3356
+ const batchHaltRef = {};
3280
3357
  const blockedTracked = Boolean(hooks?.postToolUse || hooks?.preToolUse || hooks?.postToolUseFailure || hooks?.postToolBatch);
3281
3358
  const restoreSurfaceGap = ownedEnv !== undefined && isRemoteExecutionEnv(ownedEnv) && ownedEnv.capabilities.suspendable ? missingRestoreSurface(ownedEnv) : [];
3282
3359
  const incompleteSuspendAdapter = restoreSurfaceGap.length > 0 ? restoreSurfaceGap : undefined;
@@ -3380,41 +3457,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3380
3457
  message: "policy check aborted (task timed out or cancelled)",
3381
3458
  }))
3382
3459
  : undefined;
3383
- const sanitizePreview = (node, depth = 0) => {
3384
- if (depth > 6)
3385
- return undefined;
3386
- if (typeof node === "string") {
3387
- return node.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "\u2400");
3388
- }
3389
- if (node === null || typeof node !== "object")
3390
- return node;
3391
- if (Array.isArray(node))
3392
- return node.map((v) => sanitizePreview(v, depth + 1));
3393
- const out = {};
3394
- for (const [k, v] of Object.entries(node)) {
3395
- out[sanitizePreview(k, depth + 1)] = sanitizePreview(v, depth + 1);
3396
- }
3397
- return out;
3398
- };
3399
- const approvalPreviewOf = (toolName, args) => {
3400
- const t = tools.find((x) => x.name === toolName || (x.aliases?.includes(toolName) ?? false));
3401
- if (t?.approvalPreview === undefined)
3402
- return undefined;
3403
- try {
3404
- const raw = t.approvalPreview(args);
3405
- if (raw === undefined)
3406
- return undefined;
3407
- const bytes = JSON.stringify(raw);
3408
- if (bytes === undefined)
3409
- return undefined;
3410
- if (bytes.length > 16_384)
3411
- return { truncated: true, note: `approval preview exceeded 16KiB (${bytes.length} chars serialized)` };
3412
- return sanitizePreview(raw);
3413
- }
3414
- catch {
3415
- return undefined;
3416
- }
3417
- };
3460
+ const approvalPreviewOf = (toolName, args) => resolveApprovalPreview(tools, toolName, args);
3418
3461
  const resolveAskBound = async (decision, req) => {
3419
3462
  if (inheritedUnavailableAsks.delete(req.toolCallId)) {
3420
3463
  return {
@@ -3475,6 +3518,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3475
3518
  ...(toolArg !== undefined ? { toolArg } : {}),
3476
3519
  });
3477
3520
  }
3521
+ if (resolved.action === "deny" && resolved.settledBy === "human" && resolved.humanRefusalNote !== true) {
3522
+ humanBareRejections.add(req.toolCallId);
3523
+ }
3478
3524
  return resolved;
3479
3525
  };
3480
3526
  const hooksWithPermissionDenied = hooks?.permissionDenied ? hooks : undefined;
@@ -4183,6 +4229,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4183
4229
  harness.on("tool_call", async (e) => {
4184
4230
  blockedToolCalls.delete(e.toolCallId);
4185
4231
  inheritedAskGrants.delete(e.toolCallId);
4232
+ humanBareRejections.delete(e.toolCallId);
4186
4233
  {
4187
4234
  const complianceDeny = complianceCallDenial(complianceDenies, e.toolName);
4188
4235
  if (complianceDeny !== undefined) {
@@ -4209,6 +4256,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4209
4256
  };
4210
4257
  }
4211
4258
  let result;
4259
+ let humanBareRejection = false;
4212
4260
  try {
4213
4261
  result = await runToolGate({
4214
4262
  onNotifyError: (f) => emitTrace(deps.tracer, () => ({ kind: "observer.notify_failed", version: 1, taskId: spec.taskId ?? sessionId, site: f.site, message: f.error.message, ts: Date.now() })),
@@ -4280,6 +4328,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4280
4328
  inheritedUnavailableAsks.delete(e.toolCallId);
4281
4329
  inheritedAskGrants.delete(e.toolCallId);
4282
4330
  foldAskClasses.delete(e.toolCallId);
4331
+ humanBareRejection = humanBareRejections.delete(e.toolCallId);
4283
4332
  }
4284
4333
  const ancestorAdmitted = ancestorSandboxAdmissions.get(e.toolCallId);
4285
4334
  ancestorSandboxAdmissions.delete(e.toolCallId);
@@ -4298,11 +4347,21 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4298
4347
  deps.onError?.(new Error(defect), { phase: "config", sessionId });
4299
4348
  if (settlement.record !== undefined)
4300
4349
  approvalSettlement.set(e.toolCallId, settlement.record);
4301
- return result.block
4302
- ? { block: true, reason: result.reason }
4303
- : result.updatedInput !== undefined
4304
- ? { updatedInput: result.updatedInput }
4305
- : undefined;
4350
+ if (result.block) {
4351
+ const haltRemaining = maybeHumanRejectionHalt({
4352
+ bare: humanBareRejection,
4353
+ settledBy: result.settledBy,
4354
+ isDelegatedChild: delegation.isDelegatedChild === true,
4355
+ toolName: e.toolName,
4356
+ toolCallId: e.toolCallId,
4357
+ });
4358
+ if (haltRemaining !== undefined) {
4359
+ batchHaltRef.current = { rejectedToolCallId: e.toolCallId };
4360
+ return { block: true, reason: result.reason, haltRemaining };
4361
+ }
4362
+ return { block: true, reason: result.reason };
4363
+ }
4364
+ return result.updatedInput !== undefined ? { updatedInput: result.updatedInput } : undefined;
4306
4365
  });
4307
4366
  }
4308
4367
  }
@@ -4360,6 +4419,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4360
4419
  const guardAt = guardBudget(model);
4361
4420
  const charsPerToken = model.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN;
4362
4421
  harness.on("context", async ({ messages }) => {
4422
+ batchHaltRef.current = undefined;
4363
4423
  const healed = dropEmptyFailureAssistants(messages);
4364
4424
  const capped = await capAggregateToolResults(healed, {
4365
4425
  store: offloadStore,
@@ -4624,7 +4684,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4624
4684
  const effectiveReadFaceObserved = carrierReadFace();
4625
4685
  const effectiveReadDenyObserved = readDenyAdditionsNormalized.length > 0 ? readDenyAdditionsNormalized.map((e) => ({ ...e })) : undefined;
4626
4686
  const preparedHolder = {};
4627
- const buildPrepared = () => ({ harness, session, sessionId, reminderMark, reminderDisclosureCounts, taskRootPath: taskRootFinal, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, approvalSettlement, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), ...(effectiveReadFaceObserved !== undefined ? { effectiveReadFace: effectiveReadFaceObserved } : {}), ...(effectiveReadDenyObserved !== undefined ? { effectiveReadDenyPatterns: effectiveReadDenyObserved } : {}), effectiveMemoryScopes: memoryEffectiveScopes, cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), ...(permissionRuleOrgLane !== undefined ? { permissionRuleOrg: permissionRuleOrgLane } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, deferDirectCall, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, gitStatusRef, listBackgroundTasks, hookIdentity, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
4687
+ const buildPrepared = () => ({ harness, session, sessionId, reminderMark, reminderDisclosureCounts, taskRootPath: taskRootFinal, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, approvalSettlement, batchHaltRef, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), ...(effectiveReadFaceObserved !== undefined ? { effectiveReadFace: effectiveReadFaceObserved } : {}), ...(effectiveReadDenyObserved !== undefined ? { effectiveReadDenyPatterns: effectiveReadDenyObserved } : {}), effectiveMemoryScopes: memoryEffectiveScopes, cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), ...(permissionRuleOrgLane !== undefined ? { permissionRuleOrg: permissionRuleOrgLane } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, deferDirectCall, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, gitStatusRef, listBackgroundTasks, hookIdentity, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
4628
4688
  const prepared = buildPrepared();
4629
4689
  preparedHolder.current = prepared;
4630
4690
  return prepared;
@@ -84,7 +84,10 @@ settledBy?: ApprovalSettledBy,
84
84
  /** design/252 G-7 — WHOSE settlement, from the same caller and the same channel as `settledBy`, and
85
85
  * for the same reason it is a parameter: an attribution read out of a tool's own result would let a
86
86
  * tool name the person who approved it. Omitted ⇒ this call's settlement named nobody. */
87
- approver?: string): {
87
+ approver?: string,
88
+ /** The ask resolver's deny-arm classification — same caller, same engine-owned channel and the same
89
+ * never-derived-from-`result` posture as the two above. Omitted ⇒ not an ask-resolution deny. */
90
+ resolution?: import("../tool-policy.js").AskDenyResolution): {
88
91
  output?: unknown;
89
92
  truncated?: boolean;
90
93
  totalChars?: number;
@@ -92,6 +95,7 @@ approver?: string): {
92
95
  errorCode?: string;
93
96
  settledBy?: ApprovalSettledBy;
94
97
  approver?: string;
98
+ resolution?: import("../tool-policy.js").AskDenyResolution;
95
99
  };
96
100
  /**
97
101
  * scan-1/A1 — the BODY of the synthetic `tool_end` that closes a reconcile-recovered orphan. ONE
@@ -141,7 +141,7 @@ function resumeDecisionWasNegative(resume) {
141
141
  }
142
142
  const DEFERRED_REISSUE = "[DEFERRED] This tool call shared a batch with a call that suspended for durable approval, so it was " +
143
143
  "NOT executed on resume. If you still need it, issue it again now.";
144
- function toolEndBodyFrom(result, isError, settledBy, approver) {
144
+ function toolEndBodyFrom(result, isError, settledBy, approver, resolution) {
145
145
  const o = toolOutputFrom(result);
146
146
  const st = structuredFrom(result);
147
147
  const det = isError ? result?.details : undefined;
@@ -154,6 +154,7 @@ function toolEndBodyFrom(result, isError, settledBy, approver) {
154
154
  ...(typeof code === "string" ? { errorCode: code } : {}),
155
155
  ...(settledBy !== undefined ? { settledBy } : {}),
156
156
  ...(approver !== undefined ? { approver } : {}),
157
+ ...(resolution !== undefined ? { resolution } : {}),
157
158
  };
158
159
  }
159
160
  export function reconciledToolEndBody(orphan) {
@@ -487,6 +488,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
487
488
  rs.counters.approachNoticesSent < LIMIT_APPROACH_DEFAULT_THRESHOLDS.length &&
488
489
  prepared.suspendForResource === undefined &&
489
490
  rs.turn.lastTurnHadToolCalls &&
491
+ prepared.batchHaltRef.current === undefined &&
490
492
  !boundarySteered &&
491
493
  !prepared.abortController.signal.aborted) {
492
494
  const thresholds = approachCfg?.at ?? LIMIT_APPROACH_DEFAULT_THRESHOLDS;
@@ -534,7 +536,10 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
534
536
  }
535
537
  emitTrace(rs.telemetry.tracer, () => ({ kind: "review.dropped", version: 1, taskId: rs.telemetry.taskId, ts: Date.now() }));
536
538
  }
537
- if (prepared.lspDiagnostics && !prepared.lspDiagnostics.registry.isEmpty() && !prepared.abortController.signal.aborted) {
539
+ if (prepared.lspDiagnostics &&
540
+ !prepared.lspDiagnostics.registry.isEmpty() &&
541
+ !prepared.abortController.signal.aborted &&
542
+ prepared.batchHaltRef.current === undefined) {
538
543
  const files = prepared.lspDiagnostics.registry.drain(prepared.lspDiagnostics.runIdent);
539
544
  if (files.length > 0) {
540
545
  queue.push({ type: "diagnostics", files, isNew: true, ...ident() });
@@ -550,6 +555,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
550
555
  rs.attach.sizeGuidelineState !== undefined ||
551
556
  rs.attach.attachState !== undefined) &&
552
557
  !boundarySteered &&
558
+ prepared.batchHaltRef.current === undefined &&
553
559
  rs.counters.finalVerifyInjections === 0 &&
554
560
  !prepared.abortController.signal.aborted) {
555
561
  const due = [];
@@ -780,7 +786,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
780
786
  }
781
787
  }
782
788
  }
783
- if (attachmentsPayload !== undefined || batchContextBlock !== undefined) {
789
+ if ((attachmentsPayload !== undefined || batchContextBlock !== undefined) && prepared.batchHaltRef.current === undefined) {
784
790
  const payload = attachmentsPayload !== undefined && batchContextBlock !== undefined ? `${attachmentsPayload}\n${batchContextBlock}` : (attachmentsPayload ?? batchContextBlock);
785
791
  void prepared.harness.steer(payload, { engineMinted: true }).catch(() => { });
786
792
  }
@@ -1293,7 +1299,7 @@ function makeHarnessHandlers(prepared, stats, rs, deps) {
1293
1299
  reduceToolEnd(rs.attach.attachState, det);
1294
1300
  }
1295
1301
  if (postToolBatchHook !== undefined) {
1296
- if (prepared.blockedToolCalls.delete(event.toolCallId)) {
1302
+ if (prepared.blockedToolCalls.delete(event.toolCallId) || event.notExecuted === true) {
1297
1303
  batchArgs?.delete(event.toolCallId);
1298
1304
  }
1299
1305
  else {
@@ -1338,7 +1344,7 @@ function makeHarnessHandlers(prepared, stats, rs, deps) {
1338
1344
  toolName: event.toolName,
1339
1345
  ...(toolLabels.get(event.toolName) !== undefined ? { label: toolLabels.get(event.toolName) } : {}),
1340
1346
  isError: event.isError,
1341
- ...toolEndBodyFrom(event.result, event.isError, settlement?.settledBy, settlement?.approver),
1347
+ ...toolEndBodyFrom(event.result, event.isError, settlement?.settledBy, settlement?.approver, settlement?.resolution),
1342
1348
  ...ident(),
1343
1349
  });
1344
1350
  announceWorkspaceMove();
@@ -2006,7 +2012,7 @@ export class Runner {
2006
2012
  };
2007
2013
  const unsubscribeTaskNotifications = taskNotificationQueue.subscribe((item) => {
2008
2014
  queue.push({ type: "task_notification", notification: item.payload, ...notificationIdent() });
2009
- if (notificationHarness) {
2015
+ if (notificationHarness && prepared.batchHaltRef.current === undefined) {
2010
2016
  const xml = renderTaskNotificationXml(item.payload);
2011
2017
  const deliver = notificationHarness.steer(xml, { provenance: "engine-note", enginePayload: item.payload });
2012
2018
  void deliver.then(() => item.onDisposition?.("queued"), () => {
@@ -2698,7 +2704,7 @@ export class Runner {
2698
2704
  if (stopHook || finalVerificationOn) {
2699
2705
  let consecutiveBlocks = 0;
2700
2706
  prepared.harness.setStopGate(async () => {
2701
- if (prepared.abortController.signal.aborted || prepared.suspendRef.token !== undefined)
2707
+ if (prepared.abortController.signal.aborted || prepared.suspendRef.token !== undefined || prepared.batchHaltRef.current !== undefined)
2702
2708
  return [];
2703
2709
  if (finalVerificationOn &&
2704
2710
  (rs.counters.finalVerifyInjections === 0 || (rs.counters.finalVerifyInjections === 1 && rs.counters.groundingSignalPreR9 && !rs.counters.groundingSignalPostR9)) &&
@@ -3480,6 +3486,7 @@ export class Runner {
3480
3486
  model: prepared.model.id,
3481
3487
  unpricedSpend: rs.telemetry.unpricedSpend,
3482
3488
  rewindNotes: prepared.rewindNotes,
3489
+ haltedOnUserRejection: prepared.batchHaltRef.current !== undefined,
3483
3490
  strandedHumanAnswers,
3484
3491
  remoteEnvFailures: prepared.remoteEnvFailures,
3485
3492
  effectiveReadFace: prepared.effectiveReadFace,