@sema-agent/core 5.62.0 → 5.64.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 (55) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/agents/cascade.d.ts +5 -1
  3. package/dist/agents/cascade.js +6 -1
  4. package/dist/agents/subagent.d.ts +12 -2
  5. package/dist/agents/subagent.js +4 -2
  6. package/dist/agents/verify.d.ts +5 -1
  7. package/dist/agents/verify.js +5 -2
  8. package/dist/core/auto-compaction.d.ts +6 -4
  9. package/dist/core/auto-compaction.js +3 -0
  10. package/dist/core/checkpoint-store.d.ts +5 -1
  11. package/dist/core/context-edit.d.ts +36 -29
  12. package/dist/core/context-edit.js +3 -3
  13. package/dist/core/fs-write-gate-policy.d.ts +21 -0
  14. package/dist/core/fs-write-gate-policy.js +14 -3
  15. package/dist/core/hooks.d.ts +8 -5
  16. package/dist/core/memory-engine/engine.d.ts +11 -0
  17. package/dist/core/memory-engine/engine.js +29 -3
  18. package/dist/core/memory-engine/index.d.ts +1 -1
  19. package/dist/core/memory-engine/origin-clearance.d.ts +28 -0
  20. package/dist/core/remote-env.d.ts +34 -2
  21. package/dist/core/runner/prepare-config-doors.d.ts +2 -2
  22. package/dist/core/runner/prepare-config-doors.js +11 -8
  23. package/dist/core/runner/prepare-task.d.ts +87 -5
  24. package/dist/core/runner/prepare-task.js +174 -92
  25. package/dist/core/runner/prepare-workspace-restore.js +13 -0
  26. package/dist/core/runner/runtask.js +203 -152
  27. package/dist/core/trace.d.ts +5 -4
  28. package/dist/core/types.d.ts +38 -20
  29. package/dist/core/usage-window-store.d.ts +44 -12
  30. package/dist/core/usage-window-store.js +11 -3
  31. package/dist/core/workflow-run-store-contract.js +17 -0
  32. package/dist/core/workflow-run-store.d.ts +22 -1
  33. package/dist/core/workflow-run-store.js +1 -0
  34. package/dist/engine/harness/agent-harness.d.ts +8 -3
  35. package/dist/engine/harness/agent-harness.js +29 -9
  36. package/dist/engine/harness/types.d.ts +127 -3
  37. package/dist/engine/loop/agent-loop.js +47 -4
  38. package/dist/engine/loop/types.d.ts +67 -6
  39. package/dist/index.d.ts +2 -2
  40. package/dist/internal/harness-types.d.ts +1 -1
  41. package/dist/orchestration/run-workflow-tool.d.ts +6 -0
  42. package/dist/orchestration/run-workflow-tool.js +1 -0
  43. package/dist/orchestration/workflow-types.d.ts +48 -1
  44. package/dist/orchestration/workflow-types.js +12 -4
  45. package/dist/orchestration/workflow.d.ts +18 -1
  46. package/dist/orchestration/workflow.js +45 -19
  47. package/dist/prompts/default.js +1 -1
  48. package/dist/tools/fs/bash-readonly-classifier.d.ts +44 -1
  49. package/dist/tools/fs/bash-readonly-classifier.js +132 -5
  50. package/dist/tools/fs/fs-bash.js +9 -2
  51. package/dist/tools/fs/fs-write.js +19 -8
  52. package/dist/tools/fs/index.d.ts +1 -0
  53. package/dist/tools/fs/index.js +1 -0
  54. package/package.json +1 -1
  55. package/test/export-surface.snapshot.json +7 -1
@@ -14,7 +14,8 @@
14
14
  */
15
15
  /**
16
16
  * One governed window. Its fields are allowances, not hints: `windowMs` is the window's WIDTH and
17
- * `maxTokens` / `maxCostUsd` the two CEILINGS it admits work under. `anchor` picks between the two window
17
+ * `maxTokens` / `maxCostUsd` the two CEILINGS it admits work under each optional on its own, at least
18
+ * one required (a window with neither is refused at the door). `anchor` picks between the two window
18
19
  * shapes that actually exist in the wild:
19
20
  * - `"first-use"` — the window OPENS at the key's first charge and lasts `windowMs`; when it lapses with
20
21
  * no further use, the next charge opens a fresh one. This is the "5 hours from when you started"
@@ -26,19 +27,33 @@ export interface UsageWindow {
26
27
  /** Window width in ms. Must be finite and > 0 (a zero-width window admits nothing and expires
27
28
  * instantly — refused at the door rather than silently governing every task to death). */
28
29
  windowMs: number;
