@sema-agent/core 7.3.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agents/peer-admission.d.ts +18 -3
  3. package/dist/agents/peer-admission.js +79 -4
  4. package/dist/agents/peer-held-queue.d.ts +101 -0
  5. package/dist/agents/peer-held-queue.js +229 -0
  6. package/dist/agents/peer-idle.d.ts +109 -0
  7. package/dist/agents/peer-idle.js +240 -0
  8. package/dist/agents/peer-notice-route.d.ts +33 -0
  9. package/dist/agents/peer-notice-route.js +46 -0
  10. package/dist/agents/peer-notices.d.ts +103 -0
  11. package/dist/agents/peer-notices.js +206 -0
  12. package/dist/agents/peer-session-drain.d.ts +39 -4
  13. package/dist/agents/peer-session-drain.js +248 -42
  14. package/dist/agents/send-message-tool.d.ts +8 -1
  15. package/dist/agents/send-message-tool.js +96 -30
  16. package/dist/agents/subagent.js +1 -0
  17. package/dist/brain/status-sink.d.ts +10 -0
  18. package/dist/brain/status-sink.js +13 -4
  19. package/dist/brain/stream-engine.d.ts +11 -0
  20. package/dist/brain/stream-engine.js +39 -3
  21. package/dist/core/arg-summary.d.ts +13 -3
  22. package/dist/core/arg-summary.js +138 -7
  23. package/dist/core/auto-mode-defaults.d.ts +11 -0
  24. package/dist/core/auto-mode-defaults.js +2 -0
  25. package/dist/core/auto-mode.d.ts +59 -0
  26. package/dist/core/auto-mode.js +57 -1
  27. package/dist/core/checkpoint-store.js +2 -2
  28. package/dist/core/governance-codes.d.ts +1 -1
  29. package/dist/core/governance-codes.js +8 -0
  30. package/dist/core/hooks.d.ts +30 -0
  31. package/dist/core/hooks.js +43 -8
  32. package/dist/core/mailbox-store.d.ts +33 -1
  33. package/dist/core/mailbox-store.js +42 -2
  34. package/dist/core/runner/assemble-result.d.ts +5 -0
  35. package/dist/core/runner/assemble-result.js +1 -1
  36. package/dist/core/runner/denial-limit-arms.d.ts +149 -0
  37. package/dist/core/runner/denial-limit-arms.js +91 -0
  38. package/dist/core/runner/edited-files-ledger.d.ts +33 -0
  39. package/dist/core/runner/edited-files-ledger.js +14 -0
  40. package/dist/core/runner/prepare-hands-readface.d.ts +5 -0
  41. package/dist/core/runner/prepare-hands-readface.js +1 -0
  42. package/dist/core/runner/prepare-task.d.ts +62 -1
  43. package/dist/core/runner/prepare-task.js +135 -89
  44. package/dist/core/runner/runtask.js +12 -0
  45. package/dist/core/sensitive-path-policy.d.ts +27 -6
  46. package/dist/core/sensitive-path-policy.js +57 -2
  47. package/dist/core/task-notification.d.ts +24 -2
  48. package/dist/core/task-notification.js +6 -1
  49. package/dist/core/tool-policy.d.ts +55 -4
  50. package/dist/core/tool-policy.js +28 -5
  51. package/dist/core/tools.js +1 -0
  52. package/dist/core/types.d.ts +251 -15
  53. package/dist/core/wiring-manifest.d.ts +41 -5
  54. package/dist/core/wiring-manifest.js +8 -0
  55. package/dist/engine/harness/agent-harness.d.ts +1 -0
  56. package/dist/engine/harness/agent-harness.js +3 -0
  57. package/dist/engine/harness/types.d.ts +3 -0
  58. package/dist/engine/loop/agent-loop.d.ts +7 -0
  59. package/dist/engine/loop/agent-loop.js +79 -0
  60. package/dist/engine/loop/types.d.ts +42 -0
  61. package/dist/index.d.ts +12 -6
  62. package/dist/index.js +10 -4
  63. package/dist/internal/harness-types.d.ts +1 -1
  64. package/dist/orchestration/workflow.js +7 -3
  65. package/dist/tools/fs/fs-write.d.ts +4 -4
  66. package/dist/tools/fs/fs-write.js +99 -14
  67. package/dist/tools/fs/index.d.ts +7 -1
  68. package/dist/tools/fs/index.js +1 -1
  69. package/dist/tools/fs/safety.d.ts +29 -8
  70. package/dist/tools/fs/safety.js +11 -1
  71. package/package.json +1 -1
  72. package/test/export-surface.snapshot.json +181 -1
