@sema-agent/core 7.28.1 → 7.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,119 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.29.0 — 2026-09-23
4
+
5
+ ### Added — the provider's own model name reaches the user face (#1029)
6
+
7
+ An assistant message has always carried `model`: the **engine-selected** catalogue id the request was
8
+ sent with. What the PROVIDER answered with — an alias resolved gateway-side, a pool member, a
9
+ substitution — was read on one of three wire adapters and reached no output face at all. It now has its
10
+ own seat beside `model`, and `model` is untouched.
11
+
12
+ - **All three adapters fill `AssistantMessage.responseModel`**, through one shared admission rule
13
+ (`noteResponseModel`): openai-completions reads `chunk.model` (the `OAChunk` type gains the field,
14
+ and the read sits ahead of the `choices` guard so a usage-only frame still names the model),
15
+ anthropic reads `message_start.message.model` beside the usage it already took, and Open Responses —
16
+ which already read `response.created` — now reads the `response.completed` / `response.incomplete` /
17
+ `response.failed` envelopes too. The last frame that NAMES a model decides; a frame naming the
18
+ requested id RETRACTS an earlier substitution; a frame naming nothing is not an update. The engine's
19
+ own terminal shell (connect failure, retries exhausted, abort) carries whatever the attempt observed.
20
+ - **Two new wire keys, both optional and both additive**: `turn_end.responseModel` (the turn's last
21
+ assistant message) and `TaskResult.responseModel` (the run's final one). **Present only when the
22
+ provider named a model that DIFFERS from the id the request carried.** Absence is ONE reading — "it
23
+ named the requested id" and "it named nothing" are not distinguished — which is also all the
24
+ reference implementation's own mismatch latch keeps. Never present on the engine's synthetic
25
+ messages. `SideQueryResult.responseModel` now admits on the same terms as those two.
26
+ - **One new operator notice**: `EngineNotice.code === "route.response_model_mismatch"`, audience
27
+ `operator`, `detail: { requested, reported, sessionId, runId }`, fired ONCE PER LEG PER
28
+ (requested, reported) pair. Advisory, never a refusal — an alias or a pool looks exactly like this.
29
+ `ENGINE_NOTICE_CODES` gains that one member.
30
+ - **Consumer flips.** No existing key changes meaning and nothing is removed (`handoff:diff v7.28.1` =
31
+ +0 / −0 exports). A consumer that renders "served by X" should prefer `responseModel` when present
32
+ and `model` when not; a consumer that keys anything on `model` must NOT switch to the new key — that
33
+ field is what a resume re-seeds from, what session import validates, and where the synthetic-record
34
+ sentinel lives. A consumer with a closed list of notice codes gains one row. **@cli L-507** (the
35
+ "served by" line and the notice row) and **@client-core** (the frame/result projection) are the two
36
+ named consumers.
37
+ - The release live gate's PASSED line now ends in `served=…`, read off `TaskResult.responseModel`
38
+ through a sidecar the live suite writes — the engine's own seat, not a second question to the provider.
39
+
40
+ ### The release post's dist diff is read off the published tarballs (#1031; repository tooling — `scripts/` and `test/` only, nothing in the tarball; +0 exports)
41
+
42
+ `ship-post-lint`'s leg B answered "what changed for consumers?" by rebuilding two commits with
43
+ `tsc -p tsconfig.build.json` and diffing the two `dist` trees. That is a second implementation of the
44
+ package build, and it had drifted: for 7.28.1 it reported **10** changed files where the two tarballs
45
+ consumers installed differ in **28**. Two measured causes, neither patchable in a way that would stop
46
+ the next drift — the rebuild ran ONE tsc pass, and `tsconfig.build.json` sets `removeComments: true`,
47
+ which strips declaration output too, so every published `.d.ts` reached the comparison already
48
+ comment-free and the 19 files that differ in JSDoc alone compared equal; and the packaged-extras fold
49
+ skipped `files` entries that are DIRECTORIES, leaving `wire-changes/` (which changes every release by
50
+ construction) outside the comparison, with `package.json` outside it as well.
51
+
52
+ - **Leg B compares artifacts.** The previous release is fetched from the registry (`npm pack <spec>`)
53
+ and checked against the shasum the registry publishes for it; the release being cut is produced by
54
+ `npm pack` on a scratch checkout of the RC, which runs the package's own `prepare` and therefore the
55
+ package's own build. Measured: packing `v7.28.1` this way reproduces the published tarball's shasum
56
+ `0a04a998…` exactly.
57
+ - **Two readings, and the filter is stated.** RAW (every byte — the publisher's self-check) and
58
+ COMMENTS STRIPPED (the consumer's reading: what can change behaviour or types on a re-pin). Only
59
+ comments are removed, only from the files TypeScript parses, identified by the PARSER — whitespace is
60
+ not normalised, nothing is re-printed, and `package.json`, the markdown and every JSON manifest are
61
+ compared raw in both. The gap between the two readings is reported as its own list.
62
+ - **A missing artifact is a refusal, never a delta.** An unreachable registry, a tarball whose bytes do
63
+ not match its published checksum, or a tree that will not pack ⇒ `[gate-could-not-judge]` and exit 2,
64
+ with no numbers printed. There is deliberately no fallback to a local rebuild: that fallback is the
65
+ defect. `--tree-fingerprint` anchors the tarball for the same reason.
66
+ - **A release that ships no new source must say why it exists.** When the RC publishes the same `src`
67
+ tree as the previous release — 7.28.1's shape — the post must carry a line beginning
68
+ `Renumber reason:` / `补版原因:`. No delta can answer "why is there a second number for the same
69
+ code?", so the post answers it in words.
70
+ - **`consumersHint` no longer counts bookkeeping as consumption.** Files whose SUBJECT is this
71
+ package's surface (`docs/core-consumption/`, `docs/wire-consumption/`, `*LEDGER*.json`) name every
72
+ export by construction; the 7.28.1 manifest's only neighbour hit was one such row that itself said
73
+ `state: "skip"`. Those paths are now scanned SEPARATELY and reported in `consumersHint.note` —
74
+ "N name(s) matched ONLY in bookkeeping files", listed — so excluding is visible, not silent. A
75
+ neighbour's other docs, audits and examples are not excluded: the rule is about the file's subject,
76
+ not about it being documentation.
77
+
78
+ Who is hurt: nobody at runtime — `src/` is untouched and the public surface is unchanged. Release
79
+ engineering pays two prices: leg B now needs the registry (and refuses loudly without it), and the
80
+ numbers it prints for a release will be larger than the old ones, because they are the real ones.
81
+ `docs/KNOWN-LIMITS.md` KL-lint-a is closed by this. No version is minted for this change on its own:
82
+ `scripts/` and `test/` are not in `files`, so it is disclosed here and ships with the next package
83
+ release.
84
+
85
+ ### Gates refuse instead of reporting a count they could not take (#1030; repository tooling — `scripts/` and `test/` only, nothing in the tarball; +0 exports)
86
+
87
+ A non-zero exit from `git` carried three different facts — the binary cannot run, this is not a
88
+ repository, or git answered "no match" — and roughly twenty call sites under `scripts/` and `test/`
89
+ folded them into one `catch`. Measured on this tree with a `git` that exits 1 for everything: 25 of 27
90
+ static gates read PASS, `gate:compensation` printed "0 live compensation(s) — the goal state" with an
91
+ unregistered marker sitting in `src/index.ts`, and the two reds both named the wrong cause (96 present
92
+ commits reported as "does not exist in this repo").
93
+
94
+ - **One seam**, `scripts/git-cli.mjs`: `gitAvailable()` proves the binary by resolving it on PATH and
95
+ running `--version` on that exact file; `gitRun()` then hands back git's own verdict, so a non-zero
96
+ status is data the caller reads. `repoRootOf()` / `NotARepoError` make "not a repository" an answer a
97
+ caller chooses out loud, and `requireGit()` / `requireRepo()` are a gate's first line.
98
+ - **A gate that cannot consult git exits 2 with one sentence and no counts.** `gate:all` prints
99
+ `REFUSED` for a child that says so and ends the run with "this run certifies nothing" — the marker,
100
+ not the bare exit code, because 2 already means "bad invocation" in two scripts.
101
+ - Two readings were being taken through commands that cannot express them, and now are not:
102
+ `gate:ledger` asks `rev-parse --verify --quiet` (real git answers 128 — not 1 — to
103
+ `cat-file -e <missing>^{commit}`, so an absent commit was indistinguishable from an unreadable
104
+ repository), and the blackbox gate's freshness pre-check establishes a missing snapshot with a
105
+ successful `ls-tree` rather than a failed `git show`.
106
+ - Same class, adjacent binaries: `car:gate` no longer prints PASS for a step killed by a signal, and
107
+ the `noUncheckedIndexedAccess` ratchet no longer parses a compiler's partial output as a full scan.
108
+
109
+ Who is hurt: nobody at runtime — `src/` is untouched and the public surface is unchanged. A checkout
110
+ whose git is broken now gets an exit 2 from four scripts where it previously got a number. No version was minted for this change on its own: the tarball's contents
111
+ (`dist/`, the export snapshot, `wire-changes/`) are byte-identical to 7.28.1, so it is disclosed here and ships
112
+ with the next package release.
113
+
3
114
  ## 7.28.1 — 2026-09-22
4
115
 
5
- > **7.28.0 was never published.** Its first `npm publish` was cut off mid-upload by a pipe on the publisher's side; the registry keeps a *staged* half-upload under that number and refuses every later publish of it (`E409 Cannot publish over previously staged version`), and `npm unpublish` does not clear a staged version. This release is the same tree under the next patch number — tag `v7.28.1`; no `v7.28.0` tag or package exists.
116
+ > **7.28.0 and 7.28.1 are the same tree** (tag `v7.28.0` = `e797e47f`, tag `v7.28.1` = `35c47a4d`; the only differences are this note, the version string, the criteria document's file name and the wire manifest's name). What happened: the first `npm publish` of 7.28.0 was cut off on the publisher's side, the registry held the upload as a *staged* version and answered every retry with `E409 Cannot publish over previously staged version`, so the release was renumbered — and then the registry finalized the staged 7.28.0 on its own about five minutes later. An earlier wording of this note ("7.28.0 was never published") was wrong and is corrected here; both versions are valid, `7.28.1` is `latest`.
6
117
 
7
118
  ### One shell lexer mints word FACTS; the thirteen ANSI-C letters fold; an unterminated quote is a parse failure (#994 S0; behaviour WIDENING ×1 and NARROWING ×1; +0 exports; @cli @test)
8
119
 
@@ -2,7 +2,7 @@ import {} from "../internal/llm.js";
2
2
  import { BrainError } from "./errors.js";
3
3
  import { startsWithInputTooLongAnchor } from "./input-too-long.js";
4
4
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
5
- import { STREAM_TORN_WARNING, createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
5
+ import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
6
6
  import { mintFallbackToolCallId } from "./tool-call-id.js";
7
7
  import { emitBrainTelemetry, reportReasoningWireFacts } from "./status-sink.js";
8
8
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
@@ -380,7 +380,9 @@ export function createAnthropicBrain(config = {}) {
380
380
  const handleEvent = (evt) => {
381
381
  switch (evt.type) {
382
382
  case "message_start": {
383
- const u = evt.message?.usage;
383
+ const msg = evt.message;
384
+ noteResponseModel(partial, msg?.model, model.id);
385
+ const u = msg?.usage;
384
386
  if (u) {
385
387
  usageRaw.input_tokens = u.input_tokens;
386
388
  usageRaw.cache_read_input_tokens = u.cache_read_input_tokens;
@@ -1,7 +1,7 @@
1
1
  import {} from "../internal/llm.js";
2
2
  import { BrainError } from "./errors.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
- import { STREAM_TORN_WARNING, createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
4
+ import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
@@ -423,8 +423,7 @@ export function createOpenResponsesBrain(config = {}) {
423
423
  if (env) {
424
424
  if (env.id !== undefined)
425
425
  responseId = env.id;
426
- if (env.model !== undefined && env.model !== model.id)
427
- partial.responseModel = env.model;
426
+ noteResponseModel(partial, env.model, model.id);
428
427
  auditEcho(env);
429
428
  }
430
429
  return;
@@ -552,9 +551,11 @@ export function createOpenResponsesBrain(config = {}) {
552
551
  usageRaw = env.usage;
553
552
  if (env?.id !== undefined)
554
553
  responseId = env.id;
554
+ noteResponseModel(partial, env?.model, model.id);
555
555
  return;
556
556
  }
557
557
  case "response.failed": {
558
+ noteResponseModel(partial, frame.response?.model, model.id);
558
559
  const err = frame.response?.error;
559
560
  throw new BrainError(responsesErrorCode(err?.code), `responses stream failed (${err?.code ?? "unknown"}): ${err?.message ?? ""}`, undefined, { apiError: true });
560
561
  }
@@ -1,7 +1,7 @@
1
1
  import {} from "../internal/llm.js";
2
2
  import { repairTextToolCalls } from "./tool-call-repair.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
- import { STREAM_TORN_WARNING, createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
4
+ import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
@@ -464,6 +464,7 @@ export function createOpenAIBrain(config = {}) {
464
464
  }
465
465
  if (chunk.usage)
466
466
  usageRaw = chunk.usage;
467
+ noteResponseModel(partial, chunk.model, model.id);
467
468
  const choice = chunk.choices?.[0];
468
469
  if (!choice)
469
470
  return;
@@ -265,6 +265,7 @@ export function runStreamingBrain(args) {
265
265
  let terminalRetryPhase = "recovered";
266
266
  let terminalRetryDetail = "recovered after retrying";
267
267
  let requestIdSeen;
268
+ let attemptPartial;
268
269
  void run()
269
270
  .catch((err) => {
270
271
  const aborted = signal?.aborted === true || isAbortError(err);
@@ -290,6 +291,9 @@ export function runStreamingBrain(args) {
290
291
  }
291
292
  }
292
293
  errorMsg.usageMissing = true;
294
+ const seenResponseModel = attemptPartial?.responseModel;
295
+ if (seenResponseModel !== undefined)
296
+ errorMsg.responseModel = seenResponseModel;
293
297
  out.push({ type: "error", reason: aborted ? "aborted" : "error", error: errorMsg });
294
298
  })
295
299
  .finally(() => {
@@ -330,6 +334,7 @@ export function runStreamingBrain(args) {
330
334
  let r;
331
335
  let netErr;
332
336
  requestIdSeen = undefined;
337
+ attemptPartial = undefined;
333
338
  try {
334
339
  r = await doFetch(req.url, { method: "POST", headers: req.headers, body: req.body, signal: cc.signal });
335
340
  requestIdSeen = readProviderRequestId(r);
@@ -478,6 +483,7 @@ export function runStreamingBrain(args) {
478
483
  }
479
484
  }
480
485
  const partial = emptyAssistant(model);
486
+ attemptPartial = partial;
481
487
  if (!startEmitted) {
482
488
  startEmitted = true;
483
489
  out.push({ type: "start", partial: { ...partial } });
@@ -43,3 +43,11 @@ export interface RepetitionPoll {
43
43
  * boot block doesn't log once per poll. Attached to the final message as `repetition`.
44
44
  */
45
45
  export declare function createRepetitionPoll(): RepetitionPoll;
46
+ /** Record the provider's OWN name for the model that answered, on the shared partial (hence on the final
47
+ * message every lane spreads `...partial` into) — ONE rule for the three lanes' spellings (`chunk.model` /
48
+ * `message_start.message.model` / `response.model`), WRITE twin of `providerReportedModel`
49
+ * (`core/message-utils.ts`). The LAST frame that NAMES a model decides and the seat carries it only when it
50
+ * differs; naming nothing is not an update, naming the REQUESTED id RETRACTS an earlier substitution. */
51
+ export declare function noteResponseModel(partial: {
52
+ responseModel?: string;
53
+ }, reported: unknown, requestedId: string): void;
@@ -40,3 +40,11 @@ export function createRepetitionPoll() {
40
40
  },
41
41
  };
42
42
  }
43
+ export function noteResponseModel(partial, reported, requestedId) {
44
+ if (typeof reported !== "string" || reported === "")
45
+ return;
46
+ if (reported === requestedId)
47
+ delete partial.responseModel;
48
+ else
49
+ partial.responseModel = reported;
50
+ }
@@ -222,6 +222,13 @@ export interface EngineNotice {
222
222
  * occurrenceId }` (rendered list bounded, total always exact); the `occurrenceId` is minted at this
223
223
  * announcement, independently of the `config.models_swapped` line's own, because the bounded render
224
224
  * makes two swaps moving the same first entries identical. Dedup key: (code, occurrenceId).
225
+ * - `"route.response_model_mismatch"` — the provider answered a turn naming a model that is NOT the id
226
+ * the request was sent with (an alias, a pool or a gateway-side substitution looks exactly like this),
227
+ * so it is advisory, never a refusal: the engine keeps asking for the catalog's id and reports the
228
+ * served name beside it (`turn_end.responseModel` / `TaskResult.responseModel`). ONCE PER LEG PER
229
+ * (requested, reported) PAIR — a catalog fact, not a turn fact. Audience `operator`;
230
+ * `detail: { requested, reported, sessionId, runId }`. Absent ⇒ the provider echoed the requested id
231
+ * or named nothing — one reading, not two.
225
232
  * - `"config.read_face_deployment_clamped"` (#237) — a deployment-wide `readFace: "open"` is not
226
233
  * in force beside a read-only (verifier) mount: it clamps to "roots" without throwing
227
234
  * (stricter-wins; the clamp verdict stands, only its occurrence was undisclosed). Announced
@@ -119,7 +119,7 @@ export type NoticeAudience = "user" | "operator";
119
119
  * the union's catalog ({@link EngineNotice.code}). The groups below say only which family a row joins,
120
120
  * so the catalog is not written a second time here and the two cannot drift.
121
121
  */
122
- 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_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "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", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "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", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
122
+ 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_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "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", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
123
123
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
124
124
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
125
125
  * sink is a supported shape, and narrowing that field would break it. */
@@ -157,6 +157,7 @@ export const ENGINE_NOTICE_CODES = [
157
157
  "route.fallback_to_primary",
158
158
  "route.classifier_unroutable",
159
159
  "route.base_url_changed_key_unchanged",
160
+ "route.response_model_mismatch",
160
161
  "task.user_steer_undrained",
161
162
  "task.user_followup_undrained",
162
163
  "steering.parked_input_blocked",
@@ -235,6 +236,7 @@ const NOTICE_AUDIENCE_TABLE = {
235
236
  "route.fallback_to_primary": "operator",
236
237
  "route.classifier_unroutable": "operator",
237
238
  "route.base_url_changed_key_unchanged": "operator",
239
+ "route.response_model_mismatch": "operator",
238
240
  "skills.listing_truncated": "operator",
239
241
  "agents.listing_truncated": "operator",
240
242
  "tool_result.offload_put_failed": "operator",
@@ -3,6 +3,19 @@ import type { ToolResultMessage } from "../internal/llm.js";
3
3
  /** Read the (possibly absent) `role` off an AgentMessage defensively — the union doesn't expose it
4
4
  * uniformly, so context passes narrow by role with a single cast in one place. */
5
5
  export declare function messageRole(m: AgentMessage): string | undefined;
6
+ /**
7
+ * The PROVIDER's own name for the model that answered this assistant message, when it differs from the
8
+ * ENGINE-SELECTED id the message is stamped with — the ONE read of `AssistantMessage.responseModel`, so
9
+ * the per-turn frame, the result face, the side-query projection and the mismatch notice admit it on the
10
+ * same terms rather than on four. The READ twin of `noteResponseModel` (`brain/stream-shared.ts`), which
11
+ * states the same law on the write side; this side re-tests rather than trusting, because a HOST's own
12
+ * Brain mints these messages too. `undefined` ⇒ the provider named the stamped id, named nothing, or
13
+ * named something unreadable — ONE reading, never three.
14
+ */
15
+ export declare function providerReportedModel(m: {
16
+ readonly model?: unknown;
17
+ readonly responseModel?: unknown;
18
+ }): string | undefined;
6
19
  /** True if the message is a tool-result message (shared by the context-edit and context-guard passes).
7
20
  * A TYPE PREDICATE, not a bare boolean: `role: "toolResult"` belongs to exactly one member of the
8
21
  * union, so callers that check it can read `.content`/`.toolName` and rebuild the message directly
@@ -1,6 +1,12 @@
1
1
  export function messageRole(m) {
2
2
  return m.role;
3
3
  }
4
+ export function providerReportedModel(m) {
5
+ const reported = m.responseModel;
6
+ if (typeof reported !== "string" || reported === "")
7
+ return undefined;
8
+ return reported === m.model ? undefined : reported;
9
+ }
4
10
  export function isToolResult(m) {
5
11
  return messageRole(m) === "toolResult";
6
12
  }
@@ -2,6 +2,7 @@ import { isDegenerateCutMessage } from "../../brain/terminal-cause.js";
2
2
  import { extractErrorCode, stripErrorCodePrefix } from "../../brain/errors.js";
3
3
  import { terminalProjection } from "./terminal-projection.js";
4
4
  import { haltConsumed } from "./halt-attribution.js";
5
+ import { providerReportedModel } from "../message-utils.js";
5
6
  import { isErrorLike, readMember } from "../thrown-value.js";
6
7
  import { describeRejectedValue } from "../tool-search-setting.js";
7
8
  const SALVAGE_ELIGIBLE_TERMINALS = new Set([
@@ -153,6 +154,7 @@ export function terminalCauseOf(final, stats, flags) {
153
154
  export function assembleResult(spec, sessionId, final, stats, flags) {
154
155
  const taskId = spec.taskId ?? sessionId;
155
156
  const text = final ? assistantText(final) : "";
157
+ const responseModel = final === undefined ? undefined : providerReportedModel(final);
156
158
  if (!flags.unpricedSpend) {
157
159
  const compactionMicroUsd = stats.compactionMicroUsd ?? 0;
158
160
  const nestedSubagentMicroUsd = stats.nested?.costMicroUsd ?? 0;
@@ -176,5 +178,5 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
176
178
  if (flags.unpricedSpend)
177
179
  delete publicStats.costMicroUsd;
178
180
  const stampHaltedByUser = haltConsumed(flags.haltConsumption);
179
- return { taskId, ...(flags.runId !== undefined ? { runId: flags.runId } : {}), sessionId, terminal, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, ...(apiFailure !== undefined ? { apiFailure } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.editedFiles !== undefined && flags.editedFiles.length > 0 ? { editedFiles: flags.editedFiles } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(stampHaltedByUser ? { haltedByUser: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: [...flags.remoteEnvFailures] } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveWriteFace !== undefined ? { effectiveWriteFace: flags.effectiveWriteFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
181
+ return { taskId, ...(flags.runId !== undefined ? { runId: flags.runId } : {}), sessionId, terminal, ...(flags.model !== undefined ? { model: flags.model } : {}), ...(responseModel !== undefined ? { responseModel } : {}), result: result.trim(), salvagedOutput, ...(apiFailure !== undefined ? { apiFailure } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.editedFiles !== undefined && flags.editedFiles.length > 0 ? { editedFiles: flags.editedFiles } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(stampHaltedByUser ? { haltedByUser: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: [...flags.remoteEnvFailures] } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveWriteFace !== undefined ? { effectiveWriteFace: flags.effectiveWriteFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
180
182
  }
@@ -2340,6 +2340,10 @@ export interface RunState {
2340
2340
  } | undefined;
2341
2341
  turnUsageMissing: boolean;
2342
2342
  turnStopReason: string | undefined;
2343
+ /** The PROVIDER-REPORTED model name of this turn's LAST assistant message, when it differed from the
2344
+ * requested id — surfaced on `turn_end.responseModel`, reset with the rest of the per-turn
2345
+ * bookkeeping. Same shape as `turnStopReason`: last-message-wins, absent by default. */
2346
+ turnResponseModel: string | undefined;
2343
2347
  lastTurnHadToolCalls: boolean;
2344
2348
  /** B15 三期使能:postToolBatch 的本回合聚集批(每回合边界消费并重置 — 回合语义,故归 turn)。 */
2345
2349
  toolBatch: PostToolBatchCall[];
@@ -2608,6 +2612,7 @@ export interface HarnessHandlersDeps {
2608
2612
  batchArgs: Map<string, unknown> | undefined;
2609
2613
  runnerHooks: {
2610
2614
  onError: RunnerDeps["onError"];
2615
+ onNotice: RunnerDeps["onNotice"];
2611
2616
  };
2612
2617
  /** The run's ONE isolation scope for the {@link RunInternals} observation sinks, minted by the
2613
2618
  * identity-wiring lane (which calls the first of them) and shared with these handlers, so the
@@ -4,7 +4,7 @@ export function createRunState() {
4
4
  degrade: { degradeToModel: undefined, degraded: undefined, outputErrorStreak: 0, outputInvalid: false, recordDegraded: () => { } },
5
5
  limits: { turnsExceeded: false, budgetHit: undefined, budgetAxis: undefined, platformTerminal: undefined, outputRetryCap: 0, effectiveMaxTurns: undefined },
6
6
  budget: { remainingMicroUsd: undefined, maxCostMicroUsd: undefined, remainingTokens: undefined, maxTokensWindow: undefined, overBudget: () => undefined, streamCancel: false, callOutputChars: 0, lastStreamBudgetCheck: 0, projectedOverBudget: () => undefined },
7
- turn: { callStartAt: undefined, firstTokenAt: undefined, turnUsage: undefined, turnUsageMissing: false, turnStopReason: undefined, lastTurnHadToolCalls: false, toolBatch: [] },
7
+ turn: { callStartAt: undefined, firstTokenAt: undefined, turnUsage: undefined, turnUsageMissing: false, turnStopReason: undefined, turnResponseModel: undefined, lastTurnHadToolCalls: false, toolBatch: [] },
8
8
  counters: { approachNoticesSent: 0, walltimeSyncBackstopFired: false, compactionFloor: 0, trimForceBackoff: false, repetitionCuts: 0, repetitionSpared: 0, repetitionEvents: [], REPETITION_EVENTS_CAP: 0, preemptIgnoredReported: false, wroteThisRun: false, finalVerifyInjections: 0, groundingSignalPreR9: false, groundingSignalPostR9: false, cadenceTurns: 0 },
9
9
  attach: { attachmentsCfg: undefined, agentListingOn: false, skillsListingOn: false, attachState: undefined, dateState: undefined, instrProbe: undefined, instrState: undefined, sizeGuidelineState: undefined, attachmentsInjected: 0 },
10
10
  };
@@ -0,0 +1,16 @@
1
+ import type { EngineNotice } from "../engine-notice.js";
2
+ /** The seat the harness handlers hold for the run. */
3
+ export interface ResponseModelSeat {
4
+ /** Observe one assistant message: returns the provider-reported name when it carries one (the value the
5
+ * turn's frame reports), `undefined` otherwise, announcing a pair the leg has not announced yet. */
6
+ observe(message: {
7
+ readonly model?: unknown;
8
+ readonly responseModel?: unknown;
9
+ }): string | undefined;
10
+ }
11
+ /** Ids on the notice, so an operator can attribute it without joining on stream order. */
12
+ export interface ResponseModelSeatIds {
13
+ sessionId: string;
14
+ runId: string;
15
+ }
16
+ export declare function createResponseModelSeat(onNotice: ((notice: EngineNotice) => void) | undefined, ids: ResponseModelSeatIds): ResponseModelSeat;
@@ -0,0 +1,27 @@
1
+ import { deliverEngineNotice } from "../engine-notice.js";
2
+ import { providerReportedModel } from "../message-utils.js";
3
+ export function createResponseModelSeat(onNotice, ids) {
4
+ const announced = new Set();
5
+ return {
6
+ observe(message) {
7
+ const reported = providerReportedModel(message);
8
+ if (reported === undefined)
9
+ return undefined;
10
+ const requested = typeof message.model === "string" ? message.model : "";
11
+ const pair = `${requested}\u0000${reported}`;
12
+ if (!announced.has(pair)) {
13
+ announced.add(pair);
14
+ deliverEngineNotice(onNotice, {
15
+ code: "route.response_model_mismatch",
16
+ message: `The provider answered with model "${reported}" for a request sent as "${requested || "(unnamed)"}". ` +
17
+ `The run is unchanged — the engine keeps asking for the id the catalog names, and the served name is ` +
18
+ `reported beside it on every turn (turn_end.responseModel) and on the result (TaskResult.responseModel). ` +
19
+ `This is what an alias, a pool or a gateway-side substitution looks like from here; if the two were ` +
20
+ `meant to be the same model, the catalog id and the gateway disagree about which one that is.`,
21
+ detail: { requested, reported, sessionId: ids.sessionId, runId: ids.runId },
22
+ });
23
+ }
24
+ return reported;
25
+ },
26
+ };
27
+ }
@@ -10,8 +10,10 @@ import { advanceCadenceClock, reduceToolEnd } from "./turn-attachments.js";
10
10
  import { gatedCallIdOf } from "./park-commit.js";
11
11
  import { hasVerifiableStructureSignal } from "./grounding-signal.js";
12
12
  import { batchResponseDigest, toolEndBodyFrom } from "./tool-end-body.js";
13
+ import { createResponseModelSeat } from "./response-model-seat.js";
13
14
  export function createHarnessHandlers(input) {
14
15
  const { prepared, stats, rs, deps } = input;
16
+ const responseModelSeat = createResponseModelSeat(deps.runnerHooks.onNotice, { sessionId: prepared.sessionId, runId: prepared.runId });
15
17
  const { spec, queue, internals, ident, parentToolCallId, subagentName, pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf, toolLabels, postToolBatchHook, batchArgs, internalsNotifier } = deps;
16
18
  let lastWorkspaceCwd = prepared.cwdRef?.current;
17
19
  const announceWorkspaceMove = () => {
@@ -143,6 +145,9 @@ export function createHarnessHandlers(input) {
143
145
  if (m.role === "assistant" && typeof m.stopReason === "string") {
144
146
  rs.turn.turnStopReason = m.stopReason;
145
147
  }
148
+ if (m.role === "assistant") {
149
+ rs.turn.turnResponseModel = responseModelSeat.observe(m);
150
+ }
146
151
  if (m.role === "assistant") {
147
152
  const rep = m.repetition;
148
153
  if (rep !== undefined) {
@@ -291,13 +296,15 @@ export function createHarnessHandlers(input) {
291
296
  if (prepared.abortController.signal.aborted)
292
297
  prepared.workCutRef.abortedBeforeTurnEnd = true;
293
298
  const stopExtra = rs.turn.turnStopReason !== undefined ? { stopReason: rs.turn.turnStopReason } : {};
299
+ const servedExtra = rs.turn.turnResponseModel !== undefined ? { responseModel: rs.turn.turnResponseModel } : {};
294
300
  const usage = rs.turn.turnUsage ?? { inputTokens: 0, totalInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costMicroUsd: 0 };
295
301
  const turnUsageUnknown = rs.turn.turnUsage === undefined || rs.turn.turnUsageMissing;
296
302
  if (turnUsageUnknown)
297
303
  stats.usageMissing = true;
298
- queue.push({ type: "turn_end", usage, ...(turnUsageUnknown ? { usageMissing: true } : {}), ...stopExtra, ...ident() });
304
+ queue.push({ type: "turn_end", usage, ...(turnUsageUnknown ? { usageMissing: true } : {}), ...stopExtra, ...servedExtra, ...ident() });
299
305
  rs.turn.turnUsageMissing = false;
300
306
  rs.turn.turnStopReason = undefined;
307
+ rs.turn.turnResponseModel = undefined;
301
308
  if (parentToolCallId !== undefined) {
302
309
  const progress = {
303
310
  type: "task_progress",
@@ -443,7 +443,7 @@ export class Runner {
443
443
  spec, queue, internals, ident, parentToolCallId, subagentName,
444
444
  pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf,
445
445
  toolLabels, postToolBatchHook, batchArgs, internalsNotifier,
446
- runnerHooks: { onError: (e, info) => this.deps.onError?.(e, info) },
446
+ runnerHooks: { onError: (e, info) => this.deps.onError?.(e, info), onNotice: this.deps.onNotice },
447
447
  },
448
448
  });
449
449
  const { compactionBrain, withinTaskCompaction, compactionBreaker, windowSafetyOptions } = runCompactionMachinery({ spec, prepared, rs, stats, noteUnevaluablePriceTable, runner: this.depsSeat });
@@ -83,15 +83,16 @@ export interface SideQueryResult {
83
83
  args: unknown;
84
84
  }>;
85
85
  /** The RESOLVED pool model id the query was ROUTED to (the caller's ref resolved). See
86
- * `servedModel` for the model that actually answered. */
86
+ * `servedModel` for the engine-selected id the reply came back stamped with. */
87
87
  model: string;
88
- /** The model that ACTUALLY served the reply (a degrading-brain deployment may fall
89
- * back mid-query — billing/quality attribution must follow the real server, not the routing
90
- * intent). Equals `model` when no degradation occurred. */
88
+ /** The ENGINE-SELECTED id the reply came back stamped with (a degrading deployment may fall back
89
+ * mid-query — attribution follows the entry that ran, not the routing intent); equals `model` when
90
+ * nothing degraded. Still THIS catalog's id: what the PROVIDER called it is `responseModel` below. */
91
91
  servedModel: string;
92
92
  /** Structured degradation marker when a fallback model served (from/to/reason/chain). */
93
93
  degraded?: DegradationInfo;
94
- /** Concrete provider-reported model when it differs (e.g. an `auto` route). */
94
+ /** The PROVIDER's own name for the model that answered, when it differs from `servedModel` above.
95
+ * Absent ⇒ it named the same id or named none — one reading, as on `AssistantMessage.responseModel`. */
95
96
  responseModel?: string;
96
97
  /** Usage with `input` NORMALIZED to the uncached prompt quantity (the Anthropic-protocol
97
98
  * `input_tokens` meaning) for EVERY provider family — the same gate the run loop's accounting
@@ -1,6 +1,7 @@
1
1
  import { resolveTaskModel } from "./roles.js";
2
2
  import { readDegradation } from "../brain/degrading.js";
3
3
  import { cacheFamilyOf, uncachedInputTokensOf } from "./runner/usage-accounting.js";
4
+ import { providerReportedModel } from "./message-utils.js";
4
5
  function normalizeSideQueryUsage(model, u) {
5
6
  const input = uncachedInputTokensOf(cacheFamilyOf(model), u);
6
7
  return input === u.input ? u : { ...u, input };
@@ -63,13 +64,14 @@ export async function runSideQuery(spec, deps) {
63
64
  .map((c) => ({ id: c.id, name: c.name, args: c.arguments }));
64
65
  const degraded = readDegradation(msg);
65
66
  const servedModel = typeof msg.model === "string" && msg.model.length > 0 ? msg.model : resolved.model.id;
67
+ const reportedModel = providerReportedModel(msg);
66
68
  return {
67
69
  text,
68
70
  toolCalls,
69
71
  model: resolved.model.id,
70
72
  servedModel,
71
73
  ...(degraded !== undefined ? { degraded } : {}),
72
- ...(msg.responseModel !== undefined && msg.responseModel !== servedModel ? { responseModel: msg.responseModel } : {}),
74
+ ...(reportedModel !== undefined ? { responseModel: reportedModel } : {}),
73
75
  usage: normalizeSideQueryUsage(resolved.model, msg.usage),
74
76
  ...(msg.usageMissing === true ? { usageMissing: true } : {}),
75
77
  stopReason: msg.stopReason,
@@ -581,6 +581,14 @@ export type TaskEvent = ({
581
581
  * no assistant message. Typed `string` (not the internal `StopReason`) to keep the engine's union
582
582
  * private, but the value set is exactly those five tokens. */
583
583
  stopReason?: string;
584
+ /** The PROVIDER'S OWN name for the model that answered this turn, when it DIFFERS from the id the
585
+ * request was sent with — a SECOND fact beside `TaskResult.model` / the trace's `brain.call.model`,
586
+ * which stay the ENGINE-SELECTED pool id a resume re-seeds and a catalog resolves, never an
587
+ * overwrite of either. PRESENCE: the turn's LAST assistant message carried one
588
+ * (`AssistantMessage.responseModel`). ABSENT ⇒ the provider named the requested id or named nothing —
589
+ * ONE reading, not two, as on the reference mismatch latch, also written only for a disagreeing pair.
590
+ * A mismatch is additionally announced once per leg per pair as `route.response_model_mismatch`. */
591
+ responseModel?: string;
584
592
  } & TaskEventIdentity) | ({
585
593
  /** design/99 MF-18: a compaction boundary (or, since design/119 #1, mid-turn when the prompt-too-long recovery force-compacts between a failed request and its retry). `trigger` = what caused it: `"auto"` (the context-threshold
586
594
  * auto-compaction), `"manual"` (the `TaskStream.compact` / `/compact` path), or `"forced"` (RB-398-b
@@ -198,10 +198,20 @@ export interface TaskResult {
198
198
  * {@link import("./runner/terminal-projection.js").terminalProjection} — the one cause→word derivation.
199
199
  */
200
200
  terminal: TerminalCause;
201
- /** design/99 MF-25: the EFFECTIVE model id that served this task — the RESOLVED `Model.id`, not the requested
202
- * `TaskSpec.model` ref (which may be a role / name / `@mention`). Lets a UI echo "served by X" instead of the
203
- * requested ref. A mid-run degradation is observed separately (see the degraded-model fields). */
201
+ /** design/99 MF-25: the EFFECTIVE model id this task ran on — the ENGINE-SELECTED, RESOLVED `Model.id`, not
202
+ * the requested `TaskSpec.model` ref (a role / name / `@mention`). A mid-run degradation is observed
203
+ * separately (the degraded-model fields); what the PROVIDER called it rides {@link responseModel}. */
204
204
  model?: string;
205
+ /** The PROVIDER'S OWN name for the model that answered this run's FINAL turn, when it DIFFERS from the
206
+ * id the request was sent with — the result-face twin of `turn_end.responseModel`. {@link model} above
207
+ * stays the ENGINE-SELECTED id a resume re-seeds and a catalog resolves; a UI echoing "served by X"
208
+ * prints this one when it is here and {@link model} when it is not. **In-presence condition**: the final
209
+ * assistant message carried one. ABSENT ⇒ the provider named the requested id or named nothing (one
210
+ * reading, not two), there was no final assistant message, or the final turn AGREED while an earlier one
211
+ * did not — the per-turn frames are the complete record, this is the terminal one. Never present on the
212
+ * engine's synthetic messages, which carry no provider report; a mismatch is announced either way as
213
+ * `route.response_model_mismatch`. */
214
+ responseModel?: string;
205
215
  /**
206
216
  * The leg's effective REASONING resolution: the result-face twin of {@link model} for the thinking
207
217
  * knob. How the requested tier resolved against the serving model's real capability
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.28.1",
3
+ "version": "7.29.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,185 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.29.0",
4
+ "from": "35c47a4d8e574c93501b5bfe7c7d3c41a707d53a",
5
+ "to": "v7.29.0",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [],
10
+ "closedSetMembers": [
11
+ {
12
+ "set": "ENGINE_NOTICE_CODES",
13
+ "at": [
14
+ "src/core/governance-codes.ts:315"
15
+ ],
16
+ "newSet": false,
17
+ "added": [
18
+ "route.response_model_mismatch"
19
+ ],
20
+ "removed": [],
21
+ "breaking": false,
22
+ "criteriaCovered": true
23
+ }
24
+ ],
25
+ "closedSetsUnreadable": [],
26
+ "typeKeys": [
27
+ {
28
+ "type": "CascadeRunResult",
29
+ "at": [
30
+ "src/agents/cascade.ts:130"
31
+ ],
32
+ "added": [
33
+ "responseModel"
34
+ ],
35
+ "removed": [],
36
+ "breaking": false,
37
+ "criteriaCovered": false
38
+ },
39
+ {
40
+ "type": "RepairResult",
41
+ "at": [
42
+ "src/agents/repair-loop.ts:214"
43
+ ],
44
+ "added": [
45
+ "responseModel"
46
+ ],
47
+ "removed": [],
48
+ "breaking": false,
49
+ "criteriaCovered": false
50
+ },
51
+ {
52
+ "type": "TaskEvent",
53
+ "at": [
54
+ "src/core/task-event.ts:265"
55
+ ],
56
+ "added": [
57
+ "turn_end.responseModel"
58
+ ],
59
+ "removed": [],
60
+ "breaking": false,
61
+ "criteriaCovered": true
62
+ },
63
+ {
64
+ "type": "TaskResult",
65
+ "at": [
66
+ "src/core/task-result.ts:169"
67
+ ],
68
+ "added": [
69
+ "responseModel"
70
+ ],
71
+ "removed": [],
72
+ "breaking": false,
73
+ "criteriaCovered": true
74
+ },
75
+ {
76
+ "type": "TeacherRunResult",
77
+ "at": [
78
+ "src/agents/teacher.ts:156"
79
+ ],
80
+ "added": [
81
+ "responseModel"
82
+ ],
83
+ "removed": [],
84
+ "breaking": false,
85
+ "criteriaCovered": false
86
+ },
87
+ {
88
+ "type": "VerificationResult",
89
+ "at": [
90
+ "src/agents/verify.ts:262"
91
+ ],
92
+ "added": [
93
+ "responseModel"
94
+ ],
95
+ "removed": [],
96
+ "breaking": false,
97
+ "criteriaCovered": false
98
+ }
99
+ ],
100
+ "typeKeysUnreadableArms": [
101
+ {
102
+ "type": "TaskEvent",
103
+ "at": "src/core/task-event.ts:265",
104
+ "reasons": [
105
+ "a union carrying 2 arm(s) whose keys this reader does not follow (a qualified reference (`TaskEventIdentity`); a qualified reference (`BrainStatus`)) — a key missing from this type at `to` is still reported REMOVED, carrying `uncertain` so the writer checks whether it moved there"
106
+ ]
107
+ }
108
+ ],
109
+ "typeKeysFlattenedCount": 213,
110
+ "declarationMoves": [],
111
+ "mintMoves": [],
112
+ "criteriaObserves": [
113
+ {
114
+ "kind": "engineNoticeCode",
115
+ "name": "route.response_model_mismatch",
116
+ "assertions": [
117
+ "G3.a"
118
+ ]
119
+ },
120
+ {
121
+ "kind": "taskEventField",
122
+ "name": "turn_end.responseModel",
123
+ "assertions": [
124
+ "G2.a"
125
+ ]
126
+ },
127
+ {
128
+ "kind": "typeMember",
129
+ "name": "AssistantMessage.model",
130
+ "assertions": [
131
+ "G1.a",
132
+ "G4.a"
133
+ ]
134
+ },
135
+ {
136
+ "kind": "typeMember",
137
+ "name": "AssistantMessage.responseModel",
138
+ "assertions": [
139
+ "G1.a"
140
+ ]
141
+ },
142
+ {
143
+ "kind": "typeMember",
144
+ "name": "TaskResult.model",
145
+ "assertions": [
146
+ "G2.a",
147
+ "G4.a"
148
+ ]
149
+ },
150
+ {
151
+ "kind": "typeMember",
152
+ "name": "TaskResult.responseModel",
153
+ "assertions": [
154
+ "G2.a"
155
+ ]
156
+ }
157
+ ],
158
+ "consumersHint": {
159
+ "cli": [
160
+ "CascadeRunResult",
161
+ "ENGINE_NOTICE_CODES",
162
+ "RepairResult",
163
+ "TaskEvent",
164
+ "TaskResult",
165
+ "TeacherRunResult",
166
+ "VerificationResult"
167
+ ],
168
+ "server": [
169
+ "ENGINE_NOTICE_CODES",
170
+ "RepairResult",
171
+ "TaskEvent",
172
+ "TaskResult",
173
+ "VerificationResult"
174
+ ],
175
+ "sdk": [
176
+ "ENGINE_NOTICE_CODES",
177
+ "TaskEvent",
178
+ "TaskResult"
179
+ ],
180
+ "note": "scanned cli@9bf54265 server@acc76c5f sdk@1ba8942 — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it. Ledger files (**/docs/core-consumption/**, **/docs/wire-consumption/**, **/*LEDGER*.json) are not evidence of use — server: 2 name(s) matched ONLY in bookkeeping files and were set aside (CascadeRunResult, TeacherRunResult)"
181
+ },
182
+ "breakingCount": 0,
183
+ "breakingItems": [],
184
+ "toResolvedAt": "00ea23133f3ab421c44449851f44587ca5330d97"
185
+ }