@sema-agent/core 5.19.0 → 5.20.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,5 +1,89 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.20.0 — 2026-08-08
4
+
5
+ ### BREAKING
6
+
7
+ Configuration hygiene sweep. A malformed numeric knob used to be accepted and then quietly do
8
+ something — usually the OPPOSITE of what it was set to. Every seam below now either refuses the
9
+ value at its resolution point or clamps it and says so. Well-formed values in the documented range
10
+ are untouched everywhere; the break is that values which used to be accepted-then-misbehave are
11
+ now rejected, so a deployment carrying one gets a loud failure at wiring time instead of a
12
+ mysterious runtime.
13
+
14
+ - **MCP millisecond env knobs** (`MCP_TOOL_TIMEOUT`, `MCP_TOOL_TIMEOUT_TOTAL`,
15
+ `MCP_IDLE_TIMEOUT_STDIO`, `MCP_IDLE_TIMEOUT_HTTP`, `MCP_TIMEOUT`) share one parser, which was
16
+ `parseInt`-based: `1e9` meant 1ms, `30s` meant 30ms, and `100_000_000` — the way this repo writes
17
+ the default constant it invites you to copy — meant 100ms. Past 2^31-1 the host timer truncated
18
+ the delay and fired at once, so widening a watchdog to "basically off" made it abort every call,
19
+ while the failure text quoted the requested number back. The parser now reads plain digits,
20
+ scientific notation and digit grouping, refuses anything else, and clamps into
21
+ **[1000, 2147483647] ms**, warning once on stderr with the knob name, the raw text and the value
22
+ actually in force. Timeout frames quote the resolved value. `MAX_MCP_OUTPUT_TOKENS` moves onto the
23
+ same grammar (its `1e5` used to mean a 4-char budget), keeping upstream's unbounded range.
24
+ Upgrade note (the widening side of this fix): on 5.19.0 and earlier, `1e9` / `1_800_000` — both
25
+ legal upstream spellings — silently took effect as **1ms**. After upgrading they take effect at
26
+ face value, so a deployment that wrote one of these will see the knob jump from 1ms to the
27
+ number it always said.
28
+ - **Millisecond knobs that arm a host timer** are refused above 2147483647ms (~24.8 days), where
29
+ the timer truncates and fires immediately: the stall watchdogs (`connectTimeoutMs`,
30
+ `firstTokenTimeoutMs`, `idleTimeoutMs`), `brainCallGuardrailMs`, `runWorkflow`'s `totalTimeoutMs`
31
+ / `stallMs` / `throttleBackoffMs`, and `createApprovalPolicy`'s `approvalTimeoutMs` — the last of
32
+ which would otherwise have denied every request instantly, fail-closed and silent. Each already
33
+ documents `0` / `false` / omission as "off". `limits.maxWalltimeMs` is the exception and keeps
34
+ its promise of no ceiling: the hard-abort timer now waits in representable chunks.
35
+ - **Retention policies** (`BackgroundAgentStore.reap`, `MailboxStore.reap`, `WorkflowRunStore.reap`,
36
+ `reapDurableAgents`, both roster stores' constructors) refuse a non-finite or negative bound with
37
+ `config.retention_policy_invalid`. `keep` is consumed as `slice(Math.max(0, keep))`, where NaN
38
+ collapses to `slice(0)` and deleted EVERY terminal row in the scope; the roster's `maxAgeMs` made
39
+ every durable address read as expired. Absent fields keep their "this arm is not applied" meaning.
40
+ Operational note: this is a refusal, not a sanitize — a deployment with a malformed retention knob
41
+ will see every reap throw (rows accumulate) until the knob is corrected. Fix the knob before
42
+ upgrading; nothing is auto-repaired on your behalf.
43
+ - **Web and circuit-breaker knobs**: `timeoutMs` on WebFetch / WebSearch / the Searxng backend fed a
44
+ bare `setTimeout`, where NaN and Infinity both become 1ms — a budget written as "no limit" aborted
45
+ every request on arrival and reported "timed out after NaNms". `maxResults` fed `slice(0, max)`,
46
+ so a NaN or 0 cap emptied every search from a backend that answered. The circuit breaker's
47
+ `failureThreshold` / `cooldownMs` / `halfOpenProbes` had no validation at all, and `failures >= NaN`
48
+ is false forever — an unevaluable threshold left the breaker permanently open-loop. All are now
49
+ resolved at assembly time (`config.web_timeout_invalid`,
50
+ `config.web_search_max_results_invalid`, `config.circuit_breaker_invalid`). `probeSearchBackend`
51
+ keeps its documented never-throws contract: a malformed budget comes back as `{ ok: false, error }`.
52
+
53
+ ### Added
54
+
55
+ - **`Hooks.preToolUseObservational`** — a deployment declares that its PreToolUse face is a pure
56
+ OBSERVER (a tracer, an audit sink) and never judges. 5.19.0's delegation fold keys on PRESENCE,
57
+ which cannot tell a tracer from a screener: a deployment that flipped on a default-OFF diagnostic
58
+ hook thereby added an opaque constraint to every delegated child, and a child that durably parked
59
+ recorded that count on its row — where an approval redeemed by a leg that cannot re-supply the
60
+ live screening closure has no recovery path. With the declaration the face contributes **no
61
+ chain entry**, so `parentConstraintCount` counts only constraints that actually judge; without it,
62
+ 5.19.0 behavior is unchanged. The callback still runs in the installing task's own gate, on every
63
+ call, with the same crash posture (a throw is still the fail-closed deny) — only the inherited
64
+ constraint goes away. A declared-observational face that returns something anyway has that return
65
+ **refused, not obeyed**: the call proceeds as if the face had no opinion (this covers
66
+ `additionalContext` too) and `onError` is told (`phase:"hook"`, classification
67
+ `observational-hook-verdict-ignored`), so the declaration cannot become a quiet way to keep a
68
+ screening face's verdicts while shedding the delegation constraint they belong to. A declared face
69
+ is also handed a **detached copy of the arguments** (best-effort, same helper and same limits as the
70
+ other observe-only payloads): the gate reads a rewrite off reference identity, so an in-place edit
71
+ would be a second channel outliving the dropped return. Two further consequences of "it does not
72
+ judge": a declared face no longer collapses an ancestor's screening entry that names the same
73
+ callback (the descendant no longer honors it, so the ancestor's constraint is folded instead), and it
74
+ no longer counts as an effect-aware gate for the loud ungated-write-surface warning.
75
+ - **`createPreToolUseConstraintPolicy` is now a public export**, and a durable park under an inherited
76
+ screening constraint says so at PARK time. A checkpoint records only how many opaque constraints its
77
+ leg ran under; the resume must hand the same chain back. Every entry kind except one is a policy the
78
+ deployment authored — the screening entry 5.19.0 added is engine-minted, so a hook-wired tree's
79
+ parked children were redeemable only in the process that spawned them (same-Runner resumes are
80
+ auto-re-supplied from the in-memory registry and were never affected). The mint is now exported, so a
81
+ fresh Runner can rebuild the entry — same callback, same installed env — and hand the full chain to
82
+ `resumeStream(..., internals)`; the recorded-count check is unchanged, so a short or mismatched chain
83
+ is still refused pre-CAS. And the park itself now announces the requirement once per task on
84
+ `onError` (`phase:"degraded"`, classification `screening-constraint-in-durable-chain`), naming the
85
+ recorded count and the ways out, instead of leaving it to be discovered at a redemption that fails.
86
+
3
87
  ## 5.19.0 — 2026-08-10
4
88
 
5
89
  > Three bundles, each pre-verified two-way by the evaluation line against the 5.18.1 artifact
@@ -14,10 +98,15 @@
14
98
  four-axis dedup so a deps-level hook is consulted once per call at any depth). A screening
15
99
  `ask` resolves at the installing task's frozen approver; a hook-wired parent with no approver
16
100
  denies its children's screened calls fail-closed.
17
- **Operational**: `parentConstraintCount` gains a screening entry for hook-wired trees — a
18
- pending approval checkpoint minted before this change and resumed after (or the reverse)
19
- fails pre-CAS with `resume.parent_constraint_mismatch`. **Drain pending checkpoints across
20
- the upgrade.** A delegated child of a hook-wired parent also sets
101
+ **Operational (corrected per downstream measurement)**: `parentConstraintCount` gains a
102
+ screening entry for hook-wired trees. Pre-5.19.0 rows (including hook-wired parents) recorded
103
+ the old count and rebuild legs that supply it keep matching — upgrade and rollback are clean
104
+ for EXISTING pending checkpoints. The unrecoverable case is a checkpoint **minted on 5.19.0**
105
+ by a hook-wired parent and redeemed by a rebuild leg that cannot re-supply the live screening
106
+ entry (cross-replica redemption): it fails pre-CAS with `resume.parent_constraint_mismatch`
107
+ and stays pending, with no recovery path (the count is written at park time; disabling the
108
+ hook afterwards does not change it). Same-replica resumes via `resumeStream(..., internals)`
109
+ are unaffected. A delegated child of a hook-wired parent also sets
21
110
  `requiresParentConstraint`, so its durable resume must re-supply the chain via
22
111
  `resumeStream(..., internals)`.
23
112
  - **Write guards judge the call's live cwd** (P1, host-lane proven bypass). A resident shell's
@@ -1,4 +1,5 @@
1
1
  import { mkdirSync, readFileSync } from "node:fs";
2
+ import { assertRetentionPolicy } from "../core/retention-policy.js";
2
3
  import { atomicWriteFile } from "../stores/file/fs-atomic.js";
3
4
  import { dirname } from "node:path";
4
5
  import { normalizeAgentName } from "../core/task-registry.js";
@@ -50,6 +51,7 @@ export class MemoryRosterStore {
50
51
  maxAgeMs;
51
52
  gc;
52
53
  constructor(opts) {
54
+ assertRetentionPolicy("RosterStore", opts);
53
55
  this.maxAgeMs = opts?.maxAgeMs;
54
56
  this.gc = opts;
55
57
  }
@@ -78,6 +80,7 @@ export class FileRosterStore {
78
80
  gc;
79
81
  onCorruptRead;
80
82
  constructor(path, opts) {
83
+ assertRetentionPolicy("RosterStore", opts);
81
84
  this.path = path;
82
85
  this.maxAgeMs = opts?.maxAgeMs;
83
86
  this.gc = opts;
@@ -24,10 +24,21 @@ function errorAssistantMessage(model, code, detail, stopReason = "error") {
24
24
  timestamp: Date.now(),
25
25
  };
26
26
  }
27
+ function finiteBreakerKnob(value, knob, fallback, wholeAtLeastOne) {
28
+ if (value === undefined)
29
+ return fallback;
30
+ const ok = wholeAtLeastOne ? Number.isInteger(value) && value >= 1 : Number.isFinite(value) && value >= 0;
31
+ if (!ok) {
32
+ const e = new Error(`createCircuitBreakerBrain: ${knob} must be ${wholeAtLeastOne ? "a whole number of 1 or more" : "a finite, non-negative number of milliseconds"} (got ${String(value)}) — an unevaluable knob leaves the breaker permanently open-loop, which is a protection that is silently not there`);
33
+ e.code = "config.circuit_breaker_invalid";
34
+ throw e;
35
+ }
36
+ return value;
37
+ }
27
38
  export function createCircuitBreakerBrain(inner, opts = {}) {
28
- const failureThreshold = opts.failureThreshold ?? 5;
29
- const cooldownMs = opts.cooldownMs ?? 30_000;
30
- const halfOpenProbes = opts.halfOpenProbes ?? 1;
39
+ const failureThreshold = finiteBreakerKnob(opts.failureThreshold, "failureThreshold", 5, true);
40
+ const cooldownMs = finiteBreakerKnob(opts.cooldownMs, "cooldownMs", 30_000, false);
41
+ const halfOpenProbes = finiteBreakerKnob(opts.halfOpenProbes, "halfOpenProbes", 1, true);
31
42
  const countCodes = new Set(opts.countCodes ?? ["network", "server", "rate_limit"]);
32
43
  const keyOf = opts.key ?? ((m) => `${m.provider}:${m.id}`);
33
44
  const state = opts.state ?? new InMemoryBreakerState();
@@ -1,4 +1,5 @@
1
1
  import type { StreamFn } from "../internal/llm.js";
2
+ export declare const MAX_TIMER_DELAY_MS = 2147483647;
2
3
  export declare function resolveStallTimeoutMs(value: number | undefined, knob: string): number | undefined;
3
4
  export declare const STALL_CONNECT_MS = 30000;
4
5
  export declare const STALL_FIRST_TOKEN_MS = 120000;
@@ -1,3 +1,4 @@
1
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
1
2
  export function resolveStallTimeoutMs(value, knob) {
2
3
  if (value === undefined)
3
4
  return undefined;
@@ -6,6 +7,11 @@ export function resolveStallTimeoutMs(value, knob) {
6
7
  e.code = "config.stall_timeout_invalid";
7
8
  throw e;
8
9
  }
10
+ if (value > MAX_TIMER_DELAY_MS) {
11
+ const e = new Error(`${knob} must not exceed ${MAX_TIMER_DELAY_MS}ms (~24.8 days) — a host timer truncates a larger delay and fires immediately, so this watchdog would abort every call instead of tolerating a long one (got ${String(value)}). Pass 0 to disable it.`);
12
+ e.code = "config.stall_timeout_invalid";
13
+ throw e;
14
+ }
9
15
  return value;
10
16
  }
11
17
  export const STALL_CONNECT_MS = 30_000;
@@ -66,6 +72,11 @@ export function resolveBrainCallGuardrailMs(knob) {
66
72
  e.code = "config.brain_call_guardrail_invalid";
67
73
  throw e;
68
74
  }
75
+ if (knob > MAX_TIMER_DELAY_MS) {
76
+ const e = new Error(`brainCallGuardrailMs must not exceed ${MAX_TIMER_DELAY_MS}ms (~24.8 days) — a host timer truncates a larger delay and fires immediately, so the backstop would abort every model call (got ${String(knob)}). Pass false or 0 to turn the backstop off.`);
77
+ e.code = "config.brain_call_guardrail_invalid";
78
+ throw e;
79
+ }
69
80
  return knob;
70
81
  }
71
82
  function armCallGuardrail(limitMs, outerSignal, ref) {
@@ -87,6 +87,7 @@ export interface BackgroundAgentReapOptions {
87
87
  keep?: number;
88
88
  staleRunningMaxAgeMs?: number;
89
89
  }
90
+ export declare function assertBackgroundAgentReapOptions(opts: BackgroundAgentReapOptions | undefined): void;
90
91
  export interface BackgroundAgentStore {
91
92
  put(record: BackgroundAgentRecord): Promise<void>;
92
93
  get(handle: string, scope: string): Promise<BackgroundAgentRecord | null>;
@@ -1,4 +1,5 @@
1
1
  import { uuidv7 } from "../internal/harness.js";
2
+ import { assertRetentionPolicy } from "./retention-policy.js";
2
3
  export const REVIVED_ROW_CLEARED_FIELDS = [
3
4
  "settledAt",
4
5
  "stoppedBy",
@@ -43,6 +44,9 @@ export function canAccessAgentRecord(record, access) {
43
44
  return false;
44
45
  }
45
46
  export const STALE_RUNNING_REAP_ATTRIBUTION = "the host process was interrupted while this agent was running — its in-process state was lost. Check its worktree / output file for partial work before assuming the task landed (stale running row reaped).";
47
+ export function assertBackgroundAgentReapOptions(opts) {
48
+ assertRetentionPolicy("BackgroundAgentStore.reap", opts);
49
+ }
46
50
  export async function reconcileParkedAgents(stores, scope, now, opts) {
47
51
  const out = { failed: 0, rolledBack: 0 };
48
52
  const excluded = (row) => (opts?.excludeHandles?.has(row.handle) ?? false) ||
@@ -256,6 +260,7 @@ export class InMemoryBackgroundAgentStore {
256
260
  return [...new Set([...this.rows.values()].map((r) => r.scope))].sort();
257
261
  }
258
262
  async reap(scope, now, opts) {
263
+ assertBackgroundAgentReapOptions(opts);
259
264
  if (!opts || (opts.maxAgeMs === undefined && opts.keep === undefined && opts.staleRunningMaxAgeMs === undefined))
260
265
  return 0;
261
266
  const flippedKeys = new Set();
@@ -3,6 +3,7 @@ import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../inter
3
3
  import type { PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  export interface Hooks {
5
5
  preToolUse?(toolName: string, input: unknown, ctx: HookToolContext): PreToolUseResult | undefined | Promise<PreToolUseResult | undefined>;
6
+ preToolUseObservational?: true;
6
7
  postToolUse?(toolName: string, input: unknown, output: HookToolOutput, ctx: HookToolContext): PostToolUseResult | undefined | Promise<PostToolUseResult | undefined>;
7
8
  userPromptSubmit?(prompt: string): UserPromptSubmitResult | undefined | Promise<UserPromptSubmitResult | undefined>;
8
9
  stop?(ctx: StopHookContext): StopHookResult | undefined | Promise<StopHookResult | undefined>;
@@ -1,3 +1,4 @@
1
+ import { assertRetentionPolicy } from "./retention-policy.js";
1
2
  export function newestSentAt(messages) {
2
3
  let newest;
3
4
  for (const m of messages) {
@@ -65,6 +66,7 @@ export class InMemoryMailboxStore {
65
66
  this.boxes.delete(this.key(scope, handle));
66
67
  }
67
68
  async reap(scope, now, opts) {
69
+ assertRetentionPolicy("MailboxStore.reap", opts);
68
70
  if (opts?.maxAgeMs === undefined)
69
71
  return 0;
70
72
  let dropped = 0;
@@ -63,11 +63,15 @@ export declare function gateMcpOutput(content: Array<TextContent | ImageContent>
63
63
  export declare function structuredContentErrorLine(structuredContent: unknown, collectedText: string): string | undefined;
64
64
  export declare function truncateMcpErrorText(s: string): string;
65
65
  export declare const MCP_TOOL_TIMEOUT_DEFAULT_MS = 100000000;
66
+ export declare const MCP_ENV_MS_MIN = 1000;
67
+ export declare const MCP_ENV_MS_MAX = 2147483647;
68
+ export declare function __resetMcpEnvAnnouncements(): void;
66
69
  export declare function mcpToolTimeoutMs(): number;
67
70
  export declare function mcpToolTotalTimeoutMs(perCallMs: number): number;
68
71
  export declare const MCP_IDLE_TIMEOUT_STDIO_DEFAULT_MS: number;
69
72
  export declare const MCP_IDLE_TIMEOUT_HTTP_DEFAULT_MS: number;
70
73
  export declare function mcpIdleTimeoutMs(kind: "stdio" | "http"): number;
74
+ export declare function mcpStartupTimeoutMs(): number | undefined;
71
75
  export declare function describeMcpSpecErrorCode(code: unknown): string | undefined;
72
76
  export declare function collapseMcpErrorPrefix(message: string): string;
73
77
  export declare function networkErrorCode(err: unknown, depth?: number): string | undefined;
package/dist/core/mcp.js CHANGED
@@ -24,13 +24,18 @@ export function resolveMcpDeclaredResultSize(meta) {
24
24
  return Math.min(declared, MCP_META_RESULT_SIZE_CAP);
25
25
  }
26
26
  function mcpMaxOutputTokens() {
27
- const raw = process.env.MAX_MCP_OUTPUT_TOKENS;
28
- if (raw) {
29
- const n = parseInt(raw, 10);
30
- if (Number.isFinite(n) && n > 0)
31
- return n;
27
+ const rawEnv = process.env.MAX_MCP_OUTPUT_TOKENS;
28
+ if (rawEnv === undefined)
29
+ return MCP_OUTPUT_TOKENS_DEFAULT;
30
+ const raw = rawEnv.trim();
31
+ if (raw === "")
32
+ return MCP_OUTPUT_TOKENS_DEFAULT;
33
+ const n = parseWholeNumber(raw);
34
+ if (n === undefined || n <= 0) {
35
+ announceMcpEnvKnob(`MAX_MCP_OUTPUT_TOKENS=${rawEnv} was ignored — it is not a positive whole number of tokens. Using ${MCP_OUTPUT_TOKENS_DEFAULT} instead.`);
36
+ return MCP_OUTPUT_TOKENS_DEFAULT;
32
37
  }
33
- return MCP_OUTPUT_TOKENS_DEFAULT;
38
+ return n;
34
39
  }
35
40
  function mcpTruncationNote(limitTokens) {
36
41
  return (`\n\n[OUTPUT TRUNCATED - exceeded ${limitTokens} token limit]\n\n` +
@@ -100,12 +105,54 @@ export function truncateMcpErrorText(s) {
100
105
  return out.length < s.length ? out : s;
101
106
  }
102
107
  export const MCP_TOOL_TIMEOUT_DEFAULT_MS = 100_000_000;
108
+ export const MCP_ENV_MS_MIN = 1_000;
109
+ export const MCP_ENV_MS_MAX = 2_147_483_647;
110
+ const ENV_SCIENTIFIC_RE = /^[+-]?(\d+(\.\d*)?|\.\d+)[eE][+-]?\d+$/;
111
+ const ENV_GROUPED_RE = /^[+-]?\d{1,3}([_,\u00A0\u202F ])\d{3}(?:\1\d{3})*$/;
112
+ const ENV_GROUP_SEPARATORS_RE = /[_,\u00A0\u202F ]/g;
113
+ const ENV_NUMERIC_MAX_LEN = 32;
114
+ function parseWholeNumber(raw) {
115
+ if (/^[+-]?\d+$/.test(raw)) {
116
+ const n = Number(raw);
117
+ return Number.isSafeInteger(n) ? n : undefined;
118
+ }
119
+ if (raw.length > ENV_NUMERIC_MAX_LEN)
120
+ return undefined;
121
+ if (ENV_SCIENTIFIC_RE.test(raw)) {
122
+ const n = Number(raw);
123
+ return Number.isSafeInteger(n) ? n : undefined;
124
+ }
125
+ if (ENV_GROUPED_RE.test(raw))
126
+ return parseInt(raw.replace(ENV_GROUP_SEPARATORS_RE, ""), 10);
127
+ return undefined;
128
+ }
129
+ const announcedMcpEnvKnobs = new Set();
130
+ function announceMcpEnvKnob(line) {
131
+ if (announcedMcpEnvKnobs.has(line))
132
+ return;
133
+ announcedMcpEnvKnobs.add(line);
134
+ console.warn(line);
135
+ }
136
+ export function __resetMcpEnvAnnouncements() {
137
+ announcedMcpEnvKnobs.clear();
138
+ }
103
139
  function parseEnvMs(name) {
104
- const raw = process.env[name];
105
- if (!raw)
140
+ const rawEnv = process.env[name];
141
+ if (rawEnv === undefined)
142
+ return undefined;
143
+ const raw = rawEnv.trim();
144
+ if (raw === "")
106
145
  return undefined;
107
- const n = parseInt(raw, 10);
108
- return Number.isFinite(n) && n > 0 ? n : undefined;
146
+ const n = parseWholeNumber(raw);
147
+ if (n === undefined || n <= 0) {
148
+ announceMcpEnvKnob(`${name}=${rawEnv} was ignored — it is not a positive whole number of milliseconds. Using the built-in default instead.`);
149
+ return undefined;
150
+ }
151
+ const clamped = Math.min(Math.max(n, MCP_ENV_MS_MIN), MCP_ENV_MS_MAX);
152
+ if (clamped !== n) {
153
+ announceMcpEnvKnob(`${name}=${rawEnv} is outside the range this runtime can honor (${MCP_ENV_MS_MIN}..${MCP_ENV_MS_MAX} ms). Using ${clamped}ms instead.`);
154
+ }
155
+ return clamped;
109
156
  }
110
157
  export function mcpToolTimeoutMs() {
111
158
  return parseEnvMs("MCP_TOOL_TIMEOUT") ?? MCP_TOOL_TIMEOUT_DEFAULT_MS;
@@ -150,7 +197,7 @@ function armMcpIdleWatchdog(health, idleMs, outerSignal) {
150
197
  },
151
198
  };
152
199
  }
153
- function mcpStartupTimeoutMs() {
200
+ export function mcpStartupTimeoutMs() {
154
201
  return parseEnvMs("MCP_TIMEOUT");
155
202
  }
156
203
  const MCP_SPEC_ERROR_CODE_NAMES = new Map([
@@ -0,0 +1,7 @@
1
+ export interface RetentionPolicyKnobs {
2
+ maxAgeMs?: number;
3
+ keep?: number;
4
+ staleRunningMaxAgeMs?: number;
5
+ maxEntries?: number;
6
+ }
7
+ export declare function assertRetentionPolicy(label: string, opts: RetentionPolicyKnobs | undefined): void;
@@ -0,0 +1,21 @@
1
+ function invalidPolicy(label, knob, value, requirement) {
2
+ const e = new Error(`${label}: ${knob} must be ${requirement} (got ${String(value)}) — a retention bound that cannot be evaluated silently decides what to delete instead of bounding it`);
3
+ e.code = "config.retention_policy_invalid";
4
+ throw e;
5
+ }
6
+ export function assertRetentionPolicy(label, opts) {
7
+ if (opts === undefined)
8
+ return;
9
+ for (const knob of ["maxAgeMs", "staleRunningMaxAgeMs"]) {
10
+ const v = opts[knob];
11
+ if (v !== undefined && (!Number.isFinite(v) || v < 0)) {
12
+ invalidPolicy(label, knob, v, "a finite, non-negative number of milliseconds");
13
+ }
14
+ }
15
+ for (const knob of ["keep", "maxEntries"]) {
16
+ const v = opts[knob];
17
+ if (v !== undefined && (!Number.isInteger(v) || v < 0)) {
18
+ invalidPolicy(label, knob, v, "a whole number of rows, 0 or more");
19
+ }
20
+ }
21
+ }
@@ -1074,7 +1074,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1074
1074
  const ownCallerPolicy = lockedPreflight.toolPolicy;
1075
1075
  const durableMandate = runtimeCaps?.forceDurableGate === true ||
1076
1076
  (spec.durableApproval !== undefined && !isLiveApproverSeat(frozenOnAsk));
1077
- const ownPreToolUse = hooks?.preToolUse;
1077
+ const ownPreToolUse = preToolUseObservational ? undefined : hooks?.preToolUse;
1078
1078
  const hookConstraint = ownPreToolUse !== undefined &&
1079
1079
  !(inheritedParentConstraints ?? []).some((pc) => pc.preToolUse === ownPreToolUse &&
1080
1080
  askApproverIdentity(pc.onAsk) === askApproverIdentity(frozenOnAsk) &&
@@ -2946,6 +2946,24 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2946
2946
  const denyNarrowingPolicy = narrowingLayers.length === 0 ? undefined : narrowingLayers.length === 1 ? narrowingLayers[0] : combinePolicies(...narrowingLayers);
2947
2947
  const basePolicyForResumeEdit = lockedPreflight.toolPolicy;
2948
2948
  const hooks = spec.hooks ?? deps.hooks;
2949
+ const preToolUseObservational = hooks?.preToolUse !== undefined && hooks.preToolUseObservational === true;
2950
+ const ownGatePreToolUse = !preToolUseObservational
2951
+ ? hooks?.preToolUse
2952
+ : async (toolName, input, ctx) => {
2953
+ const r = await hooks.preToolUse(toolName, cloneObserverInput(input), ctx);
2954
+ if (r === undefined)
2955
+ return undefined;
2956
+ try {
2957
+ const action = typeof r.action === "string" ? r.action : "(non-string action)";
2958
+ deps.onError?.(new Error(`a PreToolUse hook declared observational (Hooks.preToolUseObservational) returned a decision ("${action}") while screening ` +
2959
+ `"${toolName}" — the decision was NOT adopted and the call proceeded as if the hook had no opinion. ` +
2960
+ `A declared-observational face must return undefined; drop the declaration if its verdicts are meant to count ` +
2961
+ `(they then also travel to delegated children as an inherited screening constraint).`), { phase: "hook", sessionId, classification: "observational-hook-verdict-ignored" });
2962
+ }
2963
+ catch {
2964
+ }
2965
+ return undefined;
2966
+ };
2949
2967
  const sameInstanceAncestorCount = policy === undefined ? 0 : (inheritedParentConstraints ?? []).reduce((n, pc) => (pc.policy === policy ? n + 1 : n), 0);
2950
2968
  const sharedFirstDecision = new Map();
2951
2969
  const SHARED_FIRST_DECISION_CAP = 256;
@@ -3095,6 +3113,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3095
3113
  };
3096
3114
  const parentConstraintWrappers = (inheritedParentConstraints ?? []).map((pc) => pc.preToolUse !== undefined &&
3097
3115
  pc.preToolUse === hooks?.preToolUse &&
3116
+ !preToolUseObservational &&
3098
3117
  pc.durableMandate !== true &&
3099
3118
  askApproverIdentity(pc.onAsk) === askApproverIdentity(frozenOnAsk) &&
3100
3119
  pc.hookEnv === hookEnvSource
@@ -3310,7 +3329,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3310
3329
  return eff === "write" || eff === "idempotent";
3311
3330
  })
3312
3331
  : [];
3313
- const hasEffectAwareGate = Boolean(policyLayers.length > 0 || hooks?.preToolUse);
3332
+ const hasEffectAwareGate = Boolean(policyLayers.length > 0 || (hooks?.preToolUse !== undefined && !preToolUseObservational));
3314
3333
  const destructiveMcpUngated = mcp.tools.some((t) => !irreversibleTools.has(t.name) && !egressTools.has(t.name) && (toolEffects.get(t.name) ?? "write") !== "read");
3315
3334
  const firstPartyWriteUngated = (spec.tools ?? [])
3316
3335
  .map((t) => t.name)
@@ -3564,6 +3583,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3564
3583
  carried.spentMicroUsd = prior + parkedSpendMicroUsd;
3565
3584
  return carried;
3566
3585
  };
3586
+ const screeningParkDisclosedRef = { done: false };
3567
3587
  const serializeCheckpointState = (workspaceHandle, parkedSpendMicroUsd) => ({
3568
3588
  activeTools: [...activeTools],
3569
3589
  outputRef: { value: outputRef.value, set: outputRef.set },
@@ -3620,6 +3640,22 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3620
3640
  await memoryEngineSession.harvest("checkpoint");
3621
3641
  try {
3622
3642
  await checkpointStore.put(token, cp);
3643
+ const committedCount = cp.state.inheritedGate?.parentConstraintCount;
3644
+ if (cp.state.inheritedGate?.requiresParentConstraint === true &&
3645
+ !screeningParkDisclosedRef.done &&
3646
+ (inheritedParentConstraints ?? []).some((pc) => pc.preToolUse !== undefined)) {
3647
+ screeningParkDisclosedRef.done = true;
3648
+ try {
3649
+ deps.onError?.(new Error(`durable park under an inherited PreToolUse SCREENING constraint: this checkpoint records ` +
3650
+ `requiresParentConstraint with parentConstraintCount=${committedCount ?? "(unrecorded)"}, and the live ` +
3651
+ `closures cannot be persisted. A resume on THIS Runner re-supplies them automatically; a resume on a fresh ` +
3652
+ `Runner (restart / another replica) must hand back the whole chain via resumeStream(..., internals), ` +
3653
+ `rebuilding the screening entry with createPreToolUseConstraintPolicy(hook, env) — otherwise the row stays ` +
3654
+ `pending. If the face only observes, declare Hooks.preToolUseObservational and it stops entering the chain.`), { phase: "degraded", sessionId, classification: "screening-constraint-in-durable-chain" });
3655
+ }
3656
+ catch {
3657
+ }
3658
+ }
3623
3659
  return { ok: true };
3624
3660
  }
3625
3661
  catch (putErr) {
@@ -4152,7 +4188,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4152
4188
  result = await runToolGate({
4153
4189
  onNotifyError: (f) => emitTrace(deps.tracer, () => ({ kind: "observer.notify_failed", version: 1, taskId: spec.taskId ?? sessionId, site: f.site, message: f.error.message, ts: Date.now() })),
4154
4190
  event: e,
4155
- preToolUse: hooks?.preToolUse,
4191
+ preToolUse: ownGatePreToolUse,
4156
4192
  ...(hookEnvFace !== undefined ? { hookEnv: hookEnvFace } : {}),
4157
4193
  adjudicate,
4158
4194
  resolveAsk: resolveAskBound,
@@ -1357,6 +1357,7 @@ export class Runner {
1357
1357
  const hooks = h
1358
1358
  ? {
1359
1359
  ...(typeof h.preToolUse === "function" ? { preToolUse: (t, i, c) => h.preToolUse(t, i, c) } : {}),
1360
+ ...(typeof h.preToolUse === "function" && h.preToolUseObservational === true ? { preToolUseObservational: true } : {}),
1360
1361
  ...(typeof h.postToolUse === "function" ? { postToolUse: (t, i, o, c) => h.postToolUse(t, i, o, c) } : {}),
1361
1362
  ...(typeof h.userPromptSubmit === "function" ? { userPromptSubmit: (p) => h.userPromptSubmit(p) } : {}),
1362
1363
  ...(typeof h.stop === "function" ? { stop: (c) => h.stop(c) } : {}),
@@ -4084,14 +4085,28 @@ function startTimeout(harness, abortController, remainingMs, softSuspendable = f
4084
4085
  if (remainingMs !== undefined) {
4085
4086
  const hardMs = Math.max(0, remainingMs + (softSuspendable ? WALLTIME_SUSPEND_GRACE_SEC * 1000 : 0));
4086
4087
  const scheduledAtMs = Date.now() + hardMs;
4087
- const timer = setTimeout(() => {
4088
+ let leftMs = hardMs;
4089
+ let timer;
4090
+ const armChunk = () => {
4091
+ const chunk = Math.max(0, Math.min(leftMs, MAX_TIMER_DELAY_MS));
4092
+ timer = setTimeout(() => {
4093
+ leftMs -= chunk;
4094
+ if (leftMs > 0) {
4095
+ armChunk();
4096
+ return;
4097
+ }
4098
+ fireHardAbort();
4099
+ }, chunk);
4100
+ };
4101
+ const fireHardAbort = () => {
4088
4102
  if (state.fired)
4089
4103
  return;
4090
4104
  state.fired = true;
4091
4105
  state.latenessMs = Math.max(0, Date.now() - scheduledAtMs);
4092
4106
  abortController.abort();
4093
4107
  void harness.abort();
4094
- }, hardMs);
4108
+ };
4109
+ armChunk();
4095
4110
  state.clear = () => clearTimeout(timer);
4096
4111
  }
4097
4112
  return state;
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { assertRetentionPolicy } from "./retention-policy.js";
2
3
  import { uuidv7 } from "../internal/harness.js";
3
4
  import { canAccessAgentRecord, BackgroundAgentStoreError, clearRevivedRowTerminalPayload, REVIVED_ROW_CLEARED_FIELDS, STALE_RUNNING_REAP_ATTRIBUTION, } from "./background-agent-store.js";
4
5
  import { shutdownDebug } from "./shutdown-debug.js";
@@ -163,6 +164,7 @@ export function endDurableClaimLane(core, id) {
163
164
  core.claimingHandles.delete(id);
164
165
  }
165
166
  export async function reapDurableAgentsLane(core, scope, deps, policy) {
167
+ assertRetentionPolicy("reapDurableAgents", policy);
166
168
  const now = policy.now ?? Date.now();
167
169
  if (policy.staleRunningMaxAgeMs !== undefined) {
168
170
  await deps.store.reap(scope, now, { staleRunningMaxAgeMs: policy.staleRunningMaxAgeMs });
@@ -117,6 +117,9 @@ export function createApprovalPolicy(opts) {
117
117
  if (opts.approvalTimeoutMs !== undefined && !Number.isFinite(opts.approvalTimeoutMs)) {
118
118
  throw new Error(`createApprovalPolicy: approvalTimeoutMs must be a finite number of ms (got ${opts.approvalTimeoutMs}) — omit it to wait indefinitely`);
119
119
  }
120
+ if (opts.approvalTimeoutMs !== undefined && opts.approvalTimeoutMs > 2_147_483_647) {
121
+ throw new Error(`createApprovalPolicy: approvalTimeoutMs must not exceed 2147483647ms (~24.8 days) (got ${opts.approvalTimeoutMs}) — a host timer truncates a larger delay and fires immediately, denying every request at once; omit it to wait indefinitely`);
122
+ }
120
123
  const need = new Set(opts.requireApproval);
121
124
  const deny = new Set(opts.deny ?? []);
122
125
  const auto = new Set(opts.autoAllow ?? []);
@@ -1,3 +1,4 @@
1
+ import { assertRetentionPolicy } from "./retention-policy.js";
1
2
  export function summarizeWorkflowRun(run) {
2
3
  const latestPhase = run.phases.at(-1);
3
4
  return {
@@ -73,6 +74,7 @@ export class InMemoryWorkflowRunStore {
73
74
  return queryWorkflowRuns(this.runs.values(), scope, opts);
74
75
  }
75
76
  async reap(scope, now, opts) {
77
+ assertRetentionPolicy("WorkflowRunStore.reap", opts);
76
78
  if (opts?.maxAgeMs === undefined && opts?.keep === undefined)
77
79
  return 0;
78
80
  const terminal = [...this.runs.values()].filter((r) => r.scope === scope && isTerminalWorkflowStatus(r.status));
package/dist/index.d.ts CHANGED
@@ -131,7 +131,7 @@ export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmitti
131
131
  export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, type PermissionRuleStore, type PermissionRuleStoreProvider, type StoredAllowRules, type RemoveResult, type PutResult, } from "./core/permission-rule-store.js";
132
132
  export { prepareCardApproval, confirmRuleApproval, type ConfirmResult, type ConfirmRefusalReason, redeemRuleTicket, redeemRuleBatch, prepareCcImport, prepareStarterBatch, mintRuleTicket, STARTER_RULES, InMemoryRuleApprovalRecordStore, type RuleTicket, type RuleCandidate, type RuleApprovalKind, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleConsentDeps, type RedeemResult, type CcImportLayer, type ImportedSettingsLayer, type ImportPreview, type ImportResult, } from "./core/permission-rule-consent.js";
133
133
  export { FilePermissionRuleStoreProvider } from "./stores/file/permission-rule-store.js";
134
- export { formatHookFeedback, runToolGate, createHookEnvCapabilities, type Hooks, type HookToolContext, type HookEnvCapabilities, type HookToolOutput, type PreToolUseResult, type PostToolUseResult, type UserPromptSubmitResult, type HookToolFailure, type PostToolUseFailureResult, type PostToolBatchCall, type PostToolBatchResult, type PreCompactContext, type PreCompactResult, type PostCompactContext, type StopFailureContext, type PermissionDeniedPayload, type PermissionDeniedSource, } from "./core/hooks.js";
134
+ export { formatHookFeedback, runToolGate, createHookEnvCapabilities, createPreToolUseConstraintPolicy, type Hooks, type HookToolContext, type HookEnvCapabilities, type HookToolOutput, type PreToolUseResult, type PostToolUseResult, type UserPromptSubmitResult, type HookToolFailure, type PostToolUseFailureResult, type PostToolBatchCall, type PostToolBatchResult, type PreCompactContext, type PreCompactResult, type PostCompactContext, type StopFailureContext, type PermissionDeniedPayload, type PermissionDeniedSource, } from "./core/hooks.js";
135
135
  export { InMemoryMemoryStore, composeMemoryBlock, composeLayeredMemoryBlock, normalizeMemorySpec, type NormalizedMemorySpec, type MemorySpecInput, supportsConsolidation, supportsPeriodicConsolidation, firstSentence, expandLexicalTerms, lexicalSearchMatch, type Embedder, classifyPromotable, enforcePromotableWriteGate, guardedMemoryStore, MemoryGateError, detectSecret, enforceSecretWriteGate, enforceStructuredNoteSecretGate, type UtilityGate, type MemoryStore, type MemoryVectorMode, type ScoredMemory, type MemoryNoteHeader, type MemoryNoteRecord, type MemoryNoteType, type StructuredNoteInput, } from "./core/memory.js";
136
136
  export { MemoryEngine, FileMemoryEngineBackend, memoryBackendContract, assertMemoryBackendSearchEquivalence, type MemoryBackendContractHooks, buildMemoryInstruction, truncateIndex, scanEntryFiles, deriveRepoKey, deriveRepoMemoryDir, deriveControlPlaneDir, deriveRepoControlPlaneDir, resolveMemoryEngineRoot, scopeDirName, ControlPlaneCorruptError, parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_INDEX_FILENAME, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, type MemorySearchDetails, type MemorySearchHit, type MemoryGetDetails, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_MAX_ENTRY_DEPTH, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE, renderAnnouncements, enqueueMemoryAnnouncement, drainMemoryAnnouncements, peekMemoryAnnouncements, bumpScanFuse, scanFuseCount, clearScanFuse, ANNOUNCEMENTS_FILE, MEMORY_ANNOUNCEMENTS_MAX, SCAN_FUSE_FILE, SCAN_FUSE_THRESHOLD, type MemoryAnnouncement, type ScanFinding, type MemoryEngineOptions, type MemoryInjection, type MemoryBackend, type MemoryEntry, type MemoryEntryFrontmatter, type MemoryEntryHeader, type ScoredMemoryEntry, type NotePatch, type PatchReport, type MaterializedFile, type MemorySessionHandle, type HarvestReport, type HarvestRejection, type HarvestRejectionCode, type ParsedEntryFile, type ScannedEntryFile, SCOPE_SEGMENT_MAX_ENCODED, PROJECT_MARKER_PATH, encodeScopeSegment, decodeScopeSegment, parseScopeKey, formatUserScope, formatOrgScope, formatProjScope, formatUserProjScope, isPersonalScope, assertScopeContractPlacement, formatProjectMarker, parseProjectMarker, resolveProjectId, PROJECT_ID_REGEX, type ParsedScopeKey, migrateScope, type MigrateScopeReport, materializeEntriesToFiles, harvestFilesToPatches, projectionsToWriteBack, screenInboundEntries, REMOTE_HARVEST_PER_FILE_BYTES, REMOTE_MASS_DELETION_FUSE_RATIO, type RemoteMemoryFile, type RemoteMemoryBaseline, type RemoteMaterialization, type RemoteHarvestResult, type InboundEntryFinding, deriveProjectMemoryDir, deriveProjectControlDir, recordProjectIdHint, lookupProjectIdHint, PROJECT_ID_HINTS_FILE, reconcileMemoryEntries, nextSyncBaseline, mergeRecallHits, type MemorySyncCursor, type MemorySyncConflict, type MemorySyncPlan, type RecallSource, syncMemoryScope, type MemorySyncTransport, type MemorySyncRequestBody, type MemorySyncResponseBody, type MemorySyncClientConflict, type SyncMemoryScopeOptions, type MemorySyncClientResult, } from "./core/memory-engine/index.js";
137
137
  export { SHARED_MEMORY_READ_CAP_BYTES, SHARED_MEMORY_LIST_PAGE_SIZE, SharedMemoryStoreError, type SharedMemoryStoreProvider, type SharedMemoryStoreReader, type SharedMemoryStoreInfo, type SharedMemoryDocumentEntry, type SharedMemorySnapshot, type SharedMemoryRequestContext, type MemoryListDetails, type MemoryReadDetails, } from "./core/shared-memory/types.js";
package/dist/index.js CHANGED
@@ -116,7 +116,7 @@ export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmitti
116
116
  export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, } from "./core/permission-rule-store.js";
117
117
  export { prepareCardApproval, confirmRuleApproval, redeemRuleTicket, redeemRuleBatch, prepareCcImport, prepareStarterBatch, mintRuleTicket, STARTER_RULES, InMemoryRuleApprovalRecordStore, } from "./core/permission-rule-consent.js";
118
118
  export { FilePermissionRuleStoreProvider } from "./stores/file/permission-rule-store.js";
119
- export { formatHookFeedback, runToolGate, createHookEnvCapabilities, } from "./core/hooks.js";
119
+ export { formatHookFeedback, runToolGate, createHookEnvCapabilities, createPreToolUseConstraintPolicy, } from "./core/hooks.js";
120
120
  export { InMemoryMemoryStore, composeMemoryBlock, composeLayeredMemoryBlock, normalizeMemorySpec, supportsConsolidation, supportsPeriodicConsolidation, firstSentence, expandLexicalTerms, lexicalSearchMatch, classifyPromotable, enforcePromotableWriteGate, guardedMemoryStore, MemoryGateError, detectSecret, enforceSecretWriteGate, enforceStructuredNoteSecretGate, } from "./core/memory.js";
121
121
  export { MemoryEngine, FileMemoryEngineBackend, memoryBackendContract, assertMemoryBackendSearchEquivalence, buildMemoryInstruction, truncateIndex, scanEntryFiles, deriveRepoKey, deriveRepoMemoryDir, deriveControlPlaneDir, deriveRepoControlPlaneDir, resolveMemoryEngineRoot, scopeDirName, ControlPlaneCorruptError, parseEntryFile, serializeEntryFile, computeEntryRev, entryFromFile, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_INDEX_FILENAME, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_MAX_ENTRY_DEPTH, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE, renderAnnouncements, enqueueMemoryAnnouncement, drainMemoryAnnouncements, peekMemoryAnnouncements, bumpScanFuse, scanFuseCount, clearScanFuse, ANNOUNCEMENTS_FILE, MEMORY_ANNOUNCEMENTS_MAX, SCAN_FUSE_FILE, SCAN_FUSE_THRESHOLD, SCOPE_SEGMENT_MAX_ENCODED, PROJECT_MARKER_PATH, encodeScopeSegment, decodeScopeSegment, parseScopeKey, formatUserScope, formatOrgScope, formatProjScope, formatUserProjScope, isPersonalScope, assertScopeContractPlacement, formatProjectMarker, parseProjectMarker, resolveProjectId, PROJECT_ID_REGEX, migrateScope, materializeEntriesToFiles, harvestFilesToPatches, projectionsToWriteBack, screenInboundEntries, REMOTE_HARVEST_PER_FILE_BYTES, REMOTE_MASS_DELETION_FUSE_RATIO, deriveProjectMemoryDir, deriveProjectControlDir, recordProjectIdHint, lookupProjectIdHint, PROJECT_ID_HINTS_FILE, reconcileMemoryEntries, nextSyncBaseline, mergeRecallHits, syncMemoryScope, } from "./core/memory-engine/index.js";
122
122
  export { SHARED_MEMORY_READ_CAP_BYTES, SHARED_MEMORY_LIST_PAGE_SIZE, SharedMemoryStoreError, } from "./core/shared-memory/types.js";
@@ -102,6 +102,9 @@ export async function runSpec(runner, contract, opts) {
102
102
  const userHooks = opts.taskSpec.hooks && deployBaseline?.hooks
103
103
  ? {
104
104
  preToolUse: (opts.taskSpec.hooks.preToolUse ?? deployBaseline.hooks.preToolUse)?.bind(opts.taskSpec.hooks.preToolUse ? opts.taskSpec.hooks : deployBaseline.hooks),
105
+ ...((opts.taskSpec.hooks.preToolUse ? opts.taskSpec.hooks : deployBaseline.hooks).preToolUseObservational === true
106
+ ? { preToolUseObservational: true }
107
+ : {}),
105
108
  postToolUse: (opts.taskSpec.hooks.postToolUse ?? deployBaseline.hooks.postToolUse)?.bind(opts.taskSpec.hooks.postToolUse ? opts.taskSpec.hooks : deployBaseline.hooks),
106
109
  userPromptSubmit: (opts.taskSpec.hooks.userPromptSubmit ?? deployBaseline.hooks.userPromptSubmit)?.bind(opts.taskSpec.hooks.userPromptSubmit ? opts.taskSpec.hooks : deployBaseline.hooks),
107
110
  stop: (opts.taskSpec.hooks.stop ?? deployBaseline.hooks.stop)?.bind(opts.taskSpec.hooks.stop ? opts.taskSpec.hooks : deployBaseline.hooks),
@@ -118,6 +121,7 @@ export async function runSpec(runner, contract, opts) {
118
121
  let gateAbandoned = false;
119
122
  const hooks = {
120
123
  ...(userHooks?.preToolUse && { preToolUse: userHooks.preToolUse.bind(userHooks) }),
124
+ ...(userHooks?.preToolUse && userHooks.preToolUseObservational === true ? { preToolUseObservational: true } : {}),
121
125
  ...(userHooks?.postToolUse && { postToolUse: userHooks.postToolUse.bind(userHooks) }),
122
126
  ...(userHooks?.userPromptSubmit && { userPromptSubmit: userHooks.userPromptSubmit.bind(userHooks) }),
123
127
  ...(userHooks?.postToolUseFailure && { postToolUseFailure: userHooks.postToolUseFailure.bind(userHooks) }),
@@ -245,6 +245,7 @@ function normalizeConcurrency(c) {
245
245
  }
246
246
  return Math.floor(c);
247
247
  }
248
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
248
249
  function normalizeWorkflowHardCap(name, v) {
249
250
  if (v === undefined)
250
251
  return undefined;
@@ -253,12 +254,22 @@ function normalizeWorkflowHardCap(name, v) {
253
254
  }
254
255
  return v;
255
256
  }
257
+ function normalizeWorkflowTimerCap(name, v) {
258
+ const normalized = normalizeWorkflowHardCap(name, v);
259
+ if (normalized !== undefined && normalized > MAX_TIMER_DELAY_MS) {
260
+ throw new Error(`runWorkflow: ${name} must not exceed ${MAX_TIMER_DELAY_MS}ms (~24.8 days) (got ${v}) — a host timer truncates a larger delay and fires immediately, cancelling the run at once. Omit it for no deadline.`);
261
+ }
262
+ return normalized;
263
+ }
256
264
  function normalizeWorkflowStallMs(v) {
257
265
  if (v === undefined)
258
266
  return WORKFLOW_AGENT_STALL_MS;
259
267
  if (!Number.isFinite(v)) {
260
268
  throw new Error(`runWorkflow: stallMs must be a finite number of milliseconds (got ${v}); 0 or a negative value is the documented explicit "off", not NaN/Infinity`);
261
269
  }
270
+ if (v > MAX_TIMER_DELAY_MS) {
271
+ throw new Error(`runWorkflow: stallMs must not exceed ${MAX_TIMER_DELAY_MS}ms (~24.8 days) (got ${v}) — a host timer truncates a larger delay and fires immediately, so a watchdog stretched that far would trip on every agent instead of tolerating a long one. Pass 0 to turn it off.`);
272
+ }
262
273
  return v;
263
274
  }
264
275
  function createSemaphore(max) {
@@ -346,10 +357,10 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
346
357
  const maxAgents = normalizeWorkflowHardCap("maxAgents", opts.maxAgents);
347
358
  const maxLogChars = normalizeWorkflowHardCap("maxLogChars", opts.maxLogChars);
348
359
  const maxResultChars = normalizeWorkflowHardCap("maxResultChars", opts.maxResultChars);
349
- const totalTimeoutMs = normalizeWorkflowHardCap("totalTimeoutMs", opts.totalTimeoutMs);
360
+ const totalTimeoutMs = normalizeWorkflowTimerCap("totalTimeoutMs", opts.totalTimeoutMs);
350
361
  const stallMs = normalizeWorkflowStallMs(opts.stallMs);
351
362
  const agentMaxRetries = normalizeWorkflowHardCap("agentMaxRetries", opts.agentMaxRetries) ?? WORKFLOW_AGENT_MAX_RETRIES;
352
- const throttleBackoffMs = normalizeWorkflowHardCap("throttleBackoffMs", opts.throttleBackoffMs) ?? WORKFLOW_AGENT_THROTTLE_BACKOFF_MS;
363
+ const throttleBackoffMs = normalizeWorkflowTimerCap("throttleBackoffMs", opts.throttleBackoffMs) ?? WORKFLOW_AGENT_THROTTLE_BACKOFF_MS;
353
364
  const timers = opts.timers ?? REAL_WORKFLOW_TIMERS;
354
365
  const cancelController = new AbortController();
355
366
  const timeoutController = totalTimeoutMs !== undefined ? new AbortController() : undefined;
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, queryBackgroundAgents, } from "../../core/background-agent-store.js";
2
+ import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, assertBackgroundAgentReapOptions, queryBackgroundAgents, } from "../../core/background-agent-store.js";
3
3
  import { SharedLedgerTable } from "./shared-ledger.js";
4
4
  const agentLedgers = new SharedLedgerTable({
5
5
  keyOf: (r) => FileBackgroundAgentStore.key(r.handle, r.scope),
@@ -113,6 +113,7 @@ export class FileBackgroundAgentStore {
113
113
  });
114
114
  }
115
115
  async reap(scope, now, opts) {
116
+ assertBackgroundAgentReapOptions(opts);
116
117
  if (!opts || (opts.maxAgeMs === undefined && opts.keep === undefined && opts.staleRunningMaxAgeMs === undefined))
117
118
  return 0;
118
119
  const changed = new Set();
@@ -1,4 +1,5 @@
1
1
  import { join, resolve, sep } from "node:path";
2
+ import { assertRetentionPolicy } from "../../core/retention-policy.js";
2
3
  import { existsSync, readdirSync, realpathSync } from "node:fs";
3
4
  import { newestSentAt, } from "../../core/mailbox-store.js";
4
5
  import { AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords, sanitizeScope, sanitizePathComponent } from "./fs-atomic.js";
@@ -176,6 +177,7 @@ export class FileMailboxStore {
176
177
  });
177
178
  }
178
179
  async reap(scope, now, opts) {
180
+ assertRetentionPolicy("MailboxStore.reap", opts);
179
181
  if (opts?.maxAgeMs === undefined)
180
182
  return 0;
181
183
  let dropped = 0;
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { assertRetentionPolicy } from "../../core/retention-policy.js";
2
3
  import { WorkflowRunStoreError, isTerminalWorkflowStatus, nextWorkflowRunOnUpdate, queryWorkflowRuns, } from "../../core/workflow-run-store.js";
3
4
  import { SharedLedgerTable } from "./shared-ledger.js";
4
5
  const runLedgers = new SharedLedgerTable({
@@ -64,6 +65,7 @@ export class FileWorkflowRunStore {
64
65
  return queryWorkflowRuns(this.runs.values(), scope, opts);
65
66
  }
66
67
  async reap(scope, now, opts) {
68
+ assertRetentionPolicy("WorkflowRunStore.reap", opts);
67
69
  if (opts?.maxAgeMs === undefined && opts?.keep === undefined)
68
70
  return 0;
69
71
  const terminal = [...this.runs.values()].filter((r) => r.scope === scope && isTerminalWorkflowStatus(r.status));
package/dist/tools/web.js CHANGED
@@ -25,6 +25,31 @@ function resolveWebMaxBytes(value) {
25
25
  }
26
26
  return value;
27
27
  }
28
+ function resolveWebTimeoutMs(value, knob, fallback) {
29
+ if (value === undefined)
30
+ return fallback;
31
+ if (!Number.isFinite(value) || value <= 0) {
32
+ const e = new Error(`${knob} must be a positive finite number of milliseconds (got ${String(value)}) — a non-finite or non-positive budget is not "no limit", it aborts every request within a millisecond`);
33
+ e.code = "config.web_timeout_invalid";
34
+ throw e;
35
+ }
36
+ if (value > 2_147_483_647) {
37
+ const e = new Error(`${knob} must not exceed 2147483647ms (~24.8 days) (got ${String(value)}) — a host timer truncates a larger delay and fires immediately, so widening the budget past this point aborts every request at once`);
38
+ e.code = "config.web_timeout_invalid";
39
+ throw e;
40
+ }
41
+ return value;
42
+ }
43
+ function resolveSearchMaxResults(value) {
44
+ if (value === undefined)
45
+ return 10;
46
+ if (!Number.isInteger(value) || value < 1) {
47
+ const e = new Error(`WebSearch maxResults must be a whole number of 1 or more (got ${String(value)}) — a zero, negative or unevaluable cap returns an empty result set from a backend that answered`);
48
+ e.code = "config.web_search_max_results_invalid";
49
+ throw e;
50
+ }
51
+ return value;
52
+ }
28
53
  export const WEBFETCH_GROUNDING_MIN_TEXT_CHARS = 200;
29
54
  const GROUNDING_MIN_TEXT_RATIO = 0.01;
30
55
  const GROUNDING_RATIO_MAX_TEXT_CHARS = 2_000;
@@ -230,6 +255,7 @@ async function fetchAllowlisted(doFetch, start, allowHosts, userAgent, signal, o
230
255
  }
231
256
  export function webFetchToolSpec(config = {}) {
232
257
  const maxBytes = resolveWebMaxBytes(config.maxBytes);
258
+ const fetchTimeoutMs = resolveWebTimeoutMs(config.timeoutMs, "WebFetch timeoutMs", DEFAULT_FETCH_TIMEOUT_MS);
233
259
  const doFetch = config.fetchImpl ?? globalThis.fetch;
234
260
  const canSummarize = config.summarize !== undefined;
235
261
  return {
@@ -276,7 +302,7 @@ export function webFetchToolSpec(config = {}) {
276
302
  }
277
303
  if (!doFetch)
278
304
  return failure("Error (WebFetch): no fetch implementation available in this environment.");
279
- const timeoutMs = config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
305
+ const timeoutMs = fetchTimeoutMs;
280
306
  const ac = new AbortController();
281
307
  const timer = setTimeout(() => ac.abort(), timeoutMs);
282
308
  const onOuterAbort = () => ac.abort();
@@ -794,7 +820,8 @@ function webSearchResultAllowed(url, allowed, blocked) {
794
820
  return true;
795
821
  }
796
822
  export function createWebSearchTool(config) {
797
- const max = config.maxResults ?? 10;
823
+ const max = resolveSearchMaxResults(config.maxResults);
824
+ const searchTimeoutMs = resolveWebTimeoutMs(config.timeoutMs, "WebSearch timeoutMs", DEFAULT_SEARCH_TIMEOUT_MS);
798
825
  const currentMonthYear = new Date().toLocaleString("en-US", { month: "long", year: "numeric" });
799
826
  return {
800
827
  name: "WebSearch",
@@ -831,7 +858,7 @@ export function createWebSearchTool(config) {
831
858
  if (queryChars > SEARCH_QUERY_MAX_CHARS) {
832
859
  return errorResult(`Error (WebSearch): the query is ${queryChars} characters, over the ${SEARCH_QUERY_MAX_CHARS}-character limit. Shorten it to the terms that matter — an identical retry will fail the same way.`, failCard({ retryable: false }));
833
860
  }
834
- const timeoutMs = config.timeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS;
861
+ const timeoutMs = searchTimeoutMs;
835
862
  const ac = new AbortController();
836
863
  const timer = setTimeout(() => ac.abort(), timeoutMs);
837
864
  const onOuterAbort = () => ac.abort();
@@ -936,7 +963,7 @@ export function createWebSearchTool(config) {
936
963
  }
937
964
  export function createSearxngSearchBackend(baseUrl, options = {}) {
938
965
  const doFetch = options.fetchImpl ?? globalThis.fetch;
939
- const timeoutMs = options.timeoutMs ?? 10_000;
966
+ const timeoutMs = resolveWebTimeoutMs(options.timeoutMs, "createSearxngSearchBackend timeoutMs", 10_000);
940
967
  const parsedBase = new URL(baseUrl);
941
968
  parsedBase.hash = "";
942
969
  parsedBase.pathname = `${parsedBase.pathname.replace(/\/+$/, "")}/search`;
@@ -966,9 +993,9 @@ export function createSearxngSearchBackend(baseUrl, options = {}) {
966
993
  };
967
994
  }
968
995
  export async function probeSearchBackend(search, options) {
969
- const budget = options?.timeoutMs ?? 15_000;
970
996
  let timer;
971
997
  try {
998
+ const budget = resolveWebTimeoutMs(options?.timeoutMs, "probeSearchBackend timeoutMs", 15_000);
972
999
  const results = await Promise.race([
973
1000
  search("connectivity probe", AbortSignal.timeout(budget)),
974
1001
  new Promise((_, reject) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.19.0",
3
+ "version": "5.20.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",