@@ -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
@@ -715,6 +733,32 @@ export interface ToolExecuteContext {
715
733
  * the tool runs outside a Runner task.
716
734
  */
717
735
  parentCwd?: string;
736
+ /**
737
+ * The RUNNING task's file-history LINEAGE (Runner-filled, read-only, never a model/tool argument;
738
+ * present iff a `RunnerDeps.fileHistoryStore` is live for the run): the history scope this run
739
+ * records its first-touch edits into and the TREE those records' keys are minted against — the
740
+ * canonical root spelling plus the env's filesystem identity (`fs`: a remote workspace's handle,
741
+ * `"host"` for the control-plane host's filesystem, else a per-env-instance token).
742
+ *
743
+ * WHICH children are threaded it, exactly: the Agent tool's FRESH spawn legs (sync / steer /
744
+ * background / fork) pass it VERBATIM into the child's trusted internals
745
+ * (`RunInternals.fileHistoryLineage`), which is how a same-process delegation tree keeps ONE
746
+ * history: a child on the SAME tree (both coordinates equal) records into the lineage's scope (the
747
+ * root session's), so the root session's `rewindFilesTo` reaches the child's edits; a child on a
748
+ * different tree (worktree isolation, an explicit `cwd`, a fresh per-task sandbox) starts its own
749
+ * lineage, because root-relative keys re-joined against a different tree would name files that run
750
+ * never edited. Three delegated legs deliberately carry NO lineage and keep their own scope: the
751
+ * Agent tool's REVIVE arm (a revival's lineage would be the REVIVER's tree, which says nothing
752
+ * about the revived row's), and the workflow / team orchestration spawn legs (`run_workflow`,
753
+ * `runTeamDiscussion` — they build their children's internals without this seat). Edits made by
754
+ * those children are therefore NOT reachable from the root session's rewind, and a restore request
755
+ * on one of them is not refused either — it converges that child's own scope.
756
+ */
757
+ fileHistoryLineage?: {
758
+ scope: string;
759
+ root: string;
760
+ fs: string;
761
+ };
718
762
  /**
719
763
  * design/319 (A ticket) — the RUNNING task's reminder provenance mark, Runner-filled, read-only,
720
764
  * NEVER a model/tool argument. The Agent tool's FORK route threads it into the forked child's
@@ -1281,6 +1325,35 @@ export type TrackEditResult = {
1281
1325
  refusal: string;
1282
1326
  };
1283
1327
  export type TrackFileEditHook = (req: TrackEditRequest) => Promise<TrackEditResult>;
1328
+ /**
1329
+ * The hands band's MUTATION-EDITED observation seat — the counterpart of {@link TrackEditRequest},
1330
+ * fired at the other end of the same lane. Called once per Write/Edit/NotebookEdit call whose FINAL
1331
+ * env write did not PROVABLY write nothing, at the single point every mutation lane funnels through,
1332
+ * under the same three-valued law as the first-touch record's own retraction:
1333
+ * - a call the argument checks, the read-before-edit gate, the write gate or the first-touch
1334
+ * history seat refused never fires it (nothing was written);
1335
+ * - a call whose write FAILED with a code whose contract says nothing was written never fires it;
1336
+ * - a call whose write failed AMBIGUOUSLY (a non-atomic env that truncated and then errored) or
1337
+ * THREW fires it — exactly the arm where the retraction KEEPS the first-touch record because the
1338
+ * file may really have changed. The two seats agree on purpose: a file whose baseline was kept
1339
+ * for a possible modification must also be listed as possibly modified;
1340
+ * - a `Bash` command that changed a file never fires it: bash does not run through this band's
1341
+ * write lanes at all, which is the whole reason this seat is not a tool-name table.
1342
+ * PURE OBSERVATION: it returns nothing and it cannot refuse. A FAULT in it is contained in both
1343
+ * shapes an observer can fail in — a synchronous throw, and (the return type is `void`, but an
1344
+ * `async` function still type-checks there) a rejected promise, which is sunk rather than left
1345
+ * unhandled. Neither is awaited: an observer must never turn a landed write into a failed tool
1346
+ * answer, nor delay one.
1347
+ */
1348
+ export interface FileEditedNotice {
1349
+ tool: "Write" | "Edit" | "NotebookEdit";
1350
+ /** The model-supplied path argument — the SAME coordinate the delegated-child projection's
1351
+ * `SubagentEditedFile.path` carries, so the two seats can be read side by side. */
1352
+ path: string;
1353
+ /** The resolved canonical containment key the bytes actually landed on. */
1354
+ key: string;
1355
+ }
1356
+ export type FileEditedHook = (notice: FileEditedNotice) => void;
1284
1357
  /**
1285
1358
  * design/141 件2 — the SAFE deployment-configurable subset of the hands toolkit ({@link RunnerDeps.hands}).
1286
1359
  * Only fields whose injection is purely additive for a deployment are here; Runner-internal orchestration
@@ -2112,6 +2185,15 @@ export interface TaskSpec {
2112
2185
  * target with no history boundary fails loud (`rewind_snapshot.unresolvable`). Requires
2113
2186
  * `RunnerDeps.fileHistoryStore`. The session leaf is untouched — the next turn continues the
2114
2187
  * CURRENT conversation, only the files moved.
2188
+ *
2189
+ * A same-process child THE AGENT TOOL SPAWNED FRESH (the legs that are handed a lineage — see
2190
+ * `ToolExecuteContext.fileHistoryLineage` for the exact set, and for the legs that are not) which
2191
+ * shares its root session's tree records its edits into the ROOT session's history, so a rewind
2192
+ * that should cover those edits is the root session's action; a restore request on such a child run
2193
+ * is refused loud (`rewind.child_scope_unsupported`) rather than converging a scope that holds none
2194
+ * of its edits. A delegated child that was handed NO lineage — a revival, a workflow/team member —
2195
+ * keeps its own scope: its edits are outside this target's reach, and its own restore request is an
2196
+ * ordinary one.
2115
2197
  */
2116
2198
  rewindFilesTo?: string;
2117
2199
  /**
@@ -3514,7 +3596,7 @@ export interface TaskResult {
3514
3596
  * ignores the field is unchanged. A rewind that could NOT be delivered as asked is NOT a note — it is a
3515
3597
  * terminal failure (`rewind_snapshot.unresolvable` / `rewind.store_unconfigured` /
3516
3598
  * `rewind.restore_failed` / `rewind.invalid_spec` / `rewind.rewind_files_retired` /
3517
- * `rewind.conflicting_targets`).
3599
+ * `rewind.conflicting_targets` / `rewind.child_scope_unsupported`).
3518
3600
  *
3519
3601
  * - `conversation_only` — {@link TaskSpec.resumeAt} branched the transcript WITHOUT
3520
3602
  * {@link TaskSpec.restoreFiles}, so the working tree was deliberately left where it was (CC's
@@ -3537,6 +3619,56 @@ export interface TaskResult {
3537
3619
  /** Human-readable statement of what did NOT happen and why — safe to show a user verbatim. */
3538
3620
  message: string;
3539
3621
  }>;
