@sema-agent/core 5.55.0 → 5.57.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 (81) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/agents/send-message-tool.d.ts +11 -0
  3. package/dist/agents/send-message-tool.js +81 -13
  4. package/dist/agents/subagent.js +250 -89
  5. package/dist/agents/team.d.ts +10 -1
  6. package/dist/agents/team.js +1 -0
  7. package/dist/brain/anthropic.js +15 -5
  8. package/dist/brain/circuit-breaker.js +2 -1
  9. package/dist/brain/degrading.js +4 -1
  10. package/dist/brain/failover.js +16 -1
  11. package/dist/brain/open-responses.js +15 -5
  12. package/dist/brain/openai.js +16 -5
  13. package/dist/brain/request-params.d.ts +30 -27
  14. package/dist/brain/request-params.js +1 -7
  15. package/dist/brain/route-adjudicator.d.ts +190 -0
  16. package/dist/brain/route-adjudicator.js +189 -0
  17. package/dist/brain/route-conformance.d.ts +55 -0
  18. package/dist/brain/route-conformance.js +136 -0
  19. package/dist/brain/routing.js +8 -3
  20. package/dist/core/auto-compaction.d.ts +17 -4
  21. package/dist/core/auto-compaction.js +3 -0
  22. package/dist/core/context-edit.d.ts +55 -6
  23. package/dist/core/context-edit.js +12 -1
  24. package/dist/core/hooks.d.ts +293 -11
  25. package/dist/core/hooks.js +158 -11
  26. package/dist/core/human-input-projection.d.ts +20 -2
  27. package/dist/core/human-input-projection.js +9 -0
  28. package/dist/core/mcp.js +4 -4
  29. package/dist/core/memory-engine/engine.d.ts +15 -5
  30. package/dist/core/memory-engine/engine.js +3 -1
  31. package/dist/core/permission-rule-consent.d.ts +45 -0
  32. package/dist/core/permission-rule-consent.js +40 -11
  33. package/dist/core/permission-rule-model.d.ts +110 -75
  34. package/dist/core/permission-rule-model.js +61 -28
  35. package/dist/core/permission-rules.d.ts +23 -15
  36. package/dist/core/permission-rules.js +40 -31
  37. package/dist/core/runner/prepare-task.d.ts +8 -0
  38. package/dist/core/runner/prepare-task.js +66 -26
  39. package/dist/core/runner/runtask.d.ts +4 -1
  40. package/dist/core/runner/runtask.js +206 -21
  41. package/dist/core/runner/session-rule-policy.js +5 -5
  42. package/dist/core/scheduler.d.ts +5 -0
  43. package/dist/core/session-reconcile.d.ts +32 -0
  44. package/dist/core/session-reconcile.js +15 -0
  45. package/dist/core/side-query.d.ts +12 -5
  46. package/dist/core/task-notification.d.ts +34 -7
  47. package/dist/core/task-notification.js +11 -1
  48. package/dist/core/task-registry-agent.d.ts +20 -3
  49. package/dist/core/task-registry-agent.js +31 -2
  50. package/dist/core/tool-policy.d.ts +14 -9
  51. package/dist/core/tool-policy.js +27 -22
  52. package/dist/core/types.d.ts +69 -11
  53. package/dist/core/untrusted-text.js +8 -0
  54. package/dist/engine/compaction/compaction.d.ts +77 -7
  55. package/dist/engine/compaction/compaction.js +98 -9
  56. package/dist/engine/compaction/utils.d.ts +4 -0
  57. package/dist/engine/compaction/utils.js +6 -0
  58. package/dist/engine/harness/agent-harness.d.ts +84 -0
  59. package/dist/engine/harness/agent-harness.js +114 -13
  60. package/dist/engine/harness/messages.d.ts +4 -2
  61. package/dist/engine/harness/messages.js +7 -2
  62. package/dist/engine/harness/types.d.ts +16 -6
  63. package/dist/engine/llm/types.d.ts +65 -0
  64. package/dist/engine/loop/types.d.ts +7 -0
  65. package/dist/engine/session/import-validate.js +10 -0
  66. package/dist/engine/session/session.js +2 -2
  67. package/dist/index.d.ts +4 -1
  68. package/dist/index.js +3 -1
  69. package/dist/internal/llm.d.ts +1 -1
  70. package/dist/orchestration/run-spec.js +8 -1
  71. package/dist/prompts/default.d.ts +12 -6
  72. package/dist/prompts/default.js +2 -0
  73. package/dist/scenarios/scenario-registry.d.ts +5 -1
  74. package/dist/scenarios/scenario-registry.js +4 -2
  75. package/dist/tools/fs/index.js +8 -1
  76. package/dist/tools/scheduler-tools.js +28 -6
  77. package/dist/tools/web.d.ts +15 -0
  78. package/dist/tools/web.js +8 -2
  79. package/dist/tools/worktree.js +2 -2
  80. package/package.json +1 -1
  81. package/test/export-surface.snapshot.json +19 -1
@@ -20,6 +20,7 @@ import { emitTaskOutcome } from "../task-outcome.js";
20
20
  import { isDegenerateCutMessage } from "../../brain/terminal-cause.js";
21
21
  import { primaryActivityArg } from "../arg-summary.js";
22
22
  import { resolveReasoning } from "../../brain/reasoning.js";
23
+ import { adjudicateDerivedRoute, authCarrierFingerprint, fallbackToPrimaryNotice, normalizeBaseUrl, sameRouteIdentity } from "../../brain/route-adjudicator.js";
23
24
  import { readDegradation } from "../../brain/degrading.js";
24
25
  import { runWithBrainTelemetry, runWithReasoningWireFacts, runWithStatusSink } from "../../brain/status-sink.js";
25
26
  import { expandTiers, resolveModel, resolveTaskModel } from "../roles.js";