29
- /** Tokens the window admits before it is exhausted. Must be finite and >= 0; `0` is a real, always-full
30
- * window (an operator lock-out), not "unset". */
31
- maxTokens: number;
32
30
  /**
33
- * OPTIONAL second ceiling: the MONEY the window admits before it is exhausted, in absolute USD the
31
+ * Tokens the window admits before it is exhausted. Must be finite and >= 0 WHEN PRESENT; `0` is a real,
32
+ * always-full window (an operator lock-out), not "unset" — absence is the only way to say "this window
33
+ * does not govern tokens".
34
+ *
35
+ * OPTIONAL since the $ ceiling below became a peer rather than an addition: a deployment governed by
36
+ * SPEND alone would otherwise have had to declare a token ceiling no run could reach, i.e. a number
37
+ * nobody chose sitting in the field this door exists to keep honest. Either ceiling alone is a complete
38
+ * declaration; a window that declares NEITHER is refused (`config.usage_window_invalid`) rather than
39
+ * admitting everything silently.
40
+ */
41
+ maxTokens?: number;
42
+ /**
43
+ * The OTHER ceiling: the MONEY the window admits before it is exhausted, in absolute USD — the
34
44
  * same quantity and unit `TaskLimits.maxCostUsd` names, one governance level up (a task's own allowance
35
- * vs the allowance an operator grants a principal across tasks). Must be finite and >= 0; `0` is a real,
45
+ * vs the allowance an operator grants a principal across tasks). Must be finite, >= 0, and must still be
46
+ * finite once converted to the ledger's micro-USD unit (`× 1e6`, i.e. below ~1.79e302 USD — an Infinity
47
+ * threshold is not the declared ceiling in the unit the ledger counts, so such a window would read as
48
+ * governed while enforcing nothing it was given, and is refused at the door). `0` is a real,
36
49
  * always-full window, exactly as it is for `maxTokens`.
37
50
  *
38
- * The two ceilings are INDEPENDENT and both bind: a window is exhausted when EITHER its tokens reach
39
- * `maxTokens` or its cost reaches `maxCostUsd` — whichever fills first. Absent (the default, and every
40
- * pre-existing deployment) means THIS window governs tokens only its own reading never asks for or
41
- * enforces a cost. Whether cost is tracked/recorded at all is a DEPLOYMENT-level fact, not a
51
+ * The two ceilings are INDEPENDENT and every DECLARED one binds: a window is exhausted when either its
52
+ * tokens reach `maxTokens` or its cost reaches `maxCostUsd` — whichever fills first and a ceiling that
53
+ * is absent is not a ceiling at all (never a zero, never an infinity: it simply does not participate).
54
+ * Absent (the default, and every pre-existing deployment) means THIS window governs tokens only its
55
+ * own reading never asks for or enforces a cost. Whether cost is tracked/recorded at all is a
56
+ * DEPLOYMENT-level fact, not a
42
57
  * per-window one (the `UsageSlot.costMicroUsd` / `UsageBucketRow` in-presence condition: "while the
43
58
  * deployment governs at least one `maxCostUsd` window"): in a MIXED declaration the shared ledger
44
59
  * rows carry cost under every window's bucket, and only a deployment with NO $ window anywhere keeps
@@ -111,8 +126,10 @@ export interface UsageWindowReading {
111
126
  * whole window as exhausted until the unpriced charge ages out, would lock a key out for as long as
112
127
  * `windowMs` over one degraded turn, which is a larger outage than the gap it answers. */
113
128
  costUnknown?: true;
114
- /** The window admits no further work: `tokens >= window.maxTokens`, OR (when the window declares one)
115
- * `costMicroUsd >= window.maxCostUsd` — the two ceilings are independent and either one binds. */
129
+ /** The window admits no further work: (when the window declares one) `tokens >= window.maxTokens`, OR
130
+ * (when the window declares one) `costMicroUsd >= window.maxCostUsd` — the two ceilings are independent
131
+ * and every DECLARED one binds. An undeclared axis never binds, however much the window holds on it:
132
+ * `tokens` is still reported for a money-only window (what it holds), it is just not a ceiling there. */
116
133
  exhausted: boolean;
117
134
  /** Ms until this window next frees capacity: for `first-use`, when the open window lapses; for
118
135
  * `rolling`, when its OLDEST in-window slot ages out (which frees that slot's tokens, not
@@ -146,6 +163,21 @@ export interface UsageWindowStore {
146
163
  * the pre-cost-arm signature drops the argument silently — which is why the arithmetic refuses an absent
147
164
  * cost outright (`usage_window.store_cost_unanswered`) instead of folding it to a 0 that would leave the
148
165
  * money ceiling open forever with nothing able to notice.
166
+ *
167
+ * IMPLEMENTER SELF-CHECK — three questions, in order. The in-presence condition above is a
168
+ * DEPLOYMENT-wide property of the `windows` argument, not a per-window one, and a store that reasons one
169
+ * window at a time is exactly where it gets missed:
170
+ * 1. Does this call's window set carry ANY window declaring `maxCostUsd`? Then this is a COST charge
171
+ * for every row it writes — the rolling slot is ONE row every rolling window reads, and a first-use
172
+ * bucket is ONE row per width shared by every window of that width — so there is no "cost only on
173
+ * the $ windows" split to implement. A row written cost-less because a token-only window happened
174
+ * to be the one in view is a row the $ window later reads as unpriced.
175
+ * 2. Was the spend real but unpriceable (no rate for the model, a provider that answered no usage)?
176
+ * Then carry the `null` through AS `null`, never as `0`: `0` asserts "this charge cost nothing" and
177
+ * silently raises the remaining allowance, while `null` is what lets the row say so and surfaces as
178
+ * {@link UsageWindowReading.costUnknown}, keeping the reported total honest as a LOWER BOUND.
179
+ * 3. Cannot answer either — the argument never reached this layer? Then leave it `undefined` and let
180
+ * the charge be refused loudly. A guessed 0 is the one outcome the money ceiling cannot survive.
149
181
  */
150
182
  charge(key: string, tokens: number, at: number, windows: readonly UsageWindow[], costMicroUsd?: number | null): Promise<void>;
151
183
  /** Read `key`'s state for each window as of `now`, in the order the windows were given. */