3622
+ /**
3623
+ * The files THIS RUN's hands actually mutated, with how many times each — the same
3624
+ * `{path, edits}` shape the delegated-child projection publishes as
3625
+ * {@link import("../agents/subagent-steps.js").SubagentEditedFile}, so a host can read the two
3626
+ * seats side by side. A run is one host-side user message, which is the granularity a
3627
+ * "restore the code this message changed" affordance needs; the per-model-turn beat
3628
+ * (`turn_end`) deliberately has no such seat.
3629
+ *
3630
+ * **Source**: the hands band's own mutation lane. One entry is bumped when a `Write`/`Edit`/
3631
+ * `NotebookEdit` call's FINAL env write returns success — not when a tool call starts, not from a
3632
+ * tool-name table over the event stream. The consequences are the contract:
3633
+ * - a refused, gated or FAILED write is not counted (a write whose bytes never landed is not an
3634
+ * edit — the same lane point that retracts its first-touch history record);
3635
+ * - a `Bash` command that changed a file is not counted: it does not go through those lanes.
3636
+ * This matches the reference implementation's own trigger set;
3637
+ * - one tool CALL counts once, so a batch `Edit` (multiple `edits[]` against one file in one
3638
+ * call) is one edit here, exactly as the child projection counts it.
3639
+ *
3640
+ * **`path` form**: the model-supplied path argument, verbatim — the same coordinate
3641
+ * `SubagentEditedFile.path` uses, deliberately not the canonical containment key, so both seats
3642
+ * spell a file the way the transcript does. IDENTITY, however, is the canonical FILE, not the
3643
+ * spelling: a relative argument resolves against the run's LIVE cwd, so one spelling can name two
3644
+ * different files across a `cd` (two entries, each showing that spelling) and two spellings can
3645
+ * name one file (ONE entry, showing the first spelling that reached it). Insertion order = order
3646
+ * of first edit.
3647
+ *
3648
+ * **Three-valued on the failure edge, upper bound never lower**: a write failure is three-valued.
3649
+ * A failure whose error code's contract states nothing was written is a proven no-op and is NOT
3650
+ * counted. An AMBIGUOUS failure or a thrown write IS counted: an `ExecutionEnv` whose write is not
3651
+ * atomic can truncate a file and then report an error, that file may really have changed, and the
3652
+ * first-touch history record for it is kept for exactly that reason — so this list names it too,
3653
+ * and a `rewindFilesTo` will restore it. (The reference env replaces whole files by stage+rename —
3654
+ * fully old or fully new — so this edge belongs to its in-place fallback arms and to third-party
3655
+ * envs.) A host therefore reads this list as "files that may differ from before this run", never
3656
+ * as "writes the model saw succeed"; the tool answers carry that.
3657
+ *
3658
+ * **In-presence condition**: present iff this run's hands landed ≥1 such write — zero edits means
3659
+ * the key is ABSENT, never an empty array. It does NOT depend on whether a
3660
+ * `RunnerDeps.fileHistoryStore` is wired: this is an observation of what this run did, not a
3661
+ * durable history, and a deployment with no history store still gets it (what a store adds is the
3662
+ * ability to REWIND, not the ability to say what changed). It covers this run's OWN band only — a
3663
+ * delegated child's writes ride the child's own result and the delegation projection, not this
3664
+ * key.
3665
+ *
3666
+ * **Bound**: at most 1000 distinct files. Past that, already-listed files keep counting and new
3667
+ * ones are not added — a result seat cannot be unbounded, and a single user message reaching a
3668
+ * thousand distinct files is already outside what this observation is for. A consumer that must
3669
+ * know whether it is reading a saturated list can compare the length against that ceiling.
3670
+ */
3671
+ editedFiles?: import("../agents/subagent-steps.js").SubagentEditedFile[];
3540
3672
  /**
3541
3673
  * Present (`true`) exactly when the run's FINAL turn was halted by a person's BARE rejection of a
3542
3674
  * tool call — the parent-thread control-flow boundary: the rejected call's same-message siblings
@@ -3994,11 +4126,25 @@ export type BrainStatusPhase = "rate_limited" | "retrying" | "reconnecting" | "c
3994
4126
  * RB-420-c — TERMINAL frames closing a retry sequence. Without them a consumer that rendered a
3995
4127
  * `rate_limited`/`retrying`/`reconnecting` frame has nothing telling it the wait is over, so the
3996
4128
  * 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.
4129
+ * and only for a call that actually waited (a retry wait, or a `waiting_first_token` trail
4130
+ * #530): `recovered` = the call was served after the wait (a later attempt, or the first token
4131
+ * finally arriving), `gave_up` = the retry budget ran out (or the call was cancelled) and a
4132
+ * terminal error follows.
3999
4133
  * Both mean the same thing to a renderer — stop showing the retry state.
4000
4134
  */