@@ -39,15 +40,15 @@ import { TOOL_SEARCH_NAME } from "./tool-disclosure.js";
39
40
  import { hasVerifiableStructureSignal } from "./grounding-signal.js";
40
41
  import { hasDestroy, isIsolated } from "../remote-env.js";
41
42
  import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.js";
42
- import { cloneObserverInput, formatHookFeedback } from "../hooks.js";
43
- import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
43
+ import { cloneObserverInput, formatHookFeedback, hookSeatExpiredError, resolveHookTimeoutMs, runHookSeat } from "../hooks.js";
44
+ import { buildHumanInputEvent, frameMidTurnUserInput, projectHumanInput } from "../human-input-projection.js";
44
45
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
45
- import { reconcileInterruptedSession } from "../session-reconcile.js";
46
+ import { appendInterruptionMarker, reconcileInterruptedSession } from "../session-reconcile.js";
46
47
  import { RunnerSharedToolResultStore } from "../tool-result-store.js";
47
48
  import { formatDiagnosticsBlock } from "../lsp-diagnostics.js";
48
49
  import { checkToolPolicyProjection, constraintChainDigest, constraintChainEntryOf, isApprovalSettledBy, refuseOutOfContractDecision, screenApproverAttribution, toolPolicyNameSets } from "../tool-policy.js";
49
50
  import { defaultTaskRegistry } from "../task-registry.js";
