@sema-agent/core 7.0.1 → 7.1.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 (51) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/agents/repair-loop.d.ts +8 -7
  3. package/dist/agents/roster-store.d.ts +7 -2
  4. package/dist/agents/subagent.js +29 -5
  5. package/dist/brain/errors.d.ts +18 -0
  6. package/dist/brain/errors.js +3 -0
  7. package/dist/brain/stream-engine.js +6 -4
  8. package/dist/core/context-edit.d.ts +3 -0
  9. package/dist/core/governance-codes.d.ts +1 -1
  10. package/dist/core/governance-codes.js +4 -0
  11. package/dist/core/hooks.d.ts +26 -6
  12. package/dist/core/hooks.js +8 -5
  13. package/dist/core/image-downsample.d.ts +4 -3
  14. package/dist/core/memory-engine/engine.d.ts +62 -5
  15. package/dist/core/memory-engine/engine.js +90 -19
  16. package/dist/core/memory-engine/index.d.ts +1 -1
  17. package/dist/core/memory-engine/layout.d.ts +11 -3
  18. package/dist/core/roles.d.ts +36 -9
  19. package/dist/core/roles.js +19 -6
  20. package/dist/core/runner/prepare-memory.js +29 -22
  21. package/dist/core/runner/prepare-task.d.ts +5 -3
  22. package/dist/core/runner/prepare-task.js +88 -47
  23. package/dist/core/runner/runtask.d.ts +12 -3
  24. package/dist/core/runner/runtask.js +32 -4
  25. package/dist/core/safety-axis-vocab.d.ts +1 -1
  26. package/dist/core/strategy-store.d.ts +4 -1
  27. package/dist/core/task-registry-shared.d.ts +7 -3
  28. package/dist/core/tool-errors.d.ts +1 -1
  29. package/dist/core/tool-policy.d.ts +40 -7
  30. package/dist/core/tool-policy.js +63 -9
  31. package/dist/core/types.d.ts +97 -11
  32. package/dist/engine/compaction/compaction.js +6 -2
  33. package/dist/engine/harness/agent-harness.d.ts +28 -6
  34. package/dist/engine/harness/agent-harness.js +34 -2
  35. package/dist/engine/harness/messages.js +4 -0
  36. package/dist/engine/harness/types.d.ts +37 -0
  37. package/dist/engine/harness/types.js +5 -0
  38. package/dist/engine/session/session.js +3 -2
  39. package/dist/index.d.ts +1 -1
  40. package/dist/internal/harness.d.ts +1 -0
  41. package/dist/internal/harness.js +1 -0
  42. package/dist/orchestration/builtin-workflows.d.ts +17 -9
  43. package/dist/orchestration/run-workflow-tool.d.ts +9 -1
  44. package/dist/orchestration/run-workflow-tool.js +18 -8
  45. package/dist/orchestration/workflow-governance.js +1 -1
  46. package/dist/orchestration/workflow-types.d.ts +1 -0
  47. package/dist/orchestration/workflow.d.ts +9 -1
  48. package/dist/orchestration/workflow.js +5 -5
  49. package/dist/stores/file/mailbox-store.d.ts +2 -1
  50. package/package.json +1 -1
  51. package/test/export-surface.snapshot.json +3 -1
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { realpathSync } from "node:fs";
3
3
  import { resolve as resolveFsPath } from "node:path";
4
- import { AgentHarness, DEFAULT_CHARS_PER_TOKEN, DEFAULT_CLAMP_TOLERANCE, DEFAULT_COMPACTION_SETTINGS, estimateContextTokens, readCompactionActiveTools, summaryOutputBudgetTokens } from "../../internal/harness.js";
4
+ import { AgentHarness, DEFAULT_CHARS_PER_TOKEN, DEFAULT_CLAMP_TOLERANCE, DEFAULT_COMPACTION_SETTINGS, estimateContextTokens, isSyntheticApiErrorMessage, readCompactionActiveTools, summaryOutputBudgetTokens } from "../../internal/harness.js";
5
5
  const PROMPT_HASH_SALT = randomBytes(16);
6
6
  import { sanitizeCompactionSettings } from "../auto-compaction.js";
7
7
  import { projectStaleToolResults, resolveStaleToolResultOffload } from "./compaction-call-options.js";
@@ -662,6 +662,57 @@ export function batchContextAt(messages, currentId) {
662
662
  const completedCallIds = batch.filter((id) => id !== currentId && resolved.has(id));
663
663
  return { batchToolCallIds: batch, completedCallIds };
664
664
  }
