@sema-agent/core 5.21.0 → 5.22.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 (73) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/agents/send-message-tool.js +6 -3
  3. package/dist/agents/subagent.d.ts +6 -0
  4. package/dist/agents/subagent.js +45 -4
  5. package/dist/brain/errors.d.ts +20 -0
  6. package/dist/brain/errors.js +40 -0
  7. package/dist/brain/retry.d.ts +16 -2
  8. package/dist/brain/retry.js +3 -2
  9. package/dist/brain/status-sink.d.ts +9 -2
  10. package/dist/brain/stream-engine.d.ts +22 -0
  11. package/dist/brain/stream-engine.js +41 -10
  12. package/dist/core/ask-class.d.ts +48 -0
  13. package/dist/core/ask-class.js +33 -0
  14. package/dist/core/checkpoint-store.d.ts +103 -10
  15. package/dist/core/checkpoint-store.js +3 -1
  16. package/dist/core/governance-codes.d.ts +38 -0
  17. package/dist/core/governance-codes.js +11 -0
  18. package/dist/core/hooks.d.ts +39 -0
  19. package/dist/core/hooks.js +26 -2
  20. package/dist/core/locked-config.d.ts +7 -1
  21. package/dist/core/locked-config.js +2 -1
  22. package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
  23. package/dist/core/memory-engine/delegation-provenance.js +26 -0
  24. package/dist/core/memory-engine/engine.d.ts +67 -1
  25. package/dist/core/memory-engine/engine.js +270 -12
  26. package/dist/core/memory-engine/header-hints.d.ts +30 -0
  27. package/dist/core/memory-engine/header-hints.js +41 -0
  28. package/dist/core/memory-engine/index.d.ts +3 -2
  29. package/dist/core/memory-engine/index.js +3 -2
  30. package/dist/core/memory-engine/layout.d.ts +166 -0
  31. package/dist/core/memory-engine/layout.js +399 -0
  32. package/dist/core/memory-engine/tools.d.ts +30 -0
  33. package/dist/core/memory-engine/tools.js +108 -17
  34. package/dist/core/permission-rule-consent.d.ts +25 -9
  35. package/dist/core/permission-rule-consent.js +91 -20
  36. package/dist/core/permission-rule-model.d.ts +9 -1
  37. package/dist/core/permission-rule-model.js +2 -2
  38. package/dist/core/permission-rule-org.d.ts +161 -0
  39. package/dist/core/permission-rule-org.js +211 -0
  40. package/dist/core/permission-rule-store.d.ts +249 -6
  41. package/dist/core/permission-rule-store.js +313 -3
  42. package/dist/core/permission-rule-sync.d.ts +131 -0
  43. package/dist/core/permission-rule-sync.js +314 -0
  44. package/dist/core/runner/prepare-memory.js +35 -8
  45. package/dist/core/runner/prepare-task.d.ts +54 -1
  46. package/dist/core/runner/prepare-task.js +246 -27
  47. package/dist/core/runner/runtask.js +147 -6
  48. package/dist/core/shared-memory/contract.js +19 -4
  49. package/dist/core/shared-memory/normalize.d.ts +3 -1
  50. package/dist/core/shared-memory/tools.js +73 -17
  51. package/dist/core/shared-memory/types.d.ts +27 -1
  52. package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
  53. package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
  54. package/dist/core/task-notification.d.ts +5 -2
  55. package/dist/core/task-registry-agent.d.ts +1 -1
  56. package/dist/core/task-registry-agent.js +6 -2
  57. package/dist/core/task-registry-shared.d.ts +9 -2
  58. package/dist/core/task-registry.d.ts +9 -3
  59. package/dist/core/task-registry.js +2 -0
  60. package/dist/core/tool-policy.d.ts +120 -2
  61. package/dist/core/tool-policy.js +116 -6
  62. package/dist/core/trace.d.ts +32 -1
  63. package/dist/core/types.d.ts +56 -3
  64. package/dist/index.d.ts +12 -7
  65. package/dist/index.js +10 -5
  66. package/dist/stores/file/checkpoint-store.d.ts +4 -0
  67. package/dist/stores/file/checkpoint-store.js +1 -0
  68. package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
  69. package/dist/stores/file/permission-rule-adopt.js +95 -0
  70. package/dist/stores/file/permission-rule-store.d.ts +80 -2
  71. package/dist/stores/file/permission-rule-store.js +189 -46
  72. package/dist/tools/fs/fs-search-tools.js +0 -1
  73. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.22.0 — 2026-08-09