4001
- | "recovered" | "gave_up";
4135
+ | "recovered" | "gave_up"
4136
+ /**
4137
+ * #530 — the first-token WAIT, before any failure: a first-token watchdog is armed
4138
+ * (`firstTokenTimeoutMs > 0`) and nothing has arrived yet. Emitted once the wait has lasted
4139
+ * `WAITING_FIRST_TOKEN_AFTER_MS` and re-emitted every `WAITING_FIRST_TOKEN_EVERY_MS` while it goes
4140
+ * on, carrying {@link BrainStatus.elapsedMs} and {@link BrainStatus.timeoutMs}; never carries a
4141
+ * retry countdown (`retryInMs`/`retryAtMs` — no wait is being announced, a wait is being
4142
+ * OBSERVED). CLOSED like a retry trail: once a wait frame went out, the call's terminal frame
4143
+ * follows — `recovered` when the call went on to be served (the first token arrived, on this
4144
+ * attempt or a later one), `gave_up` when it failed for good — so a renderer that pinned the row
4145
+ * has its "stop" signal. No frame at all when no watchdog is armed.
4146
+ */
4147
+ | "waiting_first_token";
4002
4148
  /**
4003
4149
  * WHY a retry wait is happening, as a closed, provider-NEUTRAL bucket — the companion to
4004
4150
  * {@link BrainStatusPhase}, which says what the brain is doing about it. A consumer rendering an
@@ -4053,6 +4199,13 @@ export interface BrainStatus {
4053
4199
  * the authoritative RELATIVE quantity, and this seat is the absolute convenience derived from it.
4054
4200
  */