50
- import { discloseDroppedPending, isDelegatedAgentTerminal, PendingSessionNotifications, renderTaskNotificationXml, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
51
+ import { discloseDroppedPending, isDelegatedAgentTerminal, isSystemInjectionPriority, PendingSessionNotifications, renderTaskNotificationXml, SYSTEM_INJECTION_PRIORITIES, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
51
52
  import { ToolDetachHub } from "../tool-detach.js";
52
53
  import { createPeerInboundChainRef, createPeerSelfRef } from "../../agents/peer-admission.js";
53
54
  import { workflowSizeGuidelineChangeNotice } from "../../orchestration/workflow-size-guideline.js";
@@ -767,7 +768,11 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
767
768
  const injectedThisTurn = finalVerifyInjectedThisTurn ? "final_verification" : undefined;
768
769
  if (!boundarySteered && !prepared.abortController.signal.aborted) {
769
770
  try {
770
- const r = await postToolBatchHook(batch, injectedThisTurn !== undefined ? { injectedThisTurn } : undefined, { identity: prepared.hookIdentity });
771
+ const batchSeat = await runHookSeat("postToolBatch", { timeoutMs: prepared.hookTimeoutMs, signal: prepared.abortController.signal }, (sig) => postToolBatchHook(batch, injectedThisTurn !== undefined ? { injectedThisTurn } : undefined, { identity: prepared.hookIdentity, signal: sig }));
772
+ if (batchSeat.expired) {
773
+ runnerHooks.onError?.(hookSeatExpiredError("postToolBatch", prepared.hookTimeoutMs, batchSeat.cause, "the boundary observation was abandoned and its additionalContext dropped; the turn boundary itself is unchanged"), { phase: "hook", sessionId: prepared.sessionId });
774
+ }
775
+ const r = batchSeat.expired ? undefined : batchSeat.value;
771
776
  if (r?.additionalContext && injectedThisTurn === undefined) {
772
777
  const body = sanitizeUntrustedText(r.additionalContext, SHELLED_BODY_ENVELOPE_TAGS);
773
778
  const budget = ATTACHMENT_BYTE_CAP - boundaryAttachmentBytes;
@@ -1504,8 +1509,25 @@ export class Runner {
1504
1509
  ...(typeof h.postCompact === "function" ? { postCompact: (c) => h.postCompact(c) } : {}),
1505
1510
  ...(typeof h.stopFailure === "function" ? { stopFailure: (c) => h.stopFailure(c) } : {}),
1506
1511
  ...(typeof h.permissionDenied === "function" ? { permissionDenied: (p) => h.permissionDenied(p) } : {}),
1512
+ ...(h.timeoutMs !== undefined ? { timeoutMs: h.timeoutMs } : {}),
1507
1513
  }
1508
1514
  : undefined;
1515
+ const MIRRORED_HOOK_KEYS = [
1516
+ "preToolUse",
1517
+ "preToolUseObservational",
1518
+ "postToolUse",
1519
+ "userPromptSubmit",
1520
+ "stop",
1521
+ "postToolUseFailure",
1522
+ "postToolBatch",
1523
+ "preCompact",
1524
+ "postCompact",
1525
+ "stopFailure",
1526
+ "permissionDenied",
1527
+ "timeoutMs",
1528
+ ];
1529
+ const _mirrorIsComplete = true;
1530
+ void _mirrorIsComplete;
1509
1531
  return {
1510
1532
  ...(deps.toolPolicy !== undefined
1511
1533
  ? {
@@ -1547,6 +1569,17 @@ export class Runner {
1547
1569
  }
1548
1570
  const tiers = Object.hasOwn(next, "tiers") ? next.tiers : this.deps.tiers;
1549
1571
  const expanded = tiers && Object.keys(tiers).length > 0 ? expandTiers({ ...next.models }, tiers) : { ...next.models };
1572
+ const movedEntries = [];
1573
+ for (const [name, nextModel] of Object.entries(expanded ?? {})) {
1574
+ const prior = this.deps.models?.[name];
1575
+ if (!prior)
1576
+ continue;
1577
+ const from = normalizeBaseUrl(prior.baseUrl);
1578
+ const to = normalizeBaseUrl(nextModel.baseUrl);
1579
+ if (from !== to && authCarrierFingerprint(prior.headers) === authCarrierFingerprint(nextModel.headers)) {
1580
+ movedEntries.push({ modelId: name, from, to });
1581
+ }
1582
+ }
1550
1583
  this.deps = { ...this.deps, models: expanded, ...(tiers !== undefined ? { tiers } : {}) };
1551
1584
  if (tiers === undefined)
1552
1585
  delete this.deps.tiers;
@@ -1555,6 +1588,18 @@ export class Runner {
1555
1588
  message: `model catalog swapped: ${Object.keys(next.models).length} model(s), ${tiers ? Object.keys(tiers).length : 0} tier binding(s); in-flight tasks finish on their resolved models, new tasks resolve against the new catalog`,
1556
1589
  detail: { models: Object.keys(next.models).length, tiers: tiers ? Object.keys(tiers).length : 0 },
1557
1590
  });
1591
+ if (movedEntries.length > 0) {
1592
+ const RENDER_CAP = 8;
1593
+ const rendered = movedEntries.slice(0, RENDER_CAP);
1594
+ deliverEngineNotice(this.deps.onNotice, {
1595
+ code: "route.base_url_changed_key_unchanged",
1596
+ message: `model catalog swap moved ${movedEntries.length} entry/entries to a new baseUrl while the Model-visible credential half stayed unchanged: ` +
1597
+ rendered.map((e) => `"${e.modelId}" ${e.from || "(config root)"} → ${e.to || "(config root)"}`).join("; ") +
1598
+ (movedEntries.length > rendered.length ? `; +${movedEntries.length - rendered.length} more` : "") +
1599
+ " — if the provider changed (not just its domain), update the credential reference in the same step",
1600
+ detail: { entries: rendered, total: movedEntries.length },
1601
+ });
1602
+ }
1558
1603
  }
1559
1604
  sideQuery(spec) {
1560
1605
  return runWithReasoningWireFacts(() => { }, () => runSideQuery(spec, { brain: this.deps.brain, models: this.deps.models, roles: this.deps.roles }));
@@ -1783,6 +1828,13 @@ export class Runner {
1783
1828
  const actorIn = options?.actor;
1784
1829
  const actor = actorIn === undefined ? undefined : snapshotActorAssertion(actorIn);
1785
1830
  const projected = projectHumanInput({ text, actor, source: "steer" });
1831
+ const effectiveInputId = typeof inputId === "string" ? inputId : uuidv7();
1832
+ const parkRecord = {
1833
+ text,
1834
+ trusted,
1835
+ inputId: effectiveInputId,
1836
+ ...(actor !== undefined ? { actor } : {}),
1837
+ };
1786
1838
  let payload;
1787
1839
  let mintsAFrame;
1788
1840
  let replay;
@@ -1797,7 +1849,7 @@ export class Runner {
1797
1849
  source: "steer",
1798
1850
  delivery: "queued",
1799
1851
  sessionSeq: nextHumanInputSeq(h.harness),
1800
- ...(typeof inputId === "string" ? { inputId } : {}),
1852
+ inputId: effectiveInputId,
1801
1853
  ...(actor !== undefined ? { actor } : {}),
1802
1854
  ...(actor?.issuer !== undefined ? { issuer: actor.issuer } : {}),
1803
1855
  ...(spec.principal !== undefined ? { principal: spec.principal } : {}),
@@ -1814,7 +1866,7 @@ export class Runner {
1814
1866
  const h = handle ?? (await orTimeout(ready));
1815
1867
  if (!h)
1816
1868
  throw steeringError("the task is not running");
1817
- payload = trusted ? formatHookFeedback(projected, h.reminderMark) : projected;
1869
+ payload = trusted ? formatHookFeedback(projected, h.reminderMark) : frameMidTurnUserInput(projected);
1818
1870
  mintsAFrame = payload.trim().length !== 0;
1819
1871
  replay = { payload, trusted, ...(actor !== undefined ? { actor } : {}) };
1820
1872
  if (typeof inputId === "string") {
@@ -1830,7 +1882,7 @@ export class Runner {
1830
1882
  }
1831
1883
  }
1832
1884
  try {
1833
- await h.harness.steer(payload, { provenance: "engine-note", callerAuthored: true, ...(actor !== undefined ? { actor } : {}) });
1885
+ await h.harness.steer(payload, { provenance: "engine-note", callerAuthored: true, parkRecord, ...(actor !== undefined ? { actor } : {}) });
1834
1886
  noteAccepted(h);
1835
1887
  return;
1836
1888
  }
@@ -1841,7 +1893,7 @@ export class Runner {
1841
1893
  const birthDeadline = Date.now() + READY_TIMEOUT_MS;
1842
1894
  while (resultValue === undefined && !h.loop.ended && Date.now() < birthDeadline) {
1843
1895
  try {
1844
- await h.harness.steer(payload, { provenance: "engine-note", callerAuthored: true, ...(actor !== undefined ? { actor } : {}) });
1896
+ await h.harness.steer(payload, { provenance: "engine-note", callerAuthored: true, parkRecord, ...(actor !== undefined ? { actor } : {}) });
1845
1897
  noteAccepted(h);
1846
1898
  return;
1847
1899
  }
@@ -1882,6 +1934,9 @@ export class Runner {
1882
1934
  if (input.source !== undefined && typeof input.source !== "string") {
1883
1935
  throw notifyError("input.source must be a string when present", "notify.invalid_payload");
1884
1936
  }
1937
+ if (opts?.priority !== undefined && !isSystemInjectionPriority(opts.priority)) {
1938
+ throw notifyError(`opts.priority must be one of ${SYSTEM_INJECTION_PRIORITIES.join("/")} when present`, "notify.invalid_payload");
1939
+ }
1885
1940
  const payload = {
1886
1941
  task_id: input.task_id,
1887
1942
  task_type: "external",
@@ -1945,6 +2000,8 @@ export class Runner {
1945
2000
  const h = handle ?? (await orTimeout(ready));
1946
2001
  if (!h)
1947
2002
  return;
2003
+ if (!h.abortController.signal.aborted)
2004
+ h.loop.userInterrupted = true;
1948
2005
  h.abortController.abort();
1949
2006
  void h.harness.abort();
1950
2007
  },
@@ -2029,7 +2086,18 @@ export class Runner {
2029
2086
  });
2030
2087
  const upstreamTaskNotification = internals?.onTaskNotification;
2031
2088
  const deliveredAtTurnOpen = new Set();
2089
+ let priorityNowDisclosed = false;
2032
2090
  const injectTaskNotification = (notification, opts) => {
2091
+ if (opts?.priority === "now" && !priorityNowDisclosed) {
2092
+ priorityNowDisclosed = true;
2093
+ deliverEngineNotice(this.deps.onNotice, {
2094
+ code: "task.injection_priority_unimplemented",
2095
+ message: `a notification was injected with priority "now", which this engine does not implement: all injection ` +
2096
+ `priorities deliver at the NEXT turn boundary and none aborts the running turn. The notification is ` +
2097
+ `delivered — only the interrupting semantics are absent.`,
2098
+ detail: { priority: "now", ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}) },
2099
+ });
2100
+ }
2033
2101
  if (deliveredAtTurnOpen.has(taskNotificationDedupKey(notification)))
2034
2102
  return Promise.resolve("dropped_duplicate");
2035
2103
  if (!notificationLaneLive) {
@@ -2098,7 +2166,12 @@ export class Runner {
2098
2166
  this.pendingSessionNotifications.pend(notificationSessionId, p);
2099
2167
  };
2100
2168
  prepared.harness.engineInjectionsHeld = () => prepared.batchHaltRef.current !== undefined;
2169
+ let undrainedUserAtEnd;
2101
2170
  prepared.harness.onUndrainedUserInputs = (counts) => {
2171
+ if (prepared.suspendRef.token !== undefined || prepared.reviewRef.token !== undefined) {
2172
+ undrainedUserAtEnd = counts;
2173
+ return;
2174
+ }
2102
2175
  for (const notice of undrainedUserInputNotices(counts, spec.taskId)) {
2103
2176
  deliverEngineNotice(this.deps.onNotice, notice);
2104
2177
  }
@@ -2127,7 +2200,7 @@ export class Runner {
2127
2200
  }
2128
2201
  }
2129
2202
  }
2130
- const loopLatch = { ended: false };
2203
+ const loopLatch = { ended: false, userInterrupted: false };
2131
2204
  onReady({ harness: prepared.harness, abortController: prepared.abortController, loop: loopLatch, reminderMark: prepared.reminderMark });
2132
2205
  const stats = { turns: 0, tokens: 0, toolCalls: 0, promptTokens: 0, totalInputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, cacheWriteTokensLong: 0, outputTokens: 0, costMicroUsd: 0 };
2133
2206
  prepared.liveSpendRef.get = () => ({ costMicroUsd: stats.costMicroUsd, tokens: stats.tokens, turns: stats.turns, walltimeMs: Math.round(performance.now() - rs.telemetry.taskStartMonotonic) });
@@ -2809,12 +2882,21 @@ export class Runner {
2809
2882
  return [];
2810
2883
  let result;
2811
2884
  try {
2812
- result = await stopHook({
2885
+ const stopSeat = await runHookSeat("stop", { timeoutMs: prepared.hookTimeoutMs, signal: prepared.abortController.signal, abortEnds: true }, (sig) => stopHook({
2813
2886
  stopHookActive: consecutiveBlocks > 0,
2814
2887
  consecutiveBlocks,
2815
2888
  getBranch: () => prepared.session.getBranch(),
2816
2889
  identity: prepared.hookIdentity,
2817
- });
2890
+ signal: sig,
2891
+ }));
2892
+ if (stopSeat.expired) {
2893
+ if (stopSeat.cause === "timeout") {
2894
+ this.deps.onError?.(hookSeatExpiredError("stop", prepared.hookTimeoutMs, stopSeat.cause, "the run was allowed to END (the seat's own no-opinion answer); no pushback and no additional context were injected"), { phase: "hook", sessionId: prepared.sessionId });
2895
+ }
2896
+ consecutiveBlocks = 0;
2897
+ return [];
2898
+ }
2899
+ result = stopSeat.value;
2818
2900
  }
2819
2901
  catch (err) {
2820
2902
  this.deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId: prepared.sessionId });
@@ -2889,7 +2971,7 @@ export class Runner {
2889
2971
  ...this.seamCCompactionOptions(prepared),
2890
2972
  ...gitRestateOption(prepared),
2891
2973
  ...windowSafetyOptions(prepared.harness.getModel()),
2892
- ...this.compactionHookOptions(spec, prepared.sessionId, "forced", prepared.hookIdentity),
2974
+ ...this.compactionHookOptions(spec, prepared.sessionId, "forced", prepared.hookIdentity, { timeoutMs: prepared.hookTimeoutMs, signal: prepared.abortController.signal }),
2893
2975
  });
2894
2976
  if (comp.compacted) {
2895
2977
  compactionBreaker.failures = 0;
@@ -2954,7 +3036,7 @@ export class Runner {
2954
3036
  runnerHooks: {
2955
3037
  onError: this.deps.onError,
2956
3038
  seamCCompactionOptions: (p) => this.seamCCompactionOptions(p),
2957
- compactionHookOptions: (s, sid, trig) => this.compactionHookOptions(s, sid, trig, prepared.hookIdentity),
3039
+ compactionHookOptions: (s, sid, trig) => this.compactionHookOptions(s, sid, trig, prepared.hookIdentity, { timeoutMs: prepared.hookTimeoutMs, signal: prepared.abortController.signal }),
2958
3040
  recordCompactionReuse: (p, c) => this.recordCompactionReuse(p, c),
2959
3041
  },
2960
3042
  });
@@ -3057,6 +3139,7 @@ export class Runner {
3057
3139
  let final;
3058
3140
  let threw;
3059
3141
  let abortedLive = false;
3142
+ let userInterruptedLive = false;
3060
3143
  let strandedHumanAnswers = [];
3061
3144
  try {
3062
3145
  if (prepared.abortController.signal.aborted) {
@@ -3184,7 +3267,23 @@ export class Runner {
3184
3267
  const userPromptSubmit = (spec.hooks ?? this.deps.hooks)?.userPromptSubmit;
3185
3268
  if (userPromptSubmit) {
3186
3269
  try {
3187
- const decision = await userPromptSubmit(spec.objective, { identity: prepared.hookIdentity });
3270
+ const promptSeat = await runHookSeat("userPromptSubmit", { timeoutMs: prepared.hookTimeoutMs, signal: prepared.abortController.signal, abortEnds: true }, (sig) => userPromptSubmit(spec.objective, { identity: prepared.hookIdentity, signal: sig }));
3271
+ if (promptSeat.expired) {
3272
+ if (promptSeat.cause === "timeout") {
3273
+ try {
3274
+ this.deps.onError?.(hookSeatExpiredError("userPromptSubmit", prepared.hookTimeoutMs, promptSeat.cause, "the prompt was NOT submitted (fail-closed) and the task ends blocked"), { phase: "hook", sessionId: prepared.sessionId });
3275
+ }
3276
+ catch {
3277
+ }
3278
+ }
3279
+ prepared.blockedRef.reason = formatHookFeedback(promptSeat.cause === "timeout"
3280
+ ? `the deployment's userPromptSubmit hook did not answer within its ${prepared.hookTimeoutMs}ms bound while screening this prompt; ` +
3281
+ `the prompt was NOT submitted (fail-closed)`
3282
+ : `the task was cancelled while the deployment's userPromptSubmit hook was still screening this prompt; ` +
3283
+ `the prompt was NOT submitted (fail-closed)`, prepared.reminderMark);
3284
+ promptBlocked = true;
3285
+ }
3286
+ const decision = promptSeat.expired ? undefined : promptSeat.value;
3188
3287
  if (decision?.block) {
3189
3288
  prepared.blockedRef.reason = formatHookFeedback(decision.block, prepared.reminderMark);
3190
3289
  promptBlocked = true;
@@ -3353,6 +3452,7 @@ export class Runner {
3353
3452
  }
3354
3453
  loopLatch.ended = true;
3355
3454
  abortedLive = prepared.abortController.signal.aborted;
3455
+ userInterruptedLive = loopLatch.userInterrupted;
3356
3456
  }
3357
3457
  catch (err) {
3358
3458
  loopLatch.ended = true;
@@ -3364,6 +3464,7 @@ export class Runner {
3364
3464
  }
3365
3465
  threw = err;
3366
3466
  abortedLive = prepared.abortController.signal.aborted;
3467
+ userInterruptedLive = loopLatch.userInterrupted;
3367
3468
  }
3368
3469
  finally {
3369
3470
  timeout.clear();
@@ -3401,13 +3502,17 @@ export class Runner {
3401
3502
  const durablyPaused = suspended || reviewPaused;
3402
3503
  const interrupted = !durablyPaused &&
3403
3504
  (threw !== undefined || timeout.fired || rs.limits.turnsExceeded || rs.limits.budgetHit !== undefined || abortedLive || final?.stopReason === "aborted");
3505
+ let orphansClosed = 0;
3506
+ let reconcileComplete = false;
3404
3507
  if (interrupted) {
3405
3508
  try {
3406
3509
  const report = await reconcileInterruptedSession(prepared.session, prepared.toolEffects, undefined, startedToolCallIds);
3510
+ orphansClosed = report.recovered.length;
3407
3511
  for (const orphan of report.recovered) {
3408
3512
  queue.push({ type: "tool_end", toolCallId: orphan.toolCallId, toolName: orphan.toolName, ...(toolLabels.has(orphan.toolName) ? { label: toolLabels.get(orphan.toolName) } : {}), isError: true, ...reconciledToolEndBody(orphan), ...ident() });
3409
3513
  emitCommitted(orphan.entryId, "toolResult", orphan.toolCallId);
3410
3514
  }
3515
+ reconcileComplete = true;
3411
3516
  }
3412
3517
  catch (reconcileErr) {
3413
3518
  this.deps.onError?.(reconcileErr instanceof Error ? reconcileErr : new Error(String(reconcileErr)), {
@@ -3425,6 +3530,52 @@ export class Runner {
3425
3530
  });
3426
3531
  }
3427
3532
  }
3533
+ if (interrupted && userInterruptedLive && reconcileComplete) {
3534
+ try {
3535
+ const entryId = await appendInterruptionMarker(prepared.session, { toolUseInFlight: orphansClosed > 0 });
3536
+ emitCommitted(entryId, "user");
3537
+ }
3538
+ catch (markerErr) {
3539
+ this.deps.onError?.(markerErr instanceof Error ? markerErr : new Error(String(markerErr)), {
3540
+ phase: "interrupt-reconcile",
3541
+ sessionId: prepared.sessionId,
3542
+ });
3543
+ }
3544
+ }
3545
+ let migratedParked = { steer: 0, followUp: 0 };
3546
+ const parkToken = prepared.suspendRef.token ?? prepared.reviewRef.token;
3547
+ const parkScope = prepared.suspendRef.token !== undefined ? prepared.suspendRef.scope : prepared.reviewRef.scope;
3548
+ if (durablyPaused && parkToken !== undefined && parkScope !== undefined && this.deps.checkpointStore !== undefined) {
3549
+ const store = this.deps.checkpointStore;
3550
+ const carried = [];
3551
+ for (const record of prepared.harness.readParkableUserInputs()) {
3552
+ try {
3553
+ if (!(await store.setPendingSteer(parkToken, parkScope, record)))
3554
+ break;
3555
+ carried.push(record);
3556
+ }
3557
+ catch (parkErr) {
3558
+ this.deps.onError?.(parkErr instanceof Error ? parkErr : new Error(String(parkErr)), {
3559
+ phase: "config",
3560
+ sessionId: prepared.sessionId,
3561
+ });
3562
+ const parkCode = errorCodeOf(parkErr);
3563
+ if (parkCode === "steering.invalid_content" || parkCode === "steering.queue_full")
3564
+ continue;
3565
+ break;
3566
+ }
3567
+ }
3568
+ migratedParked = prepared.harness.dropParkedUserInputs(carried);
3569
+ }
3570
+ if (undrainedUserAtEnd !== undefined) {
3571
+ const remaining = {
3572
+ steer: Math.max(0, undrainedUserAtEnd.steer - migratedParked.steer),
3573
+ followUp: Math.max(0, undrainedUserAtEnd.followUp - migratedParked.followUp),
3574
+ };
3575
+ for (const notice of undrainedUserInputNotices(remaining, spec.taskId)) {
3576
+ deliverEngineNotice(this.deps.onNotice, notice);
3577
+ }
3578
+ }
3428
3579
  if (rs.attach.attachState?.postCompactPending === true && (rs.attach.attachmentsCfg?.backgroundTasks ?? eventDefaultOn("background_tasks")) === true) {
3429
3580
  emitTrace(rs.telemetry.tracer, () => ({ kind: "compaction.announce_dropped", version: 1, taskId: rs.telemetry.taskId, ts: Date.now() }));
3430
3581
  }
@@ -3434,6 +3585,7 @@ export class Runner {
3434
3585
  brain: compactionBrain,
3435
3586
  windowSafety: windowSafetyOptions(prepared.model),
3436
3587
  onCompactionFailed: (reason) => queue.push({ type: "compaction_outcome", outcome: "failed", trigger: "auto", reason, ...ident() }),
3588
+ cancelled: abortedLive,
3437
3589
  });
3438
3590
  try {
3439
3591
  if (comp?.compacted) {
@@ -3596,12 +3748,20 @@ export class Runner {
3596
3748
  !abortedLive &&
3597
3749
  result.errorCode !== "conflict") {
3598
3750
  try {
3599
- await stopFailureHook({
3751
+ const failureSeat = await runHookSeat("stopFailure", { timeoutMs: prepared.hookTimeoutMs }, (sig) => stopFailureHook({
3600
3752
  identity: prepared.hookIdentity,
3601
3753
  error: result.errorMessage ?? "model error",
3602
3754
  ...(result.errorCode !== undefined ? { errorKind: result.errorCode } : {}),
3603
3755
  turns: stats.turns,
3604
- });
3756
+ signal: sig,
3757
+ }));
3758
+ if (failureSeat.expired) {
3759
+ try {
3760
+ this.deps.onError?.(hookSeatExpiredError("stopFailure", prepared.hookTimeoutMs, failureSeat.cause, "the terminal observation was abandoned; the assembled TaskResult is unchanged"), { phase: "hook", sessionId: prepared.sessionId });
3761
+ }
3762
+ catch {
3763
+ }
3764
+ }
3605
3765
  }
3606
3766
  catch (err) {
3607
3767
  try {
@@ -3770,6 +3930,14 @@ export class Runner {
3770
3930
  }
3771
3931
  catch {
3772
3932
  }
3933
+ if (!sameRouteIdentity(model, prepared.model)) {
3934
+ const verdict = await adjudicateDerivedRoute({ brain: this.deps.brain, model, getApiKeyAndHeaders: spec.getApiKeyAndHeaders });
3935
+ if (verdict !== undefined && !verdict.ok) {
3936
+ deliverEngineNotice(this.deps.onNotice, fallbackToPrimaryNotice({ seat: "prompt-suggestions", from: model.id, to: prepared.model.id, verdict }));
3937
+ model = prepared.model;
3938
+ thinking = prepared.thinking;
3939
+ }
3940
+ }
3773
3941
  const pricing = this.deps.pricing?.[model.id] ?? modelCostToPricing(model.cost);
3774
3942
  const ctx = await prepared.session.buildContext();
3775
3943
  const transcript = ctx.messages.slice(-SUGGESTIONS_TRANSCRIPT_MESSAGES);
@@ -4612,10 +4780,12 @@ export class Runner {
4612
4780
  consecutiveProviderReuse: prepared.compactionReuseRef.consecutive,
4613
4781
  };
4614
4782
  }
4615
- compactionHookOptions(spec, sessionId, trigger, identity) {
4783
+ compactionHookOptions(spec, sessionId, trigger, identity, seatBound) {
4616
4784
  const hooks = spec.hooks ?? this.deps.hooks;
4617
4785
  const pre = hooks?.preCompact;
4618
4786
  const post = hooks?.postCompact;
4787
+ const seatMs = seatBound?.timeoutMs ?? resolveHookTimeoutMs(hooks?.timeoutMs);
4788
+ const seatSignal = seatBound?.signal;
4619
4789
  const withIdentity = (ctx) => identity !== undefined ? { ...ctx, identity } : ctx;
4620
4790
  return {
4621
4791
  trigger,
@@ -4631,7 +4801,12 @@ export class Runner {
4631
4801
  }
4632
4802
  };
4633
4803
  try {
4634
- const r = await pre.call(hooks, ctx);
4804
+ const seat = await runHookSeat("preCompact", { timeoutMs: seatMs, ...(seatSignal !== undefined ? { signal: seatSignal } : {}) }, (sig) => pre.call(hooks, { ...ctx, signal: sig }));
4805
+ if (seat.expired) {
4806
+ report(hookSeatExpiredError("preCompact", seatMs, seat.cause, `the "${ctx.trigger}" compaction PROCEEDED unblocked and with no additional instructions`));
4807
+ return undefined;
4808
+ }
4809
+ const r = seat.value;
4635
4810
  if (r?.block && ctx.trigger === "forced") {
4636
4811
  report(new Error(`a preCompact callback blocked a "forced" compaction — ignored (PTL/trim-pressure compaction is not optional): ${r.block}`));
4637
4812
  }
@@ -4648,7 +4823,14 @@ export class Runner {
4648
4823
  ? {
4649
4824
  postCompact: async (rawCtx) => {
4650
4825
  try {
4651
- await post.call(hooks, withIdentity(rawCtx));
4826
+ const seat = await runHookSeat("postCompact", { timeoutMs: seatMs, ...(seatSignal !== undefined ? { signal: seatSignal } : {}) }, (sig) => post.call(hooks, { ...withIdentity(rawCtx), signal: sig }));
4827
+ if (seat.expired) {
4828
+ try {
4829
+ this.deps.onError?.(hookSeatExpiredError("postCompact", seatMs, seat.cause, "the observation was abandoned; the compaction that already landed is unchanged"), { phase: "hook", sessionId });
4830
+ }
4831
+ catch {
4832
+ }
4833
+ }
4652
4834
  }
4653
4835
  catch (err) {
4654
4836
  try {
@@ -4692,7 +4874,10 @@ export class Runner {
4692
4874
  ...(prepared.onCompactionApplied ? { onApplied: prepared.onCompactionApplied } : {}),
4693
4875
  ...this.seamCCompactionOptions(prepared),
4694
4876
  ...gitRestateOption(prepared),
4695
- ...this.compactionHookOptions(spec, prepared.sessionId, "auto", prepared.hookIdentity),
4877
+ ...this.compactionHookOptions(spec, prepared.sessionId, "auto", prepared.hookIdentity, {
4878
+ timeoutMs: prepared.hookTimeoutMs,
4879
+ ...(opts?.cancelled === true ? { signal: prepared.abortController.signal } : {}),
4880
+ }),
4696
4881
  });
4697
4882
  this.recordCompactionReuse(prepared, finishComp);
4698
4883
  if (finishComp.unevaluableWindow) {
@@ -1,6 +1,6 @@
1
1
  import { canonicalizeTarget, writeTargetPath } from "../../tools/fs/safety.js";
2
2
  import { isWinFormPath } from "../../tools/fs/safety.js";
3
- import { createCoarseCommandNamePolicy, mcpCoveringEntries, mcpCoveringHit } from "../tool-policy.js";
3
+ import { createCoarseCommandNamePolicy, namespacedCoveringEntries, namespacedCoveringHit } from "../tool-policy.js";
4
4
  export const PATH_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
5
5
  export const PATH_CONFINABLE_WRITE_TOOLS = new Set([...PATH_WRITE_TOOLS, "NotebookEdit"]);
6
6
  export function isWithin(root, p) {
@@ -24,8 +24,8 @@ export function createSessionRulePolicy(rules, opts) {
24
24
  const { env, rootPath, toolEffects } = opts;
25
25
  const toolDeny = new Set(rules.toolDeny ?? []);
26
26
  const toolAllow = rules.toolAllow ? new Set(rules.toolAllow) : undefined;
27
- const toolDenyCovering = mcpCoveringEntries(rules.toolDeny);
28
- const toolAllowCovering = mcpCoveringEntries(rules.toolAllow);
27
+ const toolDenyCovering = namespacedCoveringEntries(rules.toolDeny);
28
+ const toolAllowCovering = namespacedCoveringEntries(rules.toolAllow);
29
29
  const cmdPolicy = rules.commandAllow || rules.commandDeny
30
30
  ? createCoarseCommandNamePolicy({
31
31
  ...(rules.commandAllow ? { allow: rules.commandAllow } : {}),
@@ -38,9 +38,9 @@ export function createSessionRulePolicy(rules, opts) {
38
38
  nameSets: [{ ...(rules.toolDeny?.length ? { deny: [...rules.toolDeny] } : {}), ...(rules.toolAllow?.length ? { allow: [...rules.toolAllow] } : {}) }],
39
39
  async check(req, signal) {
40
40
  const toolName = req.toolName;
41
- if (toolDeny.has(toolName) || mcpCoveringHit(toolDenyCovering, toolName))
41
+ if (toolDeny.has(toolName) || namespacedCoveringHit(toolDenyCovering, toolName))
42
42
  return deny(`tool "${req.toolName}" is denied by a session rule`);
43
- if (toolAllow && !toolAllow.has(toolName) && !mcpCoveringHit(toolAllowCovering, toolName)) {
43
+ if (toolAllow && !toolAllow.has(toolName) && !namespacedCoveringHit(toolAllowCovering, toolName)) {
44
44
  return deny(`tool "${req.toolName}" is not in the session-rule allowlist`);
45
45
  }
46
46
  if (cmdPolicy) {
@@ -147,6 +147,11 @@ export interface SchedulerCapability {
147
147
  * 不得热翻)——工具壳的门控读与 `schedule()` 是两次操作,中途翻位会让 session intent 落到不会 reap 的
148
148
  * backend 上。防线双置:声明面不可变 + `schedule()` 实现 MUST 自行拒绝它无法履行 reap 契约的
149
149
  * `lifetime:"session"` intent(fail-closed 兜底,不依赖工具壳的先行探测)。
150
+ *
151
+ * 「诚实拒绝」条款的适用边界:它约束**携带 session 语义字段的调用**(CronCreate 的 `durable` 形)。
152
+ * 一个没有 durable 逃生口的工具(ScheduleWakeup 形)对不支持位的 backend 走的是另一臂——发送
153
+ * 历史 durable intent 并在回执与工具描述上**披露**该 wakeup 不随会话终结(披露≠静默;拒绝会在
154
+ * 零收益下拿掉该宿主的整只工具)。该臂为既裁形(capability 位 opt-out 裁定),非本条款的违例。
150
155
  */
151
156
  readonly supportsSessionLifetime?: boolean;
152
157
  /**
@@ -84,6 +84,38 @@ export interface ReconcileReport {
84
84
  * `runner.resume()` uses a checkpoint-aware entry that bypasses this reconcile entirely for those calls.
85
85
  */
86
86
  export declare function findOrphanToolCalls(messages: AgentMessage[], suspendedBatch?: ReadonlySet<string>): OrphanToolCall[];
87
+ /**
88
+ * backlog #389 伴生 (D-2) — CC 2.1.223's interruption markers, VERBATIM (`$U`/`CR` @ `CC:151120-151121`,
89
+ * minted as a USER message by `Jce` @ `CC:640141-640154`). CC mints one on every abort whose reason is
90
+ * outside `{"interrupt","refusal-fallback-edit"}` — and the interactive Esc / remote cancel, which is
91
+ * what `TaskStream.interrupt()` corresponds to, is precisely on the minting side (`CC:1033698-1033733`).
92
+ * The suppressed reason is the one case where the user's own replacement message is already the context.
93
+ *
94
+ * Taken verbatim rather than reworded: this string is an INPUT to later reasoning in CC (its own
95
+ * "interrupted then immediately retried the same action" rule reads it back), and the constitution's
96
+ * standing rule is that a question CC has answered is answered in CC's form.
97
+ */
98
+ export declare const INTERRUPTED_BY_USER_MARKER = "[Request interrupted by user]";
99
+ /** backlog #389 伴生 — the tool-use variant (`CR`): the run was cut while a tool batch was in flight. */
100
+ export declare const INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER = "[Request interrupted by user for tool use]";
101
+ /**
102
+ * backlog #389 伴生 (D-2) — append the interruption marker so the SESSION records that a person stopped
103
+ * this run.
104
+ *
105
+ * Without it, an interrupt that lands on the model stream (no tool call in flight) leaves literally no
106
+ * trace: the orphan reconcile has nothing to close, and the empty aborted assistant is deliberately not
107
+ * persisted (`isEmptyFailureAssistant`). The next run on that session then reads a transcript in which
108
+ * the half-finished work simply stops, and continues as if it had ended by itself.
109
+ *
110
+ * MUST be called AFTER {@link reconcileInterruptedSession} on the same interruption: a user message
111
+ * placed between an assistant's tool calls and their results is exactly the invalid sequence the
112
+ * reconcile exists to prevent.
113
+ *
114
+ * Returns the persisted entry id so the caller can mint its `message_committed` frame.
115
+ */
116
+ export declare function appendInterruptionMarker(session: Session, opts: {
117
+ toolUseInFlight: boolean;
118
+ }): Promise<string>;
87
119
  /**
88
120
  * Reconcile a resumed session's active branch: close any orphan tool calls with a synthetic
89
121
  * interrupted `toolResult` (never re-running the tool). Returns what was recovered.
@@ -81,6 +81,21 @@ export function findOrphanToolCalls(messages, suspendedBatch) {
81
81
  });
82
82
  return orphans;
83
83
  }
84
+ export const INTERRUPTED_BY_USER_MARKER = "[Request interrupted by user]";
85
+ export const INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER = "[Request interrupted by user for tool use]";
86
+ export async function appendInterruptionMarker(session, opts) {
87
+ return await session.appendMessage({
88
+ role: "user",
89
+ content: [
90
+ {
91
+ type: "text",
92
+ text: opts.toolUseInFlight ? INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER : INTERRUPTED_BY_USER_MARKER,
93
+ },
94
+ ],
95
+ provenance: "engine-note",
96
+ timestamp: Date.now(),
97
+ });
98
+ }
84
99
  export async function reconcileInterruptedSession(session, toolEffects, suspendedBatch, startedToolCallIds) {
85
100
  const { messages } = await session.buildContext();
86
101
  const orphans = findOrphanToolCalls(messages, suspendedBatch).filter((o) => o.kind !== "result");
@@ -57,11 +57,18 @@ export interface SideQuerySpec {
57
57
  /**
58
58
  * Per-model auth — MIRRORS {@link TaskSpec.getApiKeyAndHeaders} (same signature, resolved per
59
59
  * call against the RESOLVED model, exactly like the task path's per-call hook). The brain
60
- * contract is `options.apiKey ?? config.apiKey`, and a model's own `baseUrl` outranks the
61
- * brain's so before this seat existed, a side query routed to a model carrying its own
62
- * `baseUrl` + per-model key fell back to the brain's construction-time credential and sent the
63
- * GATEWAY key to the per-model (possibly external) URL: a credential leak the task path already
64
- * prevents. Absent construction-time credentials apply, options byte-identical to before.
60
+ * contract is the route pairing law (route-adjudicator.ts): a per-model credential (this hook,
61
+ * or an auth header on `Model.headers`) always rides; the deployment credential rides only where
62
+ * its pairing is verifiable-or-unpinned a declared `config.baseUrl` with an off-root model is
63
+ * refused (`route.credential_mismatch` / `route.credential_missing`), never silently followed.
64
+ * Historically the fallback was unconditional (`options.apiKey ?? config.apiKey`, with a model's
65
+ * own `baseUrl` outranking the brain's), so a side query routed to a model carrying its own
66
+ * `baseUrl` + per-model key sent the GATEWAY key to the per-model (possibly external) URL — the
67
+ * credential leak the pairing law now stops. Absent seat ⇒ no options are minted (byte-identical
68
+ * to before): on an UNDECLARED root the construction-time credential still applies (the
69
+ * quick-start posture), while a declared-root brain + off-root model hard-fails the side query
70
+ * with the loud refusal (`stopReason: "error"`) instead of leaking — there is no primary model
71
+ * for a side query to fall back to.
65
72
  */
66
73
  getApiKeyAndHeaders?: TaskSpec["getApiKeyAndHeaders"];
67
74
  signal?: AbortSignal;