@sema-agent/core 5.60.1 → 5.62.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 (64) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/agents/subagent.d.ts +4 -2
  3. package/dist/agents/subagent.js +9 -9
  4. package/dist/brain/open-responses.js +8 -3
  5. package/dist/brain/openai.js +4 -4
  6. package/dist/brain/stream-engine.d.ts +13 -2
  7. package/dist/brain/stream-engine.js +3 -3
  8. package/dist/core/auto-mode-prompt-assets.js +1 -1
  9. package/dist/core/checkpoint-store.d.ts +36 -4
  10. package/dist/core/checkpoint-store.js +1 -0
  11. package/dist/core/governance-codes.d.ts +1 -1
  12. package/dist/core/governance-codes.js +4 -2
  13. package/dist/core/hooks.d.ts +83 -4
  14. package/dist/core/hooks.js +3 -3
  15. package/dist/core/memory-engine/consolidation-driver.d.ts +19 -1
  16. package/dist/core/memory-engine/consolidation-driver.js +75 -3
  17. package/dist/core/memory-engine/consolidation.d.ts +52 -5
  18. package/dist/core/memory-engine/consolidation.js +3 -1
  19. package/dist/core/memory-engine/distiller.d.ts +89 -1
  20. package/dist/core/memory-engine/distiller.js +94 -5
  21. package/dist/core/memory-engine/engine.d.ts +8 -0
  22. package/dist/core/memory-engine/engine.js +51 -8
  23. package/dist/core/memory-engine/index.d.ts +1 -1
  24. package/dist/core/memory-engine/index.js +1 -1
  25. package/dist/core/park-selfcheck.js +2 -0
  26. package/dist/core/pricing.d.ts +24 -0
  27. package/dist/core/pricing.js +18 -0
  28. package/dist/core/runner/prepare-config-doors.d.ts +34 -0
  29. package/dist/core/runner/prepare-config-doors.js +55 -0
  30. package/dist/core/runner/prepare-task.d.ts +52 -10
  31. package/dist/core/runner/prepare-task.js +77 -42
  32. package/dist/core/runner/runtask.d.ts +7 -0
  33. package/dist/core/runner/runtask.js +254 -38
  34. package/dist/core/runner/turn-attachments.d.ts +137 -5
  35. package/dist/core/runner/turn-attachments.js +25 -2
  36. package/dist/core/store-contracts/checkpoint-store-contract.js +19 -0
  37. package/dist/core/task-notification.d.ts +50 -23
  38. package/dist/core/task-notification.js +20 -4
  39. package/dist/core/tool-errors.d.ts +2 -1
  40. package/dist/core/tool-policy.d.ts +27 -0
  41. package/dist/core/types.d.ts +214 -31
  42. package/dist/core/untrusted-text.d.ts +5 -4
  43. package/dist/core/untrusted-text.js +8 -0
  44. package/dist/core/usage-window-store.d.ts +109 -8
  45. package/dist/core/usage-window-store.js +79 -12
  46. package/dist/engine/harness/agent-harness.d.ts +58 -2
  47. package/dist/engine/harness/agent-harness.js +115 -5
  48. package/dist/engine/loop/agent-loop.js +153 -15
  49. package/dist/engine/loop/types.d.ts +32 -0
  50. package/dist/index.d.ts +2 -2
  51. package/dist/index.js +2 -2
  52. package/dist/orchestration/run-workflow-tool.d.ts +9 -4
  53. package/dist/orchestration/run-workflow-tool.js +1 -1
  54. package/dist/orchestration/workflow.d.ts +2 -2
  55. package/dist/prompt-assembly/event-registry.js +2 -0
  56. package/dist/server/http.d.ts +1 -1
  57. package/dist/stores/file/usage-window-store.d.ts +1 -1
  58. package/dist/stores/file/usage-window-store.js +27 -6
  59. package/dist/tools/loop-tick.js +1 -1
  60. package/dist/tools/monitor.d.ts +3 -3
  61. package/dist/tools/monitor.js +1 -1
  62. package/dist/tools/scheduler-tools.js +9 -1
  63. package/package.json +1 -1
  64. package/test/export-surface.snapshot.json +7 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,130 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.62.0 — 2026-08-26
