@animalabs/agent-framework 0.33.0 → 0.34.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 (176) hide show
  1. package/README.md +136 -8
  2. package/dist/src/agent.d.ts +25 -4
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +68 -13
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/append-all.d.ts +5 -0
  7. package/dist/src/append-all.d.ts.map +1 -0
  8. package/dist/src/append-all.js +8 -0
  9. package/dist/src/append-all.js.map +1 -0
  10. package/dist/src/context-options.d.ts +9 -6
  11. package/dist/src/context-options.d.ts.map +1 -1
  12. package/dist/src/context-options.js +13 -8
  13. package/dist/src/context-options.js.map +1 -1
  14. package/dist/src/cut-off-round.js +4 -4
  15. package/dist/src/dendrite/registry.d.ts.map +1 -1
  16. package/dist/src/dendrite/registry.js +4 -2
  17. package/dist/src/dendrite/registry.js.map +1 -1
  18. package/dist/src/failure-text.d.ts +36 -0
  19. package/dist/src/failure-text.d.ts.map +1 -0
  20. package/dist/src/failure-text.js +89 -0
  21. package/dist/src/failure-text.js.map +1 -0
  22. package/dist/src/framework.d.ts +94 -30
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +987 -284
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts +40 -6
  27. package/dist/src/gate/event-gate.d.ts.map +1 -1
  28. package/dist/src/gate/event-gate.js +66 -25
  29. package/dist/src/gate/event-gate.js.map +1 -1
  30. package/dist/src/gate/reply-judge.d.ts +4 -0
  31. package/dist/src/gate/reply-judge.d.ts.map +1 -1
  32. package/dist/src/gate/reply-judge.js +12 -3
  33. package/dist/src/gate/reply-judge.js.map +1 -1
  34. package/dist/src/gate/suppression-review.js +2 -2
  35. package/dist/src/gate/suppression-review.js.map +1 -1
  36. package/dist/src/gate/types.d.ts +7 -0
  37. package/dist/src/gate/types.d.ts.map +1 -1
  38. package/dist/src/heartbeat/heartbeat.js +1 -1
  39. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  40. package/dist/src/index.d.ts +1 -0
  41. package/dist/src/index.d.ts.map +1 -1
  42. package/dist/src/index.js +1 -0
  43. package/dist/src/index.js.map +1 -1
  44. package/dist/src/inference-bounds.d.ts +2 -1
  45. package/dist/src/inference-bounds.d.ts.map +1 -1
  46. package/dist/src/inference-bounds.js +9 -6
  47. package/dist/src/inference-bounds.js.map +1 -1
  48. package/dist/src/mcpl/channel-registry.d.ts +57 -13
  49. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  50. package/dist/src/mcpl/channel-registry.js +136 -24
  51. package/dist/src/mcpl/channel-registry.js.map +1 -1
  52. package/dist/src/mcpl/modern-connection.d.ts +109 -15
  53. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  54. package/dist/src/mcpl/modern-connection.js +593 -86
  55. package/dist/src/mcpl/modern-connection.js.map +1 -1
  56. package/dist/src/mcpl/protocol-family.d.ts +3 -1
  57. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  58. package/dist/src/mcpl/protocol-family.js +15 -9
  59. package/dist/src/mcpl/protocol-family.js.map +1 -1
  60. package/dist/src/mcpl/server-connection.d.ts +37 -18
  61. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  62. package/dist/src/mcpl/server-connection.js +31 -16
  63. package/dist/src/mcpl/server-connection.js.map +1 -1
  64. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  65. package/dist/src/mcpl/tool-presence.js +10 -6
  66. package/dist/src/mcpl/tool-presence.js.map +1 -1
  67. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  68. package/dist/src/mcpl/toolset-host.js +7 -6
  69. package/dist/src/mcpl/toolset-host.js.map +1 -1
  70. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  71. package/dist/src/mcpl/toolsets-module.js +2 -1
  72. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  73. package/dist/src/mcpl/types.d.ts +25 -4
  74. package/dist/src/mcpl/types.d.ts.map +1 -1
  75. package/dist/src/mcpl/types.js.map +1 -1
  76. package/dist/src/modules/discord/index.js +1 -1
  77. package/dist/src/modules/discord/index.js.map +1 -1
  78. package/dist/src/modules/health/index.js +2 -2
  79. package/dist/src/modules/health/index.js.map +1 -1
  80. package/dist/src/modules/history/semantic.js +3 -3
  81. package/dist/src/modules/history/semantic.js.map +1 -1
  82. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  83. package/dist/src/modules/workspace/index.js +4 -3
  84. package/dist/src/modules/workspace/index.js.map +1 -1
  85. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  86. package/dist/src/modules/workspace/observe.js +2 -1
  87. package/dist/src/modules/workspace/observe.js.map +1 -1
  88. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  89. package/dist/src/modules/workspace/reconcile.js +3 -2
  90. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  91. package/dist/src/notice-tags.d.ts +10 -8
  92. package/dist/src/notice-tags.d.ts.map +1 -1
  93. package/dist/src/notice-tags.js +10 -8
  94. package/dist/src/notice-tags.js.map +1 -1
  95. package/dist/src/presentation.d.ts +2 -1
  96. package/dist/src/presentation.d.ts.map +1 -1
  97. package/dist/src/presentation.js +4 -2
  98. package/dist/src/presentation.js.map +1 -1
  99. package/dist/src/reasoning-loss.d.ts +5 -0
  100. package/dist/src/reasoning-loss.d.ts.map +1 -0
  101. package/dist/src/reasoning-loss.js +8 -0
  102. package/dist/src/reasoning-loss.js.map +1 -0
  103. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  104. package/dist/src/recovery/offline-branch.js +5 -4
  105. package/dist/src/recovery/offline-branch.js.map +1 -1
  106. package/dist/src/recovery/recover-cli.js +2 -1
  107. package/dist/src/recovery/recover-cli.js.map +1 -1
  108. package/dist/src/sent-rows.js +1 -1
  109. package/dist/src/supplied-text.d.ts +1 -1
  110. package/dist/src/supplied-text.js +3 -3
  111. package/dist/src/supplied-text.js.map +1 -1
  112. package/dist/src/thinking-drops.d.ts +2 -0
  113. package/dist/src/thinking-drops.d.ts.map +1 -1
  114. package/dist/src/thinking-drops.js.map +1 -1
  115. package/dist/src/tool-presentation.d.ts.map +1 -1
  116. package/dist/src/tool-presentation.js +38 -9
  117. package/dist/src/tool-presentation.js.map +1 -1
  118. package/dist/src/tune-out/coordinator.d.ts +13 -4
  119. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  120. package/dist/src/tune-out/coordinator.js +33 -16
  121. package/dist/src/tune-out/coordinator.js.map +1 -1
  122. package/dist/src/tune-out/tools.d.ts.map +1 -1
  123. package/dist/src/tune-out/tools.js +2 -1
  124. package/dist/src/tune-out/tools.js.map +1 -1
  125. package/dist/src/types/agent.d.ts +39 -5
  126. package/dist/src/types/agent.d.ts.map +1 -1
  127. package/dist/src/types/framework.d.ts +43 -8
  128. package/dist/src/types/framework.d.ts.map +1 -1
  129. package/dist/src/types/inference-log.d.ts +1 -0
  130. package/dist/src/types/inference-log.d.ts.map +1 -1
  131. package/dist/src/types/trace.d.ts +64 -4
  132. package/dist/src/types/trace.d.ts.map +1 -1
  133. package/package.json +3 -3
  134. package/src/agent.ts +71 -14
  135. package/src/append-all.ts +6 -0
  136. package/src/context-options.ts +18 -8
  137. package/src/cut-off-round.ts +4 -4
  138. package/src/dendrite/registry.ts +4 -2
  139. package/src/failure-text.ts +88 -0
  140. package/src/framework.ts +1018 -293
  141. package/src/gate/event-gate.ts +90 -26
  142. package/src/gate/reply-judge.ts +12 -3
  143. package/src/gate/suppression-review.ts +2 -2
  144. package/src/gate/types.ts +7 -0
  145. package/src/heartbeat/heartbeat.ts +1 -1
  146. package/src/index.ts +1 -0
  147. package/src/inference-bounds.ts +11 -6
  148. package/src/mcpl/channel-registry.ts +160 -31
  149. package/src/mcpl/modern-connection.ts +629 -91
  150. package/src/mcpl/protocol-family.ts +15 -9
  151. package/src/mcpl/server-connection.ts +28 -15
  152. package/src/mcpl/tool-presence.ts +10 -5
  153. package/src/mcpl/toolset-host.ts +7 -6
  154. package/src/mcpl/toolsets-module.ts +2 -1
  155. package/src/mcpl/types.ts +25 -4
  156. package/src/modules/discord/index.ts +1 -1
  157. package/src/modules/health/index.ts +2 -2
  158. package/src/modules/history/semantic.ts +3 -3
  159. package/src/modules/workspace/index.ts +4 -3
  160. package/src/modules/workspace/observe.ts +2 -1
  161. package/src/modules/workspace/reconcile.ts +3 -2
  162. package/src/notice-tags.ts +11 -8
  163. package/src/presentation.ts +4 -2
  164. package/src/reasoning-loss.ts +7 -0
  165. package/src/recovery/offline-branch.ts +5 -4
  166. package/src/recovery/recover-cli.ts +2 -1
  167. package/src/sent-rows.ts +1 -1
  168. package/src/supplied-text.ts +3 -3
  169. package/src/thinking-drops.ts +2 -0
  170. package/src/tool-presentation.ts +34 -9
  171. package/src/tune-out/coordinator.ts +32 -15
  172. package/src/tune-out/tools.ts +2 -1
  173. package/src/types/agent.ts +36 -5
  174. package/src/types/framework.ts +44 -8
  175. package/src/types/inference-log.ts +1 -0
  176. package/src/types/trace.ts +66 -4
