@sema-agent/core 7.3.0 → 7.3.1

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.3.1 — 2026-09-03
4
+
5
+ ### Changed (narrowing, named) — #531 Edit validates BEFORE it asks
6
+ - **`ToolSpec.validateInput?(args, ctx)`** — a pure input pre-validation hook the loop runs BEFORE the permission gate (CC parity: `validateInput` precedes the permission prompt; a refusal is a typed tool result, not a card). Edit / Write-on-existing / NotebookEdit implement it with the same `requireRead` + path checks the execute path keeps (double-checked; TOCTOU-safe). **Narrowing**: an Edit of a file the model never Read no longer raises an approval card — the model receives the read-first refusal directly (the auto classifier is not consulted); a Read-then-Edit still asks exactly as before. A throwing validator reads as "no verdict" (the call proceeds to the gate; one `onError` hook-phase disclosure).
7
+
8
+ ### Added — #530 `waiting_first_token` status phase (closed set 6 → 7)
9
+ - `BrainStatusPhase` +`"waiting_first_token"`: with an armed first-token watchdog (`firstTokenTimeoutMs` > 30s), a `status` frame is emitted once the first token has been awaited 30s and every 30s after (`elapsedMs`, `timeoutMs`; no `retryAtMs`); cleared by the token, the watchdog or the call's end. An announced wait closes with the terminal frame (`recovered`, detail `first token arrived`) — never a retry's wording. Constants `WAITING_FIRST_TOKEN_AFTER_MS` / `WAITING_FIRST_TOKEN_EVERY_MS` exported. **Consumers pinning the six-member phase set must widen** (cli client-core retry-status closed set; server status projection is a pass-through).
10
+
11
+ ### Added — #529 `WiringManifest.autoMode { armed, reason }`
12
+ - Minted on every leg (effective half; fingerprinted like `modelGate`): `reason ∈ armed | no_intent | no_face | denied | resolver_fault | latch_open` (`AUTO_MODE_ARM_REASONS`), `armed ⇔ reason === "armed"`. The per-leg READ face of the #521 arming decision.
13
+
14
+ ### Fixed — #532 format characters inside / around a secret (family fix of A-087 root C)
15
+ - The format-free scanning view sinks into `runRedactionPasses`, so BOTH entries (`scrubSecrets`, `redactSecrets`) and every consumer (`primaryActivityArg` → `TaskResult.stats.humanReview.gates[].toolArg`, previews, error text) redact a key split by U+200B / U+00AD / U+FEFF as one marker; the raw scan and one suffix scan per format slot are unioned in so a format character used as the BOUNDARY before a key (`x<ZWSP>sk-…`) is still found; past 64 format slots the input is replaced whole (over-strip, never a leak). Format characters outside a secret are preserved; format-free input is byte-identical.
16
+
3
17
  ## 7.3.0 — 2026-09-03
4
18
 
5
19
  ### Added — design/385 slice 2a + 2b (cross-session peer messaging: mailbox contract, file backend, agents domain; +40 exports, 0 removed)