4
+
5
+ ### Added
6
+ - **Usage-governance cost arm (#462, ruled form 1)**: `UsageWindow.maxCostUsd` joins `maxTokens` as an
7
+ independent second axis — either filling first exhausts the window; `freesAt`/`retryAfterMs` shared.
8
+ The charge seam (`UsageWindowStore.charge` / `chargeUsageRecord`) gains a trailing
9
+ `costMicroUsd?: number | null` three-state (number / `null` = spent-but-unpriceable / `undefined` =
10
+ store refuses on a $-governed window: `usage_window.store_cost_unanswered`); ledger rows and readings
11
+ carry `costMicroUsd`/`costUnknown` (a reading with unknown spend declares its money figure a LOWER
12
+ bound and the engine announces the gap once per run). Unpriced runs under a $-window refuse loudly:
13
+ `config.usage_window_unpriced` at the config door (the run's own model) and by name at the accounting
14
+ point (a mid-run degrade target); malformed pricing tables (NaN / negative / missing base rates)
15
+ refuse the same way. Token-arm behavior and ledger bytes are unchanged for every existing deployment.
16
+ - **Prose segment-end wire signal (#447)**: additive TaskEvent `text_end { content }` — emitted at the
17
+ brain's block-close boundary (CC's own segmentation point; CC ships each completed content block as
18
+ its own unit and has no idle heuristic). `content` is the segment's authoritative full text; absence
19
+ is honest (a brain that reports no block close ≠ the segment did not end). Subagent viewing panes
20
+ forward it opt-in. Downstream idle-flush heuristics can retire.
21
+ - **design/373 S3 — the userPromptSubmit entrance screens**: `UserPromptSubmitContext` gains
22
+ `source` (closed five-value union: objective / steer / resume_message / parked_redelivery, plus
23
+ `followUp` reserved), `inputId`, and `actor` (detached snapshot). The deployment hook now SCREENS
24
+ three entrances: live `TaskStream.steer` (chain position: domain → liveness → replay short-circuit →
25
+ screen → accept; replay identity minted from PRE-screen bytes), resume-message (pre-CAS at the verb —
26
+ a blocked message burns nothing, the row stays pending, the same token redeems once clear), and
27
+ parked-steer wake redelivery (blocks withhold THAT frame only, disclosed twice: the new notice
28
+ `steering.parked_input_blocked` + the entry's `human_input` account value `"blocked"`; siblings
29
+ deliver, a wake never wedges). Blocks refuse typed `steering.blocked_by_hook` (steer-verb error +
30
+ CheckpointError, one vocabulary, fail-closed on timeout/cancel/crash). Hook-less deployments are
31
+ byte-unchanged.
32
+ - **Preview-withheld determinant (#457②, CC 245 anchor)**: `AskRequest.previewWithheld`
33
+ (`"oversize"` / `"unavailable"`) distinguishes "the tool declares no preview" from "the projection
34
+ failed / was withheld" on the ask face, the park row, and `CheckpointSummary` — the CC-245 form where
35
+ an unreviewable edit narrows approval to one-time. `matchedAskRule` now joins the rule-offer
36
+ exclusion set (sync + park faces).
37
+ - **ScheduleWakeup `noop` seat (#455, CC 245 graduation)**: the schema and prompt carry CC's noop
38
+ contract verbatim; `noop` is REQUIRED unless `stop: true` (a loud one-round refusal, self-correcting —
39
+ the CC-245 form); the flag rides `tool_end.structured.noop` for hosts to fold streaks (core carries,
40
+ never counts). Dynamic loop-tick prompts grow the noop clause; cron templates do not.
41
+ - **Token-countdown lane (RB-318 ruled B)**: `TaskSpec.attachments.totalTokensReminder` (opt-in flag) +
42
+ `totalTokensReminderMode` (CC's five-mode closed set, default `padded-countdown`) render CC's
43
+ byte-exact `<total_tokens>N tokens left</total_tokens>` from CALLER-fed coordinates (the same
44
+ used/ceiling pair the budget gate enforces — core forwards, never estimates; no ceiling ⇒ countdown
45
+ arms stay silent). The padded arm carries CC's monotonic smoothing floor (the number never jumps
46
+ backwards). Bad modes refuse `config.attachment_invalid` at the config door.
47
+
48
+ ### Narrowings (named)
49
+ - `<total_tokens>` joins the fenced + disclosed envelope tag tables: untrusted content carrying that
50
+ spelling is now zero-width-broken (fenced lane) and verbatim external data containing it triggers the
51
+ reminder disclosure footer — this applies to EVERY deployment, opted into the countdown lane or not
52
+ (the lane's own byte-identity claim is scoped to the attachment lane; the sanitize/disclosure tables
53
+ are unconditional defense). `attachmentEnvelopeTags` becomes a two-owner table (skills_listing owns
54
+ `skills`, total_tokens_reminder owns `total_tokens`) — pins asserting a single owner will red.
55
+ - `text_end` is honest about truncation: the openai / open-responses brains no longer mint a
56
+ `text_end` for a prose segment cut mid-stream (stall promotion, disconnect, in-band overload
57
+ degrade) — a truncated segment is not COMPLETE, so the frame is absent (the anthropic form); a
58
+ stream whose `finish_reason` already arrived still closes its segment. The frame's `content` now
59
+ carries the same trimmed text the final assistant message holds (one source).
60
+ - Pre-existing behavior restored on one seam: a fresh steer landing in the post-loop teardown window
61
+ now refuses `steering.not_running` (as before 373 S3) instead of `steering.blocked_by_hook`, and
62
+ the deployment hook is no longer invoked for an undeliverable input.
63
+ - Scheduler calls omitting `noop` (without `stop`) now refuse loudly (previously accepted silently).
64
+ - Closed-set adds, named: steering error family +`steering.blocked_by_hook` (also a CheckpointError
65
+ code); `ENGINE_NOTICE_CODES` +`steering.parked_input_blocked` (audience `user`);
66
+ `HumanInputDelivery` +`"blocked"` (3→4); `FROZEN_TASK_EVENT_TYPES` +`text_end`;
67
+ `steering_injected.source` +`"total_tokens_reminder"`. Downstream exhaustive switches and closed-set
68
+ pins over any of these will red — re-pin per the new members.
69
+
70
+ ## 5.61.0 — 2026-08-25
71
+
72
+ ### Added
73
+ - **Injection-priority ladder is LIVE (design/373 S1+S2)**: `now`/`next`/`later` now carry three
74
+ distinct delivery semantics (CC 2.1.223 form). `next` = the running turn's next boundary (the
75
+ previous behavior of every frame — byte-identical for `next`); `later` = the run's
76
+ would-otherwise-stop seat (its own closing turn; non-natural endings re-pend per session, the next
77
+ run's turn-open delivers); `now` = `next`'s guarantee plus a manufactured boundary. A
78
+ caller-provenance `TaskStream.steer(text, { priority: "now" })` CUTS the in-flight turn at a
79
+ reconciled boundary (finished tools keep real results; never-started ones settle as paired
80
+ `[INTERRUPTED]` results with `details.errorKind: "interrupted_never_started"`; the CC-verbatim
81
+ interruption marker lands) and the run CONTINUES; every real cut emits the new
82
+ `task.turn_interrupted` notice (audience `user`) and counts toward `maxTurns`. Interrupt authority
83
+ is the steer face's alone.
84
+ - `TaskStream.steer` gains `options.priority` (default `"next"`); the replay identity (`inputId`)
85
+ gains the normalized tier as an axis (same id+text at a different tier ⇒ `steering.duplicate_input_id`).
86
+ - `task_notification` wire events gain an additive `priority` field (live + turn-open lanes; absent =
87
+ pre-373 producer or tier-unknown park — never read absence as `later`).
88
+ - `isTerminalTaskNotification` package-root export; engine-note backlog cap gains terminal preference
89
+ (#445): at a full cap an INTERNAL-lane terminal frame displaces the oldest event batch (which parks
90
+ losslessly) instead of parking behind it — external frames of every status stay ordinary
91
+ displaceable notes (trust cut at the marking site).
92
+ - Engine-layer seams: per-turn AbortController + `interruptTurn(frame)` (consumption guard; run
93
+ signal wins), pre-request immediate re-check, final commit point (a frame arriving during the
94
+ stop-gate await window is served by the same run), `AgentHarness.steer` returns the minted frame.
95
+ - **Product-grade origin folding behind a host-code attestation gate (design/376-C1)**:
96
+ `freezeConsolidationPlan` grows the attested arm — `proposal.mintExposure: "partitioned"` (closed
97
+ single-member set; non-members refuse structurally at the commit door) switches the fold from the
98
+ run-level single value to a per-product committed-exposure re-read over declared inputs (any marked
99
+ input ⇒ external/derived, all-clean ⇒ no origin); every UN-attested plan — every pre-C1 caller and
100
+ every foreign minter — keeps the run-level blanket byte-identical. `mintExposurePartitionedPlan`
101
+ + `LlmConsolidationPlanArm` exported: the exposure-partitioned mint bisects candidates on the
102
+ engine-minted marked flag (clean arm byte-identical to the historical chain; marked arm mints only
103
+ at markedCount ≥ 2; a lone marked row stands as its handle row, withheld from every call); the
104
+ attestation threads verbatim distiller → driver → commit. D-1b two-layer world-movement guard:
105
+ the engine re-checks every served candidate on four axes (rev moved / left listing / challenge-latch
106
+ / turned marked) and refuses the whole plan `_stale_snapshot`; the driver's cross-cycle roster guard
107
+ discards a stale mint cache and settles under the NEW stop reason `mint_invalidated`
108
+ (closed-set add; committed cycles stand add-only). Plan rows gain `markedProducts` (additive);
109
+ snapshot gate rows record `markedIds` (additive); the instruction-form gate grows the D-5 three-arm
110
+ form (attested all-clean products follow C-3's clean arm — default refusal + the
111
+ `allowInstructionProducts` hatch, a named WIDENING vs the pre-C1 marked-set blanket; a marked-input
112
+ product keeps the unconditional hard arm).
113
+
114
+ ### BREAKING
115
+ - **B1** — `notify()` frames with `priority: "later"` (INCLUDING the parameterless default) moved
116
+ from next-boundary delivery to the run's closing seat / the next run's turn-open. Honestly noted:
117
+ in the flat era `later` was silently inert (no runtime signal said so) — the engine version is the
118
+ only discriminator. Callers needing boundary delivery must pass `priority: "next"` explicitly.
119
+ - **B2** — `notify(…, { priority: "now" })` is now a typed refusal (`notify.invalid_priority`);
120
+ previously it delivered with a one-per-run disclosure notice.
121
+ - **B3** — the `task.injection_priority_unimplemented` notice code is RETIRED (catalog + audience row
122
+ + word table). Consumers must judge ladder support by version, never by the code's absence.
123
+ - Closed-set adds, named: `ENGINE_NOTICE_CODES` +`task.turn_interrupted`; notify error family
124
+ +`notify.invalid_priority`; consolidation run stop reasons +`mint_invalidated`. Internal producer
125
+ tiers re-seated: subagent terminal frames ×9 and monitor (both shapes) `later`→`next` (#445
126
+ ALIGNED), workflow terminals now explicit `next`.
127
+
3
128
  ## 5.60.1 — 2026-08-25
4
129
 
5
130
  ### Fixed
@@ -511,8 +511,10 @@ export interface SubagentToolOptions {
511
511
  /**
512
512
  * design/115 P3 — background sub-agents (CC `run_in_background`). When set, the tool exposes the
513
513
  * `run_in_background` parameter: the call returns an `a*` task_id immediately, the child runs
514
- * asynchronously, and completion fires ONE task-notification ("later" priority — CC posture: an agent
515
- * completion doesn't derail active work). Poll/stop via TaskOutput/TaskStop. Absent synchronous only.
514
+ * asynchronously, and completion fires ONE task-notification ("next" priority — design/373 §3.7 census
515
+ * re-seat, upstream-anchored: every background-task terminal is explicitly "next" there; the earlier
516
+ * "later doesn't derail active work" reading was the flat era's — a completion must reach a busy model
517
+ * at the boundary, not at the run's closing seat). Poll/stop via TaskOutput/TaskStop. Absent ⇒ synchronous only.
516
518
  */
517
519
  background?: {
518
520
  registry: import("../core/task-registry.js").TaskRegistry;
@@ -775,7 +775,7 @@ export function createSubagentResume(deps) {
775
775
  }
776
776
  else if (ownsTerminalFacesRevive) {
777
777
  try {
778
- deps.notify?.(resumeFrame, { priority: "later" });
778
+ deps.notify?.(resumeFrame, { priority: "next" });
779
779
  }
780
780
  catch {
781
781
  }
@@ -829,7 +829,7 @@ export function createSubagentResume(deps) {
829
829
  ...(abort.signal.aborted ? {} : { error: msgRevive.slice(0, REPORT_FIELD_MAX) }),
830
830
  ...(!abort.signal.aborted && errCodeReviveReject !== undefined ? { errorCode: errCodeReviveReject } : {}),
831
831
  ...(completionIdRejectRevive !== undefined ? { completionId: completionIdRejectRevive } : {}),
832
- }, { priority: "later" });
832
+ }, { priority: "next" });
833
833
  }
834
834
  catch {
835
835
  }
@@ -862,7 +862,7 @@ function parkCompletionNotify(deps) {
862
862
  ...payload,
863
863
  summary: `${payload.summary} (delivered after ${pendingCount} background ${pendingCount === 1 ? "child" : "children"} settled)`,
864
864
  }
865
- : payload, { priority: "later" });
865
+ : payload, { priority: "next" });
866
866
  }