@@ -21,12 +21,19 @@ export function resolveUsageWindows(windows) {
21
21
  if (typeof w.windowMs !== "number" || !Number.isFinite(w.windowMs) || w.windowMs <= 0) {
22
22
  throw usageConfigError("config.usage_window_invalid", `RunnerDeps.usageWindows[].windowMs must be a finite number of milliseconds > 0 (got ${String(w.windowMs)})`);
23
23
  }
24
- if (typeof w.maxTokens !== "number" || !Number.isFinite(w.maxTokens) || w.maxTokens < 0) {
25
- throw usageConfigError("config.usage_window_invalid", `RunnerDeps.usageWindows[].maxTokens must be a finite, non-negative number (got ${String(w.maxTokens)})`);
24
+ if (w.maxTokens !== undefined && (typeof w.maxTokens !== "number" || !Number.isFinite(w.maxTokens) || w.maxTokens < 0)) {
25
+ throw usageConfigError("config.usage_window_invalid", `RunnerDeps.usageWindows[].maxTokens must be a finite, non-negative number when present (got ${String(w.maxTokens)})`);
26
26
  }
27
27
  if (w.maxCostUsd !== undefined && (typeof w.maxCostUsd !== "number" || !Number.isFinite(w.maxCostUsd) || w.maxCostUsd < 0)) {
28
28
  throw usageConfigError("config.usage_window_invalid", `RunnerDeps.usageWindows[].maxCostUsd must be a finite, non-negative number of USD when present (got ${String(w.maxCostUsd)})`);
29
29
  }
30
+ const maxCostMicroUsd = maxCostMicroUsdOf(w);
31
+ if (maxCostMicroUsd !== undefined && !Number.isFinite(maxCostMicroUsd)) {
32
+ throw usageConfigError("config.usage_window_invalid", `RunnerDeps.usageWindows[].maxCostUsd ${String(w.maxCostUsd)} overflows the ledger's micro-USD unit (${String(w.maxCostUsd)} × 1e6 = ${String(maxCostMicroUsd)}) — Infinity is not the declared ceiling in the unit this ledger counts, so the window would read as governed while enforcing nothing it was given. Declare a ceiling below 1.79e302 USD.`);
33
+ }
34
+ if (w.maxTokens === undefined && w.maxCostUsd === undefined) {
35
+ throw usageConfigError("config.usage_window_invalid", `RunnerDeps.usageWindows[] must declare at least one ceiling — maxTokens, maxCostUsd, or both (got neither, over windowMs ${String(w.windowMs)}, ${String(w.anchor)}). A window with no ceiling admits everything, which is not what declaring a window means.`);
36
+ }
30
37
  if (w.anchor !== "first-use" && w.anchor !== "rolling") {
31
38
  throw usageConfigError("config.usage_window_invalid", `RunnerDeps.usageWindows[].anchor must be "first-use" or "rolling" (got ${String(w.anchor)})`);
32
39
  }
@@ -132,7 +139,8 @@ export function readUsageRecord(record, windows, now) {
132
139
  }
133
140
  }
134
141
  const maxCostMicroUsd = maxCostMicroUsdOf(w);
135
- const exhausted = tokens >= w.maxTokens || (maxCostMicroUsd !== undefined && costMicroUsd >= maxCostMicroUsd);
142
+ const exhausted = (w.maxTokens !== undefined && tokens >= w.maxTokens) ||
143
+ (maxCostMicroUsd !== undefined && costMicroUsd >= maxCostMicroUsd);
136
144
  const retryAfterMs = exhausted ? Math.max(1, freesAt === undefined ? w.windowMs : freesAt - now) : 0;
137
145
  readings.push({
138
146
  window: w,
@@ -101,6 +101,23 @@ export async function workflowRunStoreContract(make, runAssertion = defaultSeque
101
101
  assert.equal(namedSummary.description, "find flaky tests");
102
102
  assert.equal(namedSummary.currentPhase, "fix");
103
103
  });
104
+ run("the ADDITIVE terminal disclosures reach the LIST row: agentFailures + budgetOvershoot project, and a clean run keeps both keys ABSENT", async () => {
105
+ const store = make();
106
+ const over = createWorkflowRun({ id: "d-over", createdAt: 5_000, agentFailures: 2, budgetOvershoot: { budgetTokens: 150, spentTokens: 1_200 } });
107
+ const unsettled = createWorkflowRun({ id: "d-unsettled", createdAt: 6_000, budgetOvershoot: { budgetTokens: 100, spentTokens: 0, unsettledTokens: 500 } });
108
+ const clean = createWorkflowRun({ id: "d-clean", createdAt: 7_000 });
109
+ for (const r of [over, unsettled, clean])
110
+ await store.put(r.id, r);
111
+ const byId = new Map((await store.listByScope("tenant-a")).map((s) => [s.id, s]));
112
+ assert.equal(byId.get("d-over").agentFailures, 2);
113
+ assert.deepEqual(byId.get("d-over").budgetOvershoot, { budgetTokens: 150, spentTokens: 1_200 });
114
+ assert.deepEqual(byId.get("d-unsettled").budgetOvershoot, { budgetTokens: 100, spentTokens: 0, unsettledTokens: 500 });
115
+ assert.equal("agentFailures" in byId.get("d-clean"), false, "no failures ⇒ the key is OMITTED, not 0/null");
116
+ assert.equal("budgetOvershoot" in byId.get("d-clean"), false, "within (or without) a ceiling ⇒ the key is OMITTED, not null/zeroed");
117
+ assert.equal(byId.get("d-over").tokens, over.stats.tokens + over.stats.nested.tokens);
118
+ byId.get("d-over").budgetOvershoot.spentTokens = 7;
119
+ assert.equal((await store.get("d-over")).budgetOvershoot.spentTokens, 1_200);
120
+ });
104
121
  run("reap: only terminal runs, NEVER running; maxAgeMs + keep retention; no policy → no-op", async () => {
105
122
  const store = make();
106
123
  const running = createWorkflowRun({ id: "k-run", status: "running", createdAt: 100, endedAt: undefined });
@@ -49,6 +49,24 @@ export interface WorkflowRunSummary {
49
49
  /** Count of agent-runs that ended failed ({@link WorkflowRun.agentFailures}) — present only when > 0.
50
50
  * Lets a list view flag a "completed, with failures" run without an N+1 `get` of the full run. */
51
51
  agentFailures?: number;
52
+ /** The run's TERMINAL token-budget overshoot ({@link WorkflowRun.budgetOvershoot}) — present ONLY when the
53
+ * run actually spent MORE than its ceiling, absent on every run that stayed within one and on every run
54
+ * that set no budget at all. The list-view twin of {@link agentFailures}: it lets a row be flagged
55
+ * "completed, and over budget" without an N+1 `get` of the full run — the *other* way a `completed` row is
56
+ * not the unqualified good news it looks like. It cannot be DERIVED from anything else on this projection:
57
+ * {@link tokens} is a different axis (see its note), so without this seat an over-budget run reads on the
58
+ * list face exactly like a clean one.
59
+ *
60
+ * Projected as a de-aliased shallow COPY — every other member here is a primitive, so the projection must
61
+ * not hand a caller a live reference into a stored run (the same anti-aliasing posture the stores' `get`
62
+ * keeps with `structuredClone`). Read the members per {@link WorkflowRun.budgetOvershoot}: the overshoot is
63
+ * `spentTokens + unsettledTokens - budgetTokens`, and a run resting entirely on `unsettledTokens` is a
64
+ * best-effort disclosure rather than a measurement. */
65
+ budgetOvershoot?: {
66
+ budgetTokens: number;
67
+ spentTokens: number;
68
+ unsettledTokens?: number;
69
+ };
52
70
  /** Title of the latest phase recorded ({@link WorkflowRun.phases}`.at(-1).title`) — what the run is on RIGHT
53
71
  * NOW for a `running` row (so the `/workflows` list shows the live phase without subscribing to the event
54
72
  * stream). Absent when no phase has started yet. */
@@ -58,7 +76,10 @@ export interface WorkflowRunSummary {
58
76
  /** Number of agent-runs recorded so far ({@link WorkflowRun.agents}`.length`). */
59
77
  agentCount: number;
60
78
  /** Total tokens spent = own + nested (`stats.tokens + stats.nested.tokens`) — the figure a triage view
61
- * sorts/compares by. own/nested stay SEPARATE on the full run (R-5); the summary folds them for display. */
79
+ * sorts/compares by. own/nested stay SEPARATE on the full run (R-5); the summary folds them for display.
80
+ * ⚠️ NOT the budget axis's figure: run stats also count REPLAYED work, which the budget deliberately never
81
+ * charges, so comparing this against a ceiling proves nothing in either direction — whether a run overran
82
+ * its budget is read off {@link budgetOvershoot}, never inferred from here. */
62
83
  tokens: number;
63
84
  /** When the run started ({@link WorkflowRun.startedAt}, epoch ms). */
64
85
  startedAt: number;
@@ -9,6 +9,7 @@ export function summarizeWorkflowRun(run) {
9
9
  ...(run.description !== undefined ? { description: run.description } : {}),
10
10
  status: run.status,
11
11
  ...(run.agentFailures !== undefined ? { agentFailures: run.agentFailures } : {}),
12
+ ...(run.budgetOvershoot !== undefined ? { budgetOvershoot: { ...run.budgetOvershoot } } : {}),
12
13
  ...(latestPhase !== undefined ? { currentPhase: latestPhase.title } : {}),
13
14
  phaseCount: run.phases.length,
14
15
  agentCount: run.agents.length,
@@ -70,7 +70,8 @@ export interface UserMessageProvenance {
70
70
  * (e.g. a forced compaction) — otherwise the next turn's `createTurnState()` rebuilds the context
71
71
  * from the session and the in-memory shrink evaporates. So the harness contract is: `recover`
72
72
  * persists the reduction to the session and returns whether anything shrank; the harness then
73
- * rebuilds the turn state FROM the session and hands the loop the rebuilt transcript.
73
+ * rebuilds the turn state FROM the session and hands the loop the rebuilt context — transcript AND
74
+ * system prompt (#474 ①), because the reduction pass may commit a prompt-epoch change of its own.
74
75
  */
75
76
  export interface HarnessLoopRecovery {
76
77
  truncatedOutput?: LoopTruncatedOutputRecovery;
@@ -86,8 +87,12 @@ export interface HarnessLoopRecovery {
86
87
  maxContinues?: number;
87
88
  };
88
89
  promptTooLong?: {
89
- /** Persist a context reduction to the SESSION; return true if anything shrank. Must not throw. */
90
- recover: (attempt: number) => Promise<boolean>;
90
+ /** Persist a context reduction to the SESSION; return true if anything shrank. Must not throw.
91
+ * `turnSignal` is the TURN-scoped abort (#474 ): a policy whose work is expensive (a forced
92
+ * compaction's summary call) must ride it so a turn interrupt cuts the pass short instead of
93
+ * being waited out — and a policy that declines under it leaves the loop settling the turn as
94
+ * an interrupted one, never as the provider error's terminal. */
95
+ recover: (attempt: number, turnSignal?: AbortSignal) => Promise<boolean>;
91
96
  /** Override the loop's default prompt-too-long classifier. */
92
97
  detect?: (message: AssistantMessage) => boolean;
93
98
  /** Max recovery retries per turn (loop default: 2). */
@@ -547,7 +547,7 @@ export class AgentHarness {
547
547
  userInputParkRecords.delete(message);
548
548
  engineNotePayloads.delete(message);
549
549
  }
550
- createLoopConfig(getTurnState, setTurnState) {
550
+ createLoopConfig(getTurnState, setTurnState, runPromptOverride) {
551
551
  const turnState = getTurnState();
552
552
  const self = this;
553
553
  return {
@@ -564,9 +564,24 @@ export class AgentHarness {
564
564
  : {}),
565
565
  convertToLlm: (messages) => stripEngineMetadata(convertToLlm(messages)),
566
566
  shouldStopAfterTurn: () => this._stopAfterTurn,
567
- transformContext: async (messages) => {
568
- const result = await this.emitHook({ type: "context", messages: [...messages] });
569
- return result?.messages ?? messages;
567
+ transformContext: async (messages, signal) => {
568
+ const result = await this.emitHook({ type: "context", messages: [...messages], ...(signal !== undefined ? { signal } : {}) });
569
+ if (result?.adoptSessionRebuild !== true) {
570
+ return result?.messages ?? messages;
571
+ }
572
+ await this.flushPendingSessionWrites();
573
+ const nextTurnState = await this.createTurnState();
574
+ setTurnState(nextTurnState);
575
+ const rebuiltContext = this.createContext(nextTurnState, runPromptOverride);
576
+ const recheck = await this.emitHook({ type: "context", messages: [...rebuiltContext.messages], ...(signal !== undefined ? { signal } : {}), recheck: true });
577
+ return {
578
+ messages: recheck?.messages ?? rebuiltContext.messages,
579
+ adoptedContext: {
580
+ messages: rebuiltContext.messages,
581
+ systemPrompt: rebuiltContext.systemPrompt,
582
+ ...(rebuiltContext.systemBlocks !== undefined ? { systemBlocks: rebuiltContext.systemBlocks } : {}),
583
+ },
584
+ };
570
585
  },
571
586
  beforeToolCall: async ({ toolCall, args }) => {
572
587
  const result = await this.emitHook({
@@ -595,7 +610,7 @@ export class AgentHarness {
595
610
  const nextTurnState = await this.createTurnState();
596
611
  setTurnState(nextTurnState);
597
612
  return {
598
- context: this.createContext(nextTurnState),
613
+ context: this.createContext(nextTurnState, runPromptOverride),
599
614
  model: nextTurnState.model,
600
615
  thinkingLevel: nextTurnState.thinkingLevel,
601
616
  };
@@ -633,10 +648,10 @@ export class AgentHarness {
633
648
  detect: ptl.detect,
634
649
  maxRetries: ptl.maxRetries,
635
650
  withholdErrorEvents: ptl.withholdErrorEvents,
636
- recover: async (_messages, attempt) => {
651
+ recover: async (_messages, attempt, turnSignal) => {
637
652
  let shrank = false;
638
653
  try {
639
- shrank = await ptl.recover(attempt);
654
+ shrank = await ptl.recover(attempt, turnSignal);
640
655
  }
641
656
  catch {
642
657
  shrank = false;
@@ -645,7 +660,12 @@ export class AgentHarness {
645
660
  return undefined;
646
661
  const nextTurnState = await self.createTurnState();
647
662
  setTurnState(nextTurnState);
648
- return self.createContext(nextTurnState).messages;
663
+ const rebuilt = self.createContext(nextTurnState, runPromptOverride);
664
+ return {
665
+ messages: rebuilt.messages,
666
+ systemPrompt: rebuilt.systemPrompt,
667
+ ...(rebuilt.systemBlocks !== undefined ? { systemBlocks: rebuilt.systemBlocks } : {}),
668
+ };
649
669
  },
650
670
  };
651
671
  },
@@ -780,7 +800,7 @@ export class AgentHarness {
780
800
  this.runAbortController = abortController;
781
801
  const runResultPromise = (async () => {
782
802
  try {
783
- return await runAgentLoop(messages, this.createContext(turnState, beforeResult?.systemPrompt), this.createLoopConfig(getTurnState, setTurnState), (event) => this.handleAgentEvent(event, abortController.signal), abortController.signal, this.createStreamFn(getTurnState), undefined, this.loopTrace);
803
+ return await runAgentLoop(messages, this.createContext(turnState, beforeResult?.systemPrompt), this.createLoopConfig(getTurnState, setTurnState, beforeResult?.systemPrompt), (event) => this.handleAgentEvent(event, abortController.signal), abortController.signal, this.createStreamFn(getTurnState), undefined, this.loopTrace);
784
804
  }
785
805
  catch (error) {
786
806
  try {
@@ -82,12 +82,38 @@ export interface AgentHarnessStreamOptionsPatch extends Omit<Partial<AgentHarnes
82
82
  }
83
83
  /** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */
84
84
  export type FileKind = "file" | "directory" | "symlink";
85
+ /** design/380 O13 — the precondition a {@link FileSystem.writeFileGuarded} call verifies IN the same
86
+ * atomic backend step as the write (minted by the caller from its canonicalization/adjudication pass). */
87
+ export interface WriteExpectation {
88
+ /** Refuse (nothing written) unless the FINAL open target canonicalizes to this path. */
89
+ canonicalPath: string;
90
+ /** Backend object identity (inode/file-id) when the precondition minter observed one; opaque string. */
91
+ fileId?: string;
92
+ /** Target must not exist (atomic exclusive create — the writeFileExclusive semantics folded in). */
93
+ exclusive?: boolean;
94
+ /** Open with no-follow semantics: a symlink at the leaf fails instead of being traversed. */
95
+ noFollow?: boolean;
96
+ }
97
+ /** design/380 O13 — the receipt a successful {@link FileSystem.writeFileGuarded} returns: facts about
98
+ * the REAL on-disk object the atomic step produced (minter-stated, post-op). */
99
+ export interface WriteReceipt {
100
+ /** The ACTUAL canonical path written (post-op fact, minter-stated). */
101
+ canonicalPath: string;
102
+ fileId?: string;
103
+ created: boolean;
104
+ }
85
105
  /** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */
86
106
  export type FileErrorCode = "aborted" | "not_found" | "permission_denied" | "not_directory" | "is_directory" | "invalid" | "not_supported"
87
107
  /** Exclusive create refused because the path already exists ({@link FileSystem.writeFileExclusive},
88
108
  * POSIX `O_EXCL` shape). Discriminable so a caller can distinguish "lost the create race" from a
89
109
  * write failure and give honest guidance instead of overwriting. */
90
110
  | "already_exists"
111
+ /** design/380 O13 — {@link FileSystem.writeFileGuarded}'s precondition did not hold at the atomic
112
+ * write step (the final open target did not canonicalize to the expected path / the backend object
113
+ * identity moved / an `exclusive` create found the target present). NOTHING was written —
114
+ * discriminable so a caller can tell "the world moved between adjudication and write" from an
115
+ * ordinary write failure and re-read instead of blindly retrying. */
116
+ | "precondition_failed"
91
117
  /** A remote fs RPC exceeded its liveness/idle bound (a hung provider call) — retryable by the caller per
92
118
  * the op's idempotency. Mirrors `RemoteExecutionError` code `"timeout"` so a remote `FileSystem` op can
93
119
  * surface a hang as a typed, retryable error instead of `"unknown"` (core src/core/remote-env.ts contract;
@@ -97,7 +123,15 @@ export type FileErrorCode = "aborted" | "not_found" | "permission_denied" | "not
97
123
  * retryable after reconnect. Mirrors `RemoteExecutionErrorCode` `"transport_lost"`, same precedent as
98
124
  * `"timeout"` above (VENDOR.md mod #9 / service[63] SSH/ADB adapters), so a retry whitelist can see it
99
125
  * instead of an opaque `"unknown"`. */
100
- | "transport_lost" | "unknown";
126
+ | "transport_lost"
127
+ /** design/380 O12 — a WRITE-face op (writeFile/appendFile/remove…) was COMMITTED to a remote target and
128
+ * its outcome is UNKNOWABLE (the target went unreachable / restarted / was revoked after
129
+ * dispatch-commit). NEVER auto-retry and NEVER map onto `timeout`/`transport_lost` (both sit in retry
130
+ * whitelists): the bytes may already be on the target. Message MUST carry the verify-first sentence
131
+ * ("may have already executed on the target; verify its effect before re-running"). A device adapter's
132
+ * WRITE face uses this instead of `transport_lost` (whose retryable-after-reconnect contract is safe
133
+ * only for idempotent reads). */
134
+ | "outcome_unknown" | "unknown";
101
135
  /** Error returned by {@link FileSystem} file operations. */
102
136
  export declare class FileError extends Error {
103
137
  /** Backend-independent error code. */
@@ -131,7 +165,22 @@ export type ExecutionErrorCode = "aborted" | "timeout"
131
165
  * command never started, and every retry will fail the same way (and may lock the account). Mirrors
132
166
  * `RemoteExecutionErrorCode` `"auth_failed"`; deliberately NOT a mirror of `auth_transient`, which an
133
167
  * adapter retries internally per its own bounded policy rather than surfacing as an exec outcome. */
134
- | "auth_failed" | "unknown";
168
+ | "auth_failed"
169
+ /** design/380 O12 — the op was COMMITTED to a remote target and its outcome is UNKNOWABLE (the target
170
+ * went unreachable / restarted / was revoked after dispatch-commit). NEVER auto-retry and NEVER map
171
+ * onto `timeout`/`transport_lost` (both sit in retry whitelists): the command may have already
172
+ * executed. Message MUST carry the verify-first sentence ("may have already executed on the target;
173
+ * verify its effect before re-running"). Distinct from `transport_lost` (connection story known,
174
+ * idempotency-gated retry) — this word is for the arm where the SERVER of the target protocol has
175
+ * already ruled the outcome unknowable. */
176
+ | "outcome_unknown"
177
+ /** design/380 O12 — the target refused the op BEFORE commit (device offline / busy / not bound): the
178
+ * command NEVER STARTED, so a retry is safe once the target is back. The honest never-started word for
179
+ * a device/remote adapter — `spawn_error` is contractually reserved for OS-level spawn failures (its
180
+ * 2026-07-13 contract above; the Bash dead-cwd diagnosis hangs off it) and `suspended` claims a paused
181
+ * workspace this arm does not have. Spelling note: landed per design/380's arm (b); the device lane's
182
+ * wire-word mapping may re-spell at the server's wire freeze. */
183
+ | "target_unavailable" | "unknown";
135
184
  /** Error returned by {@link ExecutionEnv.exec}. */
136
185
  export declare class ExecutionError extends Error {
137
186
  /** Backend-independent error code. */
@@ -288,6 +337,25 @@ export interface FileSystem {
288
337
  * emulation and own the residual race.
289
338
  */
290
339
  writeFileExclusive?(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
340
+ /**
341
+ * design/380 O13 — OPTIONAL guarded write: verify `expect` and write in ONE atomic backend step
342
+ * (openat/O_NOFOLLOW family on a local backend; a single wire instruction on a remote one), returning
343
+ * a receipt of the real on-disk object. Precondition mismatch ⇒ FileError `"precondition_failed"`
344
+ * (closed-set addition), nothing written. Degradation law VERBATIM from writeFileExclusive: a backend
345
+ * with no such primitive MUST leave this `undefined` rather than emulate in two steps — a two-step
346
+ * emulation re-opens the very TOCTOU window this method exists to close. Callers fall back to plain
347
+ * writeFile and OWN the residual race (which the write gate then discloses as "advisory
348
+ * adjudication" instead of claiming TOCTOU closure).
349
+ *
350
+ * SCOPE OF THE CLOSURE (stated so nothing overclaims): the window closed is the one between the
351
+ * CALLER's own precondition mint (its canonicalization at execute time) and the write. A policy
352
+ * adjudication that happened EARLIER — e.g. a write-gate verdict that waited on a human approval —
353
+ * is bound to that policy's own resolution, which is not carried into the caller's expectation
354
+ * today; a symlink swapped during the approval wait is therefore still re-resolved at execute time.
355
+ * Binding the GATE's adjudicated key across the approval wait needs a trusted receipt-carriage seat
356
+ * (policy verdict → approval record → tool execution) that is a separate design, not this method.
357
+ */
358
+ writeFileGuarded?(path: string, content: string | Uint8Array, expect: WriteExpectation, abortSignal?: AbortSignal): Promise<Result<WriteReceipt, FileError>>;
291
359
  /** Create or append to a file, creating parent directories when supported. */
292
360
  appendFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
293
361
  /** Return metadata for the addressed path without following symlinks. */
@@ -919,6 +987,25 @@ export interface BeforeAgentStartEvent<TSkill extends Skill = Skill, TPromptTemp
919
987
  export interface ContextEvent {
920
988
  type: "context";
921
989
  messages: AgentMessage[];
990
+ /**
991
+ * The TURN-scoped abort signal of the request build being transformed (present when the loop
992
+ * handed one — it always does in production; absent only for bare-harness callers). A handler
993
+ * that runs an EXPENSIVE reduction (the slice-3 arm-B in-turn compaction is the standing case)
994
+ * must honor it: a turn interrupt (design/373) fires this signal, and a reduction that only
995
+ * watches the RUN-level signal would make the interrupt wait out a long summary call and commit
996
+ * a session mutation the interrupted turn no longer needs (adversarial review r3).
997
+ */
998
+ signal?: AbortSignal;
999
+ /**
1000
+ * design/374 slice 3 — set on the ONE bounded re-dispatch of the adopt seam
1001
+ * ({@link ContextResult.adoptSessionRebuild}): `messages` are the just-adopted session rebuild,
1002
+ * re-presented so the handler's request pipeline applies to what the provider will actually
1003
+ * receive (the "recheck" step of build→reduce→adopt→recheck). A handler must not request a
1004
+ * second adoption on this dispatch — the harness does not honor it (see the result member's
1005
+ * doc) — so a reduction that is still over budget takes the handler's own next arm (the trim
1006
+ * last resort) instead of looping. Absent on every ordinary dispatch.
1007
+ */
1008
+ recheck?: boolean;
922
1009
  }
923
1010
  export interface TurnBoundaryEvent {
924
1011
  /** Fires between model-request boundaries, after session flush, before the context rebuild. */
@@ -991,12 +1078,49 @@ export type AgentHarnessEvent<TSkill extends Skill = Skill, TPromptTemplate exte
991
1078
  export interface BeforeAgentStartResult {
992
1079
  /** Replacement messages for the prompt run. */
993
1080
  messages?: AgentMessage[];
994
- /** Replacement system prompt for the prompt run. */
1081
+ /**
1082
+ * Replacement system prompt for the prompt run — RUN-scoped, not turn-scoped: it is in force from
1083
+ * the first provider request until the run ends, across every turn boundary and every mid-request
1084
+ * context rebuild (the turn-boundary rebuild, the arm-B session adopt, the prompt-too-long
1085
+ * recovery retry). While it is in force it OUTRANKS the harness/session prompt, including a
1086
+ * prompt-epoch change committed mid-run: the epoch restatement lands in the session and governs
1087
+ * once no override is in force, it does not preempt the caller's replacement mid-run.
1088
+ *
1089
+ * The system prompt therefore has exactly three legal change points, and a deployment can rely on
1090
+ * it being byte-stable between them (the prefix-cache-friendly property):
1091
+ * 1. run start — this hook's return, or the harness/session prompt when it returns none;
1092
+ * 2. a compaction boundary that adopts a new prompt epoch — live for the NEXT run without an
1093
+ * override, and for the current run's remaining turns only if no override is in force;
1094
+ * 3. a session clear — the next run starts a fresh prompt.
1095
+ *
1096
+ * Returning a replacement also makes the run string-only on the prompt face: the block face
1097
+ * (`systemBlocks`) attaches only when it provably corresponds to the string being shipped, so an
1098
+ * override run ships no blocks on any of its turns.
1099
+ */
995
1100
  systemPrompt?: string;
996
1101
  }
997
1102
  /** Hook result for replacing the full context message list before provider conversion. */
998
1103
  export interface ContextResult {
999
1104
  messages: AgentMessage[];
1105
+ /**
1106
+ * design/374 slice 3 (arm B, the adopt seam) — the handler PERSISTED a session-level reduction
1107
+ * during this dispatch (e.g. an in-turn forced compaction appended to the session) and asks the
1108
+ * harness to adopt it MID-BUILD, the same three-step form as the two existing adoption flows
1109
+ * (turn_boundary / prompt-too-long recovery): flush pending session writes, rebuild the turn
1110
+ * context from the session (`createTurnState`), adopt it (`setTurnState` + the loop-context
1111
+ * adoption via {@link import("../loop/types.js").TransformedContext}), then re-dispatch the
1112
+ * context hook ONCE on the rebuilt view (`ContextEvent.recheck`) so the request pipeline applies
1113
+ * to it. Result: the provider request, every subsequent hook dispatch, and the active turn state
1114
+ * all see the SAME reduced transcript — the three-party co-view the seam exists for. `messages`
1115
+ * on this arm are the handler's best unadopted view and are superseded by the rebuild.
1116
+ *
1117
+ * Honored AT MOST ONCE per request build: on the recheck dispatch this member is ignored (its
1118
+ * `messages` are used verbatim) — the bound that keeps a reduction that cannot get under budget
1119
+ * from re-entering forever. Typed on the hook RESULT deliberately (not a closure side channel):
1120
+ * the adoption is part of the hook's answer, and the one prior closure-ref transport in this
1121
+ * area (`requestLossyRef`) is a recorded bypass shape, not a precedent to grow.
1122
+ */
1123
+ adoptSessionRebuild?: boolean;
1000
1124
  }
1001
1125
  /** Hook result for patching provider request options before payload construction. */
1002
1126
  export interface BeforeProviderRequestResult {
@@ -256,6 +256,7 @@ async function runTurnPhases(state, signal, turnController, publishSeat, retract
256
256
  await executor.settle();
257
257
  }
258
258
  raiseFatalCancellation(executor);
259
+ let interruptedInRecovery = false;
259
260
  {
260
261
  if (ptl) {
261
262
  const detect = ptlDetect;
@@ -273,18 +274,29 @@ async function runTurnPhases(state, signal, turnController, publishSeat, retract
273
274
  }
274
275
  let replaced;
275
276
  try {
276
- replaced = await ptl.recover(state.context.messages, attempt);
277
+ replaced = await ptl.recover(state.context.messages, attempt, turnController.signal);
277
278
  }
278
279
  catch {
279
280
  replaced = undefined;
280
281
  }
281
282
  if (!replaced) {
282
283
  state.context.messages.push(message);
284
+ interruptedInRecovery = turnController.signal.aborted && !signal?.aborted;
283
285
  break;
284
286
  }
285
287
  withhold?.discard();
286
288
  withhold = withholdEnabled ? createPtlWithholdBuffer(emit, ptlDetect) : undefined;
287
- state.context.messages = replaced;
289
+ if (Array.isArray(replaced)) {
290
+ state.context.messages = replaced;
291
+ }
292
+ else {
293
+ adoptRebuiltContext(state.context, replaced);
294
+ }
295
+ if (turnController.signal.aborted && !signal?.aborted) {
296
+ state.context.messages.push(message);
297
+ interruptedInRecovery = true;
298
+ break;
299
+ }
288
300
  trace?.({ kind: "continue", reason: "reactive_compact_retry" });
289
301
  executor = new StreamToolExecutor(state.context, state.config, turnController.signal, emit);
290
302
  try {
@@ -297,11 +309,21 @@ async function runTurnPhases(state, signal, turnController, publishSeat, retract
297
309
  }
298
310
  }
299
311
  }
312
+ const unrecoveredPtlFailureUnderInterrupt = ptl !== undefined &&
313
+ message.stopReason === "error" &&
314
+ ptlDetect(message) &&
315
+ isBlankFailureContent(message) &&
316
+ executor.admittedCount === 0;
317
+ const interruptOwnsTurn = (message.stopReason === "aborted" || interruptedInRecovery || unrecoveredPtlFailureUnderInterrupt) &&
318
+ turnController.signal.aborted &&
319
+ !signal?.aborted;
320
+ if (interruptOwnsTurn && message.stopReason === "error")
321
+ withhold?.discard();
300
322
  await withhold?.flush();
301
323
  state.newMessages.push(message);
302
324
  if (message.staticReasoningCut === true)
303
325
  state.staticReasoningCutRecoveries++;
304
- if (message.stopReason === "aborted" && turnController.signal.aborted && !signal?.aborted) {
326
+ if (interruptOwnsTurn) {
305
327
  retractSeat();
306
328
  return await settleInterruptedTurn(state, message, executor, [], signal, emit);
307
329
  }
@@ -425,6 +447,18 @@ async function runTurnPhases(state, signal, turnController, publishSeat, retract
425
447
  }
426
448
  return { kind: "ran" };
427
449
  }
450
+ function adoptRebuiltContext(context, adopted) {
451
+ context.messages = adopted.messages;
452
+ if (adopted.systemPrompt === undefined)
453
+ return;
454
+ context.systemPrompt = adopted.systemPrompt;
455
+ if (adopted.systemBlocks !== undefined) {
456
+ context.systemBlocks = adopted.systemBlocks;
457
+ }
458
+ else {
459
+ delete context.systemBlocks;
460
+ }
461
+ }
428
462
  function adoptNextTurnSnapshot(state, nextTurnSnapshot) {
429
463
  if (!nextTurnSnapshot)
430
464
  return;
@@ -524,7 +558,16 @@ async function settleInterruptedTurn(state, message, executor, committedResults,
524
558
  async function streamAssistantResponse(context, config, signal, emit, streamFn, runtime, executor, staticReasoningCutDowngrade) {
525
559
  let messages = context.messages;
526
560
  if (config.transformContext) {
527
- messages = await config.transformContext(messages, signal);
561
+ const transformed = await config.transformContext(messages, signal);
562
+ if (Array.isArray(transformed)) {
563
+ messages = transformed;
564
+ }
565
+ else {
566
+ if (transformed.adoptedContext !== undefined) {
567
+ adoptRebuiltContext(context, transformed.adoptedContext);
568
+ }
569
+ messages = transformed.messages;
570
+ }
528
571
  }
529
572
  const llmMessages = await config.convertToLlm(messages);
530
573
  const llmContext = {