@@ -16,6 +16,16 @@ export declare function runWithStatusSink<T>(emit: (s: BrainStatus) => void, fn:
16
16
  * call site for this exact reason and states the contract ("an advisory frame must never break a settled
17
17
  * call"); the guard belongs HERE so every call site inherits it rather than each remembering.
18
18
  */
19
+ /**
20
+ * #530 — bind the ACTIVE sink now, for an emit that will happen from a timer callback later. The
21
+ * async-local store propagates through Node's timers, but not through every timer implementation a
22
+ * process may run under (fake clocks in tests replace `setTimeout` outright and run callbacks from
23
+ * their own stack, where `getStore()` is empty). A frame scheduled inside the sink's scope must reach
24
+ * that sink whichever clock fires it, so the scheduler captures the store here and the callback emits
25
+ * through the bound function. Same swallow guard and the same retryAtMs stamp as {@link emitBrainStatus};
26
+ * a no-op function when no sink is active.
27
+ */
28
+ export declare function bindBrainStatusEmitter(): (status: BrainStatus) => void;
19
29
  export declare function emitBrainStatus(status: BrainStatus): void;
20
30
  /**
21
31
  * Silent-fallback telemetry (C1/C4/C5/C6) — the brain→runner TELEMETRY channel, a
@@ -3,16 +3,25 @@ const statusSinkStore = new AsyncLocalStorage();
3
3
  export function runWithStatusSink(emit, fn) {
4
4
  return statusSinkStore.run({ emit }, fn);
5
5
  }
6
- export function emitBrainStatus(status) {
6
+ export function bindBrainStatusEmitter() {
7
+ const sink = statusSinkStore.getStore();
8
+ if (sink === undefined)
9
+ return () => undefined;
10
+ return (status) => deliverBrainStatus(sink, status);
11
+ }
12
+ function deliverBrainStatus(sink, status) {
7
13
  try {
8
- const sink = statusSinkStore.getStore();
9
- if (sink === undefined)
10
- return;
11
14
  sink.emit(withRetryDeadline(status));
12
15
  }
13
16
  catch {
14
17
  }
15
18
  }
19
+ export function emitBrainStatus(status) {
20
+ const sink = statusSinkStore.getStore();
21
+ if (sink === undefined)
22
+ return;
23
+ deliverBrainStatus(sink, status);
24
+ }
16
25
  function withRetryDeadline(status) {
17
26
  if (status.retryAtMs !== undefined)
18
27
  return status;
@@ -89,6 +89,17 @@ export declare function sameRequestModuloCap(original: SSERequest, rebuilt: SSER
89
89
  * Tolerates a `doFetch` (BYOM injection point) whose Response-shaped return has no usable `headers`.
90
90
  */
91
91
  export declare function shouldRetryHeaderVerdict(res: Response | undefined): boolean | undefined;
92
+ /**
93
+ * #530 — the first-token WAIT disclosure. While a first-token watchdog is armed
94
+ * (`firstTokenTimeoutMs > 0`) and no content token has arrived, the engine says so on the status
95
+ * channel: the FIRST `waiting_first_token` frame goes out once the wait has lasted
96
+ * {@link WAITING_FIRST_TOKEN_AFTER_MS}, and one more every {@link WAITING_FIRST_TOKEN_EVERY_MS} after
97
+ * that, until the first token lands, the watchdog fires, or the attempt ends. A short wait (under the
98
+ * threshold) announces nothing — the frame exists for the unattended "no answer for a minute" case,
99
+ * not for every request. The same 30s slice the retry countdown re-announces on.
100
+ */
101
+ export declare const WAITING_FIRST_TOKEN_AFTER_MS = 30000;
102
+ export declare const WAITING_FIRST_TOKEN_EVERY_MS = 30000;
92
103
  export interface SSERequest {
93
104
  url: string;
94
105
  headers: Record<string, string>;
@@ -3,7 +3,7 @@ import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } fr
3
3
  import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError, namesTheFailure, readProviderRequestId } from "./errors.js";
4
4
  import { classifyInputTooLong } from "./input-too-long.js";
5
5
  import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
6
- import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
6
+ import { bindBrainStatusEmitter, emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
7
7
  import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
8
8
  const DEFAULT_MAX_RETRIES = 10;
9
9
  const MAX_RETRIES_ENV_CEILING = 15;
@@ -189,6 +189,8 @@ export function shouldRetryHeaderVerdict(res) {
189
189
  return undefined;
190
190
  }
191
191
  const RETRY_STATUS_SLICE_MS = 30_000;
192
+ export const WAITING_FIRST_TOKEN_AFTER_MS = 30_000;
193
+ export const WAITING_FIRST_TOKEN_EVERY_MS = 30_000;
192
194
  const THINKING_RETRY_BUDGET = 2;
193
195
  const EMPTY_USAGE = {
194
196
  input: 0,
@@ -249,6 +251,8 @@ export function runStreamingBrain(args) {
249
251
  const out = createAssistantMessageEventStream();
250
252
  let cleanup;
251
253
  let announcedRetry = false;
254
+ let announcedWait = false;
255
+ let terminalWaitDetail = "first token arrived";
252
256
  let terminalRetryPhase = "recovered";
253
257
  let terminalRetryDetail = "recovered after retrying";
254
258
  let requestIdSeen;
@@ -257,6 +261,7 @@ export function runStreamingBrain(args) {
257
261
  const aborted = signal?.aborted === true || isAbortError(err);
258
262
  terminalRetryPhase = "gave_up";
259
263
  terminalRetryDetail = aborted ? "cancelled while retrying" : "retries exhausted";
264
+ terminalWaitDetail = aborted ? "cancelled while waiting for the first token" : "the first-token wait ended in failure";
260
265
  const errorMsg = emptyAssistant(model);
261
266
  errorMsg.stopReason = aborted ? "aborted" : "error";
262
267
  errorMsg.errorMessage = err instanceof Error ? err.message : String(err);
@@ -278,10 +283,10 @@ export function runStreamingBrain(args) {
278
283
  })
279
284
  .finally(() => {
280
285
  cleanup?.();
281
- if (!announcedRetry)
286
+ if (!announcedRetry && !announcedWait)
282
287
  return;
283
288
  try {
284
- emitBrainStatus({ phase: terminalRetryPhase, detail: terminalRetryDetail });
289
+ emitBrainStatus({ phase: terminalRetryPhase, detail: announcedRetry ? terminalRetryDetail : terminalWaitDetail });
285
290
  }
286
291
  catch {
287
292
  }
@@ -468,11 +473,39 @@ export function runStreamingBrain(args) {
468
473
  let firstTokenSeen = false;
469
474
  let firstTokenTimedOut = false;
470
475
  let ftTimer;
476
+ let waitTimer;
477
+ const clearWaitTimer = () => {
478
+ if (waitTimer) {
479
+ clearTimeout(waitTimer);
480
+ waitTimer = undefined;
481
+ }
482
+ };
471
483
  if (firstTokenTimeoutMs && firstTokenTimeoutMs > 0) {
472
484
  ftTimer = setTimeout(() => {
473
485
  firstTokenTimedOut = true;
486
+ clearWaitTimer();
474
487
  void reader.cancel().catch(() => undefined);
475
488
  }, firstTokenTimeoutMs);
489
+ if (firstTokenTimeoutMs > WAITING_FIRST_TOKEN_AFTER_MS) {
490
+ const waitStartedAt = Date.now();
491
+ const emitWaitFrame = bindBrainStatusEmitter();
492
+ const scheduleWaitFrame = (delayMs) => {
493
+ waitTimer = setTimeout(() => {
494
+ waitTimer = undefined;
495
+ if (firstTokenSeen || firstTokenTimedOut || Date.now() - waitStartedAt >= firstTokenTimeoutMs)
496
+ return;
497
+ announcedWait = true;
498
+ emitWaitFrame({
499
+ phase: "waiting_first_token",
500
+ detail: "waiting for the first token",
501
+ elapsedMs: Date.now() - waitStartedAt,
502
+ timeoutMs: firstTokenTimeoutMs,
503
+ });
504
+ scheduleWaitFrame(WAITING_FIRST_TOKEN_EVERY_MS);
505
+ }, delayMs);
506
+ };
507
+ scheduleWaitFrame(WAITING_FIRST_TOKEN_AFTER_MS);
508
+ }
476
509
  }
477
510
  let idleTimedOut = false;
478
511
  let idleTimer;
@@ -497,12 +530,14 @@ export function runStreamingBrain(args) {
497
530
  clearTimeout(ftTimer);
498
531
  ftTimer = undefined;
499
532
  }
533
+ clearWaitTimer();
500
534
  };
501
535
  cleanup = () => {
502
536
  if (ftTimer) {
503
537
  clearTimeout(ftTimer);
504
538
  ftTimer = undefined;
505
539
  }
540
+ clearWaitTimer();
506
541
  if (idleTimer) {
507
542
  clearTimeout(idleTimer);
508
543
  idleTimer = undefined;
@@ -575,6 +610,7 @@ export function runStreamingBrain(args) {
575
610
  clearTimeout(ftTimer);
576
611
  ftTimer = undefined;
577
612
  }
613
+ clearWaitTimer();
578
614
  if (idleTimer) {
579
615
  clearTimeout(idleTimer);
580
616
  idleTimer = undefined;
@@ -71,9 +71,19 @@ export interface RedactionPass {
71
71
  /** A literal replacement (only `$1` substitution is used by these passes) or a replacer fn. */
72
72
  replace: string | ((match: string, ...groups: string[]) => string);
73
73
  }
74
- /** Run the declared passes in order, collecting findings (original-input spans) when a report is given.
75
- * An idempotent no-op replacement (inserted === matched, e.g. re-scrubbing already-redacted text) is
76
- * NOT a finding — nothing was removed. */
74
+ /**
75
+ * Run the declared passes in order, collecting findings (original-input spans) when a report is given.
76
+ *
77
+ * FORMAT CHARACTERS (#532): the passes scan a VIEW of the input with every `\p{Cf}` removed, so a
78
+ * credential split by a zero-width character is one token to the scanner, not two fragments of which
79
+ * only the first (or neither) matches. The OUTPUT keeps every format character that sits OUTSIDE a
80
+ * replaced span — a joiner in an emoji sequence, a non-joiner in Persian text, a soft hyphen in a word
81
+ * — and drops only those INSIDE the span the marker replaces (they were part of the secret). A
82
+ * format-free input takes the exact historical path (byte-identical output). When the replacements
83
+ * cannot be mapped back exactly (a later pass matched across an earlier pass's inserted text), the
84
+ * output is the redacted view itself — every format character dropped: the failure direction is
85
+ * over-stripping, never a leak.
86
+ */
77
87
  export declare function runRedactionPasses(input: string, passes: readonly RedactionPass[], report?: RedactionReport): string;
78
88
  /** The credential-shape passes (the pre-channel SECRET_PATTERNS, now with declared kinds/confidence).
79
89
  * Exported for {@link runRedactionPasses} composition (untrusted-egress chains its URL passes after
@@ -25,10 +25,10 @@ function mapBackOnePass(edits, pos) {
25
25
  return pos - delta;
26
26
  }
27
27
  const PREEXISTING_MARKER_RE = /\[redacted(?:-[a-z]+)?\]/g;
28
- export function runRedactionPasses(input, passes, report) {
29
- if (report !== undefined && report.preexistingMarkers === undefined) {
30
- report.preexistingMarkers = input.match(PREEXISTING_MARKER_RE)?.length ?? 0;
31
- }
28
+ const FORMAT_CHAR_RE = /\p{Cf}/u;
29
+ const MAX_FORMAT_SLOT_SCANS = 64;
30
+ const REDACTED_WHOLE = "[redacted]";
31
+ function applyPasses(input, passes, report, toOriginal) {
32
32
  const batches = [];
33
33
  let cur = input;
34
34
  for (const pass of passes) {
@@ -51,18 +51,149 @@ export function runRedactionPasses(input, passes, report) {
51
51
  report.findings.push({
52
52
  kind: pass.kind,
53
53
  confidence: pass.confidence,
54
- span: [s0, e0],
54
+ span: [toOriginal(s0), toOriginal(e0)],
55
55
  marker: pass.marker,
56
56
  source: pass.source,
57
57
  });
58
58
  }
59
- edits.push({ at: offset, removedLen: match.length, insertedLen: inserted.length });
59
+ edits.push({ at: offset, removedLen: match.length, insertedLen: inserted.length, inserted });
60
60
  }
61
61
  return inserted;
62
62
  });
63
63
  batches.push(edits);
64
64
  }
65
- return cur;
65
+ return { out: cur, batches };
66
+ }
67
+ function replacedSpans(batches) {
68
+ const spans = [];
69
+ for (let j = 0; j < batches.length; j++) {
70
+ for (const e of batches[j]) {
71
+ let s0 = e.at;
72
+ let e0 = e.at + e.removedLen;
73
+ for (let i = j - 1; i >= 0; i--) {
74
+ s0 = mapBackOnePass(batches[i], s0);
75
+ e0 = mapBackOnePass(batches[i], e0);
76
+ }
77
+ if (e0 - s0 !== e.removedLen)
78
+ return undefined;
79
+ spans.push({ start: s0, end: e0, inserted: e.inserted, pass: j });
80
+ }
81
+ }
82
+ spans.sort((a, b) => a.start - b.start);
83
+ for (let i = 1; i < spans.length; i++)
84
+ if (spans[i].start < spans[i - 1].end)
85
+ return undefined;
86
+ return spans;
87
+ }
88
+ export function runRedactionPasses(input, passes, report) {
89
+ if (report !== undefined && report.preexistingMarkers === undefined) {
90
+ report.preexistingMarkers = input.match(PREEXISTING_MARKER_RE)?.length ?? 0;
91
+ }
92
+ if (!FORMAT_CHAR_RE.test(input))
93
+ return applyPasses(input, passes, report, (pos) => pos).out;
94
+ const viewChars = [];
95
+ const viewToOriginal = [];
96
+ const formatAtSlot = [];
97
+ const charStartOffsets = [];
98
+ {
99
+ let orig = 0;
100
+ let viewOff = 0;
101
+ let slotBuf = "";
102
+ for (const ch of input) {
103
+ if (FORMAT_CHAR_RE.test(ch)) {
104
+ slotBuf += ch;
105
+ }
106
+ else {
107
+ formatAtSlot.push(slotBuf);
108
+ slotBuf = "";
109
+ viewToOriginal.push(orig);
110
+ charStartOffsets.push(viewOff);
111
+ viewChars.push(ch);
112
+ viewOff += ch.length;
113
+ }
114
+ orig += ch.length;
115
+ }
116
+ formatAtSlot.push(slotBuf);
117
+ viewToOriginal.push(input.length);
118
+ charStartOffsets.push(viewOff);
119
+ }
120
+ const view = viewChars.join("");
121
+ const offsetToCharIndex = new Map();
122
+ charStartOffsets.forEach((off, idx) => offsetToCharIndex.set(off, idx));
123
+ const toOriginal = (pos) => {
124
+ const idx = offsetToCharIndex.get(pos);
125
+ return idx === undefined ? input.length : viewToOriginal[idx];
126
+ };
127
+ const { out, batches } = applyPasses(view, passes, report, toOriginal);
128
+ const viewSpans = replacedSpans(batches);
129
+ if (viewSpans === undefined)
130
+ return out;
131
+ const spans = viewSpans.map((sp) => {
132
+ const startIdx = offsetToCharIndex.get(sp.start) ?? viewChars.length;
133
+ const endIdx = offsetToCharIndex.get(sp.end) ?? viewChars.length;
134
+ const lastIdx = endIdx - 1;
135
+ const endOrig = lastIdx >= startIdx && lastIdx < viewChars.length ? viewToOriginal[lastIdx] + viewChars[lastIdx].length : viewToOriginal[startIdx];
136
+ return { start: viewToOriginal[startIdx], end: endOrig, inserted: sp.inserted };
137
+ });
138
+ const viewOffsetToOriginal = (viewStart, viewEnd) => {
139
+ const startIdx = offsetToCharIndex.get(viewStart) ?? viewChars.length;
140
+ const endIdx = offsetToCharIndex.get(viewEnd) ?? viewChars.length;
141
+ const lastIdx = endIdx - 1;
142
+ const endOrig = lastIdx >= startIdx && lastIdx < viewChars.length ? viewToOriginal[lastIdx] + viewChars[lastIdx].length : viewToOriginal[startIdx];
143
+ return { start: viewToOriginal[startIdx], end: endOrig };
144
+ };
145
+ const cutOffsets = [];
146
+ for (let k = 1; k < viewChars.length; k++)
147
+ if (formatAtSlot[k] !== "")
148
+ cutOffsets.push(charStartOffsets[k]);
149
+ if (cutOffsets.length > MAX_FORMAT_SLOT_SCANS)
150
+ return REDACTED_WHOLE;
151
+ const extra = [];
152
+ const rawSpans = replacedSpans(applyPasses(input, passes, undefined, (pos) => pos).batches);
153
+ if (rawSpans !== undefined)
154
+ extra.push(...rawSpans);
155
+ for (const cut of cutOffsets) {
156
+ const cutSpans = replacedSpans(applyPasses(view.slice(cut), passes, undefined, (pos) => pos).batches);
157
+ if (cutSpans === undefined)
158
+ continue;
159
+ for (const c of cutSpans) {
160
+ const o = viewOffsetToOriginal(c.start + cut, c.end + cut);
161
+ extra.push({ start: o.start, end: o.end, inserted: c.inserted, pass: c.pass });
162
+ }
163
+ }
164
+ extra.sort((a, b) => a.start - b.start);
165
+ for (const r of extra) {
166
+ const overlapping = spans.find((v) => r.start < v.end && v.start < r.end);
167
+ if (overlapping !== undefined) {
168
+ if (r.start < overlapping.start)
169
+ overlapping.start = r.start;
170
+ if (r.end > overlapping.end)
171
+ overlapping.end = r.end;
172
+ continue;
173
+ }
174
+ spans.push({ start: r.start, end: r.end, inserted: r.inserted });
175
+ if (report !== undefined) {
176
+ const pass = passes[r.pass];
177
+ report.findings.push({ kind: pass.kind, confidence: pass.confidence, span: [r.start, r.end], marker: pass.marker, source: pass.source });
178
+ }
179
+ spans.sort((a, b) => a.start - b.start);
180
+ }
181
+ for (let i = 1; i < spans.length;) {
182
+ if (spans[i].start < spans[i - 1].end) {
183
+ spans[i - 1].end = Math.max(spans[i - 1].end, spans[i].end);
184
+ spans.splice(i, 1);
185
+ }
186
+ else
187
+ i++;
188
+ }
189
+ let result = "";
190
+ let cursor = 0;
191
+ for (const sp of spans) {
192
+ result += input.slice(cursor, sp.start) + sp.inserted;
193
+ cursor = sp.end;
194
+ }
195
+ result += input.slice(cursor);
196
+ return result;
66
197
  }
67
198
  export const SECRET_PASSES = [
68
199
  { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])(?:sk|pk|rk|gh[opsur])[-_][A-Za-z0-9_-]{8,}/g, replace: "[redacted]" },
@@ -1210,6 +1210,18 @@ function autoModeLatchHealthy(d) {
1210
1210
  return false;
1211
1211
  }
1212
1212
  }
1213
+ function autoModeArmReasonOf(intent, facePresent, capsAutoMode, capsFaulted) {
1214
+ if (!intent)
1215
+ return "no_intent";
1216
+ if (!facePresent)
1217
+ return "no_face";
1218
+ if (capsAutoMode === false)
1219
+ return capsFaulted ? "resolver_fault" : "denied";
1220
+ return "armed";
1221
+ }
1222
+ function validateInputFaultNotice(onError, sessionId) {
1223
+ return ({ toolName, toolCallId, error }) => onError?.(new Error(`tool input pre-validation (validateInput) threw for ${toolName} (call ${toolCallId}) — read as no verdict; the call proceeded to the gate: ${error instanceof Error ? error.message : String(error)}`), { phase: "hook", sessionId });
1224
+ }
1213
1225
  function refuseInvalidAutoModeSeat(spec) {
1214
1226
  if (spec.autoModeRequested !== undefined && spec.autoModeRequested !== true) {
1215
1227
  const e = new Error(`autoModeRequested must be the literal true when present (got ${JSON.stringify(spec.autoModeRequested)}) — omit it for a non-auto task; a non-boolean or false value is refused rather than read as either polarity.`);
@@ -1977,6 +1989,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1977
1989
  }
1978
1990
  agentForkDenial = forkGovernanceDenial(spec.enableFork, runtimeCaps?.allowFork);
1979
1991
  observersActive = runtimeCaps?.allowObservers === true;
1992
+ const autoModeArmReason = autoModeArmReasonOf(autoModeIntent, deps.autoMode !== undefined, runtimeCaps?.autoMode, runtimeCapsFaulted);
1980
1993
  if (autoModeIntent && deps.autoMode !== undefined && runtimeCaps?.autoMode !== false) {
1981
1994
  const am = deps.autoMode;
1982
1995
  let classifierModel;
@@ -3614,6 +3627,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3614
3627
  };
3615
3628
  const harness = new AgentHarness({
3616
3629
  abortResultDetails: () => parkContaminationMarker({ suspendRef, reviewRef }),
3630
+ onToolInputValidationFault: validateInputFaultNotice(deps.onError, sessionId),
3617
3631
  ...(spec.limits?.maxOutputTokens !== undefined && spec.limits.maxOutputTokens > 0
3618
3632
  ? { maxOutputTokens: spec.limits.maxOutputTokens }
3619
3633
  : {}),
@@ -4400,6 +4414,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4400
4414
  memoryAdmissionWired: deps.memoryScopeAdmission !== undefined,
4401
4415
  retentionPolicyWired: deps.retentionPolicy !== undefined,
4402
4416
  ...(modelGateManifest !== undefined ? { modelGate: modelGateManifest } : {}),
4417
+ autoMode: { armed: autoModeArmReason === "armed", reason: autoModeArmReason },
4403
4418
  });
4404
4419
  peerLaneRefs.askEffective = wiringManifest.ask.effective;
4405
4420
  if (peerLaneActive && internals?.onTaskNotification !== undefined) {
@@ -2872,6 +2872,8 @@ export class Runner {
2872
2872
  ...(s.retryInSec !== undefined ? { retryInSec: s.retryInSec } : {}),
2873
2873
  ...(s.retryInMs !== undefined ? { retryInMs: s.retryInMs } : {}),
2874
2874
  ...(s.retryAtMs !== undefined ? { retryAtMs: s.retryAtMs } : {}),
2875
+ ...(s.elapsedMs !== undefined ? { elapsedMs: s.elapsedMs } : {}),
2876
+ ...(s.timeoutMs !== undefined ? { timeoutMs: s.timeoutMs } : {}),
2875
2877
  ...(s.attempt !== undefined ? { attempt: s.attempt } : {}),
2876
2878
  ...(s.maxRetries !== undefined ? { maxRetries: s.maxRetries } : {}),
2877
2879
  ...(s.errClass !== undefined ? { errClass: s.errClass } : {}),
@@ -55,6 +55,7 @@ export function defineTool(spec, options) {
55
55
  ...(spec.alwaysLoad !== undefined ? { alwaysLoad: spec.alwaysLoad } : {}),
56
56
  ...(spec.prepareArguments ? { prepareArguments: spec.prepareArguments } : {}),
57
57
  ...(spec.approvalPreview ? { approvalPreview: spec.approvalPreview } : {}),
58
+ ...(spec.validateInput ? { validateInput: spec.validateInput } : {}),
58
59
  execute: async (toolCallId, rawParams, signal) => {
59
60
  const params = spec.prepareArguments ? spec.prepareArguments(rawParams) : rawParams;
60
61
  if (!Value.Check(spec.parameters, params)) {
@@ -1,5 +1,5 @@
1
1
  import type { TSchema } from "typebox";
2
- import type { AgentTool, ThinkingLevel } from "../internal/harness.js";
2
+ import type { AgentTool, ThinkingLevel, ToolInputValidationContext, ToolInputVerdict } from "../internal/harness.js";
3
3
  import type { ActorAssertion, CompleteSimpleFn, DocumentContent, ImageContent, Model, ResilienceOptions, StreamFn, TextContent } from "../internal/llm.js";
4
4
  import type { TaskNotificationPayload } from "./task-notification.js";
5
5
  /** A model reference: either a fully-formed Model, or a `"provider/id"` ref resolved via a catalog. */
@@ -241,6 +241,24 @@ export interface ToolSpec<TParams extends TSchema = TSchema> {
241
241
  /** approval display projection (see AgentTool.approvalPreview): pure args→display value
242
242
  * for human approval surfaces; clamped + throw-swallowed by the gate; never adjudication input. */
243
243
  approvalPreview?: (args: unknown) => unknown;
244
+ /**
245
+ * OPTIONAL input pre-validation — runs BEFORE the permission ask (and before every other tool-call
246
+ * gate station: PreToolUse hooks, policy, the auto-mode classifier), AFTER schema validation. A
247
+ * refusal (`{ ok: false, message }`) is returned to the model as a typed error result and NO
248
+ * approval card is shown for the call; `{ ok: true }` / `undefined` lets the call proceed to the
249
+ * gate unchanged. Upstream form (CC 2.1.250 `validateInput` → `{result:false, message, errorCode}`,
250
+ * consulted ahead of the permission prompt): a call the tool would refuse on its own precondition —
251
+ * an Edit/Write/NotebookEdit whose target file was never Read this session — used to reach the
252
+ * operator as a card that could only ever fail; the model then retried the same failing call under
253
+ * auto mode until the turn cap. A validator MUST be pure (read tracking state, path grammar; no
254
+ * side effects, no writes, nothing the run observes) and MUST refuse with the SAME text its
255
+ * execution would have produced for that input, so the model sees one refusal either way. A
256
+ * validator that THROWS is read as "no verdict": the call proceeds to the gate and the fault is
257
+ * disclosed via `RunnerDeps.onError` (phase `"hook"`) — a broken validator must not refuse tools.
258
+ * The execution-time check stays in place (validate and execute are two reads; a direct
259
+ * `AgentTool.execute` caller — durable replay — never passes this seat).
260
+ */
261
+ validateInput?: (args: unknown, ctx: ToolInputValidationContext) => Promise<ToolInputVerdict | undefined> | ToolInputVerdict | undefined;
244
262
  /**
245
263
  * [c209-C] Q4 — the model catalog names (`SubagentToolOptions.models` keys), filled by
246
264
  * `createSubagentTool` alongside {@link agentListing}. Rendered as the agent_listing INITIAL frame's
@@ -3994,11 +4012,25 @@ export type BrainStatusPhase = "rate_limited" | "retrying" | "reconnecting" | "c
3994
4012
  * RB-420-c — TERMINAL frames closing a retry sequence. Without them a consumer that rendered a
3995
4013
  * `rate_limited`/`retrying`/`reconnecting` frame has nothing telling it the wait is over, so the
3996
4014
  * warning row stays pinned until the next unrelated event. Exactly one terminal frame is emitted,
3997
- * and only for a call that actually waited: `recovered` = a later attempt served the call,
3998
- * `gave_up` = the retry budget ran out (or the call was cancelled) and a terminal error follows.
4015
+ * and only for a call that actually waited (a retry wait, or a `waiting_first_token` trail
4016
+ * #530): `recovered` = the call was served after the wait (a later attempt, or the first token
4017
+ * finally arriving), `gave_up` = the retry budget ran out (or the call was cancelled) and a
4018
+ * terminal error follows.
3999
4019
  * Both mean the same thing to a renderer — stop showing the retry state.
4000
4020
  */
4001
- | "recovered" | "gave_up";
4021
+ | "recovered" | "gave_up"
4022
+ /**
4023
+ * #530 — the first-token WAIT, before any failure: a first-token watchdog is armed
4024
+ * (`firstTokenTimeoutMs > 0`) and nothing has arrived yet. Emitted once the wait has lasted
4025
+ * `WAITING_FIRST_TOKEN_AFTER_MS` and re-emitted every `WAITING_FIRST_TOKEN_EVERY_MS` while it goes
4026
+ * on, carrying {@link BrainStatus.elapsedMs} and {@link BrainStatus.timeoutMs}; never carries a
4027
+ * retry countdown (`retryInMs`/`retryAtMs` — no wait is being announced, a wait is being
4028
+ * OBSERVED). CLOSED like a retry trail: once a wait frame went out, the call's terminal frame
4029
+ * follows — `recovered` when the call went on to be served (the first token arrived, on this
4030
+ * attempt or a later one), `gave_up` when it failed for good — so a renderer that pinned the row
4031
+ * has its "stop" signal. No frame at all when no watchdog is armed.
4032
+ */
4033
+ | "waiting_first_token";
4002
4034
  /**
4003
4035
  * WHY a retry wait is happening, as a closed, provider-NEUTRAL bucket — the companion to
4004
4036
  * {@link BrainStatusPhase}, which says what the brain is doing about it. A consumer rendering an
@@ -4053,6 +4085,13 @@ export interface BrainStatus {
4053
4085
  * the authoritative RELATIVE quantity, and this seat is the absolute convenience derived from it.
4054
4086
  */
4055
4087
  retryAtMs?: number;
4088
+ /** #530 — on a `waiting_first_token` frame: how long the first token has been awaited, ms, at the
4089
+ * moment the frame is emitted (the emitting process's clock; approximate across a hop). Absent on
4090
+ * every other phase. */
4091
+ elapsedMs?: number;
4092
+ /** #530 — on a `waiting_first_token` frame: the armed first-token watchdog, ms — the wait's ceiling,
4093
+ * so a consumer can render "45s of 120s". Absent on every other phase. */
4094
+ timeoutMs?: number;
4056
4095
  /** RB-420-c — 1-based index of the attempt that just failed (the wait precedes attempt `attempt + 1`);
4057
4096
  * same numbering as the `brain.retry` telemetry frame. Absent on frames that are not a retry wait. */
4058
4097
  attempt?: number;
@@ -7542,4 +7581,4 @@ export interface RunnerDeps {
7542
7581
  * 3, CC parity). The Runner tracks the consecutive-reuse counter per task across both compaction sites. */
7543
7582
  maxConsecutiveProviderReuse?: number;
7544
7583
  }
7545
- export type { AgentTool, Model, StreamFn, ThinkingLevel };
7584
+ export type { AgentTool, Model, StreamFn, ThinkingLevel, ToolInputValidationContext, ToolInputVerdict };
@@ -56,6 +56,28 @@ export type QuestionChannelState = "wired" | "absent" | "stripped_bg_lane";
56
56
  export type SeamProvenance = "spec" | "deps";
57
57
  /** Machine-readable reason codes for a park lane that is not (or not provably) effective. */
58
58
  export type ParkLaneReason = "no_checkpoint_store" | "no_durable_approval_opt_in" | "no_force_durable_gate" | "no_armed_safety_vocabulary" | "await_runtime_caps" | "await_tool_vocabulary";
59
+ /**
60
+ * #529 — the closed set of auto-mode ARMING outcomes a leg's manifest reports
61
+ * ({@link WiringManifest.autoMode}). Minted from the arming predicate's own three-arm read, as the
62
+ * FIRST failing arm in predicate order:
63
+ * - `no_intent` — no `TaskSpec.autoModeRequested` seat and no intent inherited on the chain /
64
+ * recorded on the resumed checkpoint (a non-auto run; byte-identical arming).
65
+ * - `no_face` — intent, but no `RunnerDeps.autoMode` deployment face (the trust gate).
66
+ * - `denied` — intent + face, but the organization's deny bit (`RuntimeCaps.autoMode:false`)
67
+ * as the resolver STATED it.
68
+ * - `resolver_fault` — intent + face, and the deny bit was COINED by a resolver fault (a throw, or a
69
+ * marshalled/non-record result the value screen refused) — an outage, not a
70
+ * verdict; the same fail-closed outcome as `denied`, named apart so a consumer
71
+ * can tell "policy said no" from "the entitlement source was unreachable".
72
+ * - `armed` — all three arms held; the classifier decider was minted for this leg.
73
+ * - `latch_open` — the session's one-way "back to non-auto" breaker has tripped. A leg-START
74
+ * manifest never carries it (a decider is minted fresh at prepare and its latch
75
+ * is closed by construction; a tripped ancestor hands no intent forward, which
76
+ * reads as `no_intent`); it is in the vocabulary so a consumer switching on the
77
+ * closed set is complete for a mid-leg re-read face, which nothing mints today.
78
+ */
79
+ export declare const AUTO_MODE_ARM_REASONS: readonly ["armed", "no_intent", "no_face", "denied", "resolver_fault", "latch_open"];
80
+ export type AutoModeArmReason = (typeof AUTO_MODE_ARM_REASONS)[number];
59
81
  /**
60
82
  * design/173 §2.2/§8.4 — the wiring manifest. Consumers MUST ignore unknown fields
61
83
  * (`schemaVersion` bumps only on a semantic break of an EXISTING field). The TaskEvent face is the
@@ -190,6 +212,19 @@ export interface WiringManifest {
190
212
  removed: readonly string[];
191
213
  restore: string;
192
214
  };
215
+ /**
216
+ * #529 — EFFECTIVE half only, and ALWAYS present on an engine-minted effective manifest (unlike
217
+ * `modelGate`, which is present only when a trim happened: auto mode has an answer on every leg,
218
+ * so absence here means an older mint or an external derivation, never "not applicable"). The
219
+ * per-leg READ face of the auto-mode arming decision: `armed` ⇔ `reason === "armed"` (the
220
+ * classifier decider was minted for this leg), and `reason` names the first arm that failed
221
+ * otherwise — see {@link AUTO_MODE_ARM_REASONS}. A serving layer that used to infer the mode from
222
+ * spec shape reads it here instead.
223
+ */
224
+ autoMode?: {
225
+ armed: boolean;
226
+ reason: AutoModeArmReason;
227
+ };
193
228
  /** EFFECTIVE half only — a short, non-sensitive fingerprint (sha256 prefix over the canonical
194
229
  * JSON of this manifest's own resolved facts; every field here is an enum/boolean/count, no
195
230
  * secrets) so an operator can correlate legs that ran under the same resolved assembly. */
@@ -252,6 +287,12 @@ export interface WiringFacts {
252
287
  removed: readonly string[];
253
288
  restore: string;
254
289
  };
290
+ /** Effective half only — see {@link WiringManifest.autoMode}; the static half has no leg to arm.
291
+ * `armed` must agree with `reason` (`armed ⇔ reason === "armed"`); a contradicting pair is refused. */
292
+ autoMode?: {
293
+ armed: boolean;
294
+ reason: AutoModeArmReason;
295
+ };
255
296
  }
256
297
  /** Named view of the deps seats the static half reads (a `Pick` of the real {@link RunnerDeps} —
257
298
  * single-source shapes, no parallel hand-copied interface). */
@@ -313,11 +354,6 @@ export declare function resolveSubagentTranscriptTier(agentStoreWired: boolean,
313
354
  * else the headless degrade `auto_deny`.
314
355
  */
315
356
  export declare function deriveAskEffective(form: AskSeamForm, parkEffective: boolean | "unresolved"): AskEffective;
316
- /**
317
- * design/173 §2.1 — the SINGLE derivation from facts to manifest, shared by both halves. The static
318
- * half omits `ask.effective`, `leg` and the fingerprint; the effective half carries all three. Any
319
- * second construction point for {@link WiringManifest} is a defect (single-mint rule).
320
- */
321
357
  export declare function deriveWiringManifest(facts: WiringFacts): WiringManifest;
322
358
  /** The one ask-seat resolution (`spec` wins over `deps`), shared with the effective half so the
323
359
  * form/provenance read cannot fork. Junk values (a JS caller passing something outside the OnAsk
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { canonicalize } from "./canonical-json.js";
3
3
  import { resolveCheckpointStore } from "./checkpoint-store.js";
4
4
  import { isLiveQuestionFace } from "./ask-question.js";
5
+ export const AUTO_MODE_ARM_REASONS = ["armed", "no_intent", "no_face", "denied", "resolver_fault", "latch_open"];
5
6
  export function resolveDeclaredDurability(store, storeName) {
6
7
  const declared = store?.durability;
7
8
  if (declared === undefined)
@@ -66,6 +67,12 @@ function deriveParkLane(facts) {
66
67
  reasons.push("no_armed_safety_vocabulary");
67
68
  return { capable: true, effective: unresolved ? "unresolved" : false, reasons, ...durability };
68
69
  }
70
+ function readAutoModeFact(fact) {
71
+ if (!AUTO_MODE_ARM_REASONS.includes(fact.reason) || fact.armed !== (fact.reason === "armed")) {
72
+ throw new Error(`the auto-mode wiring fact is inconsistent (armed=${String(fact.armed)}, reason=${JSON.stringify(fact.reason)}) — armed must hold exactly when reason is "armed", and reason must be one of ${AUTO_MODE_ARM_REASONS.join("|")}`);
73
+ }
74
+ return { armed: fact.armed, reason: fact.reason };
75
+ }
69
76
  export function deriveWiringManifest(facts) {
70
77
  if (facts.half === "static" && facts.leg !== undefined) {
71
78
  throw new Error("a static wiring manifest has no leg — leg identity is an effective-half fact");
@@ -118,6 +125,7 @@ export function deriveWiringManifest(facts) {
118
125
  retention: facts.retentionPolicyWired,
119
126
  },
120
127
  ...(facts.half === "effective" && facts.modelGate !== undefined ? { modelGate: { class: facts.modelGate.class, removed: [...facts.modelGate.removed], restore: facts.modelGate.restore } } : {}),
128
+ ...(facts.half === "effective" && facts.autoMode !== undefined ? { autoMode: readAutoModeFact(facts.autoMode) } : {}),
121
129
  };
122
130
  if (facts.half === "effective") {
123
131
  const { leg: _leg, ...assembly } = manifest;
@@ -203,6 +203,7 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
203
203
  private stallTimeoutsPerCall?;
204
204
  private abortResultDetails?;
205
205
  private loopTrace?;
206
+ private onToolInputValidationFault?;
206
207
  private resilience?;
207
208
  private maxToolConcurrency?;
208
209
  private streamingToolExecution?;
@@ -252,6 +252,7 @@ export class AgentHarness {
252
252
  stallTimeoutsPerCall;
253
253
  abortResultDetails;
254
254
  loopTrace;
255
+ onToolInputValidationFault;
255
256
  resilience;
256
257
  maxToolConcurrency;
257
258
  streamingToolExecution;
@@ -285,6 +286,7 @@ export class AgentHarness {
285
286
  this.stallTimeoutsPerCall = options.stallTimeoutsPerCall;
286
287
  this.abortResultDetails = options.abortResultDetails;
287
288
  this.loopTrace = options.loopTrace;
289
+ this.onToolInputValidationFault = options.onToolInputValidationFault;
288
290
  this.resilience = options.resilience;
289
291
  this.maxToolConcurrency = options.maxToolConcurrency;
290
292
  this.streamingToolExecution = options.streamingToolExecution;
@@ -577,6 +579,7 @@ export class AgentHarness {
577
579
  ...(this.maxOutputTokensPerCall !== undefined ? { maxTokensPerCall: this.maxOutputTokensPerCall } : {}),
578
580
  ...(this.stallTimeoutsPerCall !== undefined ? { stallTimeoutsPerCall: this.stallTimeoutsPerCall } : {}),
579
581
  ...(this.abortResultDetails !== undefined ? { abortResultDetails: this.abortResultDetails } : {}),
582
+ ...(this.onToolInputValidationFault !== undefined ? { onToolInputValidationFault: this.onToolInputValidationFault } : {}),
580
583
  ...(this.resilience !== undefined ? { resilience: this.resilience } : {}),
581
584
  ...(this.maxToolConcurrency !== undefined ? { maxToolConcurrency: this.maxToolConcurrency } : {}),
582
585
  ...(this.streamingToolExecution === true && (this.getHandlers("tool_call")?.size ?? 0) === 0
@@ -1367,6 +1367,9 @@ export interface AgentHarnessOptions<TSkill extends Skill = Skill, TPromptTempla
1367
1367
  * recoveries were invisible outside display:false nudge messages). Must not throw; hot path.
1368
1368
  */
1369
1369
  loopTrace?: (step: import("../loop/agent-loop.js").LoopStep) => void;
1370
+ /** Loop-config pass-through — see `AgentLoopConfig.onToolInputValidationFault` (a throwing
1371
+ * `AgentTool.validateInput` is read as no verdict and disclosed here). */
1372
+ onToolInputValidationFault?: import("../loop/types.js").AgentLoopConfig["onToolInputValidationFault"];
1370
1373
  /**
1371
1374
  * design/131 (拍): per-task resilience intent flags, forwarded to every provider
1372
1375
  * call (`StreamOptions.resilience`) so the deployment's decorator stack (degrading / failover /
@@ -103,3 +103,10 @@ export declare const ROSTER_LISTING_MAX = 25;
103
103
  * core/tool-errors.ts, same reason).
104
104
  */
105
105
  export declare const ROSTER_SEARCH_HINT_NAME = "ToolSearch";
106
+ /** The structured `details.code` of a tool result minted by an input pre-validation refusal (lifted onto
107
+ * the wire frame's `errorCode` like every other loop-minted code, e.g. `tool.not_found`). */
108
+ export declare const TOOL_VALIDATION_REFUSED_CODE = "tool.validation_refused";
109
+ /** How long an asynchronous `validateInput` may take to answer before it is read as "no verdict" (and
110
+ * reported on the fault seat): a validator is a precondition read, not a network call, so a single
111
+ * bound serves every tool; a deployment validator that needs longer is a validator doing too much. */
112
+ export declare const TOOL_INPUT_VALIDATION_TIMEOUT_MS = 10000;
@@ -1122,6 +1122,76 @@ function formatRosterRecovery(availableTools) {
1122
1122
  : "";
1123
1123
  return ` Available tools: ${listing}${hint}`;
1124
1124
  }
1125
+ export const TOOL_VALIDATION_REFUSED_CODE = "tool.validation_refused";
1126
+ export const TOOL_INPUT_VALIDATION_TIMEOUT_MS = 10_000;
1127
+ async function runInputValidation(tool, args, toolCallId, signal, onFault) {
1128
+ if (tool.validateInput === undefined)
1129
+ return undefined;
1130
+ try {
1131
+ const verdict = await boundedValidatorCall(() => tool.validateInput(args, signal !== undefined ? { toolCallId, signal } : { toolCallId }), signal);
1132
+ if (verdict !== null && typeof verdict === "object" && verdict.ok === false && typeof verdict.message === "string") {
1133
+ return typeof verdict.code === "string" ? { message: verdict.message, code: verdict.code } : { message: verdict.message };
1134
+ }
1135
+ return undefined;
1136
+ }
1137
+ catch (error) {
1138
+ if (signal?.aborted === true)
1139
+ return undefined;
1140
+ try {
1141
+ const ret = onFault?.({ toolName: tool.name, toolCallId, error });
1142
+ if (ret !== null && typeof ret === "object" && typeof ret.then === "function") {
1143
+ ret.then(undefined, () => undefined);
1144
+ }
1145
+ }
1146
+ catch {
1147
+ }
1148
+ return undefined;
1149
+ }
1150
+ }
1151
+ function boundedValidatorCall(call, signal) {
1152
+ return new Promise((resolve, reject) => {
1153
+ let settled = false;
1154
+ const finish = (fn) => {
1155
+ if (settled)
1156
+ return;
1157
+ settled = true;
1158
+ clearTimeout(timer);
1159
+ signal?.removeEventListener("abort", onAbort);
1160
+ fn();
1161
+ };
1162
+ const onAbort = () => finish(() => reject(Object.assign(new Error("tool input pre-validation aborted"), { name: "AbortError" })));
1163
+ const timer = setTimeout(() => finish(() => reject(new Error(`tool input pre-validation did not answer within ${TOOL_INPUT_VALIDATION_TIMEOUT_MS}ms`))), TOOL_INPUT_VALIDATION_TIMEOUT_MS);
1164
+ if (signal?.aborted === true) {
1165
+ onAbort();
1166
+ return;
1167
+ }
1168
+ signal?.addEventListener("abort", onAbort, { once: true });
1169
+ let out;
1170
+ try {
1171
+ out = call();
1172
+ }
1173
+ catch (err) {
1174
+ finish(() => reject(err));
1175
+ return;
1176
+ }
1177
+ Promise.resolve(out).then((v) => finish(() => resolve(v)), (err) => finish(() => reject(err)));
1178
+ });
1179
+ }
1180
+ function sameToolArgs(a, b) {
1181
+ if (a === b)
1182
+ return true;
1183
+ const canon = (v) => JSON.stringify(v, (_k, x) => (x !== null && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, x[k]])) : x));
1184
+ return canon(a) === canon(b);
1185
+ }
1186
+ function validationRefusedOutcome(tool, refusal) {
1187
+ return {
1188
+ kind: "immediate",
1189
+ result: createErrorToolResult(refusal.message, {
1190
+ details: { code: TOOL_VALIDATION_REFUSED_CODE, toolName: tool.name, ...(refusal.code !== undefined ? { reason: refusal.code } : {}) },
1191
+ }),
1192
+ isError: true,
1193
+ };
1194
+ }
1125
1195
  async function prepareToolCall(currentContext, assistantMessage, toolCall, config, signal) {
1126
1196
  const tool = findToolByName(currentContext.tools, toolCall.name);
1127
1197
  if (!tool) {
@@ -1135,6 +1205,10 @@ async function prepareToolCall(currentContext, assistantMessage, toolCall, confi
1135
1205
  try {
1136
1206
  const preparedToolCall = prepareToolCallArguments(tool, toolCall);
1137
1207
  let finalArgs = validateToolArguments(tool, preparedToolCall);
1208
+ const refusal = await runInputValidation(tool, finalArgs, toolCall.id, signal, config.onToolInputValidationFault);
1209
+ if (refusal !== undefined)
1210
+ return validationRefusedOutcome(tool, refusal);
1211
+ const validatedArgs = finalArgs;
1138
1212
  if (config.beforeToolCall) {
1139
1213
  const beforeResult = await config.beforeToolCall({
1140
1214
  assistantMessage,
@@ -1161,6 +1235,11 @@ async function prepareToolCall(currentContext, assistantMessage, toolCall, confi
1161
1235
  ...preparedToolCall,
1162
1236
  arguments: beforeResult.updatedInput,
1163
1237
  });
1238
+ if (!sameToolArgs(validatedArgs, finalArgs)) {
1239
+ const rewrittenRefusal = await runInputValidation(tool, finalArgs, toolCall.id, signal, config.onToolInputValidationFault);
1240
+ if (rewrittenRefusal !== undefined)
1241
+ return validationRefusedOutcome(tool, rewrittenRefusal);
1242
+ }
1164
1243
  }
1165
1244
  }
1166
1245
  if (signal?.aborted) {
@@ -19,6 +19,26 @@ export type StreamFn = LlmStreamFn;
19
19
  * while tool-result message artifacts are emitted later in assistant source order.
20
20
  */
21
21
  export type ToolExecutionMode = "sequential" | "parallel";
22
+ /**
23
+ * The verdict a tool's INPUT PRE-VALIDATION returns (see {@link AgentTool.validateInput}). `ok: true`
24
+ * (or an `undefined` return) lets the call proceed to the gate; `ok: false` refuses it BEFORE any
25
+ * permission question is asked — `message` is the model-facing text (the same text the tool's own
26
+ * execution would have produced for the same input, so the refusal reads identically either way) and
27
+ * `code` an optional machine-readable reason that rides the error result's structured details.
28
+ */
29
+ export type ToolInputVerdict = {
30
+ ok: true;
31
+ } | {
32
+ ok: false;
33
+ message: string;
34
+ code?: string;
35
+ };
36
+ /** The context an input pre-validation hook receives: the call's identity and its abort signal, and
37
+ * nothing else — a validator reads state, it does not act on the run. */
38
+ export interface ToolInputValidationContext {
39
+ toolCallId: string;
40
+ signal?: AbortSignal;
41
+ }
22
42
  /**
23
43
  * Controls how many queued user messages are injected when the agent loop reaches a queue drain point.
24
44
  *
@@ -527,6 +547,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
527
547
  * signal re-check, which has always minted its abort result with no details.
528
548
  */
529
549
  abortResultDetails?: () => Record<string, unknown> | undefined;
550
+ /**
551
+ * Fault seat for a THROWING {@link AgentTool.validateInput}: the loop reads the throw as "no
552
+ * verdict" (the call proceeds to the gate as if the tool had no validator) and reports the fault
553
+ * here so a host can disclose it. Must not throw; a throwing sink is swallowed.
554
+ */
555
+ onToolInputValidationFault?: (info: {
556
+ toolName: string;
557
+ toolCallId: string;
558
+ error: unknown;
559
+ }) => void;
530
560
  /**
531
561
  * Called after a tool finishes executing, before `tool_execution_end` and tool-result message events are emitted.
532
562
  *
@@ -684,6 +714,18 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = unk
684
714
  * (boundInputHash binds args, never the preview). NEVER adjudication input. Mechanism-neutral:
685
715
  * any tool may declare one (run_workflow projects its script meta). */
686
716
  approvalPreview?: (args: unknown) => unknown;
717
+ /**
718
+ * OPTIONAL input pre-validation, run by the loop AFTER schema validation and BEFORE the tool-call
719
+ * gate (permission hooks, policy, the human/classifier ask). A refusal is returned to the model as a
720
+ * typed error result and no question is ever asked for the call — the point of the seat: a call the
721
+ * tool would refuse on its own precondition (a file edit whose target was never read this session)
722
+ * must not cost the operator an approval card it can only ever fail on. Pure by contract: reads
723
+ * state (read-tracking, path grammar), never acts. A THROWING validator is read as "no verdict"
724
+ * (the call proceeds to the gate; the fault is disclosed through the loop's fault seat) — a broken
725
+ * validator must not refuse tools. The tool's own execution keeps validating the same precondition
726
+ * (the two reads are not one atomic step, and direct `execute` callers skip this seat).
727
+ */
728
+ validateInput?: (args: Static<TParameters>, ctx: ToolInputValidationContext) => Promise<ToolInputVerdict | undefined> | ToolInputVerdict | undefined;
687
729
  /** Execute the tool call. Throw on failure instead of encoding errors in `content`. */
688
730
  execute: (toolCallId: string, params: Static<TParameters>, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<TDetails>) => Promise<AgentToolResult<TDetails>>;
689
731
  /**
package/dist/index.d.ts CHANGED
@@ -119,7 +119,7 @@ export { createFsWriteGatePolicy, type FsWriteGatePolicyOptions } from "./core/f
119
119
  export { RETIRED_TOOL_NAMES } from "./core/tool-name-aliases.js";
120
120
  export { DEFAULT_SUBAGENT_TOOL_NAME } from "./agents/subagent.js";
121
121
  export { renderTaskNotificationXml, taskNotificationDedupKey, isDelegatedAgentTerminal, isTerminalTaskNotification, SystemInjectionQueue, type TaskNotificationPayload, type TaskNotificationStatus, type ExternalNotificationInput, type SystemInjection, type SystemInjectionPriority, SYSTEM_INJECTION_PRIORITIES, isSystemInjectionPriority, AGENT_MESSAGE_TAG, renderAgentMessageFrame, type SemaProvenance, } from "./core/task-notification.js";
122
- export { describeStaticWiring, deriveWiringManifest, deriveAskEffective, resolveDeclaredDurability, resolveSubagentTranscriptTier, type SubagentTranscriptTier, resolveAskSeamForm, resolveQuestionSeam, countElicitOptIns, type WiringManifest, type WiringFacts, type WiringLegKind, type AskSeamForm, type AskEffective, type QuestionChannelState, type SeamProvenance, type ParkLaneReason, type ManifestDurability, type StaticWiringDeps, type StaticWiringSpec, } from "./core/wiring-manifest.js";
122
+ export { describeStaticWiring, deriveWiringManifest, deriveAskEffective, resolveDeclaredDurability, resolveSubagentTranscriptTier, type SubagentTranscriptTier, resolveAskSeamForm, resolveQuestionSeam, countElicitOptIns, AUTO_MODE_ARM_REASONS, type AutoModeArmReason, type WiringManifest, type WiringFacts, type WiringLegKind, type AskSeamForm, type AskEffective, type QuestionChannelState, type SeamProvenance, type ParkLaneReason, type ManifestDurability, type StaticWiringDeps, type StaticWiringSpec, } from "./core/wiring-manifest.js";
123
123
  export { probeParkRoundTrip, durableParkGapOf, durableParkGapFor, PARK_SELFCHECK_SCOPE_PREFIX, type ParkSelfCheckResult, type ParkProbeFinding, type ParkProbeFindingCode, } from "./core/park-selfcheck.js";
124
124
  export { type StoreDurability } from "./core/checkpoint-store.js";
125
125
  export { type StoreFidelity } from "./core/checkpoint-store.js";
@@ -277,13 +277,14 @@ export { repairTextToolCalls } from "./brain/tool-call-repair.js";
277
277
  export { createCircuitBreakerBrain, type CircuitBreakerOptions, type BreakerState, type BreakerSnapshot, type BreakerPhase, CIRCUIT_OPEN_MARKER, } from "./brain/circuit-breaker.js";
278
278
  export { createDegradingBrain, readDegradation, DEGRADED_DIAGNOSTIC_TYPE, type DegradingBrainOptions, type DegradeReason, type DegradationInfo, } from "./brain/degrading.js";
279
279
  export { retryBackoffMs, parseRetryAfter } from "./brain/retry.js";
280
+ export { WAITING_FIRST_TOKEN_AFTER_MS, WAITING_FIRST_TOKEN_EVERY_MS } from "./brain/stream-engine.js";
280
281
  export { adjudicateModelRoute, resolveRouteCredential, routeRefusalText, routePairingStatus, normalizeBaseUrl, hasAuthCarrier, type RoutePairingStatus, } from "./brain/route-adjudicator.js";
281
282
  export type { RouteAdjudication, RouteCredential, RouteCredentialSource, RoutePairingConfig, RoutePairingPosture, RouteRefusalCode, RouteRefusalDetail, } from "./internal/llm.js";
282
283
  export { ROUTE_ADJUDICATION_CONFORMANCE_CORPUS, type RouteAdjudicationVector } from "./brain/route-conformance.js";
283
284
  export { type BrainTimeoutConfig } from "./brain/timeout.js";
284
285
  export { createAssistantMessageEventStream } from "./internal/llm.js";
285
286
  export type { AssistantMessage, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, StopReason, StreamFn, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserMessage, } from "./internal/llm.js";
286
- export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, TrackFileEditHook, TrackEditRequest, TrackEditResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, BrainRetryErrClass, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, A2aServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, ResumePreflightInfo, ResumePreflightVerdict, EngineNotice, RuntimeCaps, BackgroundChildEvent, DelegationLifecycleEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskLimits, StaleToolResultOffloadOptions, TaskResult, EffectiveMemoryScopes, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ReversibilityVerdict, ToolEffect, ToolContentOrigin, WorkflowGovernanceBaseline, DelegationTaskType, } from "./core/types.js";
287
+ export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, TrackFileEditHook, TrackEditRequest, TrackEditResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, BrainRetryErrClass, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, A2aServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, ResumePreflightInfo, ResumePreflightVerdict, EngineNotice, RuntimeCaps, BackgroundChildEvent, DelegationLifecycleEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskLimits, StaleToolResultOffloadOptions, TaskResult, EffectiveMemoryScopes, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ToolInputVerdict, ToolInputValidationContext, ReversibilityVerdict, ToolEffect, ToolContentOrigin, WorkflowGovernanceBaseline, DelegationTaskType, } from "./core/types.js";
287
288
  export { Type } from "typebox";
288
289
  export type { TSchema, Static } from "typebox";
289
290
  export { explainPromptAssembly, describeDefaultPack, type DefaultPackDescription, type ExplainInput } from "./prompt-assembly/explain.js";
package/dist/index.js CHANGED
@@ -96,7 +96,7 @@ export { createFsWriteGatePolicy } from "./core/fs-write-gate-policy.js";
96
96
  export { RETIRED_TOOL_NAMES } from "./core/tool-name-aliases.js";
97
97
  export { DEFAULT_SUBAGENT_TOOL_NAME } from "./agents/subagent.js";
98
98
  export { renderTaskNotificationXml, taskNotificationDedupKey, isDelegatedAgentTerminal, isTerminalTaskNotification, SystemInjectionQueue, SYSTEM_INJECTION_PRIORITIES, isSystemInjectionPriority, AGENT_MESSAGE_TAG, renderAgentMessageFrame, } from "./core/task-notification.js";
99
- export { describeStaticWiring, deriveWiringManifest, deriveAskEffective, resolveDeclaredDurability, resolveSubagentTranscriptTier, resolveAskSeamForm, resolveQuestionSeam, countElicitOptIns, } from "./core/wiring-manifest.js";
99
+ export { describeStaticWiring, deriveWiringManifest, deriveAskEffective, resolveDeclaredDurability, resolveSubagentTranscriptTier, resolveAskSeamForm, resolveQuestionSeam, countElicitOptIns, AUTO_MODE_ARM_REASONS, } from "./core/wiring-manifest.js";
100
100
  export { probeParkRoundTrip, durableParkGapOf, durableParkGapFor, PARK_SELFCHECK_SCOPE_PREFIX, } from "./core/park-selfcheck.js";
101
101
  export {} from "./core/checkpoint-store.js";
102
102
  export {} from "./core/checkpoint-store.js";
@@ -234,6 +234,7 @@ export { repairTextToolCalls } from "./brain/tool-call-repair.js";
234
234
  export { createCircuitBreakerBrain, CIRCUIT_OPEN_MARKER, } from "./brain/circuit-breaker.js";
235
235
  export { createDegradingBrain, readDegradation, DEGRADED_DIAGNOSTIC_TYPE, } from "./brain/degrading.js";
236
236
  export { retryBackoffMs, parseRetryAfter } from "./brain/retry.js";
237
+ export { WAITING_FIRST_TOKEN_AFTER_MS, WAITING_FIRST_TOKEN_EVERY_MS } from "./brain/stream-engine.js";
237
238
  export { adjudicateModelRoute, resolveRouteCredential, routeRefusalText, routePairingStatus, normalizeBaseUrl, hasAuthCarrier, } from "./brain/route-adjudicator.js";
238
239
  export { ROUTE_ADJUDICATION_CONFORMANCE_CORPUS } from "./brain/route-conformance.js";
239
240
  export {} from "./brain/timeout.js";
@@ -7,7 +7,7 @@
7
7
  export type { CompactionPreparation, SummarizationClampDryRun } from "../engine/compaction/compaction.js";
8
8
  export type { InvokedSkillRetention } from "../engine/compaction/utils.js";
9
9
  export type { AgentCoreRuntimeDeps } from "../engine/loop/runtime-deps.js";
10
- export type { AgentMessage, AgentTool, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel, ToolExecutionMode, } from "../engine/loop/types.js";
10
+ export type { AgentMessage, AgentTool, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel, ToolExecutionMode, ToolInputValidationContext, ToolInputVerdict, } from "../engine/loop/types.js";
11
11
  export type { AgentHarnessEvent, CompactionSettings, ExecutionEnv, ExecutionErrorCode, FileError, FileErrorCode, FileInfo, Result, WriteExpectation, WriteReceipt, Session, SessionMetadata, SessionRepo, SessionStorage, SessionTreeEntry, Skill, } from "../engine/harness/types.js";
12
12
  export type { ExecutionEnvExecOptions, ExecResult } from "../engine/harness/types.js";
13
13
  export type { SessionWriteOptions, CompactionEntry } from "../engine/harness/types.js";
@@ -83,6 +83,35 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
83
83
  replace_all: Type.Optional(Type.Boolean({ description: "Replace all occurrences of old_string (default false)" })),
84
84
  }),
85
85
  effect: "write",
86
+ validateInput: async (args, ctx) => {
87
+ const a = args;
88
+ if (Array.isArray(a.edits) && a.edits.length > 0)
89
+ return { ok: true };
90
+ if (typeof a.old_string !== "string" || typeof a.new_string !== "string")
91
+ return { ok: true };
92
+ const path = fileArgPath(args);
93
+ if (path === undefined)
94
+ return { ok: true };
95
+ const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
96
+ if (!r.ok)
97
+ return { ok: true };
98
+ if (a.old_string === a.new_string)
99
+ return { ok: true };
100
+ const exists = await env.exists(r.key, ctx.signal);
101
+ if (!exists.ok || !exists.value)
102
+ return { ok: true };
103
+ const editInfo = await env.fileInfo(r.key, ctx.signal);
104
+ if (editInfo.ok && editInfo.value.size > MAX_EDIT_BYTES)
105
+ return { ok: true };
106
+ if (a.old_string === "")
107
+ return { ok: true };
108
+ if (ipynbRedirect("Edit", path))
109
+ return { ok: true };
110
+ const notRead = requireRead(state, r.key);
111
+ if (notRead === undefined)
112
+ return { ok: true };
113
+ return { ok: false, code: notRead.code, message: await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT) };
114
+ },
86
115
  execute: async (args, ctx) => {
87
116
  const a = args;
88
117
  const batch = Array.isArray(a.edits) && a.edits.length > 0;
@@ -290,6 +319,23 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
290
319
  content: Type.String({ description: "The content to write to the file" }),
291
320
  }),
292
321
  effect: "write",
322
+ validateInput: async (args, ctx) => {
323
+ const path = fileArgPath(args);
324
+ if (path === undefined)
325
+ return { ok: true };
326
+ if (ipynbRedirect("Write", path))
327
+ return { ok: true };
328
+ const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
329
+ if (!r.ok)
330
+ return { ok: true };
331
+ const exists = await env.exists(r.key, ctx.signal);
332
+ if (!exists.ok || !exists.value)
333
+ return { ok: true };
334
+ const notRead = requireRead(state, r.key);
335
+ if (notRead === undefined)
336
+ return { ok: true };
337
+ return { ok: false, code: notRead.code, message: await notReadRefusalText(env, "Write", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT) };
338
+ },
293
339
  execute: async (args, ctx) => {
294
340
  const { content } = args;
295
341
  const path = fileArgPath(args);
@@ -356,6 +402,9 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
356
402
  },
357
403
  });
358
404
  }
405
+ const notebookNotIpynbMessage = (notebookPath) => `Error (NotebookEdit): "${notebookPath}" is not a .ipynb file; use Edit for other file types.`;
406
+ const NOTEBOOK_CELL_TYPE_REQUIRED_MESSAGE = `Error (NotebookEdit): Cell type is required when using edit_mode=insert.`;
407
+ const NOTEBOOK_CELL_ID_REQUIRED_MESSAGE = `Error (NotebookEdit): cell_id is required for replace/delete.`;
359
408
  export function createNotebookEditTool(env, state, rootCanonical, cwdRef, additionalRoots, beforeWrite, trackEdit) {
360
409
  return defineTool({
361
410
  name: "NotebookEdit",
@@ -382,18 +431,35 @@ export function createNotebookEditTool(env, state, rootCanonical, cwdRef, additi
382
431
  })),
383
432
  }),
384
433
  effect: "write",
434
+ validateInput: async (args, ctx) => {
435
+ const a = args;
436
+ const mode = a.edit_mode ?? "replace";
437
+ if (!a.notebook_path.toLowerCase().endsWith(".ipynb"))
438
+ return { ok: false, code: "invalid", message: notebookNotIpynbMessage(a.notebook_path) };
439
+ if (mode === "insert" && !a.cell_type)
440
+ return { ok: false, code: "invalid", message: NOTEBOOK_CELL_TYPE_REQUIRED_MESSAGE };
441
+ if (mode !== "insert" && !a.cell_id)
442
+ return { ok: false, code: "invalid", message: NOTEBOOK_CELL_ID_REQUIRED_MESSAGE };
443
+ const r = await resolveKey(env, rootCanonical, a.notebook_path, ctx.signal, cwdRef?.current, additionalRoots);
444
+ if (!r.ok)
445
+ return { ok: true };
446
+ const notRead = requireRead(state, r.key);
447
+ if (notRead === undefined)
448
+ return { ok: true };
449
+ return { ok: false, code: notRead.code, message: await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT) };
450
+ },
385
451
  execute: async (args, ctx) => {
386
452
  const a = args;
387
453
  const { notebook_path, cell_id, new_source } = a;
388
454
  let cellType = a.cell_type;
389
455
  const mode = a.edit_mode ?? "replace";
390
456
  if (!notebook_path.toLowerCase().endsWith(".ipynb")) {
391
- return errorResult(`Error (NotebookEdit): "${notebook_path}" is not a .ipynb file; use Edit for other file types.`);
457
+ return errorResult(notebookNotIpynbMessage(notebook_path));
392
458
  }
393
459
  if (mode === "insert" && !cellType)
394
- return errorResult(`Error (NotebookEdit): Cell type is required when using edit_mode=insert.`);
460
+ return errorResult(NOTEBOOK_CELL_TYPE_REQUIRED_MESSAGE);
395
461
  if (mode !== "insert" && !cell_id)
396
- return errorResult(`Error (NotebookEdit): cell_id is required for replace/delete.`);
462
+ return errorResult(NOTEBOOK_CELL_ID_REQUIRED_MESSAGE);
397
463
  const r = await resolveKey(env, rootCanonical, notebook_path, ctx.signal, cwdRef?.current, additionalRoots);
398
464
  if (!r.ok)
399
465
  return errorResult(violationText("NotebookEdit", r.violation), violationDetails(r.violation));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.3.0",
3
+ "version": "7.3.1",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_comment": "design/87 L3 — frozen public export surface of src/index.ts (name -> kind). DO NOT edit by hand to silence a red test. A removed/changed entry = a SemVer-BREAKING change; bump MAJOR and update this fixture in the SAME commit (design/87 §4.2 / §5.2). Regenerate via REGEN in test/export-surface.test.ts.",
3
3
  "_tierComment": "#435 v1 — machine-readable layering of the public surface: stable = demonstrated by README.md / src/examples; internal = an `Internal`-marked name or a runner/engine deep-subtree declaration (the model seam src/engine/llm is excluded — it is the BYOM contract, not an engine internal); advanced = a supported export the front door does not walk you through. THIS IS AN INITIAL HEURISTIC, derived mechanically and expected to be refined ticket by ticket: no human reviewed these 1700+ entries one by one, and nothing here claims otherwise. Known bias: a short or English-word export name (ok, err, Result, Usage) can match ordinary prose in README.md and land `stable` on a coincidence. Every export MUST carry a tier — a new export with no row fails the gate in export-surface.test.ts.",
4
- "count": 1958,
4
+ "count": 1964,
5
5
  "exports": {
6
6
  "A2ATaskState": "type",
7
7
  "A2ATaskStateReversal": "type",
@@ -30,6 +30,7 @@
30
30
  "AUTONOMOUS_LOOP_PREAMBLE_PERSISTENT": "variable",
31
31
  "AUTONOMOUS_LOOP_SENTINEL": "variable",
32
32
  "AUTO_MODE_ARMING_RECIPE_VERSION": "variable",
33
+ "AUTO_MODE_ARM_REASONS": "variable",
33
34
  "AUTO_MODE_BASE_PROMPT": "variable",
34
35
  "AUTO_MODE_DEFAULTS_SENTINEL": "variable",
35
36
  "AUTO_MODE_PERMISSIONS_EXTERNAL": "variable",
@@ -78,6 +79,7 @@
78
79
  "AssertOracleIsolationOptions": "interface",
79
80
  "AssistantMessage": "interface",
80
81
  "AssistantMessageEvent": "type",
82
+ "AutoModeArmReason": "type",
81
83
  "AutoModeArmingFace": "interface",
82
84
  "AutoModeArmingFold": "type",
83
85
  "AutoModeArmingRecipe": "interface",
@@ -1199,6 +1201,8 @@
1199
1201
  "ToolEffect": "type",
1200
1202
  "ToolExecuteContext": "interface",
1201
1203
  "ToolFingerprintInput": "interface",
1204
+ "ToolInputValidationContext": "interface",
1205
+ "ToolInputVerdict": "type",
1202
1206
  "ToolModelGateRule": "interface",
1203
1207
  "ToolOrigin": "type",
1204
1208
  "ToolPolicy": "interface",
@@ -1251,6 +1255,8 @@
1251
1255
  "VerificationResult": "interface",
1252
1256
  "VerifyConfig": "interface",
1253
1257
  "VmLifecycleOptions": "interface",
1258
+ "WAITING_FIRST_TOKEN_AFTER_MS": "variable",
1259
+ "WAITING_FIRST_TOKEN_EVERY_MS": "variable",
1254
1260
  "WEBFETCH_SUMMARY_GUIDELINES": "variable",
1255
1261
  "WEBFETCH_SUMMARY_MAX_CONTENT": "variable",
1256
1262
  "WEB_FETCH_TOOL_NAME": "variable",
@@ -1990,6 +1996,7 @@
1990
1996
  "AUTONOMOUS_LOOP_PREAMBLE_PERSISTENT": "advanced",
1991
1997
  "AUTONOMOUS_LOOP_SENTINEL": "advanced",
1992
1998
  "AUTO_MODE_ARMING_RECIPE_VERSION": "advanced",
1999
+ "AUTO_MODE_ARM_REASONS": "advanced",
1993
2000
  "AUTO_MODE_BASE_PROMPT": "advanced",
1994
2001
  "AUTO_MODE_DEFAULTS_SENTINEL": "advanced",
1995
2002
  "AUTO_MODE_PERMISSIONS_EXTERNAL": "advanced",
@@ -2038,6 +2045,7 @@
2038
2045
  "AssertOracleIsolationOptions": "advanced",
2039
2046
  "AssistantMessage": "stable",
2040
2047
  "AssistantMessageEvent": "stable",
2048
+ "AutoModeArmReason": "advanced",
2041
2049
  "AutoModeArmingFace": "advanced",
2042
2050
  "AutoModeArmingFold": "advanced",
2043
2051
  "AutoModeArmingRecipe": "advanced",
@@ -3159,6 +3167,8 @@
3159
3167
  "ToolEffect": "advanced",
3160
3168
  "ToolExecuteContext": "advanced",
3161
3169
  "ToolFingerprintInput": "advanced",
3170
+ "ToolInputValidationContext": "internal",
3171
+ "ToolInputVerdict": "internal",
3162
3172
  "ToolModelGateRule": "advanced",
3163
3173
  "ToolOrigin": "advanced",
3164
3174
  "ToolPolicy": "stable",
@@ -3211,6 +3221,8 @@
3211
3221
  "VerificationResult": "advanced",
3212
3222
  "VerifyConfig": "advanced",
3213
3223
  "VmLifecycleOptions": "advanced",
3224
+ "WAITING_FIRST_TOKEN_AFTER_MS": "advanced",
3225
+ "WAITING_FIRST_TOKEN_EVERY_MS": "advanced",
3214
3226
  "WEBFETCH_SUMMARY_GUIDELINES": "advanced",
3215
3227
  "WEBFETCH_SUMMARY_MAX_CONTENT": "advanced",
3216
3228
  "WEB_FETCH_TOOL_NAME": "advanced",