867
867
  catch {
868
868
  }
@@ -2387,7 +2387,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2387
2387
  sessionId: forkedId,
2388
2388
  resumable: false,
2389
2389
  ...(completionIdReap !== undefined ? { completionId: completionIdReap } : {}),
2390
- }, { priority: "later" }), "subagent.reapTerminalNotify");
2390
+ }, { priority: "next" }), "subagent.reapTerminalNotify");
2391
2391
  });
2392
2392
  if (agentName !== undefined) {
2393
2393
  recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, sessionId: forkedId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...(modelFallback !== undefined ? { modelFallback } : {}), ...((ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: Date.now() }, (err) => opts.onObserverError?.(err, { site: "roster.recordSpawn" }));
@@ -2588,7 +2588,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2588
2588
  resumable: resumableFork,
2589
2589
  usage: { tokens: child.stats.tokens, turns: child.stats.turns, ...delegatedCostField(child.stats) },
2590
2590
  ...(completionIdFork !== undefined ? { completionId: completionIdFork } : {}),
2591
- }, { priority: "later" });
2591
+ }, { priority: "next" });
2592
2592
  }
2593
2593
  catch {
2594
2594
  }
@@ -2639,7 +2639,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2639
2639
  ...(settledBg === "failed" ? { error: msgFork.slice(0, REPORT_FIELD_MAX) } : {}),