@@ -22,6 +22,9 @@ import { canonical, duplicateName, lastRecipePath, noChanges, reconcileLine, rec
22
22
  import { closestNames, isNearMiss } from '../closest-names.js';
23
23
  import { MAX_TIMER_MS } from '../deadline-timer.js';
24
24
  import { safeSlice } from '../safe-slice.js';
25
+ import { failureWords } from '../failure-text.js';
26
+ import { JudgeFailure } from './reply-judge.js';
27
+ import { isSystemNotice } from '../mcpl/tags.js';
25
28
 
26
29
  import type { ToolDefinition } from '../types/events.js';
27
30
  import type { TraceEventInput } from '../types/trace.js';
@@ -45,6 +48,7 @@ import type {
45
48
  JudgeVerdict,
46
49
  UpcomingWake,
47
50
  } from './types.js';
51
+ import { appendAll } from '../append-all.js';
48
52
 
49
53
  // Re-export types consumers need
50
54
  export type {
@@ -132,7 +136,9 @@ interface PendingEvent {
132
136
  * composite channel id. */
133
137
  channelLabel?: string;
134
138
  /** Author id of the event (discord snowflake etc.), when the metadata carries
135
- * one — a debounced wake names the newest such author as its counterparty. */
139
+ * one — a debounced wake's counterparty is the author of the event
140
+ * wakeProvenance picks; when it picks none and gives no
141
+ * `batchChannels`, the author of the batch's newest notice that has one. */
136
142
  authorId?: string;
137
143
  /** MCPL server id the event came from — the namespace for the counterparty
138
144
  * (`<serverId>:user:<id>`), same as the direct channel-incoming path. */
@@ -141,6 +147,7 @@ interface PendingEvent {
141
147
  * newer ambient chatter when a batched wake picks its provenance — the same
142
148
  * rule the framework applies to the turn's speech locus. */
143
149
  addressed: boolean;
150
+ systemNotice?: true;
144
151
  /** The REGISTERED (composite) channel id the event arrived on, resolved by
145
152
  * the host at enqueue time. Differs from `channelId` for push events,
146
153
  * whose `channelId` is the adapter's raw id (a bare Discord snowflake for a
@@ -179,7 +186,13 @@ export interface WakeProvenance {
179
186
  * channel); the framework derives the composite id on the direct path. */
180
187
  channelId?: string;
181
188
  /** Server-namespaced author id: `<serverId>:user:<id>` (falls back to the
182
- * channel id's first segment only when the event carried no server id). */
189
+ * channel id's first segment only when the event carried no server id).
190
+ * A `chat:system` notice, unless it's a coalesced render's latest
191
+ * occurrence (GateEventInfo.folds), is never chosen for a batch's route,
192
+ * channel or `addressed` flag. In a batch in which no other event names a
193
+ * channel or an author, and which has no `batchChannels`, the chosen
194
+ * event is its newest notice that has an author, of which the wake names
195
+ * only the author and its time (`at`). */
183
196
  counterparty?: string;
184
197
  /** True when the chosen event was `chat:addressed`. */
185
198
  addressed?: boolean;
@@ -196,6 +209,19 @@ export interface WakeProvenance {
196
209
  * event named its server: channel ids are unique only within one
197
210
  * server's connection (MCPL §14.2). */
198
211
  routeServerId?: string;
212
+ /** The channels a batch came from, each its registered id and server, in
213
+ * the order they first appear, when no event of it addressed the agent:
214
+ * those the host resolved, one or more. A channel some events gave with
215
+ * its server and others without is listed once each way, since only the
216
+ * host can tell they're one. A `chat:system` notice counts
217
+ * neither as addressing the agent nor as a channel, except as a
218
+ * coalesced render's latest occurrence, which the gate takes as a
219
+ * message (GateEventInfo.folds). The wake has no route, and the turn's
220
+ * no-locus notice names them, one given without its server only if its id
221
+ * still names one registered channel when the wake is admitted, unless the
222
+ * turn's own wake named a channel that resolves to none, or the turn is an
223
+ * ephemeral run's, which writes none. */
224
+ batchChannels?: Array<{ channelId: string; serverId?: string }>;
199
225
  /** Timestamp (ms) of the chosen event, so a consumer coalescing several
200
226
  * requests can order by event recency, not by flush order. */
201
227
  at?: number;
@@ -296,15 +322,25 @@ function isMetadataTruthy(value: unknown): boolean {
296
322
  }
297
323
 
298
324
  /**
299
- * Provenance of a batched wake: the newest ADDRESSED channel-bearing event
300
- * wins (mention / reply / DM outranks newer ambient chatter — the framework's
301
- * own locus rule); else the newest channel-bearing event. Channel, author and
302
- * addressed all come from that one event, so they can never disagree.
325
+ * Provenance of a batched wake: the newest ADDRESSED event that names a
326
+ * channel or an author wins (mention / reply / DM outranks newer ambient
327
+ * chatter — the framework's own locus rule); else the newest event that
328
+ * names either. An addressed event's resolved route counts as its channel;
329
+ * an ambient one's doesn't. Channel, author and addressed all come from
330
+ * that one event, so they can never disagree. A `chat:system` notice is
331
+ * never chosen for the batch's route, channel or addressed flag, even one
332
+ * addressed by its place (a pin in a DM), since a notice never decides where
333
+ * the agent's speech goes; a coalesced render whose latest occurrence is a
334
+ * notice isn't taken as one (GateEventInfo.folds). A batch with no such event
335
+ * carries only its `batchChannels` when it has them; else its chosen event is
336
+ * its newest notice that has an author, of which it takes only the author
337
+ * and time.
303
338
  */
304
339
  export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefined {
340
+ const said = events.filter((e) => !e.systemNotice);
305
341
  let newest: PendingEvent | undefined;
306
342
  let newestAddressed: PendingEvent | undefined;
307
- for (const e of events) {
343
+ for (const e of said) {
308
344
  // A resolved route counts as naming a channel only for an ADDRESSED
309
345
  // event: a push whose origin declares only `mcplChannelId` (no raw
310
346
  // channel id, no author) is still a routable addressed message, and
@@ -317,22 +353,42 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin
317
353
  if (e.addressed && (!newestAddressed || e.timestamp >= newestAddressed.timestamp)) newestAddressed = e;
318
354
  }
319
355
  const pick = newestAddressed ?? newest;
320
- if (!pick) return undefined;
356
+ const channels = said.some((e) => e.addressed) ? [] : batchRoutes(said);
357
+ if (!pick && channels.length === 0) {
358
+ const notice = events.reduce<PendingEvent | undefined>(
359
+ (n, e) => (e.systemNotice && e.authorId && (!n || e.timestamp >= n.timestamp) ? e : n), undefined);
360
+ return notice ? { counterparty: counterpartyOf(notice), at: notice.timestamp } : undefined;
361
+ }
321
362
  // push-event channel ids are the adapter's raw ids (a Discord snowflake):
322
363
  // not a composite channel id, so not reported as one
323
- const channelId = pick.channelId && pick.eventType !== 'mcpl:push-event' ? pick.channelId : undefined;
324
- const ns = pick.serverId || (pick.channelId ? pick.channelId.split(':')[0] : '') || 'channel';
364
+ const channelId = pick?.channelId && pick.eventType !== 'mcpl:push-event' ? pick.channelId : undefined;
325
365
  return {
326
366
  ...(channelId ? { channelId } : {}),
327
- ...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
328
- ...(pick.addressed ? { addressed: true } : {}),
329
- ...(pick.addressed && pick.routeChannelId
367
+ ...(pick?.authorId ? { counterparty: counterpartyOf(pick) } : {}),
368
+ ...(pick?.addressed ? { addressed: true } : {}),
369
+ ...(pick?.addressed && pick.routeChannelId
330
370
  ? { routeChannelId: pick.routeChannelId, ...(pick.serverId ? { routeServerId: pick.serverId } : {}) }
331
371
  : {}),
332
- at: pick.timestamp,
372
+ ...(channels.length > 0 ? { batchChannels: channels } : {}),
373
+ ...(pick ? { at: pick.timestamp } : {}),
333
374
  };
334
375
  }
335
376
 
377
+ function counterpartyOf(e: PendingEvent): string {
378
+ const ns = e.serverId || (e.channelId ? e.channelId.split(':')[0] : '') || 'channel';
379
+ return `${ns}:user:${e.authorId}`;
380
+ }
381
+
382
+ function batchRoutes(events: PendingEvent[]): Array<{ channelId: string; serverId?: string }> {
383
+ const routes: Array<{ channelId: string; serverId?: string }> = [];
384
+ for (const e of events) {
385
+ if (!e.routeChannelId) continue;
386
+ if (routes.some((r) => r.channelId === e.routeChannelId && r.serverId === e.serverId)) continue;
387
+ routes.push({ channelId: e.routeChannelId, ...(e.serverId ? { serverId: e.serverId } : {}) });
388
+ }
389
+ return routes;
390
+ }
391
+
336
392
  /** A `[Gate: …]` line's reference to messages already in the window: how
337
393
  * many, in which channel, and how long ago the oldest arrived, as of `now`. */
338
394
  function referenceLine(count: number, channel: string, oldest: number, now: number): string {
@@ -1689,7 +1745,7 @@ function readConfig(
1689
1745
  };
1690
1746
  }
1691
1747
  }
1692
- for (const rule of rules) sentences.push(...readingSentences(rule.reading, rule.outcome));
1748
+ for (const rule of rules) appendAll(sentences, readingSentences(rule.reading, rule.outcome));
1693
1749
  // A list with no rules, or none the gate reads, where no sentence above said it has none.
1694
1750
  if (notUsed !== undefined && !sentences.some((said) => said.includes(notUsed))) {
1695
1751
  sentences.push(`${File} has no rules${rules.length > 0 ? ' but ones the gate leaves out' : ''}, so ${notUsedWhy}.`);
@@ -2126,9 +2182,15 @@ export function formatShadowWarning(w: ShadowWarning): string {
2126
2182
  + `A scope on ${later} naming the event types it's for clears this note.`;
2127
2183
  }
2128
2184
 
2185
+ function judgeFailureWords(err: unknown): string {
2186
+ if (err instanceof JudgeFailure) return err.message;
2187
+ return err instanceof Error ? failureWords(err, 'the call failed') : 'the call failed';
2188
+ }
2189
+
2129
2190
  /** The text of a thrown value, for a log line. It never throws itself, whatever was thrown. */
2130
2191
  function errorText(err: unknown): string {
2131
2192
  try {
2193
+ if (err instanceof JudgeFailure && err.detail !== undefined) return `${err.message} (${err.detail})`;
2132
2194
  return err instanceof Error ? err.message : String(err);
2133
2195
  } catch {
2134
2196
  return 'a value that could not be printed';
@@ -4023,6 +4085,7 @@ export class EventGate {
4023
4085
  authorId: this.extractAuthorId(info.metadata) ?? undefined,
4024
4086
  serverId: info.serverId || undefined,
4025
4087
  addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'),
4088
+ ...(isSystemNotice(info.tags) && !info.folds ? { systemNotice: true as const } : {}),
4026
4089
  routeChannelId: this.routeChannelFor(info),
4027
4090
  ...(typeof info.metadata?.messageId === 'string' ? { messageId: info.metadata.messageId } : {}),
4028
4091
  ...(typeof info.metadata?.eventId === 'string' ? { eventId: info.metadata.eventId } : {}),
@@ -4226,12 +4289,13 @@ export class EventGate {
4226
4289
 
4227
4290
  // Provenance of the batched wake (who/where woke the agent): the newest
4228
4291
  // ADDRESSED event first, else the newest event naming a channel or an
4229
- // author; channel + author + addressed from that ONE event. When that
4230
- // event addressed the agent and its registered channel is known,
4231
- // `routeChannelId` names the turn's speech locus too — the reply goes
4232
- // back to whoever addressed the agent, not to whichever channel last saw
4233
- // any traffic. Ambient-only batches set no locus. A batch whose line the
4234
- // gate left to the turn's start carries it too (`line`), one object
4292
+ // author, `chat:system` notices aside; channel + author + addressed from
4293
+ // that ONE event. When that event addressed the agent and its registered
4294
+ // channel is known, `routeChannelId` names the turn's speech locus too —
4295
+ // the reply goes back to whoever addressed the agent, not to whichever
4296
+ // channel last saw any traffic. Ambient-only batches set no locus, and
4297
+ // name the channels they came from (`batchChannels`). A batch whose line
4298
+ // the gate left to the turn's start carries it too (`line`), one object
4235
4299
  // shared by every agent woken with these messages, so it is written once.
4236
4300
  const chosen = wakeProvenance(events);
4237
4301
  const provenance = line ? { ...chosen, line } : chosen;
@@ -4268,7 +4332,7 @@ export class EventGate {
4268
4332
  if (this.disposed) return;
4269
4333
  const deciding = this.judging.get(policyName);
4270
4334
  if (deciding) {
4271
- deciding.queued.push(...events);
4335
+ appendAll(deciding.queued, events);
4272
4336
  this.capJudgeEvents(deciding.queued);
4273
4337
  return;
4274
4338
  }
@@ -4287,8 +4351,8 @@ export class EventGate {
4287
4351
  this.judging.set(policyName, state);
4288
4352
  const timedOut = new Promise<never>((_, reject) => {
4289
4353
  state.timer = setTimeout(() => {
4354
+ reject(new JudgeFailure(`the judge took more than ${this.judgeTimeoutMs} ms`));
4290
4355
  abort.abort();
4291
- reject(new Error(`the judge took more than ${this.judgeTimeoutMs} ms`));
4292
4356
  }, this.judgeTimeoutMs);
4293
4357
  // Never keep the process alive on the judge's account.
4294
4358
  state.timer.unref?.();
@@ -4321,7 +4385,7 @@ export class EventGate {
4321
4385
  private holdForJudge(policyName: string, events: PendingEvent[], judge: JudgeConfig): void {
4322
4386
  const held = this.judgeHeld.get(policyName);
4323
4387
  if (held) {
4324
- held.events.push(...events);
4388
+ appendAll(held.events, events);
4325
4389
  held.judge = judge;
4326
4390
  this.capJudgeEvents(held.events);
4327
4391
  } else {
@@ -4447,7 +4511,7 @@ export class EventGate {
4447
4511
  }
4448
4512
  const outcome: 'reply' | 'wait' | 'error' = failed ? 'error' : verdict.reply ? 'reply' : 'wait';
4449
4513
  const reason = failed
4450
- ? (err !== undefined ? errorText(err) : 'the judge gave no verdict')
4514
+ ? (err !== undefined ? judgeFailureWords(err) : 'the judge gave no verdict')
4451
4515
  : typeof verdict.reason === 'string' ? verdict.reason : '';
4452
4516
  const record = this.judgeRecords.get(policyName) ?? { verdicts: { reply: 0, wait: 0, error: 0 } };
4453
4517
  record.verdicts[outcome]++;
@@ -4469,7 +4533,7 @@ export class EventGate {
4469
4533
  const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
4470
4534
  if (failed) {
4471
4535
  console.error(
4472
- `[gate] Rule ${quotedValue(policyName)}: the reply judge failed (${reason}), so its batch of ${state.events.length} `
4536
+ `[gate] Rule ${quotedValue(policyName)}: the reply judge failed (${err !== undefined ? errorText(err) : reason}), so its batch of ${state.events.length} `
4473
4537
  + `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
4474
4538
  );
4475
4539
  }
@@ -29,6 +29,15 @@ import { NativeFormatter } from '@animalabs/membrane';
29
29
  import type { ContentBlock, DetailedUsage, Membrane, NormalizedMessage, NormalizedRequest, NormalizedResponse } from '@animalabs/membrane';
30
30
  import type { JudgeEventSummary, JudgeFn, JudgeRequest, JudgeVerdict, ReplyJudgeOptions, UpcomingWake } from './types.js';
31
31
 
32
+ export class JudgeFailure extends Error {
33
+ readonly detail?: string;
34
+ constructor(message: string, detail?: string) {
35
+ super(message);
36
+ this.name = 'JudgeFailure';
37
+ if (detail !== undefined) this.detail = detail;
38
+ }
39
+ }
40
+
32
41
  /** The judge's model when the host names none. */
33
42
  export const DEFAULT_JUDGE_MODEL = 'claude-haiku-5-5';
34
43
  /** The judge's input budget when the host names none: under Haiku 5.5's 100K price line, with margin. */
@@ -334,9 +343,9 @@ export class ReplyJudge {
334
343
  /** Build the judge's request for one batch: the system prompt, the document in chunks, the question. */
335
344
  async buildRequest(request: JudgeRequest, now = Date.now()): Promise<{ request: NormalizedRequest; agentName: string; chars: number }> {
336
345
  const agentName = this.chooseAgent(request.agentNames);
337
- if (!agentName) throw new Error('no agent to judge for');
346
+ if (!agentName) throw new JudgeFailure('no agent to judge for');
338
347
  const preview = await this.preview(agentName);
339
- if (request.signal.aborted) throw new Error('the judge was stopped before its request');
348
+ if (request.signal.aborted) throw new JudgeFailure('the judge was stopped before its request');
340
349
 
341
350
  let extra: UpcomingWake[] = [];
342
351
  try {
@@ -499,7 +508,7 @@ export class ReplyJudge {
499
508
  const verdict = parseVerdict(text);
500
509
  if (!verdict) {
501
510
  const stop = response.stopReason;
502
- throw new Error(`the judge's answer held no verdict (stop: ${stop}${text ? `, text: ${JSON.stringify(cut(text, 120))}` : ''})`);
511
+ throw new JudgeFailure("the judge's answer held no verdict", `stop: ${stop}${text ? `, text: ${JSON.stringify(cut(text, 120))}` : ''}`);
503
512
  }
504
513
  return {
505
514
  ...verdict,
@@ -388,7 +388,7 @@ export class SuppressionReview {
388
388
  // A rule the reviewer couldn't answer about last time is reviewed from
389
389
  // where its unreviewed events begin.
390
390
  const fromOf = (name: string): number => Math.min(this.retryFrom.get(name) ?? fromMs, fromMs);
391
- const earliest = Math.min(fromMs, ...this.retryFrom.values());
391
+ const earliest = [...this.retryFrom.values()].reduce((min, from) => Math.min(min, from), fromMs);
392
392
  const byRule = new Map<string, SuppressedEventRecord[]>();
393
393
  for (const record of this.deps.records(agentName, earliest, toMs)) {
394
394
  if (Date.parse(record.at) < fromOf(record.rule)) continue;
@@ -410,7 +410,7 @@ export class SuppressionReview {
410
410
  rules.push(await this.judge(agentName, { name, description, ...(rule ?? {}) }, sample, records.length, { fromMs: fromOf(name), toMs }, base));
411
411
  }
412
412
  const summary: SuppressionReviewSummary = {
413
- fromMs: rules.length > 0 ? Math.min(...rules.map((r) => fromOf(r.rule))) : fromMs, toMs, rules,
413
+ fromMs: rules.length > 0 ? rules.reduce((min, r) => Math.min(min, fromOf(r.rule)), Infinity) : fromMs, toMs, rules,
414
414
  total: rules.reduce((n, r) => n + r.suppressed, 0),
415
415
  flagged: rules.reduce((n, r) => n + r.flagged.length, 0),
416
416
  };
package/src/gate/types.ts CHANGED
@@ -521,6 +521,13 @@ export interface GateEventInfo {
521
521
  * that lands where no view hides suppressed rows.
522
522
  */
523
523
  suppressionAsked?: boolean;
524
+ /**
525
+ * A coalesced render, which folds its batch's every occurrence while its
526
+ * `tags` are the latest's (MCPL RFC-006): never taken as a `chat:system`
527
+ * notice, as the render's own wake isn't, so a message it folds keeps
528
+ * its channel when a notice came last.
529
+ */
530
+ folds?: true;
524
531
  }
525
532
 
526
533
  /** An MCPL event whose content the framework stored at arrival, as a
@@ -603,7 +603,7 @@ export class Heartbeat {
603
603
  this.cancelReminders?.();
604
604
  this.cancelReminders = null;
605
605
  if (!this.started || this.state.reminders.length === 0) return;
606
- const due = Math.min(...this.state.reminders.map(dueAt));
606
+ const due = this.state.reminders.reduce((min, rem) => Math.min(min, dueAt(rem)), Infinity);
607
607
  this.cancelReminders = callAt(Math.max(due, this.notBefore), this.now, () => {
608
608
  this.cancelReminders = null;
609
609
  this.fireDueReminders();
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from './types/index.js';
3
3
 
4
4
  // Core classes
5
5
  export { AgentFramework, AgentStoppedError, BudgetPreflightError, DefaultErrorPolicy, ProviderWaitNotSleptError, QuiesceBlockedError, ResumeBlockedError } from './framework.js';
6
+ export { isReasoningLoss } from './reasoning-loss.js';
6
7
  export type { RuntimeSettingsPreview, HostModeStatus, DeriveAgentOptions } from './framework.js';
7
8
  export { Agent } from './agent.js';
8
9
  export { FLAT_MINIMUM_MS, type InferenceBoundsOptions } from './inference-bounds.js';
@@ -72,11 +72,9 @@ export function wholeCallMs(bounds: InferenceBounds, maxTokens: number | undefin
72
72
  return setupMs(bounds.progressBounds) + allowanceMs(bounds.progressBounds, maxTokens);
73
73
  }
74
74
 
75
- // membrane 0.8.0 marks a deadline it reports as run out with this registered
76
- // symbol (on a sent request, or the Anthropic SDK's timeout, which covers its
77
- // connect timeout too) and exports no reader or marker for it. The one reader:
78
- // the deadline class here and the transient-failure check of an exhausted
79
- // turn's re-arm both use it.
75
+ // membrane 0.8.0 and 0.9.0 mark a deadline they report as run out with this
76
+ // registered symbol (on a sent request, or the Anthropic SDK's timeout, which
77
+ // covers its connect timeout too) and export no reader or marker for it.
80
78
  const DEADLINE_EXPIRY = Symbol.for('membrane.deadlineExpiry');
81
79
 
82
80
  export function isDeadlineExpiry(error: unknown): boolean {
@@ -85,7 +83,7 @@ export function isDeadlineExpiry(error: unknown): boolean {
85
83
 
86
84
  /**
87
85
  * Names the host setting for the bound a stream cut hit, read from the opening
88
- * of membrane 0.8.0's message for it (its progress-bounds.ts and
86
+ * of membrane 0.8.0's and 0.9.0's message for it (their progress-bounds.ts and
89
87
  * providers/utils.ts), so the reason an operator and the agent see names the
90
88
  * host's setting for that bound, as the README's table maps them. A message it
91
89
  * doesn't know is left as it is.
@@ -99,6 +97,8 @@ export function withHostSetting(message: string): string {
99
97
  return setting === undefined ? message : `${message} (the host's setting for this bound: \`inferenceBounds.${setting}\`)`;
100
98
  }
101
99
 
100
+ const CUTS = new WeakSet<object>();
101
+
102
102
  export function deadlineCut(message: string | undefined): MembraneError {
103
103
  const error = new MembraneError({
104
104
  type: 'timeout',
@@ -107,5 +107,10 @@ export function deadlineCut(message: string | undefined): MembraneError {
107
107
  rawError: undefined,
108
108
  });
109
109
  Object.defineProperty(error, DEADLINE_EXPIRY, { value: true });
110
+ CUTS.add(error);
110
111
  return error;
111
112
  }
113
+
114
+ export function isDeadlineCut(error: unknown): boolean {
115
+ return typeof error === 'object' && error !== null && CUTS.has(error);
116
+ }