@sema-agent/core 5.40.0 → 5.41.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.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,61 @@
1
1
  # Changelog
2
2
 
3
- ## 5.40.0 — 2026-08-18
3
+ ## 5.41.0 — 2026-08-18
4
4
 
5
- No BREAKING changes. Two behavioral narrowings disclosed below (the requestedCwd × workspace-restore
5
+ No BREAKING changes. Three behavioral narrowings disclosed below (garbage values on the three
6
+ tool-face seats now refuse loudly; the strict sidecar family's journal read and lock fence both
7
+ join the fail-closed law).
8
+
9
+ ### Added
10
+
11
+ - backlog #259 — user steers stranded at agent_end are ANNOUNCED, never silently dropped behind
12
+ their "queued" receipts: the terminal sweep hands ENGINE notes back for per-session redelivery,
13
+ but USER inputs have no redelivery semantics (a steer aimed at a finished run must not fire at
14
+ the next one) — their remnant counts now ride a dedicated harness sink, the `settled` frame
15
+ (additive `undrainedSteerCount`/`undrainedFollowUpCount`), and a new `EngineNotice` family
16
+ `task.user_steer_undrained`. The stranding window is a narrow race past the loop's final queue
17
+ check, which is exactly why it gets a loud terminal account.
18
+ - backlog #251 — the mixed-version window of the design/186 ledger v2 is machine-detected:
19
+ recovering a v1-format journal over a v2 disk ledger (a shape only a pre-v2 process mints)
20
+ announces the old-writer-alive warning on the external-findings channel; recovery is unchanged.
21
+
22
+ ### Fixed
23
+
24
+ - backlog #297 (ruled: narrow) — the three tool-face seats (`excludeTools` / `deferTools` /
25
+ `alwaysLoadTools`) get the fourth seat's value door: a garbage value (a bare string, a truthy
26
+ non-iterable, a non-string entry) refuses `config.tool_face_control` instead of stringifying
27
+ into a never-matching list, throwing an uncoded TypeError, or a falsy value silently reading as
28
+ absent. **Narrowing**: a deployment whose misspelled seat previously "worked" now hears it.
29
+ - backlog #299 (ruled: fix alone) — the auto-mode classifier's action block bounds tool args at
30
+ 48,000 chars (its own subject-level cap, deliberately above the delegation-review lanes'
31
+ 12,000-per-field sampled payloads so their coverage notes ride through uncut): it was the one
32
+ unbounded string in the classifier's prompt, so a single long tool argument (a SendMessage body
33
+ needs nothing crafted, just length) could blow the classifier's context and trip the session
34
+ breaker out of auto mode.
35
+ - backlog #307 (also filed as #256) — the journal-aware strict sidecar read joins the ENOENT-only
36
+ absence law: an EACCES/EIO journal read refused `ControlPlaneCorruptError` instead of silently
37
+ serving the pre-transaction state while a committed next state sat unreadable beside it — the
38
+ one fail-open left in the strict-read family. **Narrowing**: a deployment whose journal reads
39
+ were failing now hears it instead of silently reading stale.
40
+ - backlog #255 — the sidecar lock's commit fence splits by family: the fail-closed LEDGER family
41
+ (challenge/lineage locked updates and the rebuild path) aborts on an UNPROVABLE owner
42
+ (absent/unreadable owner file) — "cannot disprove a steal" is not a fence; the calibration
43
+ family keeps its documented pre-token leniency, and a FOREIGN token aborts both as before.
44
+ **Narrowing**: within the ledger family only.
45
+ - CHANGELOG erratum for 5.40.0 (recorded in that entry): the header counts THREE narrowings
46
+ (the defer alignment was the missing third) and the release date corrects to 08-17.
47
+
48
+ ## 5.40.0 — 2026-08-17
49
+
50
+ No BREAKING changes. Three behavioral narrowings disclosed below (the requestedCwd × workspace-restore
6
51
  combination now refuses loudly; Grep's ripgrep leg withholds alias-spelled deny-listed paths it
7
- previously returned).
52
+ previously returned; `deferMode: "auto"` never sweeps engine built-ins — a small-window deployment
53
+ whose built-ins were previously deferred now carries their schemas inline, trading context bytes for
54
+ the first-use activation error the sweep caused).
55
+
56
+ <!-- Post-release erratum (2026-08-17, server review [4351]): the header originally said "Two
57
+ behavioral narrowings" and dated the entry 2026-08-18 (pre-written); the defer narrowing was listed
58
+ under Fixed but missing from this header, and the release actually shipped late on 08-17. -->
8
59
 
