@sema-agent/core 7.0.2 → 7.1.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 (41) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/agents/repair-loop.d.ts +8 -7
  3. package/dist/agents/roster-store.d.ts +7 -2
  4. package/dist/brain/errors.d.ts +18 -0
  5. package/dist/brain/errors.js +3 -0
  6. package/dist/brain/stream-engine.js +6 -4
  7. package/dist/core/context-edit.d.ts +3 -0
  8. package/dist/core/governance-codes.d.ts +1 -1
  9. package/dist/core/governance-codes.js +4 -0
  10. package/dist/core/hooks.d.ts +26 -6
  11. package/dist/core/hooks.js +8 -5
  12. package/dist/core/image-downsample.d.ts +4 -3
  13. package/dist/core/roles.d.ts +30 -8
  14. package/dist/core/roles.js +12 -8
  15. package/dist/core/runner/prepare-task.d.ts +2 -1
  16. package/dist/core/runner/prepare-task.js +75 -39
  17. package/dist/core/runner/runtask.d.ts +12 -3
  18. package/dist/core/runner/runtask.js +32 -4
  19. package/dist/core/safety-axis-vocab.d.ts +1 -1
  20. package/dist/core/strategy-store.d.ts +4 -1
  21. package/dist/core/task-registry-shared.d.ts +7 -3
  22. package/dist/core/tool-errors.d.ts +1 -1
  23. package/dist/core/tool-policy.d.ts +40 -7
  24. package/dist/core/tool-policy.js +63 -9
  25. package/dist/core/types.d.ts +87 -9
  26. package/dist/engine/compaction/compaction.js +6 -2
  27. package/dist/engine/harness/agent-harness.d.ts +28 -6
  28. package/dist/engine/harness/agent-harness.js +34 -2
  29. package/dist/engine/harness/messages.js +4 -0
  30. package/dist/engine/harness/types.d.ts +37 -0
  31. package/dist/engine/harness/types.js +5 -0
  32. package/dist/engine/session/session.js +3 -2
  33. package/dist/internal/harness.d.ts +1 -0
  34. package/dist/internal/harness.js +1 -0
  35. package/dist/orchestration/builtin-workflows.d.ts +17 -9
  36. package/dist/orchestration/run-workflow-tool.js +7 -2
  37. package/dist/orchestration/workflow-governance.js +1 -1
  38. package/dist/orchestration/workflow-types.d.ts +1 -0
  39. package/dist/orchestration/workflow.js +1 -1
  40. package/dist/stores/file/mailbox-store.d.ts +2 -1
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.1.0 — 2026-09-01
4
+
5
+ ### BREAKING (timing) — design/384 slice 1 (#513): turn interrupts reach the ask waiting side
6
+ - **`resolveAsk` no longer waits for the approver to settle once the abort signal fires.** The onAsk await is raced against the composed `AbortSignal.any([run, call])`; the wait settles as the abort-family deny on its own. The prior guarantee "no return before onAsk settles" is REMOVED. Detached discipline: the still-pending approver promise is swallow-guarded; a late APPROVE becomes the `task.late_approval` engine notice; a late REJECT reaches `onError(phase:"hook")` — disclosed, never silent. This also closes the pre-existing run-abort deaf-approver exposure (a walltime/cancel end can no longer be held open by an approver ignoring its signal).
7
+ - **Signal thread (additive seats)**: the loop's turn-scoped signal rides the `tool_call` hook event as an optional `signal` seat; `ToolGateInput.callSignal` (optional) hands it to the three synchronous wait seats (`adjudicate`/`resolveAsk`/`resolveContentAsk`) as a new optional trailing argument — same-arity guarantee when absent (conditional invocation, no explicit trailing `undefined`).
8
+ - **`ENGINE_NOTICE_CODES` closed-set +2** (downstream full-set pins go red): `task.halt_unconsumed` (halt verb answered while the run's ending was already owned by its own abort — nothing cut, `haltedByUser` unsigned, this notice is the halt's only trace) and `task.late_approval` (the detached approver's late yes ran nothing; NON-CONSUMPTION semantics — the engine claims neither who decided nor which came first). Both audience `user`.
9
+ - **`task_aborted` widened by DEFINITION, zero closed-set additions**: the deny resolution now names the run's OR turn's abort signal; the deny message widens to `approval aborted for X (run or turn interrupted)` at all four family sites (probes pinning the old `(task ended)` bytes re-pinned).
10
+ - **Ledger narrowing (named)**: the humanReview gates ledger stops booking abort-family denies (`task_aborted` joins the `approverUnavailable` skip — interrupts are not denials; also covers the pre-existing run-abort booking distortion).
11
+ - **steer `priority:"now"` revokes a pending card and the run continues** (CC-aligned; same seat as `interruptTurn`, pinned e2e).
12
+
13
+ ### Added
14
+ - **Terminal API failure now persists in the transcript** (#506㋐, CC 2.1.250 re-anchor): the harness mints a SEPARATE assistant record (reserved `<synthetic>` model sentinel + `isApiErrorMessage` + one bounded `API Error:` text block ≤2000 chars, `apiErrorStatus`/`requestId` twins, honest `"error"` stop + `usageMissing` — deliberately NOT copying CC's lying `stop_sequence`/zero-usage fields). `convertToLlm` drops every such record from every request view (the invalid-sequence class stays closed), with the exclusion carried at four further seats: auto-mode classifier window, prompt-suggestions grounding tail, compaction cut points, session derived-model tracking. ABORT arm untouched.
15
+ - **`BrainStatus.errorStatus`** (#506㋑): the HTTP status that named a retry's failure now rides the status channel in `api_retry` context (single `namesTheFailure` predicate governs both this seat and `apiErrorStatus`; 0-sentinel and 2xx never publish). WIRE EXTENSION — server/cli consumption named in the ship post.
16
+
17
+ ### Fixed
18
+ - **#512 model-vocabulary batch** (six items): run_workflow card stops teaching a tier-word claim false on tiers-configured deployments (real two-part gate = allowlist ∩ catalog); `parseModelMention` JSDoc recipe corrected to the EXPANDED table; four prototype-chain reads own-gated (live leak: `resolveModelDisplayLabel` returned Object.prototype functions for "toString" onto `agent_start.model`; `__proto__` writes now `Object.defineProperty` own data properties — a codex-caught setter regression); `suggestNextPrompts` bare catch now announces through `onError(phase:"suggestions")` on an explicit unresolvable `role`; roster/durable-registry `model` re-documented as the RECORD KEY; `agent_end.modelResolved` added to the WorkflowEvent union (additive — emitter always wrote it).
19
+ - **`scripts/verify-debts.mjs` two-evidence gate told the truth** (ledger-audit A-03): CLOSED rows now require two DISTINCT addressable coordinates; the old gate passed on a single commit OR post number.
20
+
21
+ ### Docs
22
+ - Self-scan doc-rot batch (33 findings triaged, 13 confirmed + extensions): every rotted `file.ts:NNN` cross-reference in src re-spelled by symbol (line-anchor ratchet banks the cleanup); six orphaned JSDoc blocks reattached to their symbols (incl. the public `Runner` class and `expandTiers`); the stale `agent-harness` abort-clears-queues contract sentence corrected to the #389 truth; release-docs gate register drift fixed in seven places; REVIEW-BACKLOG stale status cells refreshed against the tree.
23
+
24
+
3
25
  ## 7.0.2 — 2026-08-30
4
26
 
5
27
  ### Added
@@ -13,7 +13,7 @@ import { type OracleIsolationVerdict } from "../core/oracle-isolation.js";
13
13
  * `oracleIsolation` boundary that this slice deliberately does not build — SAFE-tier escalates to a human
14
14
  * (`needs_human_oracle`) or surfaces a candidate, but never clears its own work.
15
15
  *
16
- * Like {@link verifyCompleted}, this is a **thin composition** over `runner.runTask` (verify.ts:18-21 posture)
16
+ * Like {@link verifyCompleted}, this is a **thin composition** over `runner.runTask` (verify.ts's DEPLOYMENT POSTURE section)
17
17
  * — it adds no Runner-core surface, touches no vendored code, and is called by a leader/profile (the sibling
18
18
  * of {@link runWithVerification}). The oracle internals (which gate/judge/property-harness to compose) live
19
19
  * in the PROFILE-injected {@link RepairOracle} closure; core only fixes the {@link OracleResult} shape and the
@@ -75,12 +75,13 @@ export interface OracleResult {
75
75
  }
76
76
  /**
77
77
  * The PROFILE-injected oracle (§1 裁决①). The profile composes the actual grading inside this closure —
78
- * `runExecGate(graderEnv, steps)` (exec-gate.ts:185, env is the FIRST param = L2 provenance bound to the
79
- * grader) → `verifyCompleted` (verify.ts:212, the read-only L3 judge) → `checkInvariants`
80
- * (property-harness.ts:217, explore-only) — and maps the composite to an {@link OracleResult}. Core never
78
+ * `runExecGate(graderEnv, steps)` (core/exec-gate.ts, env is the FIRST param = L2 provenance bound to the
79
+ * grader) → `verifyCompleted` (agents/verify.ts, the read-only L3 judge) → `checkInvariants`
80
+ * (core/property-harness.ts, explore-only) — and maps the composite to an {@link OracleResult}. Core never
81
81
  * composes these internals (so the oracle type can't grow a long discriminated union); it only fixes the
82
82
  * `OracleResult` shape and the read-only/identity contract. `graderEnv` is the isolated grader env; `evidence`
83
- * is the diff/results to judge (recompute it from the post-resume working tree on a resume — verify.ts:328 BUG5).
83
+ * is the diff/results to judge (recompute it from the post-resume working tree on a resume — threat BUG5, the
84
+ * same freshness duty verify.ts states on its own `evidence` config field).
84
85
  */
85
86
  export type RepairOracle = (graderEnv: ExecutionEnv, evidence: string | undefined) => Promise<OracleResult>;
86
87
  /**
@@ -166,12 +167,12 @@ export interface RepairLoopConfig {
166
167
  */
167
168
  resumeBundle?: RepairBundle;
168
169
  /**
169
- * Stop the loop once cumulative cost reaches this (verify.ts:278 backstop). HRD-AGO-6 — "cumulative" is
170
+ * Stop the loop once cumulative cost reaches this (verify.ts's `costCeilingMicroUsd` backstop). HRD-AGO-6 — "cumulative" is
170
171
  * every leg the loop pays for: each attempt's own + nested `runTask` cost PLUS each verdict's
171
172
  * {@link OracleResult.costMicroUsd} (an LLM-judge oracle is charged per attempt too).
172
173
  */
173
174
  costCeilingMicroUsd?: number;
174
- /** Overall ACTIVE wall-clock ceiling for the whole loop (verify.ts:277 backstop). F1: both ceilings
175
+ /** Overall ACTIVE wall-clock ceiling for the whole loop (verify.ts's `totalTimeoutMs` backstop). F1: both ceilings
175
176
  * are CROSS-RESUME — the carried {@link RepairBundle.spentMicroUsd}/{@link RepairBundle.activeElapsedMs}
176
177
  * re-seed the accounts, so a durable resume continues the budget rather than restarting it. */
177
178
  totalTimeoutMs?: number;
@@ -12,8 +12,13 @@ export interface RosterEntry {
12
12
  * key; equals the spawner's session at depth 1). Stored verbatim; no predicate arm consumes it
13
13
  * yet (enumeration/recovery is the reader). */
14
14
  rootSessionId?: string;
15
- /** design/151 S3b — the RESOLVED model id at spawn (revival lookup key; display/model-routing
16
- * hints only, never a serialized spec). */
15
+ /** design/151 S3b — the model RECORD KEY at spawn (revival lookup key; display/model-routing hints
16
+ * only, never a serialized spec). NOT the resolved model id: a spawn that named a model in WORDS
17
+ * records the caller's spelling VERBATIM (a catalog key, tier word or CC alias), because a revival
18
+ * re-resolves this value against the catalog in force at WAKE time and a resolved id would only
19
+ * ever resolve again on deployments whose catalog keys happen to equal model ids. A spawn that
20
+ * carried a Model OBJECT (definition/tool-level/inherited ref) records that object's id, which on
21
+ * such a deployment is not a catalog key and degrades softly on the revival lane. */
17
22
  model?: string;
18
23
  /** Ruled 2026-08-05: the spawn requested a model word that did not bind — the row runs on its
19
24
  * inherited default. Closed set, single member today; absent = bound normally (or no word). */
@@ -82,6 +82,24 @@ export declare function readProviderRequestId(res: {
82
82
  get?: (name: string) => string | null;
83
83
  };
84
84
  } | undefined): string | undefined;
85
+ /**
86
+ * Does this HTTP status NAME the failure it is being attached to? The one predicate governing every
87
+ * seat on which this engine publishes a provider status — the terminal assistant frame's
88
+ * `apiErrorStatus` and the retry-wait status frame's `errorStatus` (#506 ㋑). One helper on purpose:
89
+ * both seats promise the same three-state discipline, and two copies of the rule would drift.
90
+ *
91
+ * Two distinct shapes are refused, and both are reachable:
92
+ * · The engine's "no status" SENTINEL, 0 — the terminal HTTP throw carries `r?.status ?? 0` when a
93
+ * transport hands back neither a response nor a throw. Publishing it would be absence rendered as a
94
+ * number a consumer may format, which is worse than silence.
95
+ * · A SUCCESS status. The connect loop only streams a response that is `ok` AND has a body, so an
96
+ * `ok` response with a NULL body (an empty 200 from a proxy, a 204) reaches the terminal throw
97
+ * carrying 200 — and a provider's own `x-should-retry` verdict can send that same 2xx into a RETRY
98
+ * wait. The boundary really did fail, but the status is not what failed, and a consumer routing or
99
+ * rendering by a 2xx is exactly the misreading these seats exist to prevent.
100
+ * Stalls, connect failures and in-band error frames inside a 200 leave the seat absent by the same rule.
101
+ */
102
+ export declare function namesTheFailure(status: number | undefined): status is number;
85
103
  /** Map an HTTP status to an error class. 401/403 = auth (don't retry); 429 = rate limit; 5xx = server. */
86
104
  export declare function classifyHttp(status: number): BrainErrorCode;
87
105
  /** Lift the machine-readable code back out of a `[code] …` prefixed `errorMessage` (the single shared
@@ -45,6 +45,9 @@ export function readProviderRequestId(res) {
45
45
  }
46
46
  return undefined;
47
47
  }
48
+ export function namesTheFailure(status) {
49
+ return status !== undefined && status >= 100 && !(status >= 200 && status < 300);
50
+ }
48
51
  export function classifyHttp(status) {
49
52
  if (status === 401 || status === 403)
50
53
  return "auth";
@@ -1,6 +1,6 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
3
- import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError, readProviderRequestId } from "./errors.js";
3
+ import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError, namesTheFailure, readProviderRequestId } from "./errors.js";
4
4
  import { classifyInputTooLong } from "./input-too-long.js";
5
5
  import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
6
6
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
@@ -267,9 +267,8 @@ export function runStreamingBrain(args) {
267
267
  errorMsg.inputTooLongRuledOut = true;
268
268
  if (err.apiError === true) {
269
269
  errorMsg.isApiErrorMessage = true;
270
- const st = err.status;
271
- if (st !== undefined && st >= 100 && !(st >= 200 && st < 300))
272
- errorMsg.apiErrorStatus = st;
270
+ if (namesTheFailure(err.status))
271
+ errorMsg.apiErrorStatus = err.status;
273
272
  if (requestIdSeen !== undefined)
274
273
  errorMsg.requestId = requestIdSeen;
275
274
  }
@@ -376,6 +375,7 @@ export function runStreamingBrain(args) {
376
375
  attempt: attempt + 1,
377
376
  maxRetries,
378
377
  errClass: "output_cap",
378
+ ...(namesTheFailure(r?.status) ? { errorStatus: r.status } : {}),
379
379
  });
380
380
  continue;
381
381
  }
@@ -428,6 +428,7 @@ export function runStreamingBrain(args) {
428
428
  discardResponseBody(r);
429
429
  cc.abort();
430
430
  cc.dispose();
431
+ const retryStatus = namesTheFailure(r?.status) ? r.status : undefined;
431
432
  await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
432
433
  phase: statusPhase,
433
434
  detail: statusPhase === "rate_limited"
@@ -440,6 +441,7 @@ export function runStreamingBrain(args) {
440
441
  attempt: attempt + 1,
441
442
  maxRetries: laneMaxRetries,
442
443
  errClass,
444
+ ...(retryStatus !== undefined ? { errorStatus: retryStatus } : {}),
443
445
  }));
444
446
  continue;
445
447
  }
@@ -257,6 +257,9 @@ export interface ContextEditOptions {
257
257
  * append-only sessions cannot be rewritten. Dropping the junk from the REQUEST VIEW restores a legal
258
258
  * transcript (TR3 rejoins its batch). Deterministic ⇒ byte-stable per request ⇒ prefix-cache safe.
259
259
  * New sessions never contain these (the loop exits cleanly on abort; the harness skips persisting them).
260
+ * #506 ㋐ did NOT re-open this class: the API-failure record the harness now writes carries `API Error: …`
261
+ * TEXT, so it is not an empty failure assistant and this heal correctly leaves it alone. That record is
262
+ * excluded one layer later, at `convertToLlm`, by its own three-key identity (`isSyntheticApiErrorMessage`).
260
263
  */
261
264
  export declare function dropEmptyFailureAssistants(messages: AgentMessage[]): AgentMessage[];
262
265
  /**
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
103
103
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
104
104
  * no longer minted.
105
105
  */
106
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
106
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
107
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
108
108
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
109
109
  * sink is a supported shape, and narrowing that field would break it. */
@@ -124,6 +124,8 @@ export const ENGINE_NOTICE_CODES = [
124
124
  "task.user_followup_undrained",
125
125
  "steering.parked_input_blocked",
126
126
  "task.turn_interrupted",
127
+ "task.halt_unconsumed",
128
+ "task.late_approval",
127
129
  "memory.capture_opted_out",
128
130
  "memory.capture_optout_unpersisted",
129
131
  "tool_result.offload_put_failed",
@@ -139,6 +141,8 @@ const NOTICE_AUDIENCE_TABLE = {
139
141
  "task.user_followup_undrained": "user",
140
142
  "task.turn_interrupted": "user",
141
143
  "steering.parked_input_blocked": "user",
144
+ "task.halt_unconsumed": "user",
145
+ "task.late_approval": "user",
142
146
  "memory.capture_opted_out": "user",
143
147
  "memory.capture_optout_unpersisted": "user",
144
148
  "memory.consolidation_withheld": "user",
@@ -1169,7 +1169,10 @@ export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
1169
1169
  unreadable?: true;
1170
1170
  coverage?: readonly import("./permission-rule-model.js").SegmentCoverage[];
1171
1171
  };
1172
- /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort signal. */
1172
+ /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort
1173
+ * signal; when the caller also supplies {@link ToolGateInput.callSignal}, the Runner's closures
1174
+ * additionally bind their waits to that per-call signal (`AbortSignal.any` of the two), so a turn
1175
+ * interrupt releases a pending ask instead of leaving it hanging on the run's lifetime alone. */
1173
1176
  export interface ToolGateInput {
1174
1177
  /** RB-473 batch 2b — bounded disclosure for a HOST callback that throws inside this gate call's
1175
1178
  * isolation scope (onHookError itself, the deny observer, the durable-park observer). The runner
@@ -1214,13 +1217,28 @@ export interface ToolGateInput {
1214
1217
  * builds ({@link HookEnvCapabilities}). Built ONCE per task by the runner (after the env is minted) and
1215
1218
  * passed through unchanged; omitted when the deployment wired no execution environment. */
1216
1219
  hookEnv?: HookEnvCapabilities;
1217
- /** The combined tool-policy check (abort-bound), or undefined when no policy is wired (→ allow). */
1218
- adjudicate?: (req: ToolCallRequest) => Promise<PermissionResult>;
1220
+ /**
1221
+ * design/384 slice 1 — the PER-CALL abort signal, carried in from the `tool_call` hook event's
1222
+ * own `signal` seat (the loop's turn-scoped controller; the run abort is composed into it). The
1223
+ * gate itself never awaits on it — it hands the signal verbatim to the three synchronous-wait
1224
+ * seats below (`adjudicate`, `resolveAsk`, `resolveContentAsk`) as their optional trailing
1225
+ * argument, and the Runner's closures compose it with the run signal so a turn interrupt (bare
1226
+ * halt / steer-now) releases the wait. Absent (a host driving the gate directly, or an unthreaded
1227
+ * emit) ⇒ every seat is invoked exactly as before — same arity, no trailing `undefined` (the
1228
+ * threading is a conditional invocation) — and waits stay bound to the run signal alone: the
1229
+ * historical shape, byte-identical.
1230
+ */
1231
+ callSignal?: AbortSignal;
1232
+ /** The combined tool-policy check (abort-bound), or undefined when no policy is wired (→ allow).
1233
+ * The optional trailing `callSignal` (design/384) is {@link ToolGateInput.callSignal}, passed
1234
+ * through verbatim; an implementation that ignores it keeps the run-level binding. */
1235
+ adjudicate?: (req: ToolCallRequest, callSignal?: AbortSignal) => Promise<PermissionResult>;
1219
1236
  /** Resolve an `ask` to allow/deny via `onAsk` (abort-bound). Required iff a decision can be `ask`.
1220
1237
  * G1 three-value: the result may carry `approverUnavailable` — the live approver affirmed no
1221
1238
  * operator is reachable for THIS ask; the gate then re-routes onto the durable park leg (below),
1222
- * keeping the carried fail-closed deny when no park is armed. */
1223
- resolveAsk: (decision: PermissionResult, req: ToolCallRequest) => Promise<ResolvedAsk>;
1239
+ * keeping the carried fail-closed deny when no park is armed. The optional trailing `callSignal`
1240
+ * (design/384) is {@link ToolGateInput.callSignal}, passed through verbatim. */
1241
+ resolveAsk: (decision: PermissionResult, req: ToolCallRequest, callSignal?: AbortSignal) => Promise<ResolvedAsk>;
1224
1242
  /**
1225
1243
  * design/45: route a policy `ask` to a **durable suspension** instead of the synchronous `resolveAsk`
1226
1244
  * (onAsk) path. Called in the `ask` branch with the FINAL post-hook args; if it returns a suspend
@@ -1309,8 +1327,10 @@ export interface ToolGateInput {
1309
1327
  * from "nobody home" — the two have opposite re-routing rules). Passed UNCONDITIONALLY by the runner,
1310
1328
  * including on runs with no live face: it then answers `unavailable` without contacting anything, which
1311
1329
  * is exactly the pre-174 park/refusal behavior. Absent entirely ⇒ the pre-174 path verbatim.
1330
+ * The optional trailing `callSignal` (design/384) is {@link ToolGateInput.callSignal}, passed
1331
+ * through verbatim; an implementation that ignores it keeps the run-level binding.
1312
1332
  */
1313
- resolveContentAsk?: (req: ToolCallRequest) => Promise<ContentAskOutcome>;
1333
+ resolveContentAsk?: (req: ToolCallRequest, callSignal?: AbortSignal) => Promise<ContentAskOutcome>;
1314
1334
  /**
1315
1335
  * design/70: the called tool is egress-marked (`ToolSpec.egress` — an external write: open a PR,
1316
1336
  * push, send). The gate tightens a surviving `allow` to `ask` so an egress tool is NEVER
@@ -426,6 +426,7 @@ export function persistedRuleMandateOf(marks) {
426
426
  export async function runToolGate(input) {
427
427
  const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
428
428
  const { toolCallId, toolName } = event;
429
+ const callSignal = input.callSignal;
429
430
  const hookCtx = (seatSignal) => ({
430
431
  toolCallId,
431
432
  toolName,
@@ -506,7 +507,7 @@ export async function runToolGate(input) {
506
507
  }
507
508
  }
508
509
  const req = { toolName, args: currentInput, toolCallId };
509
- let decision = adjudicate ? await adjudicate(req) : { action: "allow" };
510
+ let decision = adjudicate ? await (callSignal !== undefined ? adjudicate(req, callSignal) : adjudicate(req)) : { action: "allow" };
510
511
  let denySource = "policy";
511
512
  let policyRewrite;
512
513
  if ((decision.action === "allow" || decision.action === "ask") && decision.updatedInput !== undefined) {
@@ -867,7 +868,7 @@ export async function runToolGate(input) {
867
868
  }
868
869
  }
869
870
  if (decision.action === "ask" && req.toolName === ASK_USER_QUESTION_TOOL_NAME) {
870
- const outcome = input.resolveContentAsk !== undefined ? await input.resolveContentAsk(req) : { kind: "unavailable", parkDeclined: false };
871
+ const outcome = input.resolveContentAsk !== undefined ? await (callSignal !== undefined ? input.resolveContentAsk(req, callSignal) : input.resolveContentAsk(req)) : { kind: "unavailable", parkDeclined: false };
871
872
  if (outcome.kind === "answered") {
872
873
  decision = { action: "allow", updatedInput: outcome.presentedInput };
873
874
  }
@@ -902,7 +903,7 @@ export async function runToolGate(input) {
902
903
  }
903
904
  if (decision.action === "ask") {
904
905
  const askBeforeResolve = decision;
905
- const resolved = await resolveAsk(decision, req);
906
+ const resolved = await (callSignal !== undefined ? resolveAsk(decision, req, callSignal) : resolveAsk(decision, req));
906
907
  if (resolved.action !== "ask" && resolved.approver !== undefined)
907
908
  resolvedApprover = resolved.approver;
908
909
  if (resolved.action === "deny" && isAskDenyResolution(resolved.resolution))
@@ -978,7 +979,7 @@ export async function runToolGate(input) {
978
979
  let recheck = { action: "allow" };
979
980
  if (adjudicate) {
980
981
  try {
981
- recheck = await adjudicate({ toolName, args: editArgs, toolCallId });
982
+ recheck = await (callSignal !== undefined ? adjudicate({ toolName, args: editArgs, toolCallId }, callSignal) : adjudicate({ toolName, args: editArgs, toolCallId }));
982
983
  }
983
984
  catch (err) {
984
985
  recheck = {
@@ -1014,7 +1015,9 @@ export async function runToolGate(input) {
1014
1015
  editArgs = recheck.updatedInput;
1015
1016
  editRewrittenSinceHuman = true;
1016
1017
  }
1017
- const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
1018
+ const editAskDecision = { ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
1019
+ const editAskReq = { toolName, args: editArgs, toolCallId };
1020
+ const rr = await (callSignal !== undefined ? resolveAsk(editAskDecision, editAskReq, callSignal) : resolveAsk(editAskDecision, editAskReq));
1018
1021
  resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
1019
1022
  if (rr.action !== "allow") {
1020
1023
  if (rr.action === "deny" && isAskDenyResolution(rr.resolution))
@@ -15,9 +15,10 @@
15
15
  export declare const MCP_IMAGE_MAX_BASE64: number;
16
16
  /**
17
17
  * 批③ image-pipeline — CC 2.1.x `constants/apiLimits.ts` values, shared by the MCP inline-image bound
18
- * AND the Read tool's image branch (tools/fs — internal consistency: ONE yardstick per limit). See also
19
- * `tools/fs/index.ts:40-41` ("one limit, two consumers, no drift") — the other consumer of this same
20
- * yardstick, which is where this invariant was originally called out.
18
+ * AND the Read tool's image branch (tools/fs — internal consistency: ONE yardstick per limit). That other
19
+ * consumers are the file tools (`tools/fs/fs-read.ts`'s image branch, plus `fs-pdf`/`fs-bash`), which IMPORT
20
+ * {@link MCP_IMAGE_MAX_BASE64} / {@link IMAGE_TARGET_RAW_SIZE} from here (through core/mcp.js's re-export)
21
+ * rather than re-deriving them — one limit, every consumer reading it, no drift.
21
22
  * - `IMAGE_TARGET_RAW_SIZE` (apiLimits.ts:29): raw-byte target that guarantees the base64 encoding stays
22
23
  * under {@link MCP_IMAGE_MAX_BASE64} (raw × 4/3 = base64 → 3.75MB raw = 5MB base64).
23
24
  * - `IMAGE_MAX_WIDTH/HEIGHT` (apiLimits.ts:42-43): client-side resize box. The API internally resizes
@@ -9,6 +9,12 @@ export declare function resolveModel(ref: ModelRef, models?: Record<string, Mode
9
9
  export declare const DEFAULT_TIER_ORDER: readonly string[];
10
10
  /** CC 档位词 → sema 档位(内置固定,兼容层):fable/mythos→max · opus→pro · sonnet→flash · haiku→lite。 */
11
11
  export declare const CC_MODEL_TIER_ALIASES: Readonly<Record<string, string>>;
12
+ /** the DISPLAY face of a string model ref: a CC tier ALIAS (haiku/sonnet/…) resolves to its
13
+ * sema tier name (lite/flash/…) for labels — the alias verbatim reads as a strong claim about a
14
+ * specific vendor's model while the actual routing may land anywhere ("haiku" showing over a
15
+ * DeepSeek run, an operator field report). A non-alias string is the deployment's own model key — its
16
+ * honest name — and passes through unchanged. Display only; routing (`expandTiers`) is untouched. */
17
+ export declare function resolveModelDisplayLabel(ref: string): string;
12
18
  /**
13
19
  * Expand a deployment's tier bindings into the model catalog: every tier name (and every CC alias whose
14
20
  * tier resolves) becomes a catalog key pointing at the bound Model, so the ENTIRE resolve surface
@@ -29,12 +35,6 @@ export declare const CC_MODEL_TIER_ALIASES: Readonly<Record<string, string>>;
29
35
  * `best` wins); with no bound known-order tier (or no tiers at all) the key stays absent and
30
36
  * `resolveModel("best")` refuses like any unknown ref.
31
37
  */
32
- /** the DISPLAY face of a string model ref: a CC tier ALIAS (haiku/sonnet/…) resolves to its
33
- * sema tier name (lite/flash/…) for labels — the alias verbatim reads as a strong claim about a
34
- * specific vendor's model while the actual routing may land anywhere ("haiku" showing over a
35
- * DeepSeek run, an operator field report). A non-alias string is the deployment's own model key — its
36
- * honest name — and passes through unchanged. Display only; routing (`expandTiers`) is untouched. */
37
- export declare function resolveModelDisplayLabel(ref: string): string;
38
38
  export declare function expandTiers(models: Record<string, Model> | undefined, tiers: Record<string, ModelRef> | undefined): Record<string, Model> | undefined;
39
39
  export interface ModelMention {
40
40
  /** The matched model name — always one of `allowedNames` — or undefined if none was found. */
@@ -49,8 +49,27 @@ export interface ModelMention {
49
49
  * pick *which configured model* to use, but can never inject an arbitrary `baseUrl`/`apiKey`/Model
50
50
  * (see design/11). Matches the first allow-listed mention; the rest of the text is returned cleaned.
51
51
  *
52
- * Typical use: `const { model, cleanedText } = parseModelMention(userMsg, Object.keys(deps.models));`
52
+ * **Case-INSENSITIVE, deliberately** and deliberately UNLIKE the catalog, whose lookups
53
+ * ({@link resolveModel}) are exact. The two answer different questions and the asymmetry is the
54
+ * design, not an oversight: a chat mention is a HUMAN-typed UX surface where `@Opus` and `@opus` are
55
+ * the same intent, so intake is wide; a catalog key is a machine identifier a deployment declared, so
56
+ * lookup is exact. The join is safe because the wide half never widens the narrow one: this returns
57
+ * the allowlist's OWN canonical spelling (never the user's casing), so the value handed to
58
+ * `resolveModel` is always a key the deployment wrote. The only visible consequence is that a catalog
59
+ * carrying two keys differing ONLY in case is addressed by whichever the allowlist iterates last —
60
+ * `@x` cannot name both, and there is no spelling that disambiguates them.
61
+ *
62
+ * Typical use: `const { model, cleanedText } = parseModelMention(userMsg, Object.keys(runner.agentCatalog.models ?? {}));`
53
63
  * then `runTask({ objective: cleanedText, model })` (omit `model` to fall back to the default role).
64
+ *
65
+ * **Feed it the EXPANDED table.** The allowlist this takes is the host's own, and on a deployment that
66
+ * configured `RunnerDeps.tiers` the host's `deps.models` is NOT what the Runner resolves against: the
67
+ * constructor runs {@link expandTiers} once and keeps the expansion PRIVATE, so tier words and CC
68
+ * aliases (`pro`/`flash`/`opus`/`sonnet`/`best`/…) are catalog keys inside the Runner and absent from
69
+ * the host's table. Passing `Object.keys(deps.models)` therefore makes a user's `@opus` silently not
70
+ * match — the mention falls through as ordinary text and the task quietly runs on the default role.
71
+ * `runner.agentCatalog.models` is the expanded table (a per-read copy); a caller that has no Runner in
72
+ * hand can pre-expand its own with the exported {@link expandTiers}.
54
73
  */
55
74
  export declare function parseModelMention(text: string, allowedNames: Iterable<string>): ModelMention;
56
75
  /**
@@ -60,7 +79,10 @@ export declare function parseModelMention(text: string, allowedNames: Iterable<s
60
79
  * - `summarize` → `flash`(压缩本该便宜;flash 未绑沿降档链到 lite);
61
80
  * - `advisor` → 第一档(`ultra` 起,未绑降档到 max/pro——teacher 用最强的);
62
81
  * - `verifier` → `pro`;
63
- * - `subagent`/`team`/`synthesize` 无档位默认 = FALLBACK 继承主模型(LLM 可经 model 参数自选档)
82
+ * - `classifier` `flash`(design/143 批2b:auto-mode 权限分类腿是便宜档;flash 未绑沿降档链到 lite);
83
+ * - `subagent`/`team`/`synthesize` 无档位默认 = 走 FALLBACK 继承主模型(LLM 可经 model 参数自选档);
84
+ * - `consolidate` 同样无档位默认,但它的 FALLBACK 刻意只有 `summarize` 一站(design/376,见
85
+ * {@link FALLBACK} 的注):未配即响亮拒,不静默骑主模型。
64
86
  * 未配 tiers 的部署:catalog 无 TIERS_ACTIVE 标记 → 表整体 INERT,行为与从前逐字节一致
65
87
  * (即使 catalog 恰有裸模型名 `pro`/`flash` 也不入档位路由——INERT 靠配置成立,不靠键名运气)。
66
88
  */
@@ -18,7 +18,7 @@ export const CC_MODEL_TIER_ALIASES = {
18
18
  haiku: "lite",
19
19
  };
20
20
  export function resolveModelDisplayLabel(ref) {
21
- return CC_MODEL_TIER_ALIASES[ref] ?? ref;
21
+ return Object.hasOwn(CC_MODEL_TIER_ALIASES, ref) ? CC_MODEL_TIER_ALIASES[ref] : ref;
22
22
  }
23
23
  const TIERS_ACTIVE = Symbol.for("sema.tiersActive");
24
24
  export function expandTiers(models, tiers) {
@@ -27,7 +27,7 @@ export function expandTiers(models, tiers) {
27
27
  const out = { ...models };
28
28
  out[TIERS_ACTIVE] = true;
29
29
  const bound = (name) => {
30
- const b = tiers[name];
30
+ const b = Object.hasOwn(tiers, name) ? tiers[name] : undefined;
31
31
  return b === undefined ? undefined : resolveModel(b, models);
32
32
  };
33
33
  const resolveTier = (name) => {
@@ -44,26 +44,30 @@ export function expandTiers(models, tiers) {
44
44
  }
45
45
  return undefined;
46
46
  };
47
+ const shadowed = (key) => Object.hasOwn(out, key) && Boolean(out[key]);
48
+ const put = (key, m) => {
49
+ Object.defineProperty(out, key, { value: m, writable: true, enumerable: true, configurable: true });
50
+ };
47
51
  const tierNames = new Set([...DEFAULT_TIER_ORDER, ...Object.keys(tiers)]);
48
52
  for (const name of tierNames) {
49
- if (out[name])
53
+ if (shadowed(name))
50
54
  continue;
51
55
  const m = resolveTier(name);
52
56
  if (m)
53
- out[name] = m;
57
+ put(name, m);
54
58
  }
55
59
  for (const [alias, tier] of Object.entries(CC_MODEL_TIER_ALIASES)) {
56
- if (out[alias])
60
+ if (shadowed(alias))
57
61
  continue;
58
62
  const m = resolveTier(tier);
59
63
  if (m)
60
- out[alias] = m;
64
+ put(alias, m);
61
65
  }
62
- if (!out["best"]) {
66
+ if (!shadowed("best")) {
63
67
  for (const name of DEFAULT_TIER_ORDER) {
64
68
  const m = bound(name);
65
69
  if (m) {
66
- out["best"] = m;
70
+ put("best", m);
67
71
  break;
68
72
  }
69
73
  }
@@ -1121,7 +1121,8 @@ export interface PrepareResume {
1121
1121
  * to restore — prepare rebuilds the per-task env via `deps.executionEnvFactory` then `resumeVM(snapshotId)`
1122
1122
  * + `postResumeInit()` (instead of running on a fresh, empty env). Threaded HERE (not via `ResumeTaskConfig`)
1123
1123
  * so the factory stays a deployment-level `RunnerDeps` capability and never pollutes `TaskSpec` — preserving
1124
- * the "untrusted caller can't inject an env" red line (remote-env.ts:234, code-ready council round-2). */
1124
+ * the "untrusted caller can't inject an env" red line ({@link import("../remote-env.js").ExecutionEnvFactory}'s
1125
+ * own contract states it: "Lives on `RunnerDeps` (deployment-level) — NOT on `TaskSpec`"; code-ready council round-2). */
1125
1126
  workspaceHandle?: import("../remote-env.js").WorkspaceHandle;
1126
1127
  /**
1127
1128
  * design/174 — the call id of the CONTENT ask whose answer an operator's approval was spent on, when