4
+
5
+ ### BREAKING
6
+
7
+ - **Checkpoint version 6 → 7.** A checkpoint row that carries the new inheritance-chain constraint
8
+ snapshot or delegation-provenance state is stamped v7; a pre-5.22 worker refuses it up front
9
+ (`checkpoint.unsupported_version`) instead of resuming with the ancestors' gate silently dropped.
10
+ Rows without the new faces keep their old stamp and resume everywhere.
11
+ - **Durable re-supply contract: content digest replaces the count check.** Resuming a suspended task
12
+ under inherited constraints now verifies a `cpv1:` sha256 digest of the persisted constraint
13
+ projection (topology + decision-chain metadata: autoMode armed, durable/content mandates) — a
14
+ same-length substitute chain, or one that dropped the ancestor's classifier, is refused
15
+ (`resume.parent_constraint_mismatch`). Old checkpoints without a digest fall back to the count check.
16
+ - **Approver-edited args re-adjudicate BEFORE the resolve CAS, and a refusal keeps the park pending.**
17
+ Previously the edit recheck ran after the checkpoint was consumed; a refusal now surfaces as
18
+ `resume.constraint_rejected` / `resume.constraint_unprojectable` with the checkpoint still
19
+ `pending` and re-decidable (new reopen reason `constraint_rejected`). Deployments that pinned the
20
+ old "resume completes, denial injected" shape must re-pin.
21
+ - **`basePolicyForResumeEdit` absent no longer skips the edit recheck.** The knob becomes an
22
+ override; absence falls back to the deployment's locked preflight policy, so an approver edit is
23
+ never executed with zero re-adjudication. Configured deployments are unchanged.
24
+
25
+ ### Added
26
+
27
+ - **Inheritance-chain decision-chain integrity (F-012).** Ancestor deny verdicts freeze into the
28
+ chain as a canonical projection; the ancestor's auto-mode classifier now travels with the chain
29
+ (both wrapper arms and the approved-edit recheck) — a delegated child no longer executes a call the
30
+ ancestor's own gate would have classified into a block. The budget ledger is documented as
31
+ task-scoped (it does not follow the chain), and the edit-recheck path now sees a real budget
32
+ snapshot instead of an absent one.
33
+ - **Sandbox admission (opt-in, narrow form).** Under an execution environment that declares
34
+ `capabilities.isolation`, surviving engine-minted local asks on engine hand tools admit
35
+ automatically with a durable `permission.sandbox_admitted` disclosure (`decisionReason:"sandbox"`)
36
+ instead of parking. Caller-registered in-process tools never auto-admit — the isolation capability
37
+ only vouches for what runs inside that environment. Enabling the mode requires durable parking and
38
+ a checkpoint store that declares re-decision capability; otherwise preparation refuses loudly.
39
+ - **Deterministic connect refusals take a short retry lane (#107).** ECONNREFUSED/ENOTFOUND-class
40
+ failures retry 2× with a 1s backoff cap instead of consuming the full transient budget
41
+ (measured 186s → 1.8s); `BrainStatus` and `brain.retry` telemetry carry a closed `errClass` and
42
+ the in-force lane budget. Consumers pinning the `reconnecting` detail text or reading
43
+ `maxRetries` as the engine-wide budget are affected.
44
+ - **Delegation attestation persists across processes (design/180 R-4 half).** The three-value
45
+ delegation-provenance aggregate rides the durable checkpoint; a resumed leg missing the state
46
+ reads as `unknown` (static floor), never as `clean`.
47
+ - **`buildToolResultRef` exported from the package root** — a host wiring an HTTP tool-result read
48
+ face binds an incoming ref to its owning task by prefix recomputation.
49
+ - **Content mandate split from the approval mandate (#94).** A delegated child's question routes on
50
+ the QUESTION seat, not the approver seat. Behavior widening, called out explicitly: under
51
+ `durableApproval` with a live `onQuestion`, an absent or string approver seat used to leave child
52
+ questions undeliverable — they now reach the live question face. The permission leg is unchanged.
53
+ - **Background-agent revival honesty (#84).** `resumable` answers the real revival ladder;
54
+ the revive lane distinguishes a transient `recycling` window (`resume.row_recycling`, retry) from
55
+ `row_gone` (terminal), checks access before disclosing row lifecycle, and
56
+ `TaskRegistry.reviveBackgroundAgent`'s refusal union gains the `recycling` member — exhaustive
57
+ consumers add one arm.
58
+
59
+ ## 5.21.1 — 2026-08-09
60
+
61
+ - **Fix: every `hands: none` deployment failed at the door on 5.21.0** (P0). `HAND_TOOL_EFFECTS`
62
+ carried a dead `MultiEdit` row while the same package's `RETIRED_TOOL_NAMES` lists that name as
63
+ retired — one fact, two registries, disagreeing. A consumer deriving a deny list from the effects
64
+ table (the server's scenario hands lane was the first) fed the retired name into the roster
65
+ audit's unconditional retired-name refusal, killing scan/code-review/team scenarios with zero
66
+ turns. The dead row is gone and a test pins the two tables disjoint, so the next tool retirement
67
+ cannot silently re-open this.
68
+
3
69
  ## 5.21.0 — 2026-08-09
4
70
 
5
71
  ### BREAKING
@@ -709,9 +709,12 @@ export function createSendMessageTool(opts) {
709
709
  : code === "steering.still_running"
710
710
  ?
711
711
  `${who} (or a prior follow-up to it) is still running — wait for its completion notification. ${DEDUP_RETRY_NOTE}`
712
- : code === "resume.row_gone"
713
- ? `${who}'s registry row no longer exists (terminal GC) — relaunch a new agent instead.`
714
- : `${e instanceof Error ? e.message : String(e)} ${DEDUP_RETRY_NOTE}`;
712
+ : code === "resume.row_recycling"
713
+ ?
714
+ `${who}'s registry row is being adjudicated right now (a revival claim or a reap sweep holds it) — send again in a moment. ${DEDUP_RETRY_NOTE}`
715
+ : code === "resume.row_gone"
716
+ ? `${who}'s registry row no longer exists (terminal GC) — relaunch a new agent instead.`
717
+ : `${e instanceof Error ? e.message : String(e)} ${DEDUP_RETRY_NOTE}`;
715
718
  return { content: `Message not sent: ${text}`, details: { error: code ?? "resume_failed", to }, isError: true };
716
719
  }
717
720
  });
@@ -293,6 +293,12 @@ export declare function completedAgentCard(child: {
293
293
  * flagged (or no review was armed), so a consumer must not read presence as a verdict source for
294
294
  * anything else. */
295
295
  handbackWarning?: string;
296
+ /** design/180 half A — the child's delegation runtime-provenance attestation, reduced from its
297
+ * recorder aggregate at delivery. Present only when the parent chain armed the recorder; the
298
+ * parent-side judgment (the content-origin wrap) reads it off this card — `clean` retires the
299
+ * static face verdict for this call, `external` marks with the observed-event reason, `unknown`
300
+ * (or absence) keeps the static floor. Deliberate whitelist admission (see the boundary note). */
301
+ attestation?: import("../core/memory-engine/delegation-provenance.js").DelegationAttestation;
296
302
  }): Record<string, unknown>;
297
303
  /**
298
304
  * ruled 2026-08-04 — the DELEGATION-REPORT vocabulary for a child that durably paused at an approval
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { withDelegationProvenance } from "../core/tool-policy.js";
4
+ import { newDelegationProvenanceAggregate, reduceDelegationAttestation } from "../core/memory-engine/delegation-provenance.js";
4
5
  import { resolveModel, resolveModelDisplayLabel } from "../core/roles.js";
5
6
  import { OUTPUT_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME } from "../core/runner/synthetic-tools.js";
6
7
  import { TOOL_SEARCH_NAME } from "../core/runner/tool-disclosure.js";
@@ -311,6 +312,13 @@ const ccElapsedTag = (ms) => {
311
312
  return sec >= 60 ? ` \u00b7 ${Math.floor(sec / 60)}m${sec % 60}s` : ` \u00b7 ${sec}s`;
312
313
  };
313
314
  const ccCompletionText = (desc, settled, rawStatus, elapsedMs) => `Agent "${desc}" ${settled === "killed" ? "stopped" : settled === "completed" ? "finished" : rawStatus}${ccElapsedTag(elapsedMs)}`;
315
+ function resumabilityClaim(f) {
316
+ if (f.status === "killed" && f.stoppedBy === "user")
317
+ return false;
318
+ if (f.liveRetainPresent)
319
+ return f.liveRetainHasBudget;
320
+ return f.durableRow && f.named;
321
+ }
314
322
  const BG_AGENT_COLLATERAL_REAP_REASON = "its parent run ended";
315
323
  const RESERVED_AGENT_NAMES = new Set([OUTPUT_TOOL_NAME, TOOL_SEARCH_NAME, OFFLOAD_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME, DEFAULT_SUBAGENT_TOOL_NAME]);
316
324
  export const REPORT_FIELD_MAX = 300;
@@ -341,6 +349,7 @@ export function classifySubagentError(child) {
341
349
  export function completedAgentCard(child, extras) {
342
350
  return {
343
351
  type: "agent",
352
+ ...(extras.attestation !== undefined ? { attestation: extras.attestation } : {}),
344
353
  ...(extras.subagentType !== undefined ? { subagent_type: extras.subagentType } : {}),
345
354
  status: child.status,
346
355
  taskId: child.taskId,
@@ -522,7 +531,9 @@ export function createSubagentResume(deps) {
522
531
  throw configError(revived.reason === "still_running"
523
532
  ?
524
533
  "resume unavailable: the agent's registry row is not available for a resume right now (it is running, or another revival claimed it) — wait for its completion notification and send again."
525
- : "resume unavailable: the agent's registry row no longer exists (terminal GC) — relaunch a new agent instead.", revived.reason === "still_running" ? "steering.still_running" : "resume.row_gone");
534
+ : revived.reason === "recycling"
535
+ ? "resume unavailable: the agent's registry row is being adjudicated right now (a revival claim or a reap sweep holds it) — this clears on its own; send again in a moment."
536
+ : "resume unavailable: the agent's registry row no longer exists (terminal GC) — relaunch a new agent instead.", revived.reason === "still_running" ? "steering.still_running" : revived.reason === "recycling" ? "resume.row_recycling" : "resume.row_gone");
526
537
  }
527
538
  reviveCycle = revived.cycle;
528
539
  if (deps.bgSink !== undefined) {
@@ -682,6 +693,16 @@ export function createSubagentResume(deps) {
682
693
  const reviveTerminalSummary = failReasonRevive !== undefined
683
694
  ? `Agent "${reviveName}" (resumed) failed: ${failReasonRevive}${ccElapsedTag(Date.now() - reviveStartedAt)}`.slice(0, 300) + errorKindClause(errClassRevive)
684
695
  : `Agent "${reviveName}" (resumed) ${status === "killed" ? "stopped" : child.status === "completed" ? "finished" : String(child.status)}${ccElapsedTag(Date.now() - reviveStartedAt)}`;
696
+ const reviveDurableProbe = deps.registry !== undefined && deps.taskId !== undefined ? deps.registry.durableAgentRowProbe(deps.taskId) : undefined;
697
+ const reviveDurableRow = reviveDurableProbe !== undefined ? await reviveDurableProbe().catch(() => false) : false;
698
+ const resumableRevive = resumabilityClaim({
699
+ status,
700
+ ...(stoppedByRevive !== undefined ? { stoppedBy: stoppedByRevive } : {}),
701
+ liveRetainPresent: ledger.get(deps.parentToolCallId) === entry,
702
+ liveRetainHasBudget: entry.resumeCount < SUBAGENT_RESUME_CAP,
703
+ durableRow: reviveDurableRow,
704
+ named: deps.rowName !== undefined,
705
+ });
685
706
  const ownsTerminalFacesRevive = deps.registry !== undefined && deps.taskId !== undefined ? deps.registry.claimAgentTerminalNotify(deps.taskId) : true;
686
707
  if (ownsTerminalFacesRevive)
687
708
  reviveEmit?.({
@@ -696,7 +717,7 @@ export function createSubagentResume(deps) {
696
717
  ...(stoppedByRevive !== undefined ? { stoppedBy: stoppedByRevive } : {}),
697
718
  summary: reviveTerminalSummary,
698
719
  ...resumeResidual(),
699
- resumable: status !== "killed",
720
+ resumable: resumableRevive,
700
721
  usage: { tokens: child.stats.tokens, turns: child.stats.turns, ...delegatedCostField(child.stats) },
701
722
  ...(completionIdRevive !== undefined ? { completionId: completionIdRevive } : {}),
702
723
  });
@@ -714,7 +735,7 @@ export function createSubagentResume(deps) {
714
735
  ...(resumeHandbackResult ? { result: notifyResultField(resumeHandbackResult) } : {}),
715
736
  ...(status === "killed" && child.result ? { partial: true } : {}),
716
737
  ...resumeResidual(),
717
- resumable: status !== "killed",
738
+ resumable: resumableRevive,
718
739
  usage: { tokens: child.stats.tokens, turns: child.stats.turns, ...delegatedCostField(child.stats) },
719
740
  ...(completionIdRevive !== undefined ? { completionId: completionIdRevive } : {}),
720
741
  };
@@ -1589,8 +1610,12 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1589
1610
  return { ...(recentSteps ? { recentSteps } : {}), ...(editedFiles ? { editedFiles } : {}) };
1590
1611
  };
1591
1612
  const treeScope = reviveClaim?.row.scope ?? ctx.principal ?? opts.background?.scope;
1613
+ const provenanceRequest = ctx.delegationProvenanceForChildren?.();
1614
+ const childProvenanceRef = provenanceRequest !== undefined ? { current: newDelegationProvenanceAggregate() } : undefined;
1615
+ const childAttestation = (status) => childProvenanceRef !== undefined ? reduceDelegationAttestation(childProvenanceRef.current, { completed: status === "completed" }) : undefined;
1592
1616
  const childInternals = {
1593
1617
  ...(inheritedManifestScope ? { inheritedManifestScope } : {}),
1618
+ ...(childProvenanceRef !== undefined && provenanceRequest !== undefined ? { delegationProvenance: { ref: childProvenanceRef, contentSafety: provenanceRequest } } : {}),
1594
1619
  ...(treeScope !== undefined ? { registryScope: treeScope } : {}),
1595
1620
  ...(ctx.inheritedGateForChildren ? { inheritedGate: ctx.inheritedGateForChildren() } : {}),
1596
1621
  ownOrgAdmissionRef: { current: reviveClaim !== undefined ? readDurableOrgAdmission(reviveClaim.row) : undefined },
@@ -2274,6 +2299,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2274
2299
  details: completedAgentCard(forkChild, {
2275
2300
  subagentType: FORK_SUBAGENT_TYPE,
2276
2301
  error: forkErr,
2302
+ ...(() => {
2303
+ const a = childAttestation(forkChild.status);
2304
+ return a !== undefined ? { attestation: a } : {};
2305
+ })(),
2277
2306
  ...(forkHandbackWarning !== undefined ? { handbackWarning: forkHandbackWarning } : {}),
2278
2307
  ...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
2279
2308
  ...(() => {
@@ -2795,7 +2824,15 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2795
2824
  const completionIdBg = bg.registry.getCompletionId(taskId);
2796
2825
  const observerNote = observerNoteFor(await startBoundedObserverDrain());
2797
2826
  const residual = residualFields();
2798
- const resumableBg = bgRetain !== undefined && settled !== "killed";
2827
+ const bgDurableRow = await bgRowConfirmed();
2828
+ const resumableBg = resumabilityClaim({
2829
+ status: settled,
2830
+ ...(stoppedBy !== undefined ? { stoppedBy } : {}),
2831
+ liveRetainPresent: settled !== "killed" && bgRetain !== undefined && bgRetainLedger?.get(ctx.toolCallId) === bgRetain,
2832
+ liveRetainHasBudget: bgRetain !== undefined && bgRetain.resumeCount < SUBAGENT_RESUME_CAP,
2833
+ durableRow: bgDurableRow,
2834
+ named: agentName !== undefined,
2835
+ });
2799
2836
  const failReasonBg = settled === "failed" ? unparkedPauseReason ?? child.errorMessage : undefined;
2800
2837
  const bgTerminalSummary = failReasonBg !== undefined
2801
2838
  ? `Agent "${shortDesc}" failed: ${failReasonBg}${ccElapsedTag(Date.now() - bgStartedAt)}${observerNote}`.slice(0, 300) + errorKindClause(errClassBg)
@@ -3106,6 +3143,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3106
3143
  details: completedAgentCard(child, {
3107
3144
  ...(def !== undefined ? { subagentType: def.name } : {}),
3108
3145
  error: errClass,
3146
+ ...(() => {
3147
+ const a = childAttestation(child.status);
3148
+ return a !== undefined ? { attestation: a } : {};
3149
+ })(),
3109
3150
  ...(handbackWarning !== undefined ? { handbackWarning } : {}),
3110
3151
  ...(modelFallback !== undefined ? { modelFallback } : {}),
3111
3152
  ...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
@@ -45,6 +45,26 @@ export declare function extractErrorCode(errorMessage: string | undefined): Brai
45
45
  * human text. Pairs with {@link extractErrorCode} so a caller can lift the code and clean the message
46
46
  * without re-hardcoding the code list (the Runner does both when assembling `TaskResult`). */
47
47
  export declare function stripErrorCodePrefix(errorMessage: string): string;
48
+ /**
49
+ * Whether a transport-level connect failure is DETERMINISTIC (see {@link DETERMINISTIC_CONNECT_CODES})
50
+ * or gives no such verdict. Classification is by `code` along the error graph — never by message text,
51
+ * which providers and runtimes reword freely.
52
+ *
53
+ * The graph, not just the chain: undici nests the actionable error under `cause`, and a dual-stack
54
+ * connect nests one `AggregateError` whose `errors[]` hold the per-family results. A code outside the
55
+ * set ANYWHERE in the graph returns `"indeterminate"`, and every AGGREGATE LEG must reach a
56
+ * deterministic verdict of its own — so a hostname whose IPv4 leg is refused while its IPv6 leg timed
57
+ * out, or whose second leg says nothing this classifier can read, keeps the full ladder. Position
58
+ * decides how silence is read: a WRAPPER with no code (the `TypeError("fetch failed")` shell, an
59
+ * `AbortSignal.timeout` DOMException whose `code` is a number) is structural and defers to what it
60
+ * wraps, while a LEG with nothing readable is an outcome nobody accounted for and blocks the verdict.
61
+ *
62
+ * Fail-safe by construction — every uncertain outcome (empty aggregate, an unreadable leg, a graph
63
+ * larger than the node budget, no deterministic code found at all) answers `"indeterminate"`, i.e. the
64
+ * pre-existing behavior.
65
+ */
66
+ export type ConnectFailureClass = "deterministic" | "indeterminate";
67
+ export declare function classifyConnectFailure(e: unknown): ConnectFailureClass;
48
68
  /** RB-386③ ([2090]) — render a transport-level error WITHOUT folding away its cause chain. undici's
49
69
  * `fetch` rejects with `TypeError("fetch failed", { cause })` where the CAUSE carries the actually
50
70
  * actionable fact (`connect ECONNREFUSED …` / `getaddrinfo ENOTFOUND …` / `SocketError: other side
@@ -61,6 +61,46 @@ function hasStaleConnectionCode(e) {
61
61
  }
62
62
  return false;
63
63
  }
64
+ const DETERMINISTIC_CONNECT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND"]);
65
+ const CONNECT_GRAPH_NODE_BUDGET = 16;
66
+ function walkConnectFailure(node, budget) {
67
+ if (budget.left <= 0)
68
+ return "indeterminate";
69
+ budget.left--;
70
+ if (node === undefined || node === null)
71
+ return "silent";
72
+ let verdict = "silent";
73
+ const code = node.code;
74
+ if (typeof code === "string") {
75
+ if (!DETERMINISTIC_CONNECT_CODES.has(code))
76
+ return "indeterminate";
77
+ verdict = "deterministic";
78
+ }
79
+ if (!(node instanceof Error))
80
+ return verdict;
81
+ const members = node.errors;
82
+ if (Array.isArray(members)) {
83
+ if (members.length === 0)
84
+ return "indeterminate";
85
+ for (const member of members) {
86
+ if (walkConnectFailure(member, budget) !== "deterministic")
87
+ return "indeterminate";
88
+ }
89
+ verdict = "deterministic";
90
+ }
91
+ const cause = node.cause;
92
+ if (cause !== undefined && cause !== null) {
93
+ const below = walkConnectFailure(cause, budget);
94
+ if (below === "indeterminate")
95
+ return "indeterminate";
96
+ if (below === "deterministic")
97
+ verdict = "deterministic";
98
+ }
99
+ return verdict;
100
+ }
101
+ export function classifyConnectFailure(e) {
102
+ return walkConnectFailure(e, { left: CONNECT_GRAPH_NODE_BUDGET }) === "deterministic" ? "deterministic" : "indeterminate";
103
+ }
64
104
  export function describeNetworkError(e) {
65
105
  const top = e instanceof Error ? e.message : String(e);
66
106
  const parts = [];
@@ -46,9 +46,23 @@ export interface ProviderWaitHint {
46
46
  * ask as if nothing had been truncated at all, even though the pair's honored wait is identical either
47
47
  * way. */
48
48
  export declare function providerWaitHint(res: Response | undefined): ProviderWaitHint | undefined;
49
+ /**
50
+ * Ceiling on the exponential term for the SHORT lane that serves deterministic connect failures
51
+ * (`ECONNREFUSED` / a name with no address — see `errors.ts` `classifyConnectFailure`).
52
+ *
53
+ * That lane exists because waiting does not change the answer: the only thing that can is the peer
54
+ * itself coming up. So its ladder is sized to cover a service that is already mid-restart, not to
55
+ * out-wait an overloaded provider — which is what the 32s ceiling above is for. The two are separate
56
+ * constants rather than one parameter because they answer different questions, and collapsing them
57
+ * would make either lane's number look arbitrary.
58
+ */
59
+ export declare const FAST_MAX_BACKOFF_MS = 1000;
49
60
  /**
50
61
  * Backoff (ms) for retry `attempt` (0-based) of a transient failure: an increasing capped exponential
51
62
  * plus ≤25% additive jitter, floored by the response's provider wait hints (see the module note).
52
- * `rand` is injectable for deterministic tests.
63
+ * `rand` is injectable for deterministic tests. `maxBackoffMs` overrides the exponential ceiling for a
64
+ * caller running a deliberately shorter lane; it bounds only the COMPUTED term — a provider wait hint
65
+ * still floors the result, because a lane choosing to be short is not a licence to ignore a server that
66
+ * said when to come back (and such a lane's failures carry no response to read a hint off anyway).
53
67
  */
54
- export declare function retryBackoffMs(baseDelayMs: number, attempt: number, res?: Response, rand?: () => number): number;
68
+ export declare function retryBackoffMs(baseDelayMs: number, attempt: number, res?: Response, rand?: () => number, maxBackoffMs?: number): number;
@@ -48,8 +48,9 @@ export function providerWaitHint(res) {
48
48
  function providerWaitHintMs(res) {
49
49
  return providerWaitHint(res)?.ms;
50
50
  }
51
- export function retryBackoffMs(baseDelayMs, attempt, res, rand = Math.random) {
52
- const exp = Math.min(MAX_BACKOFF_MS, baseDelayMs * 2 ** attempt);
51
+ export const FAST_MAX_BACKOFF_MS = 1_000;
52
+ export function retryBackoffMs(baseDelayMs, attempt, res, rand = Math.random, maxBackoffMs = MAX_BACKOFF_MS) {
53
+ const exp = Math.min(maxBackoffMs, baseDelayMs * 2 ** attempt);
53
54
  const computed = Math.round(exp + rand() * JITTER_FRACTION * exp);
54
55
  const hint = providerWaitHintMs(res);
55
56
  return hint === undefined ? computed : Math.max(hint, computed);
@@ -1,4 +1,4 @@
1
- import type { BrainStatus } from "../core/types.js";
1
+ import type { BrainRetryErrClass, BrainStatus } from "../core/types.js";
2
2
  /** Run `fn` with a per-task brain-status sink in scope. ALS propagates it through the async brain calls
3
3
  * inside `fn` (the harness's prompt → brain.stream → connect/retry loop), so {@link emitBrainStatus}
4
4
  * reaches THIS task's sink and nothing else. */
@@ -34,10 +34,17 @@ export type BrainTelemetry = {
34
34
  key: string;
35
35
  phase: "open" | "half-open" | "closed";
36
36
  failures: number;
37
- } | {
37
+ }
38
+ /** A retried provider call. `errClass` is the same closed bucket the user-facing status frame carries
39
+ * (so an operator reading traces and a user reading a progress line agree on why); `nextDelayMs` is
40
+ * the backoff actually about to be slept — 0 for the immediate re-sends that take no backoff. Both
41
+ * optional so a caller that has no verdict to report is not forced to invent one. */
42
+ | {
38
43
  kind: "retry";
39
44
  attempt: number;
40
45
  phase: "connect" | "midstream";
46
+ errClass?: BrainRetryErrClass;
47
+ nextDelayMs?: number;
41
48
  } | {
42
49
  kind: "vision_placeholder";
43
50
  count: number;
@@ -25,6 +25,21 @@ export interface StreamEngineConfig extends BrainTimeoutConfig {
25
25
  maxRetries?: number;
26
26
  /** First-retry backoff ms; each further attempt doubles it (capped, +≤25% jitter). Default 500. */
27
27
  retryDelayMs?: number;
28
+ /**
29
+ * Retries allowed for a DETERMINISTIC connect failure — one whose error codes say the target itself
30
+ * is not there (`ECONNREFUSED`, or a name with no address; see `errors.ts` `classifyConnectFailure`).
31
+ * Absent ⇒ {@link DEFAULT_DETERMINISTIC_CONNECT_RETRIES}.
32
+ *
33
+ * These retries exist for one case only: a peer that is mid-restart and about to accept. Waiting
34
+ * longer cannot change any other outcome — re-sending at an address where nothing listens gets the
35
+ * same refusal in milliseconds — so spending the full transient budget on it buys nothing and costs
36
+ * an unattended caller minutes of silence.
37
+ *
38
+ * The value is capped by {@link maxRetries} (this lane can only ever be the SHORTER of the two).
39
+ * Setting it AT or ABOVE `maxRetries` turns the lane off entirely: budget and backoff both revert to
40
+ * the shared transient ladder, which is the opt-out for a deployment that wants the old behavior.
41
+ */
42
+ deterministicConnectRetries?: number;
28
43
  }
29
44
  /**
30
45
  * Resolve the transient-failure retry budget — explicit config > `SEMA_MAX_RETRIES` env > default 10
@@ -32,6 +47,13 @@ export interface StreamEngineConfig extends BrainTimeoutConfig {
32
47
  * value is ignored rather than silently meaning "zero retries"; an oversized one is clamped.
33
48
  */
34
49
  export declare function resolveMaxRetries(configured: number | undefined): number;
50
+ /**
51
+ * Resolve the deterministic-connect retry budget. Same posture as {@link resolveMaxRetries}: a chosen
52
+ * NUMBER is honored, a non-finite or negative one falls back to the default rather than silently
53
+ * meaning something (a NaN here would compare false against every budget gate and disable the lane by
54
+ * accident, which is the sentinel-inversion shape CLS-A-8 pinned for the shared budget).
55
+ */
56
+ export declare function resolveDeterministicConnectRetries(configured: number | undefined): number;
35
57
  /**
36
58
  * Is `rebuilt` still THE SAME REQUEST as `original`, differing only in the output cap and the values
37
59
  * that follow from it (ruled 2026-08-05)?
@@ -1,7 +1,7 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
3
- import { BrainError, classifyHttp, describeNetworkError } from "./errors.js";
4
- import { providerWaitHint, retryBackoffMs } from "./retry.js";
3
+ import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError } from "./errors.js";
4
+ import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
5
5
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
6
6
  import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
7
7
  const DEFAULT_MAX_RETRIES = 10;
@@ -18,6 +18,12 @@ export function resolveMaxRetries(configured) {
18
18
  }
19
19
  return DEFAULT_MAX_RETRIES;
20
20
  }
21
+ const DEFAULT_DETERMINISTIC_CONNECT_RETRIES = 2;
22
+ export function resolveDeterministicConnectRetries(configured) {
23
+ if (configured !== undefined && Number.isFinite(configured) && configured >= 0)
24
+ return Math.floor(configured);
25
+ return DEFAULT_DETERMINISTIC_CONNECT_RETRIES;
26
+ }
21
27
  const SHOULD_RETRY_HEADER = "x-should-retry";
22
28
  function sameHeaders(a, b) {
23
29
  const aKeys = Object.keys(a);
@@ -272,6 +278,8 @@ export function runStreamingBrain(args) {
272
278
  let maxOutputTokensOverride;
273
279
  const maxRetries = resolveMaxRetries(config.maxRetries);
274
280
  const baseDelay = config.retryDelayMs ?? 500;
281
+ const deterministicConnectBudget = Math.min(resolveDeterministicConnectRetries(config.deterministicConnectRetries), maxRetries);
282
+ const deterministicLaneActive = deterministicConnectBudget < maxRetries;
275
283
  const firstTokenTimeoutMs = resolveStallTimeoutMs(config.firstTokenTimeoutMs ?? stallTimeouts?.firstTokenMs, "firstTokenTimeoutMs");
276
284
  const idleTimeoutMs = resolveStallTimeoutMs(config.idleTimeoutMs ?? stallTimeouts?.idleMs, "idleTimeoutMs");
277
285
  const connectTimeoutMs = resolveStallTimeoutMs(config.connectTimeoutMs ?? stallTimeouts?.connectMs, "connectTimeoutMs");
@@ -342,13 +350,14 @@ export function runStreamingBrain(args) {
342
350
  maxOutputTokensOverride = plan.maxOutputTokens;
343
351
  req = rebuilt;
344
352
  req.onCommitted?.();
345
- emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect" });
353
+ emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect", errClass: "output_cap", nextDelayMs: 0 });
346
354
  announcedRetry = true;
347
355
  emitBrainStatus({
348
356
  phase: "retrying",
349
357
  detail: `output limit lowered from ${previousCap} to ${plan.maxOutputTokens} tokens, retrying`,
350
358
  attempt: attempt + 1,
351
359
  maxRetries,
360
+ errClass: "output_cap",
352
361
  });
353
362
  continue;
354
363
  }
@@ -370,14 +379,33 @@ export function runStreamingBrain(args) {
370
379
  const retryableByStatus = netErr !== undefined ||
371
380
  (r !== undefined && (r.status >= 500 || r.status === 429 || r.status === 408 || r.status === 409));
372
381
  const retryable = shouldRetryHeaderVerdict(r) ?? retryableByStatus;
373
- if (retryable && attempt < maxRetries) {
374
- const delayMs = retryBackoffMs(baseDelay, attempt, r);
382
+ const deterministicConnect = netErr !== undefined && classifyConnectFailure(netErr) === "deterministic";
383
+ const fastLane = deterministicConnect && deterministicLaneActive;
384
+ const laneMaxRetries = fastLane ? deterministicConnectBudget : maxRetries;
385
+ const errClass = netErr !== undefined
386
+ ? deterministicConnect
387
+ ? "connect_refused"
388
+ : "transport"
389
+ : r?.status === 429
390
+ ? "rate_limit"
391
+ : r !== undefined && (r.status >= 500 || r.status === 408 || r.status === 409)
392
+ ? "server"
393
+ : "http";
394
+ if (retryable && attempt < laneMaxRetries) {
395
+ const delayMs = fastLane
396
+ ? retryBackoffMs(baseDelay, attempt, r, Math.random, FAST_MAX_BACKOFF_MS)
397
+ : retryBackoffMs(baseDelay, attempt, r);
375
398
  const waitHint = providerWaitHint(r);
376
399
  const truncationNote = waitHint?.rawMs !== undefined
377
400
  ? ` (the requested wait was longer than the cap; honoring ${Math.ceil(waitHint.ms / 1000)}s instead of ${Math.ceil(waitHint.rawMs / 1000)}s)`
378
401
  : "";
379
402
  const statusPhase = r?.status === 429 ? "rate_limited" : netErr !== undefined ? "reconnecting" : "retrying";
380
- emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect" });
403
+ const reconnectDetail = errClass !== "connect_refused"
404
+ ? "connection lost, reconnecting"
405
+ : fastLane
406
+ ? "no endpoint answering, retrying briefly"
407
+ : "no endpoint answering, retrying";
408
+ emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect", errClass, nextDelayMs: delayMs });
381
409
  announcedRetry = true;
382
410
  discardResponseBody(r);
383
411
  cc.abort();
@@ -387,12 +415,13 @@ export function runStreamingBrain(args) {
387
415
  detail: statusPhase === "rate_limited"
388
416
  ? `rate limited, backing off${truncationNote}`
389
417
  : statusPhase === "reconnecting"
390
- ? "connection lost, reconnecting"
418
+ ? reconnectDetail
391
419
  : `transient error, retrying${truncationNote}`,
392
420
  retryInSec: Math.ceil(remainingMs / 1000),
393
421
  retryInMs: remainingMs,
394
422
  attempt: attempt + 1,
395
- maxRetries,
423
+ maxRetries: laneMaxRetries,
424
+ errClass,
396
425
  }));
397
426
  continue;
398
427
  }
@@ -561,7 +590,7 @@ export function runStreamingBrain(args) {
561
590
  thinkingRetries++;
562
591
  parser.sealForRetry();
563
592
  const delayMs = 100 * thinkingRetries;
564
- emitBrainTelemetry({ kind: "retry", attempt: thinkingRetries, phase: "midstream" });
593
+ emitBrainTelemetry({ kind: "retry", attempt: thinkingRetries, phase: "midstream", errClass: "transport", nextDelayMs: delayMs });
565
594
  announcedRetry = true;
566
595
  await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
567
596
  phase: "reconnecting",
@@ -570,6 +599,7 @@ export function runStreamingBrain(args) {
570
599
  retryInMs: remainingMs,
571
600
  attempt: thinkingRetries,
572
601
  maxRetries: THINKING_RETRY_BUDGET,
602
+ errClass: "transport",
573
603
  }));
574
604
  continue;
575
605
  }
@@ -577,7 +607,7 @@ export function runStreamingBrain(args) {
577
607
  throw failure.err;
578
608
  const delayMs = retryBackoffMs(baseDelay, attempt);
579
609
  attempt++;
580
- emitBrainTelemetry({ kind: "retry", attempt, phase: "midstream" });
610
+ emitBrainTelemetry({ kind: "retry", attempt, phase: "midstream", errClass: "transport", nextDelayMs: delayMs });
581
611
  announcedRetry = true;
582
612
  await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
583
613
  phase: "reconnecting",
@@ -586,6 +616,7 @@ export function runStreamingBrain(args) {
586
616
  retryInMs: remainingMs,
587
617
  attempt,
588
618
  maxRetries,
619
+ errClass: "transport",
589
620
  }));
590
621
  }
591
622
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * F-012 L2 — the ENGINE-INTERNAL ask-class envelope.
3
+ *
4
+ * An `ask` verdict's SEMANTIC CLASS decides whether a deployment-declared sandbox may auto-admit it
5
+ * (clay's ruling: a declared sandbox admits everything inside it — the boundary crossings park to a
6
+ * human, escape prevention belongs to the sandbox, not to engine re-policing). The class deliberately
7
+ * lives OUTSIDE the public `PermissionResult`: a decision object is caller-supplied data, and a
8
+ * self-reported "this ask is sandbox-local" field would be the same trust hole as the
9
+ * `decisionReason:"safety"` self-declaration this codebase already refuses. Instead, BUILT-IN policy
10
+ * factories register their instances here (private WeakMap brand — nothing a deployment policy can
11
+ * forge by shaping its return value), and everything unregistered reads as `unknown`.
12
+ *
13
+ * Classes and their dominance (STRICT — merge keeps the strongest; only an all-`sandbox_local` set
14
+ * may auto-admit):
15
+ * - `sandbox_local` — an engine-authored "confirm a potentially destructive LOCAL action" ask
16
+ * (name/pattern confirmation lists). Sandbox containment answers it.
17
+ * - `external_authority` — an ask whose whole point is real judgment (the safety-rule policies with
18
+ * `requiresRealApproval`); containment answers nothing about it.
19
+ * - `hook` — a screening-face ask (a deployment's PreToolUse verdict folded to policy
20
+ * form); the face's authority is the deployment's, not the sandbox's.
21
+ * - `mandate` — an ask bound to durable-park semantics (inherited durable mandate /
22
+ * approver-unavailable float); it must reach the park untouched.
23
+ * - `unknown` — everything unregistered, every deployment policy. Fail-closed: never
24
+ * admitted.
25
+ */
26
+ export type AskClass = "sandbox_local" | "external_authority" | "hook" | "mandate" | "unknown";
27
+ /**
28
+ * Brand a built-in policy instance with its ask class (engine factories only — not exported from the
29
+ * package). The `check` binding is LOCKED as a non-writable, non-configurable DATA property before the
30
+ * policy is returned (codex adversarial round 4, HIGH): reading `policy.check` and INVOKING it must
31
+ * name the same callable, atomically. A plain `===` re-read was TOCTOU-able by an accessor — a getter
32
+ * could return the branded function to `policyAskClassOf` and a deployment ask to the gate's
33
+ * invocation. Locking the property makes the callable un-swappable and un-redefinable, so the class
34
+ * travels with the exact decision function. An `check` that is ALREADY accessor-backed (or a frozen
35
+ * object we cannot lock) is refused branding — it reads back `unknown` (fail-closed), never trusted.
36
+ */
37
+ export declare function brandPolicyAskClass<T extends {
38
+ check: unknown;
39
+ }>(policy: T, cls: AskClass): T;
40
+ /** The registered class of a policy instance — `unknown` for anything unregistered, whose `check` is
41
+ * no longer the locked data value, or whose `check` became accessor-backed (fail-closed: a
42
+ * replaced/decorated/getter-shadowed decision function is not the branded one). Reads the property
43
+ * DESCRIPTOR's value (never invokes a getter), so the check is atomic with what the gate will call. */
44
+ export declare function policyAskClassOf(policy: (object & {
45
+ check?: unknown;
46
+ }) | null | undefined): AskClass;
47
+ /** The strongest class in a set (strict dominance — any non-`sandbox_local` member dominates). */
48
+ export declare function dominantAskClass(classes: readonly AskClass[]): AskClass;
@@ -0,0 +1,33 @@
1
+ const RANK = { sandbox_local: 0, external_authority: 1, hook: 2, mandate: 3, unknown: 4 };
2
+ const registry = new WeakMap();
3
+ export function brandPolicyAskClass(policy, cls) {
4
+ const desc = Object.getOwnPropertyDescriptor(policy, "check");
5
+ if (desc === undefined || desc.get !== undefined || desc.set !== undefined)
6
+ return policy;
7
+ try {
8
+ Object.defineProperty(policy, "check", { value: policy.check, writable: false, configurable: false, enumerable: desc.enumerable ?? true });
9
+ }
10
+ catch {
11
+ return policy;
12
+ }
13
+ registry.set(policy, { cls, check: policy.check });
14
+ return policy;
15
+ }
16
+ export function policyAskClassOf(policy) {
17
+ if (policy === null || policy === undefined)
18
+ return "unknown";
19
+ const entry = registry.get(policy);
20
+ if (entry === undefined)
21
+ return "unknown";
22
+ const desc = Object.getOwnPropertyDescriptor(policy, "check");
23
+ if (desc === undefined || desc.get !== undefined || desc.set !== undefined)
24
+ return "unknown";
25
+ return desc.value === entry.check ? entry.cls : "unknown";
26
+ }
27
+ export function dominantAskClass(classes) {
28
+ let strongest = "sandbox_local";
29
+ for (const c of classes)
30
+ if (RANK[c] > RANK[strongest])
31
+ strongest = c;
32
+ return strongest;
33
+ }