9
60
  ### Added
10
61
 
@@ -102,5 +102,5 @@ export function renderAutoModeWindow(messages, options) {
102
102
  export function renderAutoModeAction(input) {
103
103
  const ask = input.askMessage ? `\npermission gate: ${input.askMessage}` : "";
104
104
  return (`\n## New action to classify (the agent's most recent action — evaluate THIS)\n\n` +
105
- `[tool_call] ${input.req.toolName} ${JSON.stringify(input.req.args ?? {})}${ask}\n`);
105
+ `[tool_call] ${input.req.toolName} ${excerpt(JSON.stringify(input.req.args ?? {}), 48_000)}${ask}\n`);
106
106
  }
@@ -1166,6 +1166,17 @@ export class FileMemoryEngineBackend {
1166
1166
  throw new ControlPlaneCorruptError(`transaction journal v1 ledger snapshot entry ${JSON.stringify(id)} is not a string: ${jp}`);
1167
1167
  snapshotRows[id] = { rev };
1168
1168
  }
1169
+ try {
1170
+ const diskRaw = readControlFileOrAbsent(join(this.controlPlaneRoot, LEDGER_FILE), "committed-rev ledger");
1171
+ const diskParsed = diskRaw !== undefined ? JSON.parse(diskRaw) : undefined;
1172
+ if (diskParsed !== null && typeof diskParsed === "object" && "v" in diskParsed && diskParsed.v === LEDGER_SCHEMA_VERSION) {
1173
+ this.enqueueExternalItems([
1174
+ "memory ledger: recovering a v1-format journal over a v2 ledger — a pre-v2 process wrote AFTER this store migrated (mixed-version deployment window). Retire old readers/writers before further writes; see the v2 rollback runbook.",
1175
+ ]);
1176
+ }
1177
+ }
1178
+ catch {
1179
+ }
1169
1180
  }