665
+ function composeCallSignal(runSignal, callSignal) {
666
+ return callSignal !== undefined ? AbortSignal.any([runSignal, callSignal]) : runSignal;
667
+ }
668
+ function lateAskSettlementObserver(args) {
669
+ return (late) => {
670
+ if (late.kind === "approve") {
671
+ deliverEngineNotice(args.onNotice, {
672
+ code: "task.late_approval",
673
+ message: `an approval for "${args.toolName}" was not consumed: a run or turn interrupt released the ask wait, ` +
674
+ `so the tool did NOT run and the approval was not honored (an unconsumed answer is the approver ` +
675
+ `releasing its wait, never a verdict).`,
676
+ detail: {
677
+ toolName: args.toolName,
678
+ toolCallId: args.toolCallId,
679
+ sessionId: args.sessionId,
680
+ runId: args.runId,
681
+ ...(args.taskId !== undefined ? { taskId: args.taskId } : {}),
682
+ },
683
+ });
684
+ return;
685
+ }
686
+ args.onError?.(late.error, { phase: "hook", sessionId: args.sessionId });
687
+ };
688
+ }
689
+ function askGrantShapeOf(args) {
690
+ try {
691
+ return JSON.stringify(args ?? null);
692
+ }
693
+ catch {
694
+ return undefined;
695
+ }
696
+ }
697
+ function consumeInheritedAskGrant(grants, onAsk, humanReviewRef, decision, req) {
698
+ if (decision.decisionReason !== undefined && decision.decisionReason !== "rule")
699
+ return undefined;
700
+ const grant = grants.get(req.toolCallId);
701
+ if (grant === undefined || askApproverIdentity(grant.approver) !== askApproverIdentity(onAsk) || grant.argsJson !== askGrantShapeOf(req.args))
702
+ return undefined;
703
+ grants.delete(req.toolCallId);
704
+ humanReviewRef.count += 1;
705
+ humanReviewRef.totalWaitMs += grant.waitMs;
706
+ const toolArg = primaryActivityArg(req.args);
707
+ humanReviewRef.gates.push({
708
+ kind: "human",
709
+ waitMs: grant.waitMs,
710
+ decision: "allow",
711
+ toolName: req.toolName,
712
+ ...(toolArg !== undefined ? { toolArg } : {}),
713
+ });
714
+ return { action: "allow", presentedInput: grant.presented };
715
+ }
665
716
  function raceAbort(p, signal, onAbort) {
666
717
  if (signal.aborted)
667
718
  return Promise.resolve(onAbort());
@@ -894,6 +945,15 @@ function explicitlyDeferredMemoryTrio(mounted, roster, deferNames) {
894
945
  function memoryGroupRetractionSet(builtinDeferPairNames, engineTrioInPlay) {
895
946
  return new Set([...builtinDeferPairNames, ...(engineTrioInPlay ? MEMORY_ENGINE_TOOL_NAMES : [])]);
896
947
  }
948
+ function assembleParentCaptureState(o, i, ctl, ancestors) {
949
+ const build = (optedOut, indeterminate) => ({
950
+ optedOut: optedOut === true,
951
+ indeterminate: indeterminate === true,
952
+ ...(ctl !== undefined ? { controlDir: ctl } : {}),
953
+ ancestors,
954
+ });
955
+ return o instanceof Promise || i instanceof Promise ? Promise.all([o, i]).then(([ov, iv]) => build(ov, iv)) : build(o, i);
956
+ }
897
957
  async function spliceSessionOverlayRows(overlay, sessionId, persisted, tracer, hostTaskId) {
898
958
  if (overlay === undefined)
899
959
  return persisted;
@@ -1501,10 +1561,10 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1501
1561
  ...(spec.envFacts !== undefined ? { envFacts: { ...spec.envFacts } } : {}),
1502
1562
  ...(spec.memoryPersistenceCapable !== undefined ? { memoryPersistenceCapable: spec.memoryPersistenceCapable } : {}),
1503
1563
  get memoryCaptureOptedOut() {
1504
- return memoryEngineSession?.captureOptOut?.optedOut() === true;
1564
+ return memoryEngineSession?.captureOptOut?.optedOut() ?? false;
1505
1565
  },
1506
1566
  get memoryCaptureIndeterminate() {
1507
- return memoryEngineSession?.captureOptOut?.indeterminate() === true;
1567
+ return memoryEngineSession?.captureOptOut?.indeterminate() ?? false;
1508
1568
  },
1509
1569
  get memoryCaptureControlDir() {
1510
1570
  return memoryEngineSession?.engine.controlPlaneDir;
@@ -1657,7 +1717,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1657
1717
  ...(am.onBreakerOpen !== undefined ? { onBreakerOpen: am.onBreakerOpen } : {}),
1658
1718
  classify: async (input, signal) => {
1659
1719
  const ctx = await session.buildContext();
1660
- const known = ctx.messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult");
1720
+ const known = ctx.messages.filter((m) => !isSyntheticApiErrorMessage(m) &&
1721
+ (m.role === "user" || m.role === "assistant" || m.role === "toolResult"));
1661
1722
  const userPrompt = renderAutoModeWindow(known, am.window) + renderAutoModeAction(input);
1662
1723
  const classifierAuth = await spec.getApiKeyAndHeaders?.(classifierModel);
1663
1724
  const response = await classifierRuntime.completeSimple(classifierModel, { systemPrompt: classifierSystemPrompt, messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }] }, {
@@ -1731,12 +1792,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1731
1792
  ...(resolvedInteractionPosture !== undefined ? { parentInteractionPosture: resolvedInteractionPosture } : {}),
1732
1793
  parentMemoryCaptureState: () => {
1733
1794
  const ctl = memoryEngineSession?.engine.controlPlaneDir;
1734
- return {
1735
- optedOut: memoryEngineSession?.captureOptOut?.optedOut() === true,
1736
- indeterminate: memoryEngineSession?.captureOptOut?.indeterminate() === true,
1737
- ...(ctl !== undefined ? { controlDir: ctl } : {}),
1738
- ancestors: [...(internals?.memoryCaptureAncestors ?? []), { sessionId, ...(ctl !== undefined ? { controlDir: ctl } : {}) }],
1739
- };
1795
+ const co = memoryEngineSession?.captureOptOut;
1796
+ return assembleParentCaptureState(co?.optedOut() ?? false, co?.indeterminate() ?? false, ctl, [...(internals?.memoryCaptureAncestors ?? []), { sessionId, ...(ctl !== undefined ? { controlDir: ctl } : {}) }]);
1740
1797
  },
1741
1798
  autoModeReview: () => (autoModeDecider !== undefined ? { decider: autoModeDecider } : undefined),
1742
1799
  workflowDepth: internals?.workflowDepth,
@@ -3482,14 +3539,6 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3482
3539
  return true;
3483
3540
  };
3484
3541
  const inheritedAskGrants = new Map();
3485
- const askGrantShapeOf = (args) => {
3486
- try {
3487
- return JSON.stringify(args ?? null);
3488
- }
3489
- catch {
3490
- return undefined;
3491
- }
3492
- };
3493
3542
  const recordInheritedAskGrant = (toolCallId, approver, presented, waitMs) => {
3494
3543
  if (typeof approver !== "function")
3495
3544
  return;
@@ -4154,14 +4203,18 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4154
4203
  });
4155
4204
  }
4156
4205
  if (effectivePolicy || hooks?.preToolUse || egressTools.size > 0 || irreversibleTools.size > 0 || resourceSuspendEligible || platformSuspendArmed || spec.enablePlanMode === true) {
4206
+ const composedCallSignal = (callSignal) => composeCallSignal(abortController.signal, callSignal);
4157
4207
  const adjudicate = effectivePolicy
4158
- ? (req) => raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot, ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}) }, abortController.signal)), abortController.signal, () => ({
4159
- action: "deny",
4160
- message: "policy check aborted (task timed out or cancelled)",
4161
- }))
4208
+ ? (req, callSignal) => {
4209
+ const signal = composedCallSignal(callSignal);
4210
+ return raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot, ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}) }, signal)), signal, () => ({
4211
+ action: "deny",
4212
+ message: "policy check aborted (task timed out or cancelled)",
4213
+ }));
4214
+ }
4162
4215
  : undefined;