2640
2640
  ...(settledBg === "failed" && errCodeForkReject !== undefined ? { errorCode: errCodeForkReject } : {}),
2641
2641
  ...(completionIdForkReject !== undefined ? { completionId: completionIdForkReject } : {}),
2642
- }, { priority: "later" });
2642
+ }, { priority: "next" });
2643
2643
  }
2644
2644
  catch {
2645
2645
  }
@@ -2967,7 +2967,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2967
2967
  sessionId: bgChildSessionId,
2968
2968
  resumable: false,
2969
2969
  ...(completionIdReap !== undefined ? { completionId: completionIdReap } : {}),
2970
- }, { priority: "later" }), "subagent.reapTerminalNotify");
2970
+ }, { priority: "next" }), "subagent.reapTerminalNotify");
2971
2971
  });
2972
2972
  const bgDurableProbe = bg.registry.durableAgentRowProbe(taskId);
2973
2973
  const bgRowConfirmed = async () => (bgDurableProbe ? await bgDurableProbe().catch(() => false) : false);
@@ -3438,7 +3438,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3438
3438
  }
3439
3439
  else if (ownsTerminalFaces) {
3440
3440
  try {
3441
- notify?.(completionFrame, { priority: "later" });
3441
+ notify?.(completionFrame, { priority: "next" });
3442
3442
  }
3443
3443
  catch {
3444
3444
  }
@@ -3508,7 +3508,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3508
3508
  ...(settled === "failed" ? { error: msg.slice(0, REPORT_FIELD_MAX) } : {}),
3509
3509
  ...(settled === "failed" && errCodeBgReject !== undefined ? { errorCode: errCodeBgReject } : {}),
3510
3510
  ...(completionIdBgReject !== undefined ? { completionId: completionIdBgReject } : {}),
3511
- }, { priority: "later" });
3511
+ }, { priority: "next" });
3512
3512
  }