1170
1181
  for (const op of journal.ops) {
1171
1182
  const invalid = journalOpInvalid(op, this.directoryRoot);
@@ -248,6 +248,16 @@ export interface SidecarLockOptions {
248
248
  * volume, revoked permission) makes mkdir fail forever while the lock dir it would stat never
249
249
  * exists — spun this synchronous loop at full speed and never reached its own cap. */
250
250
  export declare function acquireSidecarLock(lockDir: string, opts?: SidecarLockOptions): string;
251
+ /** 证伪式复审 L2-analog: re-verify the owner token at the COMMIT POINT — a stolen-from holder
252
+ * aborts instead of writing over the stealer. A FOREIGN token always aborts (positive proof the
253
+ * lock is someone else's now). What an ABSENT/UNREADABLE owner file means splits by caller family
254
+ * (backlog #255): the fail-closed LEDGER family (`"strict"` — challenges/lineage via
255
+ * lockedStrictUpdate) treats unprovable ownership as lost — those sidecars promise their commit
256
+ * point is fenced, and "cannot disprove" is not a fence; the fail-open calibration family
257
+ * (`"lenient"`, the pre-#255 shape — e.g. the scope registry, whose sidecars self-heal at the next
258
+ * locked update) keeps the leniency, because its token writes are best-effort and an absent owner
259
+ * file there is the documented pre-token degraded shape, not evidence of a steal. */
260
+ export declare function assertSidecarLockOwnership(lockDir: string, token: string, what: string, mode?: "strict" | "lenient"): void;
251
261
  interface AnnouncementsRecord {
252
262
  /** How many announcements were dropped by the bounded-queue fold (disclosed at render). */
253
263
  folded: number;
@@ -598,12 +598,15 @@ function lockedJournaledUpdate(controlDir, fileName, mutate) {
598
598
  releaseSidecarLock(lock, token);
599
599
  }
600
600
  }
601
- function assertSidecarLockOwnership(lockDir, token, what) {
601
+ export function assertSidecarLockOwnership(lockDir, token, what, mode = "lenient") {
602
602
  let held;
603
603
  try {
604
604
  held = readFileSync(join(lockDir, "owner"), "utf8");
605
605
  }
606
- catch {
606
+ catch (err) {
607
+ if (mode === "strict" && err.code !== undefined) {
608
+ throw new ControlPlaneCorruptError(`${what}: sidecar lock ownership UNPROVABLE at commit (owner file ${err.code === "ENOENT" ? "absent" : `unreadable: ${err.code}`}) — a fail-closed ledger must not commit on a fence it cannot prove; update aborted`, { cause: err });
609
+ }
607
610
  return;
608
611
  }
609
612
  if (held !== token) {
@@ -897,7 +900,7 @@ function lockedStrictUpdate(controlDir, fileName, what, coerce, fn) {
897
900
  const { next, result } = fn(current);
898
901
  if (next !== undefined) {
899
902
  const data = `${JSON.stringify(next, null, 2)}\n`;
900
- assertSidecarLockOwnership(lock, token, what);
903
+ assertSidecarLockOwnership(lock, token, what, "strict");
901
904
  atomicWriteFileSync(journal, data);
902
905
  atomicWriteFileSync(file, data);
903
906
  rmSync(journal, { force: true });
@@ -932,7 +935,10 @@ function readStrictSidecar(controlDir, fileName, what) {
932
935
  try {
933
936
  journalRaw = readFileSync(journal, "utf8");
934
937
  }
935
- catch {
938
+ catch (err) {
939
+ if (err.code !== "ENOENT") {
940
+ throw new ControlPlaneCorruptError(`control-plane journal unreadable ((${err.code ?? "io error"})) — a committed next state may exist that cannot be proven; repair the read fault rather than serving the pre-transaction state: ${journal}`, { cause: err });
941
+ }
936
942
  journalRaw = undefined;
937
943
  }
938
944
  if (journalRaw !== undefined) {
@@ -1421,7 +1427,7 @@ export function rebuildStrictControlPlaneLedger(controlDir, ledger, now) {
1421
1427
  }
1422
1428
  const at = now();
1423
1429
  const quarantinedTo = [];
1424
- assertSidecarLockOwnership(lock, token, what);
1430
+ assertSidecarLockOwnership(lock, token, what, "strict");
1425
1431
  for (const path of [file, journal]) {
1426
1432
  if (!existsSync(path))
1427
1433
  continue;
@@ -1439,7 +1445,7 @@ export function rebuildStrictControlPlaneLedger(controlDir, ledger, now) {
1439
1445
  }
1440
1446
  quarantinedTo.push(dest);
1441
1447
  }
1442
- assertSidecarLockOwnership(lock, token, what);
1448
+ assertSidecarLockOwnership(lock, token, what, "strict");
1443
1449
  atomicWriteFileSync(file, `${JSON.stringify(empty, null, 2)}\n`);
1444
1450
  rmSync(journal, { force: true });
1445
1451
  return { ledger, quarantinedTo, at };
@@ -87,6 +87,26 @@ export function prepareConfigDoors(input) {
87
87
  const { deps, sessions, resume, internals } = input;
88
88
  let spec = input.spec;
89
89
  assertRestoreGatedToolsValue(spec.restoreGatedTools);
90
+ const assertToolNameListValue = (value, seat) => {
91
+ if (value === undefined)
92
+ return;
93
+ if (!Array.isArray(value)) {
94
+ const e = new Error(`TaskSpec.${seat} must be an array of tool names or absent (got ${value === null ? "null" : typeof value}) — a garbage list must refuse loudly, never be read as "no ${seat}".`);
95
+ e.code = "config.tool_face_control";
96
+ throw e;
97
+ }
98
+ const entries = value;
99
+ for (const entry of entries) {
100
+ if (typeof entry !== "string") {
101
+ const e = new Error(`TaskSpec.${seat} entries must be strings (got ${entry === null ? "null" : typeof entry}).`);
102
+ e.code = "config.tool_face_control";
103
+ throw e;
104
+ }
105
+ }
106
+ };
107
+ assertToolNameListValue(spec.excludeTools, "excludeTools");
108
+ assertToolNameListValue(spec.deferTools, "deferTools");
109
+ assertToolNameListValue(spec.alwaysLoadTools, "alwaysLoadTools");
90
110
  const toolFaceSnapshot = {
91
111
  exclude: spec.excludeTools ? Object.freeze([...spec.excludeTools]) : undefined,
92
112
  defer: spec.deferTools ? Object.freeze([...spec.deferTools]) : undefined,
@@ -1,6 +1,6 @@
1
1
  import { persistedReadDenyEntryProblem } from "../../tools/fs/read-deny.js";
2
2
  import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js";
3
- import { deliverDelegationLifecycle } from "../types.js";
3
+ import { deliverDelegationLifecycle, deliverEngineNotice } from "../types.js";
4
4
  import { AgentHarness, DEFAULT_COMPACTION_SETTINGS, uuidv7 } from "../../internal/harness.js";
5
5
  import { snapshotActorAssertion } from "../../internal/llm.js";
6
6
  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";
@@ -2068,6 +2068,14 @@ export class Runner {
2068
2068
  for (const p of payloads)
2069
2069
  this.pendingSessionNotifications.pend(notificationSessionId, p);
2070
2070
  };
2071
+ prepared.harness.onUndrainedUserInputs = (counts) => {
2072
+ deliverEngineNotice(this.deps.onNotice, {
2073
+ code: "task.user_steer_undrained",
2074
+ message: `${counts.steer + counts.followUp} user input(s) accepted as "queued" were never consumed — the run ended first ` +
2075
+ `(${counts.steer} steer, ${counts.followUp} follow-up). They are NOT redelivered; re-send against a live run if still wanted.`,
2076
+ detail: { steer: counts.steer, followUp: counts.followUp, ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}) },
2077
+ });
2078
+ };
2071
2079
  prepared.harness.onEngineNoteConsumed = (p) => {
2072
2080
  const peer = p?.peer;
2073
2081
  if (peer !== undefined && Array.isArray(peer.hopChain))
@@ -4669,6 +4669,13 @@ export interface EngineNotice {
4669
4669
  * value IS in force the prepare refuses with the same code as `TaskResult.errorCode` (one
4670
4670
  * fact, one code, two loudness dialects); `detail: { raw }`.
4671
4671
  *
4672
+ * - `"task.user_steer_undrained"` (#259) — user steers/follow-ups whose receipts said "queued"
4673
+ * were still in the queues at agent_end: the run ended before any turn could drain them. They
4674
+ * are NOT redelivered (a steer aimed at a finished run must not fire at the next one — unlike
4675
+ * ENGINE notes, which pend per session); the notice is the loud half of the #257 contract's
4676
+ * "accepted = enqueued, not consumed" sentence; `detail: { steer, followUp, taskId? }`.
4677
+ * Per-run, at most once (the terminal sweep is a single site).
4678
+ *
4672
4679
  * Deliberately NOT a notice family: brain retry/reconnect liveness (a rate limit, a 5xx, a
4673
4680
  * transient network failure being retried). Those are per-attempt liveness frames with their own
4674
4681
  * frequency semantics and ride the wire `status` channel ({@link BrainStatus}), whose sink the
@@ -80,6 +80,16 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
80
80
  private thinkingLevel;
81
81
  /** RB-30 terminal fix — runner-set sink for engine-note payloads left undrained at agent_end. */
82
82
  onUndrainedEngineNotes?: (payloads: unknown[]) => void;
83
+ /** backlog #259 — USER-authored queue remnants at agent_end: steers/follow-ups whose receipts said
84
+ * "queued" but that no turn will ever drain (the run ended first). The engine-note sweep above
85
+ * hands ENGINE payloads back for redelivery; user inputs have no redelivery semantics (a steer
86
+ * aimed at a finished run must not silently fire at the next one), so their loss is ANNOUNCED
87
+ * instead — the runner surfaces it as an operator notice, closing the "accepted then silently
88
+ * dropped" window the #257 contract could only document. */
89
+ onUndrainedUserInputs?: (counts: {
90
+ steer: number;
91
+ followUp: number;
92
+ }) => void;
83
93
  /** design/176 — runner-set sink fired at the CONSUMPTION boundary, once per engine-note payload,
84
94
  * in consumption order (steer/followUp drain and the turn-open nextTurn splice — the two points
85
95
  * where a queued frame actually enters the model's input). The runner uses it to record the
@@ -99,6 +109,13 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
99
109
  * so a double call is a no-op. */
100
110
  recoverUndrainedEngineNotes(): void;
101
111
  private sweepUndrainedEngineNotes;
112
+ /** backlog #259 — what remains in the steer/follow-up queues AFTER the engine-note sweep is USER
113
+ * input that was accepted ("queued") and will never be consumed: the run reached agent_end first.
114
+ * User inputs have no redelivery semantics (unlike engine notes — a steer aimed at a finished run
115
+ * must not fire at the next one), so the loss is ANNOUNCED, never silent. The window is a narrow
116
+ * race (an injection landing after the loop's final queue check), which is exactly why it needs a
117
+ * loud terminal account rather than an e2e reproduction. Returns the counts for the settled frame. */
118
+ private announceUndrainedUserInputs;
102
119
  private systemPrompt;
103
120
  /** S4: physical system blocks (static per leg, additive — see AgentHarnessOptions.systemBlocks). */
104
121
  private systemBlocks;
@@ -158,6 +158,7 @@ export class AgentHarness {
158
158
  model;
159
159
  thinkingLevel;
160
160
  onUndrainedEngineNotes;
161
+ onUndrainedUserInputs;
161
162
  onEngineNoteConsumed;
162
163
  recoverUndrainedEngineNotes() {
163
164
  this.sweepUndrainedEngineNotes([this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
@@ -182,6 +183,17 @@ export class AgentHarness {
182
183
  }
183
184
  }
184
185
  }
186
+ announceUndrainedUserInputs() {
187
+ const counts = { steer: this.steerQueue.length, followUp: this.followUpQueue.length };
188
+ if ((counts.steer > 0 || counts.followUp > 0) && this.onUndrainedUserInputs) {
189
+ try {
190
+ this.onUndrainedUserInputs(counts);
191
+ }
192
+ catch {
193
+ }
194
+ }
195
+ return counts;
196
+ }
185
197
  systemPrompt;
186
198
  systemBlocks;
187
199
  streamOptions;
@@ -620,9 +632,15 @@ export class AgentHarness {
620
632
  if (event.type === "agent_end") {
621
633
  await this.flushPendingSessionWrites();
622
634
  this.sweepUndrainedEngineNotes([this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
635
+ const undrainedUser = this.announceUndrainedUserInputs();
623
636
  this.phase = "idle";
624
637
  await this.emitAny(event, signal);
625
- await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal);
638
+ await this.emitOwn({
639
+ type: "settled",
640
+ nextTurnCount: this.nextTurnQueue.length,
641
+ ...(undrainedUser.steer > 0 ? { undrainedSteerCount: undrainedUser.steer } : {}),
642
+ ...(undrainedUser.followUp > 0 ? { undrainedFollowUpCount: undrainedUser.followUp } : {}),
643
+ }, signal);
626
644
  return;
627
645
  }
628
646
  await this.emitAny(event, signal);
@@ -815,6 +815,11 @@ export interface AbortEvent {
815
815
  export interface SettledEvent {
816
816
  type: "settled";
817
817
  nextTurnCount: number;
818
+ /** backlog #259 (additive) — USER steers accepted ("queued") but never drained before agent_end.
819
+ * Present only when > 0; their loss is announced through `onUndrainedUserInputs` too. */
820
+ undrainedSteerCount?: number;
821
+ /** backlog #259 (additive) — same for the follow-up queue. */
822
+ undrainedFollowUpCount?: number;
818
823
  }
819
824
  export interface BeforeAgentStartEvent<TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate> {
820
825
  type: "before_agent_start";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.40.0",
3
+ "version": "5.41.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",