4163
4216
  const approvalPreviewOf = (toolName, args) => resolveApprovalPreview(tools, toolName, args);
4164
- const resolveAskBound = async (decision, req) => {
4217
+ const resolveAskBound = async (decision, req, callSignal) => {
4165
4218
  if (inheritedUnavailableAsks.delete(req.toolCallId)) {
4166
4219
  return {
4167
4220
  action: "deny",
@@ -4172,23 +4225,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4172
4225
  approverUnavailable: true,
4173
4226
  };
4174
4227
  }
4175
- if (decision.decisionReason === undefined || decision.decisionReason === "rule") {
4176
- const grant = inheritedAskGrants.get(req.toolCallId);
4177
- if (grant !== undefined && askApproverIdentity(grant.approver) === askApproverIdentity(onAsk) && grant.argsJson === askGrantShapeOf(req.args)) {
4178
- inheritedAskGrants.delete(req.toolCallId);
4179
- humanReviewRef.count += 1;
4180
- humanReviewRef.totalWaitMs += grant.waitMs;
4181
- const toolArg = primaryActivityArg(req.args);
4182
- humanReviewRef.gates.push({
4183
- kind: "human",
4184
- waitMs: grant.waitMs,
4185
- decision: "allow",
4186
- toolName: req.toolName,
4187
- ...(toolArg !== undefined ? { toolArg } : {}),
4188
- });
4189
- return { action: "allow", presentedInput: grant.presented };
4190
- }
4191
- }
4228
+ const grantReuse = consumeInheritedAskGrant(inheritedAskGrants, onAsk, humanReviewRef, decision, req);
4229
+ if (grantReuse !== undefined)
4230
+ return grantReuse;
4192
4231
  const t0 = now();
4193
4232
  const resolved = await resolveAsk({
4194
4233
  toolName: req.toolName,
@@ -4207,9 +4246,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4207
4246
  ...(decision.action === "ask" && decision.probeReason !== undefined ? { probeReason: decision.probeReason } : {}),
4208
4247
  ...(decision.action === "ask" && decision.probeCause !== undefined ? { probeCause: decision.probeCause } : {}),
4209
4248
  ...(decision.action === "ask" && decision.ruleEvidence !== undefined ? { ruleEvidence: decision.ruleEvidence } : {}),
4210
- }, onAsk, abortController.signal);
4249
+ }, onAsk, composedCallSignal(callSignal), lateAskSettlementObserver({ toolName: req.toolName, toolCallId: req.toolCallId, sessionId, runId, ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}), onNotice: deps.onNotice, onError: deps.onError }));
4211
4250
  const waitMs = Math.max(0, now() - t0);