3513
3513
  catch {
3514
3514
  }
@@ -389,6 +389,7 @@ export function createOpenResponsesBrain(config = {}) {
389
389
  }
390
390
  }
391
391
  };
392
+ const messageFace = (acc) => acc === degenItem && degenFace === "text" && rep.cut !== undefined ? trimDegenerateTail(acc.text, rep.cut) : acc.text;
392
393
  const closeItem = (index, acc) => {
393
394
  if (acc.closed)
394
395
  return;
@@ -399,7 +400,7 @@ export function createOpenResponsesBrain(config = {}) {
399
400
  }
400
401
  else if (acc.kind === "message") {
401
402
  if (acc.block)
402
- out.push({ type: "text_end", contentIndex: index, content: acc.text, partial: { ...partial } });
403
+ out.push({ type: "text_end", contentIndex: index, content: messageFace(acc), partial: { ...partial } });
403
404
  }
404
405
  else {
405
406
  const tc = closeFunctionCall(acc);
@@ -598,7 +599,7 @@ export function createOpenResponsesBrain(config = {}) {
598
599
  }
599
600
  handleFrame(frame);
600
601
  },
601
- finalize() {
602
+ finalize(opts) {
602
603
  for (const [index, acc] of [...items.entries()].sort((a, b) => a[0] - b[0])) {
603
604
  if (acc.closed)
604
605
  continue;
@@ -606,6 +607,10 @@ export function createOpenResponsesBrain(config = {}) {
606
607
  acc.closed = true;
607
608
  continue;
608
609
  }
610
+ if (acc.kind === "message" && opts?.truncated === true) {
611
+ acc.closed = true;
612
+ continue;
613
+ }
609
614
  closeItem(index, acc);
610
615
  }
611
616
  const finalContent = [];
@@ -626,7 +631,7 @@ export function createOpenResponsesBrain(config = {}) {
626
631
  });
627
632
  }
628
633
  else if (acc.kind === "message") {
629
- const text = acc === degenItem && degenFace === "text" && rep.cut !== undefined ? trimDegenerateTail(acc.text, rep.cut) : acc.text;
634
+ const text = messageFace(acc);
630
635
  if (text !== "") {
631
636
  finalContent.push({ type: "text", text });
632
637
  if (text.trim() !== "")
@@ -481,15 +481,15 @@ export function createOpenAIBrain(config = {}) {
481
481
  }
482
482
  }
483
483
  },
484
- finalize() {
484
+ finalize(opts) {
485
485
  closeThinking();
486
- if (emittedTextStart) {
487
- out.push({ type: "text_end", contentIndex: textIndex, content: accumText, partial: { ...partial } });
486
+ const textFace = degenerate && degenFace === "text" && rep.cut !== undefined ? trimDegenerateTail(accumText, rep.cut) : accumText;
487
+ if (emittedTextStart && (opts?.truncated !== true || finishReason != null)) {
488
+ out.push({ type: "text_end", contentIndex: textIndex, content: textFace, partial: { ...partial } });
488
489
  }
489
490
  const finalContent = [];
490
491
  if (accumReasoning)
491
492
  finalContent.push({ type: "thinking", thinking: accumReasoning });
492
- const textFace = degenerate && degenFace === "text" && rep.cut !== undefined ? trimDegenerateTail(accumText, rep.cut) : accumText;
493
493
  if (textFace)
494
494
  finalContent.push({ type: "text", text: textFace });
495
495
  const toolCalls = [];
@@ -159,8 +159,19 @@ export interface StreamControls {
159
159
  export interface StreamParser {
160
160
  /** Handle one raw SSE line (`data: {...}`); parse, emit events, call `ctrl.sawContentToken()` on content. */
161
161
  onLine(line: string): void;
162
- /** Build + emit the final `done`/`error` message after the stream ends. */
163
- finalize(): void;
162
+ /**
163
+ * Build + emit the final `done`/`error` message after the stream ends.
164
+ *
165
+ * `truncated: true` = this finalize is a design/124 tier-A PROMOTION of a mid-stream failure
166
+ * (stall / connection cut / in-band overload downgrade): the provider never terminated the
167
+ * stream, so any still-open prose segment was CUT, not closed. A parser that mints segment-end
168
+ * frames (`text_end`) must withhold them on this arm — the TaskEvent contract promises the frame
169
+ * means "the model closed the block" (HONEST ABSENCE otherwise) — while still building the same
170
+ * final message (the facade stamps `partialFinalized` + the warning on the terminal event).
171
+ */
172
+ finalize(opts?: {
173
+ truncated?: boolean;
174
+ }): void;
164
175
  /**
165
176
  * design/124 §0.5-2: introspection for the mid-stream failure tiering. Read directly off the
166
177
  * parser's accumulated state (cheap, no side effects):
@@ -551,7 +551,7 @@ export function runStreamingBrain(args) {
551
551
  start = nl + 1;
552
552
  if (onLineOverloadTierA(line)) {
553
553
  midstreamWarning = INBAND_OVERLOAD_WARNING;
554
- parser.finalize();
554
+ parser.finalize({ truncated: true });
555
555
  return;
556
556
  }
557
557
  }
@@ -576,7 +576,7 @@ export function runStreamingBrain(args) {
576
576
  buffer += decoder.decode();
577
577
  if (buffer.trim() && onLineOverloadTierA(buffer))
578
578
  midstreamWarning = INBAND_OVERLOAD_WARNING;
579
- parser.finalize();
579
+ parser.finalize(midstreamWarning !== undefined ? { truncated: true } : undefined);
580
580
  return;
581
581
  }
582
582
  const snap = parser.snapshot();
@@ -585,7 +585,7 @@ export function runStreamingBrain(args) {
585
585
  failure.kind === "stalled"
586
586
  ? "Response stalled mid-stream. The response above may be incomplete."
587
587
  : "Connection closed mid-response. The response above may be incomplete.";
588
- parser.finalize();
588
+ parser.finalize({ truncated: true });
589
589
  return;
590
590
  }
591
591
  if (snap.hasCompletedToolCall)