4055
4201
  retryAtMs?: number;
4202
+ /** #530 — on a `waiting_first_token` frame: how long the first token has been awaited, ms, at the
4203
+ * moment the frame is emitted (the emitting process's clock; approximate across a hop). Absent on
4204
+ * every other phase. */
4205
+ elapsedMs?: number;
4206
+ /** #530 — on a `waiting_first_token` frame: the armed first-token watchdog, ms — the wait's ceiling,
4207
+ * so a consumer can render "45s of 120s". Absent on every other phase. */
4208
+ timeoutMs?: number;
4056
4209
  /** RB-420-c — 1-based index of the attempt that just failed (the wait precedes attempt `attempt + 1`);
4057
4210
  * same numbering as the `brain.retry` telemetry frame. Absent on frames that are not a retry wait. */
4058
4211
  attempt?: number;
@@ -5915,6 +6068,20 @@ export interface EngineNotice {
5915
6068
  * (whoever answered the card is the one entitled to hear the answer ran nothing); a host may
5916
6069
  * forward it on its own wire.
5917
6070
  *
6071
+ * - `"classifier.denial_limit"` (#548, CC 2.1.250 `FO`/"too many classifier denials in headless
6072
+ * mode") — the auto-mode classifier's DENIAL LIMIT was reached (3 consecutive blocks, or 20 in the
6073
+ * run; `RunnerDeps.autoMode.denialLimit`) and the fallback ask it turns into had NO approver to go
6074
+ * to: none wired, a blanket `onAsk:"allow"` (refused for a real-approval ask), or an approver that
6075
+ * reported unavailable with no durable park to take it. The run was STOPPED — `TaskResult.status`
6076
+ * `"failed"`, `errorCode` the same code, `errorMessage` the limit sentence — rather than kept
6077
+ * spending on a model the classifier denies without end; the triggering call's own result is the
6078
+ * deny that stood. Once per run (the first headless fallback owns the terminal);
6079
+ * `detail: { sessionId, runId, toolName, toolCallId, consecutive, total, limit }` — `limit` is
6080
+ * `"consecutive"` | `"total"`, the bound that tripped. Audience `"user"` (the person whose run
6081
+ * ended is the one entitled to hear why, and to review the transcript the sentence points at).
6082
+ * With an approver wired the same bound mints NO notice — the fallback ask reaches the person
6083
+ * instead (`AskRequest.denialLimitFallback`).
6084
+ *
5918
6085
  * - `"steering.parked_input_blocked"` (design/373 §4.3) — a PARKED steer entry was withheld when
5919
6086
  * a resume redelivered it (any resume kind that drains parked steers — wake included) by the
5920
6087
  * deployment's `userPromptSubmit` screen (block verdict, or a fail-closed non-answer/crash):
@@ -6086,23 +6253,61 @@ export interface EngineNotice {
6086
6253
  * disposition table with the error dispositions. The loser MUST NOT retry into the winner's
6087
6254
  * account. A consumer diffing its own table against the catalog must not add a row for it.
6088
6255
  *
6256
+ * - `"config.peer_admission_out_of_range"` (#551) — a `RunnerDeps.peerAdmission` field the
6257
+ * resolver could not use: outside its legal range, or not a finite number at all (a string off
6258
+ * an untyped host's wiring, `NaN`, `Infinity`). THAT FIELD falls back to its own default (never
6259
+ * a clamp to the nearest edge, never a whole-config reject) and every other field is unaffected.
6260
+ * One notice PER FIELD — two bad fields in one config are two notices — deduped per SINK on
6261
+ * `(field, given)` rather than per call (the resolver runs on every SendMessage and every drain
6262
+ * round; the fact is about the wiring, not about the call). Per SINK, not per process: two
6263
+ * Runners in one process each own an `onNotice`, and a process-global ledger would deliver the
6264
+ * fact to whichever resolved first and leave the other silently without its own disclosure. The
6265
+ * unwired arm (no `onNotice`: the shared `console.warn` throat) latches per process instead —
6266
+ * the console IS one process-wide channel. `detail: { field, given, default,
6267
+ * range: [lo, hi], reason }`, where `reason` is `"out_of_range" | "not_a_finite_number"`.
6268
+ * Audience `"operator"` (a configuration fact; the fix is the deployment's).
6089
6269
  * - `"config.peer_lane_unmounted"` (design/385 §1.2⑥) — `RunnerDeps.peerDirectory` is wired but
6090
6270
  * the cross-session lane could not mount on this leg: no `mailboxStore`, or one that does not
6091
6271
  * declare `crossProcessSafe: true` (several terminals would share one session box on luck). The
6092
6272
  * seat is inert for the run (no ListAgents, peer addresses refuse with the same reason). Once per
6093
6273
  * prepared leg; `detail: { reason, code, mailboxWired, sessionId, runId }`. Audience `"operator"`
6094
6274
  * (a wiring fact; the fix is the deployment's).
6095
- * - `"peer.inbound_disposition"` (design/385 §1.2④ / §4.6) — a message parked in THIS session's
6096
- * own box was settled at the drain WITHOUT reaching the model. `detail.disposition` is the arm:
6097
- * `"refused"` (this session's `crossSessionInbound` setting, or a record whose typed fields
6098
- * cannot be rendered canonically), `"admission_refused"` (the drain-stage admission re-check:
6099
- * duplicate / hop loop / runaway — `cause` names it), `"notice_unrouted"` (a notice-kind record
6100
- * idle/delivery notice met a build with no notice face: settled without delivery, never
6101
- * ridden through the peer-message envelope), or `"held"` (mode parity held it; the
6102
- * held-message review face is not mounted in this build, so the message STAYS PARKED and the
6103
- * drain stops at it disclosed once per seq per leg). `detail: { disposition, cause, seq, box,
6275
+ * - `"peer.inbound_disposition"` (design/385 §1.2④ / §4.6 / §4.4) — a message parked in THIS
6276
+ * session's own box was settled at the drain WITHOUT reaching the model. `detail.disposition` is
6277
+ * the arm (closed set `PeerInboundDisposition`): `"refused"` (this session's `crossSessionInbound`
6278
+ * setting, or a record whose typed fields cannot be rendered canonically), `"admission_refused"`
6279
+ * (the drain-stage admission re-check: duplicate / hop loop / runaway — `cause` names it),
6280
+ * `"notice_unrouted"` (a notice-kind record the notice face cannot render no typed `notice`, or
6281
+ * a state that does not fit its kind settled without delivery, never ridden through the
6282
+ * peer-message envelope), `"notice_user_only"` (a delivery receipt / idle notice whose sender this
6283
+ * session's parity would HOLD: surfaced here for the user, not read by the model CC's
6284
+ * `modelVisible:false` arm; `cause` carries the rendered notice text), or `"held"` — the parity
6285
+ * judgment held it: the message ENTERED this session's process-level held queue and was acked
6286
+ * from the box (later messages are not blocked behind it), where it is re-judged at every
6287
+ * boundary, released or refused with a receipt, and expires WITH a receipt under `dialogExpiry`
6288
+ * (see `peer.held_settled`); `cause` is a `PeerInboundHoldCause` — the parity causes, or the
6289
+ * drain gate's TRANSIENT `rate_limited` (that one does NOT enter the queue: the message stays in
6290
+ * the box and the drain stops at its seq until the bucket refills). Disclosed once per seq per leg,
6291
+ * and again when a re-judgment moves a held entry's cause. `detail: { disposition, cause, seq, box,
6104
6292
  * fromSession?, sessionId, runId }`. Audience `"user"`: the session's user is the one who did
6105
6293
  * not receive it.
6294
+ * - `"peer.held_settled"` (design/385 §4.4, slice 4) — a message left THIS session's held queue.
6295
+ * `detail.settlement` names how: `"delivered"` (a re-judgment or an approval released it into
6296
+ * this session's model context), `"refused"` (the setting moved to refuse, or an approval met a
6297
+ * refusing policy), `"expired"` (the `dialogExpiry` deadline, an eviction when the 100-entry queue
6298
+ * was full, a cancelled review, or a graceful shutdown), `"denied"` (the review face), `"dropped"`
6299
+ * (the injection lane refused the released frame). `detail.reason` is the trigger
6300
+ * (`deadline | evicted | shutdown | rejudge | approved | denied | cancelled | inject_failed`); the
6301
+ * sender was sent the matching receipt whenever the record carried a reply address.
6302
+ * `detail: { settlement, reason, seq, box, fromSession?, heldCount, sessionId, runId }`. Audience
6303
+ * `"user"`: it is this session's user who held (or was holding) the message.
6304
+ * - `"peer.idle_subscription"` (design/385 §5.2, slice 4) — a peer session asked to be told when
6305
+ * THIS session next goes idle (`SendMessage … notify_when_idle`). `detail.outcome`: `"recorded"`
6306
+ * / `"refreshed"` (a same-requester re-ask; the subscription is one-shot and expires unfired after
6307
+ * 12 h), `"full"` (the 32-entry table is full) or `"refused"` (this session's `crossSessionInbound`
6308
+ * is `refuse`) — the last two answered the requester with an `unavailable` notice. `detail:
6309
+ * { outcome, fromSession, live, seq, box, sessionId, runId }`. Audience `"user"`: the person whose
6310
+ * session is being watched is the one entitled to know (CC announces it in the session UI).
6106
6311
  *
6107
6312
  * - `"delegation.transcript_integrity"` (subagent transcript persistence) — a durable agent row
6108
6313
  * with a BOUND transcript sessionId met a session store that attests `not_found` for it: the
@@ -6852,6 +7057,25 @@ export interface RunnerDeps {
6852
7057
  consecutiveFailures: number;
6853
7058
  lastCause: string;
6854
7059
  }) => void;
7060
+ /**
7061
+ * The classifier DENIAL LIMIT (CC 2.1.250 `FO`/`AKe`): a run whose classifier keeps blocking falls
7062
+ * back to a PERSON instead of being denied without end. Per run: a `block` first increments the
7063
+ * consecutive and total counts and then judges `consecutive >= maxConsecutive || total >= maxTotal`
7064
+ * — the block that reaches a bound is itself the one that becomes an `ask` (the 3rd consecutive
7065
+ * block asks). That ask carries `requiresRealApproval: true` (no automatic lane may clear it — not
7066
+ * a sandbox admission, not an inherited resolver, not a blanket `onAsk:"allow"`) plus the additive
7067
+ * `denialLimitFallback` member with the counts and its own auto-deny window; an unanswered ask
7068
+ * auto-denies after `autoDenyAfterMs` (default 120s; `0` = no window). A classifier allow, or a
7069
+ * person's allow of the fallback ask, zeroes the consecutive count; reaching the total bound
7070
+ * zeroes everything. With no approver wired at all (headless), the fallback has nowhere to go and
7071
+ * the run STOPS with `TaskResult.errorCode = "classifier.denial_limit"` (a notice of the same
7072
+ * code is minted).
7073
+ *
7074
+ * Every member optional (defaults 3 / 20 / 120_000). A present member with a bad value is refused
7075
+ * loudly at prepare — never clamped, never silently read as the default.
7076
+ * See {@link import("./auto-mode.js").AutoModeDenialLimitOptions}.
7077
+ */
7078
+ denialLimit?: import("./auto-mode.js").AutoModeDenialLimitOptions;
6855
7079
  /**
6856
7080
  * #503 — OPT IN to recording this arming's serializable criteria (an
6857
7081
  * {@link import("./auto-mode-arming.js").AutoModeArmingRecipe}) on the constraint-chain entries a
@@ -7012,6 +7236,18 @@ export interface RunnerDeps {
7012
7236
  * everywhere (mode parity, the CC default). Inert without {@link peerDirectory}.
7013
7237
  */
7014
7238
  crossSessionInbound?: import("../agents/cross-session-judge.js").CrossSessionInboundSettingLayers | (() => import("../agents/cross-session-judge.js").CrossSessionInboundSettingLayers);
7239
+ /**
7240
+ * design/385 §4.4 (slice 4) — this deployment's `dialogExpiry` (CC settings key, verbatim vocabulary
7241
+ * `"60s" | "5m" | "10m" | "never"`, default `"5m"`): how long a HELD cross-session message whose cause
7242
+ * a human review could resolve (mode-mismatch / no-mode-asserted / invalid attestation) waits in this
7243
+ * session's held queue before it resolves to its safe no-action default — EXPIRED, dropped WITH a
7244
+ * receipt to the sender, never silently. `"never"` disables the deadline. Read at every drain round
7245
+ * (getter form for a host that re-reads its settings). A value outside the vocabulary is announced
7246
+ * ONCE per leg through `onError` (`classification: "peer-dialog-expiry"`) and the default applies —
7247
+ * a garbage setting never silently reads as a policy. Absent = the default. Inert without
7248
+ * {@link peerDirectory}.
7249
+ */
7250
+ crossSessionDialogExpiry?: import("../agents/peer-notices.js").CrossSessionDialogExpiry | (() => import("../agents/peer-notices.js").CrossSessionDialogExpiry | undefined);
7015
7251
  /**
7016
7252
  * design/164 件五 — DEPLOYMENT-level usage governance: allowances that span TASKS, evaluated per
7017
7253
  * principal (or once for the whole deployment when a task declares none). A different axis from
@@ -7542,4 +7778,4 @@ export interface RunnerDeps {
7542
7778
  * 3, CC parity). The Runner tracks the consecutive-reuse counter per task across both compaction sites. */
7543
7779
  maxConsecutiveProviderReuse?: number;
7544
7780
  }
7545
- export type { AgentTool, Model, StreamFn, ThinkingLevel };
7781
+ 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) {