@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.
- package/CHANGELOG.md +49 -0
- package/dist/agents/peer-admission.d.ts +18 -3
- package/dist/agents/peer-admission.js +79 -4
- package/dist/agents/peer-held-queue.d.ts +101 -0
- package/dist/agents/peer-held-queue.js +229 -0
- package/dist/agents/peer-idle.d.ts +109 -0
- package/dist/agents/peer-idle.js +240 -0
- package/dist/agents/peer-notice-route.d.ts +33 -0
- package/dist/agents/peer-notice-route.js +46 -0
- package/dist/agents/peer-notices.d.ts +103 -0
- package/dist/agents/peer-notices.js +206 -0
- package/dist/agents/peer-session-drain.d.ts +39 -4
- package/dist/agents/peer-session-drain.js +248 -42
- package/dist/agents/send-message-tool.d.ts +8 -1
- package/dist/agents/send-message-tool.js +96 -30
- package/dist/agents/subagent.js +1 -0
- package/dist/brain/status-sink.d.ts +10 -0
- package/dist/brain/status-sink.js +13 -4
- package/dist/brain/stream-engine.d.ts +11 -0
- package/dist/brain/stream-engine.js +39 -3
- package/dist/core/arg-summary.d.ts +13 -3
- package/dist/core/arg-summary.js +138 -7
- package/dist/core/auto-mode-defaults.d.ts +11 -0
- package/dist/core/auto-mode-defaults.js +2 -0
- package/dist/core/auto-mode.d.ts +59 -0
- package/dist/core/auto-mode.js +57 -1
- package/dist/core/checkpoint-store.js +2 -2
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +8 -0
- package/dist/core/hooks.d.ts +30 -0
- package/dist/core/hooks.js +43 -8
- package/dist/core/mailbox-store.d.ts +33 -1
- package/dist/core/mailbox-store.js +42 -2
- package/dist/core/runner/assemble-result.d.ts +5 -0
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/denial-limit-arms.d.ts +149 -0
- package/dist/core/runner/denial-limit-arms.js +91 -0
- package/dist/core/runner/edited-files-ledger.d.ts +33 -0
- package/dist/core/runner/edited-files-ledger.js +14 -0
- package/dist/core/runner/prepare-hands-readface.d.ts +5 -0
- package/dist/core/runner/prepare-hands-readface.js +1 -0
- package/dist/core/runner/prepare-task.d.ts +62 -1
- package/dist/core/runner/prepare-task.js +135 -89
- package/dist/core/runner/runtask.js +12 -0
- package/dist/core/sensitive-path-policy.d.ts +27 -6
- package/dist/core/sensitive-path-policy.js +57 -2
- package/dist/core/task-notification.d.ts +24 -2
- package/dist/core/task-notification.js +6 -1
- package/dist/core/tool-policy.d.ts +55 -4
- package/dist/core/tool-policy.js +28 -5
- package/dist/core/tools.js +1 -0
- package/dist/core/types.d.ts +251 -15
- package/dist/core/wiring-manifest.d.ts +41 -5
- package/dist/core/wiring-manifest.js +8 -0
- package/dist/engine/harness/agent-harness.d.ts +1 -0
- package/dist/engine/harness/agent-harness.js +3 -0
- package/dist/engine/harness/types.d.ts +3 -0
- package/dist/engine/loop/agent-loop.d.ts +7 -0
- package/dist/engine/loop/agent-loop.js +79 -0
- package/dist/engine/loop/types.d.ts +42 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +10 -4
- package/dist/internal/harness-types.d.ts +1 -1
- package/dist/orchestration/workflow.js +7 -3
- package/dist/tools/fs/fs-write.d.ts +4 -4
- package/dist/tools/fs/fs-write.js +99 -14
- package/dist/tools/fs/index.d.ts +7 -1
- package/dist/tools/fs/index.js +1 -1
- package/dist/tools/fs/safety.d.ts +29 -8
- package/dist/tools/fs/safety.js +11 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +181 -1
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
|
3998
|
-
* `
|
|
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
|
|
6096
|
-
* own box was settled at the drain WITHOUT reaching the model. `detail.disposition` is
|
|
6097
|
-
* `"refused"` (this session's `crossSessionInbound`
|
|
6098
|
-
* cannot be rendered canonically), `"admission_refused"`
|
|
6099
|
-
* duplicate / hop loop / runaway — `cause` names it),
|
|
6100
|
-
*
|
|
6101
|
-
*
|
|
6102
|
-
*
|
|
6103
|
-
*
|
|
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) {
|