4212
- if (resolved.approverUnavailable !== true) {
4251
+ if (resolved.approverUnavailable !== true && resolved.resolution !== "task_aborted") {
4213
4252
  humanReviewRef.count += 1;
4214
4253
  humanReviewRef.totalWaitMs += waitMs;
4215
4254
  const toolArg = primaryActivityArg(req.args);
@@ -4597,7 +4636,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4597
4636
  return true;
4598
4637
  }
4599
4638
  : undefined;
4600
- const resolveContentAsk = async (req) => {
4639
+ const resolveContentAsk = async (req, callSignal) => {
4640
+ const contentAskSignal = composedCallSignal(callSignal);
4601
4641
  if (!contentAskRoutable(req.toolCallId) || liveQuestionFace === undefined || mountedQuestionTool === undefined) {
4602
4642
  return { kind: "unavailable", parkDeclined: false };
4603
4643
  }
@@ -4628,7 +4668,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4628
4668
  if (contentAskBindings.size >= CONTENT_ASK_BINDING_CAP && !contentAskBindings.has(req.toolCallId)) {
4629
4669
  return { kind: "unavailable", parkDeclined: true };
4630
4670
  }
4631
- if (abortController.signal.aborted) {
4671
+ if (contentAskSignal.aborted) {
4632
4672
  return { kind: "delivery_failure", code: "question.aborted", presentedInput: retained };
4633
4673
  }
4634
4674
  try {
@@ -4639,8 +4679,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4639
4679
  sourceTaskId: sessionId,
4640
4680
  boundInputHash: boundInputHashOf(retained),
4641
4681
  deliveryId,
4642
- }, abortController.signal))();
4643
- const settlement = await raceAbort(facePromise.then((outcome) => ({ tag: "outcome", outcome }), (error) => ({ tag: "threw", error })), abortController.signal, () => ({ tag: "aborted" }));
4682
+ }, contentAskSignal))();
4683
+ const settlement = await raceAbort(facePromise.then((outcome) => ({ tag: "outcome", outcome }), (error) => ({ tag: "threw", error })), contentAskSignal, () => ({ tag: "aborted" }));
4644
4684
  if (settlement.tag === "aborted") {
4645
4685
  void facePromise.then((late) => {
4646
4686
  if (classifyQuestionOutcome(late).shape !== "answered")
@@ -4668,7 +4708,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4668
4708
  bindOutcome({ kind: "failed", error }, retainedQuestionsHash);
4669
4709
  return {
4670
4710
  kind: "delivery_failure",
4671
- code: settlement.tag === "aborted" || abortController.signal.aborted ? "question.aborted" : "question.human_channel_failed",
4711
+ code: settlement.tag === "aborted" || contentAskSignal.aborted ? "question.aborted" : "question.human_channel_failed",
4672
4712
  presentedInput: retained,
4673
4713
  };
4674
4714
  }
@@ -4951,6 +4991,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4951
4991
  result = await runToolGate({
4952
4992
  onNotifyError: (f) => emitTrace(deps.tracer, () => ({ kind: "observer.notify_failed", version: 1, taskId: hostTaskId, site: f.site, message: f.error.message, ts: Date.now() })),
4953
4993
  event: e,
4994
+ ...(e.signal !== undefined ? { callSignal: e.signal } : {}),
4954
4995
  identity: hookIdentity,
4955
4996
  reminderMark,
4956
4997
  preToolUse: ownGatePreToolUse,
@@ -147,6 +147,10 @@ export declare function awaitChargeWithSlowDisclosure<T>(charge: Promise<T>, onS
147
147
  * already hold would be a fabricated stall. The second pass is what makes the loop terminate.
148
148
  */
149
149
  export declare function raceUntilDeadline<T>(p: Promise<T>, deadline: number): Promise<T | typeof GOVERNANCE_READ_STALLED>;
150
+ /**
151
+ * A stateless task runner. Holds shared deps (the external brain, model catalog) and an
152
+ * in-memory session store so that passing a `sessionId` continues a prior conversation.
153
+ */
150
154
  export declare class Runner {
151
155
  private deps;
152
156
  readonly sessions: SessionStore;
@@ -223,9 +227,14 @@ export declare class Runner {
223
227
  /**
224
228
  * Hot-swap the model catalog (and optionally the tier bindings) without restarting the process or
225
229
  * rebuilding the Runner — the deployment seat that makes "switching models" a zero-restart
226
- * operation for catalogs the constructor froze (the constructor runs {@link expandTiers} once and
227
- * keeps a private expanded copy, so mutating a shared table after construction never took effect;
228
- * this verb is the sanctioned generation change).
230
+ * operation, and the ONLY sanctioned generation change.
231
+ *
232
+ * (Precisely: the constructor runs {@link expandTiers} once and keeps a private expanded copy **only
233
+ * when `RunnerDeps.tiers` is configured** — that is the arm where mutating the shared table after
234
+ * construction provably never took effect. A tiers-less deployment's Runner holds the caller's own
235
+ * `models` object BY REFERENCE, so mutating it after construction does leak through; that is an
236
+ * accident of the expansion being a no-op, not a contract, and this verb is still the supported way
237
+ * to change a generation — it is what validates, announces, and computes the pairing disclosure.)
229
238
  *
230
239
  * Semantics:
231
240
  * - **Atomic**: the candidate catalog is tier-expanded and validated FIRST (an illegal tier
@@ -4,7 +4,7 @@ import { mintSystemReminder, openSystemReminder } from "../reminder-mint.js";
4
4
  import { GLOBAL_USAGE_KEY, resolveUsageWindows, usageRetryAfterMs } from "../usage-window-store.js";
5
5
  import { deliverDelegationLifecycle, deliverEngineNotice, undrainedUserInputNotices } from "../types.js";
6
6
  import { planRejectionClears, resolveTriggerWindow } from "../context-edit.js";
7
- import { AgentHarness, DEFAULT_COMPACTION_SETTINGS, uuidv7 } from "../../internal/harness.js";
7
+ import { AgentHarness, DEFAULT_COMPACTION_SETTINGS, isSyntheticApiErrorMessage, uuidv7 } from "../../internal/harness.js";
8
8
  import { snapshotActorAssertion } from "../../internal/llm.js";
9
9
  import { CheckpointError, BINDING_CHECKPOINT_VERSION, checkpointVersionOf, F012_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, FACE_CHECKPOINT_VERSION, remainingBudgetMicroUsd, readPendingSteerQueue, remainingTokens, LEGACY_PENDING_STEER_INPUT_ID, MAX_STEER_INPUT_ID_CHARS, validatePendingSteer, winnerFromOutcome, } from "../checkpoint-store.js";
10
10
  import { GIT_STATUS_ECHO_PREVIEW, branchCarriesVisiblePositiveGitFrame, newestEngineGitFrame, stripGitStatusUnits } from "./git-status-frame.js";
@@ -2166,8 +2166,21 @@ export class Runner {
2166
2166
  const apply = () => {
2167
2167
  const abortOwnedBeforeHalt = h.abortController.signal.aborted;
2168
2168
  const receipt = h.harness.halt();
2169
- if (receipt.accepted && !abortOwnedBeforeHalt)
2169
+ if (receipt.accepted && !abortOwnedBeforeHalt) {
2170
2170
  h.loop.userHalted = true;
2171
+ }
2172
+ else {
2173
+ deliverEngineNotice(this.deps.onNotice, {
2174
+ code: "task.halt_unconsumed",
2175
+ message: "a user halt arrived while the run was already ending for its own reason: nothing was cut or stopped " +
2176
+ "by it — the run's own ending stands, and the result will not carry haltedByUser for this halt.",
2177
+ detail: {
2178
+ sessionId: h.sessionId,
2179
+ runId: h.runId,
2180
+ ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}),
2181
+ },
2182
+ });
2183
+ }
2171
2184
  if (receipt.turnCut) {
2172
2185
  deliverEngineNotice(this.deps.onNotice, {
2173
2186
  code: "task.turn_interrupted",
@@ -2861,6 +2874,7 @@ export class Runner {
2861
2874
  ...(s.attempt !== undefined ? { attempt: s.attempt } : {}),
2862
2875
  ...(s.maxRetries !== undefined ? { maxRetries: s.maxRetries } : {}),
2863
2876
  ...(s.errClass !== undefined ? { errClass: s.errClass } : {}),
2877
+ ...(s.errorStatus !== undefined ? { errorStatus: s.errorStatus } : {}),
2864
2878
  ...ident(),
2865
2879
  };
2866
2880
  Object.freeze(frame);
@@ -4317,7 +4331,21 @@ export class Runner {
4317
4331
  model = resolved.model;
4318
4332
  thinking = resolved.thinking;
4319
4333
  }
4320
- catch {
4334
+ catch (roleErr) {
4335
+ if (cfg.role !== undefined) {
4336
+ let asked;
4337
+ try {
4338
+ asked = String(cfg.role);
4339
+ }
4340
+ catch {
4341
+ asked = `<unrenderable ${typeof cfg.role}>`;
4342
+ }
4343
+ try {
4344
+ this.deps.onError?.(new Error(`suggestNextPrompts.role ${JSON.stringify(asked.length > 80 ? `${asked.slice(0, 80)}…` : asked)} did not resolve to a model — the prompt-suggestion pass ran on the task's own model (${prepared.model.id}) instead. Configure that role in RunnerDeps.roles / TaskSpec.roles, or omit the field to use the "summarize" role.`, { cause: roleErr }), { phase: "suggestions", sessionId: prepared.sessionId });
4345
+ }
4346
+ catch {
4347
+ }
4348
+ }
4321
4349
  }
4322
4350
  if (!sameRouteIdentity(model, prepared.model)) {
4323
4351
  const verdict = await adjudicateDerivedRoute({ brain: this.deps.brain, model, getApiKeyAndHeaders: spec.getApiKeyAndHeaders });
@@ -4329,7 +4357,7 @@ export class Runner {
4329
4357
  }
4330
4358
  const pricing = this.deps.pricing?.[model.id] ?? modelCostToPricing(model.cost);
4331
4359
  const ctx = await prepared.session.buildContext();
4332
- const transcript = ctx.messages.slice(-SUGGESTIONS_TRANSCRIPT_MESSAGES);
4360
+ const transcript = ctx.messages.filter((m) => !isSyntheticApiErrorMessage(m)).slice(-SUGGESTIONS_TRANSCRIPT_MESSAGES);
4333
4361
  const out = await generatePromptSuggestions({ brain: this.deps.brain, model, pricing, thinking, getApiKeyAndHeaders: spec.getApiKeyAndHeaders, signal: ac.signal }, transcript, count);
4334
4362
  if (out.tokens > 0 || out.costMicroUsd > 0) {
4335
4363
  result.stats.suggestions = { tokens: out.tokens, costMicroUsd: out.costMicroUsd };
@@ -25,7 +25,7 @@ export declare const SAFETY_AXIS_VOCABULARY: {
25
25
  readonly safetyAxis: readonly ["egress", "irreversible", "shell"];
26
26
  /** `RiskDescriptor.severity` — ToolEmu-style tier (5 = most severe), the inbox triage key (checkpoint-store.ts:85). */
27
27
  readonly severity: readonly [1, 2, 3, 4, 5];
28
- /** `TaskSpec.shellGate` — deployment shell-command gate rank, `off` < `classify` < `always` (types.ts:632). */
28
+ /** `TaskSpec.shellGate` (declared in core/types.ts) — deployment shell-command gate rank, `off` < `classify` < `always`. */
29
29
  readonly shellGate: readonly ["off", "classify", "always"];
30
30
  /** `ToolPolicy` decision — per-tool adjudication; `deny` short-circuits the `deny > ask > allow` fold (tool-policy.ts). */
31
31
  readonly permissionDecision: readonly ["allow", "ask", "deny"];
@@ -28,7 +28,10 @@ export interface StoredStrategy {
28
28
  scope: string;
29
29
  /** ISO timestamp stored. */
30
30
  ts: string;
31
- /** Teacher model id, for future staleness handling. */
31
+ /** What the teacher leg was ADDRESSED as, for future staleness handling: the caller's word verbatim
32
+ * when the teacher was configured by string (a catalog key, tier word or CC alias), else the
33
+ * `Model.id` of the object it was configured with. NOT normalized to a served model id — two rows
34
+ * written by the same physical model under different spellings do not compare equal. */
32
35
  teacherModel?: string;
33
36
  /** Reserved for a future generalized signature (v3 semantic matching). */
34
37
  signature?: string;
@@ -874,9 +874,13 @@ export interface RegisterBackgroundAgentInput extends TaskAccess {
874
874
  * internals chain; equals parentSessionId at depth 1). Persisted on the handle, the durable row
875
875
  * and the roster so recovery faces enumerate the whole tree under the root without alias walks. */
876
876
  rootSessionId?: string;
877
- /** design/151 S3b — revival lookup keys (CC meta-sidecar shape): the RESOLVED model id and the
878
- * team name, persisted on the durable row so a tier-3 revival rebuilds the spec the way a fresh
879
- * named-teammate spawn would. Lookup keys only, never a serialized spec. */
877
+ /** design/151 S3b — revival lookup keys (CC meta-sidecar shape): the model RECORD KEY and the team
878
+ * name, persisted on the durable row so a tier-3 revival rebuilds the spec the way a fresh
879
+ * named-teammate spawn would. Lookup keys only, never a serialized spec. `model` is NOT the
880
+ * resolved model id: a spawn that named a model in WORDS records the caller's spelling verbatim
881
+ * (catalog key / tier word / CC alias) so the revival can re-resolve it against the catalog in
882
+ * force at WAKE time; only a spawn that carried a Model OBJECT records that object's id. Same
883
+ * value and same rule as the roster row's `model` column. */
880
884
  model?: string;
881
885
  teamName?: string;
882
886
  toolUseId?: string;
@@ -40,7 +40,7 @@ export declare function formatToolError(error: unknown): string;
40
40
  * D-G data contract; the WorkerReport "errorClass" small-slice). A terminal `errorCode` (1.37+) is a
41
41
  * **dotted namespace** (`limits.max_tokens_exceeded` / `limits.max_cost_exceeded` / `output.invalid` …)
42
42
  * so a consumer can prefix-match a whole CLASS — but every aggregator/consumer hand-rolls
43
- * `errorCode.startsWith("budget.")` (types.ts:707 documents this very pattern), which silently
43
+ * `errorCode.startsWith("budget.")` a prefix convention no declaration site records, which silently
44
44
  * mis-classifies any code that does NOT follow the convention and drifts as new prefixes are added.
45
45
  * This is the single shared folder.
46
46
  *
@@ -341,7 +341,8 @@ export declare function decisionText(d: PermissionResult): string | undefined;
341
341
  *
342
342
  * `check` may be async, which is also how **human-in-the-loop approval** works: a deployment can
343
343
  * hold the promise open until an operator approves/denies. `signal` fires when the task aborts
344
- * (timeout / max turns / cancel) honor it to release a pending approval instead of hanging (F4).
344
+ * (timeout / max turns / cancel) and, since design/384, when the asking turn is interrupted (a bare
345
+ * user halt / steer-now boundary cut) — honor it to release a pending approval instead of hanging (F4).
345
346
  * The Runner also races `check` against `signal` itself, so a policy that ignores it still cannot
346
347
  * hang the worker past the deadline; passing it through just lets you clean up the wait early.
347
348
  */
@@ -725,8 +726,9 @@ export declare function createApprovalPolicy(opts: {
725
726
  requireApproval: string[];
726
727
  /**
727
728
  * The approval decision (e.g. await an operator). Resolve true to allow, false to deny. `signal`
728
- * fires when the task aborts — race your wait against it (e.g. an OA approval callback) so a
729
- * never-answered request is released at the deadline rather than holding the worker.
729
+ * fires when the task aborts — and, since design/384, when the asking turn is interrupted race
730
+ * your wait against it (e.g. an OA approval callback) so a never-answered request is released at
731
+ * the deadline rather than holding the worker.
730
732
  */
731
733
  approve: (req: ToolCallRequest, signal?: AbortSignal) => boolean | Promise<boolean>;
732
734
  /** Always-denied tools. */
@@ -942,7 +944,11 @@ export interface AskDelegationProvenance {
942
944
  * never adjudication input — same posture as {@link AskRequest.sourceAgentName}. */
943
945
  readonly agentName?: string;
944
946
  }
945
- /** The structured context an `onAsk` approver receives for an `ask` decision (design/37). */
947
+ /** The structured context an `onAsk` approver receives for an `ask` decision (design/37).
948
+ * Lifecycle (design/384): the wait this request fronts can be released by the run's abort AND by a
949
+ * turn-level interrupt; after that release the engine no longer awaits the approver, so anything an
950
+ * approver retains off this object it must release itself, keyed on its `signal` argument's abort
951
+ * (see {@link OnAsk} for the full detached-settlement contract). */
946
952
  export interface AskRequest {
947
953
  toolName: string;
948
954
  /** #144: a persisted allow rule MATCHED this call but could not clear the ask (mandated — see
@@ -1212,7 +1218,16 @@ export interface AskRequest {
1212
1218
  * deterministically to `deny` with a model-readable reason. The safe default for stateless automation.
1213
1219
  * - `"allow"` — auto-approve every `ask` (e.g. a trusted batch run).
1214
1220
  * - a function — await an operator's decision (true=allow, false=deny). `signal` fires when the task
1215
- * aborts; race your wait against it so an unanswered ask is released at the deadline, not hung.
1221
+ * aborts AND (design/384, when the gate threaded a per-call signal) when the asking turn is
1222
+ * interrupted — a bare user halt or a steer-now boundary cut releases the ask exactly as the run's
1223
+ * own end does; race your wait against it so an unanswered ask is released at the deadline, not hung.
1224
+ * Since design/384 the engine no longer waits for you after the signal fires: `resolveAsk` races its
1225
+ * await against the signal and settles the gate as an abort-family deny on its own. Your still-pending
1226
+ * promise is DETACHED — a resolve the released wait never consumes is you releasing your wait, never
1227
+ * a verdict (an unconsumed approval is disclosed as a notice, not honored — no claim is made about
1228
+ * which settled first); an unconsumed reject is disclosed on the deployment's error face, never
1229
+ * silently swallowed. Cleanup of anything you hold for the wait (the request
1230
+ * payload, the signal, your own timers) is YOUR responsibility, keyed on the signal's abort.
1216
1231
  * G1 three-value: the function may also return `"unavailable"` — an affirmative "no operator
1217
1232
  * is reachable for THIS ask right now" (judged PER-ASK inside the callback, not at wire time). It is a
1218
1233
  * ROUTING verdict, not a decision: the gate re-routes the ask onto the durable park leg (same behavior
@@ -1371,7 +1386,10 @@ export declare function describeThrown(err: unknown): string;
1371
1386
  * - `"blanket_allow_refused"` — a blanket allow posture met a `requiresRealApproval` ask;
1372
1387
  * - `"approver_unavailable"` — the approver answered the ROUTING question "nobody reachable"
1373
1388
  * (the G1 marker's fail-closed carry — the gate may re-route it to a durable park instead);
1374
- * - `"task_aborted"` — the task's own signal ended the wait (pre-wait and mid-wait arms);
1389
+ * - `"task_aborted"` — the wait's abort signal ended it (pre-wait, mid-wait and race arms). The
1390
+ * signal is the run's own end AND, since design/384, any turn-level interrupt composed into the
1391
+ * wait (a bare user halt, a steer-now boundary cut): one abort family, one word — a consumer
1392
+ * that must tell the sources apart reads the run's own terminal facts, not this classification;
1375
1393
  * - `"presentation_failed"` — the args/edit could not be safely presented or adopted (unclonable);
1376
1394
  * - `"approver_error"` — the approver callback threw;
1377
1395
  * - `"approver_contract"` — the approver returned something outside the contract (non-boolean
@@ -1466,5 +1484,20 @@ export declare function carriesBidiControls(value: unknown, limits?: {
1466
1484
  * headless auto-deny — carry NO source on purpose: nobody was asked, so there is no wait for anyone to
1467
1485
  * have ended, and `decisionReason: "mode"` is already the honest word for what produced them.
1468
1486
  */
1469
- export declare function resolveAsk(req: AskRequest, onAsk: OnAsk | undefined, signal?: AbortSignal): Promise<ResolvedAsk>;
1487
+ export declare function resolveAsk(req: AskRequest, onAsk: OnAsk | undefined, signal?: AbortSignal,
1488
+ /** design/384 — observer for a DETACHED approver's settlement, consulted only after the race
1489
+ * arm released the wait on `signal`'s abort: `"approve"` = a value that reads as an approval
1490
+ * went unconsumed by the released wait (a person said yes to an action that will never run —
1491
+ * the caller turns this into its own disclosure, e.g. an engine notice; no arrival-order claim
1492
+ * is made); `"error"` = the detached promise
1493
+ * rejected, or its members threw on the post-release read (the caller's error face, never
1494
+ * silence). An unconsumed NON-approve resolve is the approver releasing its wait — no verdict,
1495
+ * no record, this observer is not consulted. Optional and advisory: absent, the detached
1496
+ * settlement is still swallow-guarded (no unhandled rejection), it just leaves no trace. */
1497
+ onLateSettlement?: (late: {
1498
+ kind: "approve";
1499
+ } | {
1500
+ kind: "error";
1501
+ error: unknown;
1502
+ }) => void): Promise<ResolvedAsk>;
1470
1503
  export {};
@@ -314,7 +314,7 @@ export function createApprovalPolicy(opts) {
314
314
  }
315
315
  if (need.has(toolName) || namespacedCoveringHit(needCovering, toolName)) {
316
316
  if (signal?.aborted) {
317
- return withCoreMintedResolution({ action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, settledBy: "aborted" }, "task_aborted", req);
317
+ return withCoreMintedResolution({ action: "deny", message: `approval aborted for "${req.toolName}" (run or turn interrupted)`, settledBy: "aborted" }, "task_aborted", req);
318
318
  }
319
319
  let ok;
320
320
  try {
@@ -336,7 +336,7 @@ export function createApprovalPolicy(opts) {
336
336
  }, "window_expired", req);
337
337
  }
338
338
  if (signal?.aborted) {
339
- return withCoreMintedResolution({ action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, settledBy: "aborted" }, "task_aborted", req);
339
+ return withCoreMintedResolution({ action: "deny", message: `approval aborted for "${req.toolName}" (run or turn interrupted)`, settledBy: "aborted" }, "task_aborted", req);
340
340
  }
341
341
  const okRaw = ok;
342
342
  if (okRaw === true)
@@ -1003,13 +1003,58 @@ export function carriesBidiControls(value, limits) {
1003
1003
  return false;
1004
1004
  }
1005
1005
  }
1006
- export async function resolveAsk(req, onAsk, signal) {
1007
- const r = await resolveAskArms(req, onAsk, signal);
1006
+ export async function resolveAsk(req, onAsk, signal, onLateSettlement) {
1007
+ const r = await resolveAskArms(req, onAsk, signal, onLateSettlement);
1008
1008
  if (r.action === "deny" && isAskDenyResolution(r.resolution))
1009
1009
  return withCoreMintedResolution(r, r.resolution, req);
1010
1010
  return r;
1011
1011
  }
1012
- async function resolveAskArms(req, onAsk, signal) {
1012
+ function raceAskWaitAgainstSignal(wait, signal) {
1013
+ const settledWait = wait.then((value) => ({ tag: "value", value }), (error) => ({ tag: "threw", error }));
1014
+ if (signal === undefined)
1015
+ return settledWait;
1016
+ if (signal.aborted)
1017
+ return Promise.resolve({ tag: "aborted" });
1018
+ return new Promise((resolve) => {
1019
+ let settled = false;
1020
+ const finish = (r) => {
1021
+ if (settled)
1022
+ return;
1023
+ settled = true;
1024
+ signal.removeEventListener("abort", onAbort);
1025
+ resolve(r);
1026
+ };
1027
+ const onAbort = () => finish({ tag: "aborted" });
1028
+ signal.addEventListener("abort", onAbort, { once: true });
1029
+ void settledWait.then(finish);
1030
+ });
1031
+ }
1032
+ function detachLateAskWait(wait, onLate) {
1033
+ const disclose = (late) => {
1034
+ try {
1035
+ onLate?.(late);
1036
+ }
1037
+ catch {
1038
+ }
1039
+ };
1040
+ void wait
1041
+ .then((late) => {
1042
+ let approved = false;
1043
+ try {
1044
+ approved = late === true || (typeof late === "object" && late !== null && late.allow === true);
1045
+ }
1046
+ catch (err) {
1047
+ disclose({ kind: "error", error: err });
1048
+ return;
1049
+ }
1050
+ if (approved)
1051
+ disclose({ kind: "approve" });
1052
+ }, (error) => {
1053
+ disclose({ kind: "error", error });
1054
+ })
1055
+ .catch(() => undefined);
1056
+ }
1057
+ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1013
1058
  if (onAsk === "allow") {
1014
1059
  if (req.requiresRealApproval === true) {
1015
1060
  return {
@@ -1032,7 +1077,7 @@ async function resolveAskArms(req, onAsk, signal) {
1032
1077
  };
1033
1078
  }
1034
1079
  if (signal?.aborted) {
1035
- return { action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, decisionReason: "mode",
1080
+ return { action: "deny", message: `approval aborted for "${req.toolName}" (run or turn interrupted)`, decisionReason: "mode",
1036
1081
  resolution: "task_aborted", settledBy: "aborted" };
1037
1082
  }
1038
1083
  const presented = tryCloneArgs(req.args);
@@ -1059,12 +1104,21 @@ async function resolveAskArms(req, onAsk, signal) {
1059
1104
  }
1060
1105
  const bidi = carriesBidiControls(presented.value) || carriesBidiControls(req.preview);
1061
1106
  const { hasBidiControls: _carried, ...bare } = req;
1062
- ok = await onAsk({
1107
+ const wait = Promise.resolve(onAsk({
1063
1108
  ...bare,
1064
1109
  boundInputHash: boundInputHashOf(presented.value),
1065
1110
  args: approverView.value,
1066
1111
  ...(bidi ? { hasBidiControls: true } : {}),
1067
- }, signal);
1112
+ }, signal));
1113
+ const raced = await raceAskWaitAgainstSignal(wait, signal);
1114
+ if (raced.tag === "aborted") {
1115
+ detachLateAskWait(wait, onLateSettlement);
1116
+ return { action: "deny", message: `approval aborted for "${req.toolName}" (run or turn interrupted)`, decisionReason: "mode",
1117
+ resolution: "task_aborted", settledBy: "aborted" };
1118
+ }
1119
+ if (raced.tag === "threw")
1120
+ throw raced.error;
1121
+ ok = raced.value;
1068
1122
  }
1069
1123
  catch (err) {
1070
1124
  return {
@@ -1076,7 +1130,7 @@ async function resolveAskArms(req, onAsk, signal) {
1076
1130
  };
1077
1131
  }
1078
1132
  if (signal?.aborted) {
1079
- return { action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, decisionReason: "mode",
1133
+ return { action: "deny", message: `approval aborted for "${req.toolName}" (run or turn interrupted)`, decisionReason: "mode",
1080
1134
  resolution: "task_aborted", settledBy: "aborted" };
1081
1135
  }
1082
1136
  if (ok === "unavailable") {