@matatbread/matbot-triggers 0.3.4 → 0.3.6

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.3.4",
7
+ "version": "0.3.6",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -33,11 +33,11 @@
33
33
  "access": "public"
34
34
  },
35
35
  "peerDependencies": {
36
- "@matatbread/matbot-plugin-api": "^0.3.4"
36
+ "@matatbread/matbot-plugin-api": "^0.3.6"
37
37
  },
38
38
  "devDependencies": {
39
39
  "typescript": "^6.0.3",
40
- "@matatbread/matbot-plugin-api": "^0.3.4"
40
+ "@matatbread/matbot-plugin-api": "^0.3.6"
41
41
  },
42
42
  "scripts": {
43
43
  "typecheck": "tsc --noEmit"
package/src/manager.ts CHANGED
@@ -162,7 +162,7 @@ export class TriggerManager implements Triggers {
162
162
  'relational (refers to what was asked, answered, disputed, or repeated). Fire a condition when, ' +
163
163
  `reading the "${subject.label}" in light of the "${context.label}", it holds. Return ONLY a JSON ` +
164
164
  'object mapping each condition id (the bracketed value) to an object {"match": true|false, ' +
165
- '"why": "<one short sentence citing the specific evidence>"}. No other text.',
165
+ '"why": "<a terse fragment, at most ~8 words, citing the specific evidence — not a full sentence>"}. No other text.',
166
166
  prompt:
167
167
  `${context.label} (earlier):\n${context.text === '' ? '(none)' : clip(context.text)}\n\n` +
168
168
  `${subject.label} (later — evaluate the conditions against THIS):\n${clip(subject.text)}\n\n` +
package/src/plugin.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { PLUGIN_API_VERSION } from '@matatbread/matbot-plugin-api';
2
- import type { MatbotPluginSpec, MatbotMachine, Store, Message, MessageContent } from '@matatbread/matbot-plugin-api';
2
+ import type { MatbotPluginSpec, MatbotMachine, Store, Message, MessageContent, Session, PromptFn, DeferredCorrection } from '@matatbread/matbot-plugin-api';
3
3
  import { TriggerManager } from './manager.js';
4
4
  import { dispatchTrigger, renderResult } from './dispatch.js';
5
5
  import { createTriggerActionTool, createTriggersConfigTool } from './tools.js';
@@ -13,6 +13,30 @@ interface FiredSource {
13
13
  matched: FiredCondition[];
14
14
  }
15
15
 
16
+ /** The outcome of the concurrent user-phase evaluation (see the racing note in {@link setupTriggers}):
17
+ * the rendered result bodies split by delivery — `ephemeral` (a `ephemeral`-kind fire, informs one
18
+ * answer) vs `durable` (a `contextual`-kind fire, folds onto the user turn and persists) — plus the
19
+ * firing sources and any markers (a silent side-effect's tool markers, or a trace). All empty ⇒
20
+ * nothing fired / nothing to do. */
21
+ interface UserPhaseOutcome {
22
+ ephemeralBodies: string[];
23
+ durableBodies: string[];
24
+ sources: FiredSource[];
25
+ markers: MessageContent[];
26
+ }
27
+
28
+ /** A user-phase evaluation in flight for one turn, shared between the three places that may deliver its
29
+ * correction: the runner's in-situ restart (via the `DeferredScreen.claim()` the screen hook returns),
30
+ * an optional pre-first-token grace inject (screen, when `classifierGraceMs > 0`), and the post-commit
31
+ * `followup` retract. `ready`/`correction` let the runner poll synchronously; `claimed` is the
32
+ * exactly-once latch — whoever delivers sets it, so the other two see it and don't re-deliver. */
33
+ interface UserPhaseInFlight {
34
+ verdict: Promise<UserPhaseOutcome>;
35
+ ready: boolean;
36
+ correction: DeferredCorrection; // fenced ephemeral/durable blocks; both empty ⇒ no result-fire
37
+ claimed: boolean;
38
+ }
39
+
16
40
  declare module '@matatbread/matbot-plugin-api' {
17
41
  interface MatbotServices {
18
42
  /** The live trigger set (data-driven hooks). Registered by setupTriggers; consumed by plugins
@@ -48,15 +72,34 @@ function fence(body: string): string {
48
72
  `Take it into account when responding.]\n\n${body}\n\n[End of additional context.]`;
49
73
  }
50
74
 
51
- // A durable trace of a user-phase injection. For `ephemeral-inject` the injected `text` is never
52
- // otherwise persisted, so without this a post-mortem can't see what the system fed the model before it
53
- // answered; for `durable-inject` the text IS persisted (folded onto the user turn), so `text` is
54
- // omitted and the marker records only WHICH condition fired and why. LLM-invisible like any marker; a
55
- // frontend may ignore it (diagnostic, not user-facing). `triggers` are the firing sources (id +
56
- // matched conditions). (The agent-phase injections are traced elsewhere: a retract redo's context by
57
- // the core retraction marker, and a `followup` resubmit by its persisted robo turn.)
58
- function injectionMarker(event: 'ephemeral-inject' | 'durable-inject', triggers: FiredSource[], text?: string): MessageContent {
59
- return { type: 'marker', creator: 'triggers', data: { event, surface: 'user', triggers, ...(text !== undefined ? { text } : {}) } };
75
+ // A durable trace that a user-phase result-fire caused a retract-redo. The redo's injected context is
76
+ // already recorded by the core retraction marker, but that marker doesn't know about triggers — this
77
+ // records WHICH user-phase trigger(s)/condition(s) drove the supersede, so a false-positive post-mortem
78
+ // can see why. A distinct `event` from the agent-phase `retract-fired` so the agent convergence guard
79
+ // (retractActiveLastTurn) never mistakes a user-phase source for one of its own retract rules.
80
+ function userRetractFiredMarker(triggers: FiredSource[]): MessageContent {
81
+ return { type: 'marker', creator: 'triggers', data: { event: 'user-retract-fired', surface: 'user', triggers } };
82
+ }
83
+
84
+ // A user-phase result-fire delivered on the CLEAN path — folded into the turn in-situ (the runner
85
+ // restarted a doomed generation) or before the first token (the screen grace inject) — rather than as a
86
+ // post-commit retract. No `matbot-retraction` marker exists in that case (nothing was popped), so this
87
+ // is the whole durable trace that the correction fired and via which trigger(s)/condition(s).
88
+ function userInsituFiredMarker(triggers: FiredSource[]): MessageContent {
89
+ return { type: 'marker', creator: 'triggers', data: { event: 'user-insitu-fired', surface: 'user', triggers } };
90
+ }
91
+
92
+ // Fence a user-phase outcome's result bodies into a delivery-ready correction: ephemeral bodies become a
93
+ // plain fenced block (tail-folded for one answer), durable bodies become a fenced `origin: 'robo'` block
94
+ // (folded onto the user turn, persisted + visible). Either side may be empty.
95
+ function fenceCorrection(out: UserPhaseOutcome): DeferredCorrection {
96
+ return {
97
+ ...(out.ephemeralBodies.length > 0 ? { ephemeral: [{ type: 'text', text: fence(out.ephemeralBodies.join(JOIN)) }] } : {}),
98
+ ...(out.durableBodies.length > 0 ? { durable: [{ type: 'text', text: fence(out.durableBodies.join(JOIN)), origin: 'robo' as const }] } : {}),
99
+ };
100
+ }
101
+ function hasCorrection(c: DeferredCorrection): boolean {
102
+ return (c.ephemeral?.length ?? 0) > 0 || (c.durable?.length ?? 0) > 0;
60
103
  }
61
104
 
62
105
  // The core retraction marker's creator (core/src/session-runner.ts). Hardcoded as a
@@ -75,7 +118,17 @@ function suppressedMarker(cause: SuppressCause, reason: string, triggers: FiredS
75
118
  return { type: 'marker', creator: 'triggers', data: { event: 'suppressed', cause, reason, triggers } };
76
119
  }
77
120
  function retractFiredMarker(triggers: FiredSource[]): MessageContent {
78
- return { type: 'marker', creator: 'triggers', data: { event: 'retract-fired', triggers } };
121
+ return { type: 'marker', creator: 'triggers', data: { event: 'retract-fired', surface: 'agent', triggers } };
122
+ }
123
+
124
+ // The agent-surface twin of `userInsituFiredMarker`: a `followup`-kind fire keeps the response and
125
+ // resubmits a robo turn, so — unlike a retract — nothing else records it (no core retraction marker,
126
+ // no suppression). Without this the steer arrives with no trace of WHICH rule judged the response and
127
+ // why, which the model then guesses at backwards from the rule text (and those guesses can re-match
128
+ // the same rule, looping invisibly). Deliberately NOT read back by the convergence guard: that guard
129
+ // is retract-only, and `retractActiveLastTurn` keys on `retract-fired`.
130
+ function followupFiredMarker(triggers: FiredSource[]): MessageContent {
131
+ return { type: 'marker', creator: 'triggers', data: { event: 'followup-fired', surface: 'agent', triggers } };
79
132
  }
80
133
 
81
134
  // True when the latest turn is a retract-redo: a `matbot-retraction` marker sits after the last genuine
@@ -125,17 +178,21 @@ function textOf(msg: Message | undefined): string {
125
178
  * Wire the triggers subsystem: build the {@link TriggerManager}, load persisted triggers, register
126
179
  * the `Triggers` service and the `trigger_action` tool, then install the two evaluation hooks:
127
180
  *
128
- * user — a `screen` hook (pre-response) that judges `user`-phase conditions against the incoming
129
- * message, invokes each fired trigger's tool, and delivers the result per the fired
130
- * condition's `kind`: `ephemeral` (inject for this turn only, never persisted) or
131
- * `contextual` (fold DURABLY onto the user message — persisted + visible — so it updates
132
- * the conversation rather than informing one answer).
133
- * agent — a `followup` hook (post-commit) that judges agent-surface conditions against the
181
+ * user — a `screen` hook (pre-response) that KICKS OFF the `user`-phase classify+dispatch but does
182
+ * NOT await it: the classifier races the turn's own generation instead of gating the first
183
+ * token. Its verdict is consumed post-commit by the `followup` hook (below), so a user-phase
184
+ * result-fire supersedes the answer via a retract-redo rather than informing it in advance.
185
+ * This collapses the user surface toward the agent one — both are now concurrent-classify +
186
+ * post-turn-correct, differing only in subject (user message vs response). The `ephemeral`
187
+ * /`contextual` kinds no longer split delivery (a pre-response injection point is gone): any
188
+ * result-fire is delivered as a retract; a silent side-effect (no result) needs no delivery.
189
+ * agent — the `followup` hook (post-commit) also judges agent-surface conditions against the
134
190
  * assistant's response, invokes each fired trigger's tool, and delivers the result per the
135
191
  * fired condition's `kind`: `retract` (discard the response and re-run with the result
136
- * injected) or `followup` (keep the response and resubmit the result as a robo turn).
192
+ * injected) or `followup` (keep the response and resubmit the result as a robo turn). It
193
+ * merges the raced user-phase outcome into the same retract/resubmit decision.
137
194
  *
138
- * Both phases fence the injected payload (see {@link fence}) so the model reads it as system-supplied
195
+ * Both surfaces fence the injected payload (see {@link fence}) so the model reads it as system-supplied
139
196
  * context, not a user utterance.
140
197
  *
141
198
  * Returns the manager so a specialization (a node watcher, say) could attach to the same instance.
@@ -155,9 +212,61 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
155
212
  services.tools.register(createTriggerActionTool(manager));
156
213
  services.tools.register(createTriggersConfigTool(services));
157
214
 
158
- // user phase — pre-response. Judges the incoming user message and delivers each fired trigger's tool
159
- // result by its kind: `ephemeral` informs this response only; `contextual` folds durably onto the
160
- // user turn so it persists into the conversation.
215
+ // In-flight user-phase evaluations, keyed by the turn's traceId. The `screen` hook kicks one off
216
+ // (without awaiting) so the classifier races the turn's generation; the runner claims it in-situ via
217
+ // the DeferredScreen, or the `followup` hook (same traceId) consumes-and-deletes it post-commit. An
218
+ // entry exists only for a genuine, non-redo user turn. Keyed by traceId, so concurrent turns across
219
+ // sessions never collide.
220
+ const pendingUserPhase = new Map<string, UserPhaseInFlight>();
221
+
222
+ // Optional pre-first-token grace: how long `screen` waits for the verdict before handing off to the
223
+ // race. 0 (default) ⇒ pure race (the responsive path — no added latency, correction lands in-situ or
224
+ // as a retract). A positive value trades that responsiveness back for cleanliness: a classifier that
225
+ // settles within the window injects BEFORE the first token (the old "slow but clean" feel), a slower
226
+ // one still races. Read live so a triggers_config/settings change takes effect next turn.
227
+ const resolveGraceMs = async (): Promise<number> => {
228
+ const v = await services.settings().get<number | string>('classifierGraceMs');
229
+ const n = typeof v === 'string' ? Number(v) : v;
230
+ return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : 0;
231
+ };
232
+
233
+ // Judge the user message and dispatch every fired user-phase trigger, partitioning result bodies by
234
+ // kind: `contextual` → durable (folded onto the user turn, persisted), everything else → ephemeral
235
+ // (informs one answer). A silent fire contributes only its tool's markers. Runs off the turn's
236
+ // critical path (kicked off in `screen`); the correction is delivered later on whichever path wins.
237
+ const runUserPhase = async (ctx: { session: Session; signal: AbortSignal; provider: string; prompt?: PromptFn }): Promise<UserPhaseOutcome> => {
238
+ const lastUser = ctx.session.messages.findLast(l => l.role === 'user');
239
+ const fired = await manager.evaluate(
240
+ 'user',
241
+ { label: 'latest user message', text: textOf(lastUser) },
242
+ { label: 'preceding assistant message', text: textOf(ctx.session.messages.findLast(
243
+ l => l.role === 'assistant' && l.content.some(c => c.type === 'text'),
244
+ )) },
245
+ ctx.signal,
246
+ ctx.provider,
247
+ );
248
+ const ephemeralBodies: string[] = [];
249
+ const durableBodies: string[] = [];
250
+ const sources: FiredSource[] = [];
251
+ const markers: MessageContent[] = [];
252
+ for (const { trigger, kinds, matched } of fired) {
253
+ const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) });
254
+ if (out.hadResult) {
255
+ // Contextual dominates: a trigger carrying both kinds folds durable (a superset of ephemeral —
256
+ // it persists AND informs this answer), mirroring the agent surface's retract-over-followup.
257
+ if (kinds.includes('contextual')) durableBodies.push(renderResult(out.result));
258
+ else ephemeralBodies.push(renderResult(out.result));
259
+ sources.push({ id: trigger.id, matched });
260
+ }
261
+ markers.push(...out.markers);
262
+ }
263
+ return { ephemeralBodies, durableBodies, sources, markers };
264
+ };
265
+
266
+ // user phase — kicked off pre-response, NOT awaited. Judging the user message concurrently with the
267
+ // turn keeps the classifier off the pre-first-token critical path. The correction is delivered by
268
+ // whichever of three points wins: the runner's in-situ restart (via the returned DeferredScreen), a
269
+ // pre-first-token grace inject (when `classifierGraceMs > 0`), or the post-commit `followup` retract.
161
270
  services.hooks.register({
162
271
  on: 'screen',
163
272
  async handler(ctx) {
@@ -165,62 +274,55 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
165
274
  // Skip our own agent-phase robo resubmissions — they are not real user turns.
166
275
  if (!lastUser || lastUser.content.every(c => c.origin === 'robo')) return;
167
276
 
168
- // Guard: a retract-redo re-runs this same user turn. The user-phase (ephemeral/contextual)
169
- // triggers already fired — and their side effects (e.g. remember_fact's store write, or a
170
- // contextual fold persisted onto the user message) already happened — on the original attempt,
171
- // so re-firing here double-applies them. Hold off, and record why (suppression is never silent).
172
- // The redo still gets the retract correction via the injected context.
277
+ // Guard: a retract-redo re-runs this same user turn. The user-phase triggers already fired — and
278
+ // their side effects (e.g. remember_fact's store write) already happened — on the original
279
+ // attempt, so re-firing would double-apply them. Don't kick a second evaluation off; the redo
280
+ // already carries the retract correction as injected context. Record why (never silent).
173
281
  if (isRetractRedo(ctx.session.messages)) {
174
282
  return { markers: [suppressedMarker('user-redo', 'user-phase triggers held off: retract-redo (user message already processed on the original attempt)', [])] };
175
283
  }
176
284
 
177
- const fired = await manager.evaluate(
178
- 'user',
179
- { label: 'latest user message', text: textOf(lastUser) },
180
- { label: 'preceding assistant message', text: textOf(ctx.session.messages.findLast(
181
- l => l.role === 'assistant' && l.content.some(c => c.type === 'text'),
182
- )) },
183
- ctx.signal,
184
- ctx.config.provider,
185
- );
186
- if (fired.length === 0) return;
187
-
188
- // Partition fired triggers' output by delivery. A trigger can carry both kinds on this surface;
189
- // if any `contextual` condition fired the output goes durable, since a durable fold is also sent
190
- // on this very turn (it lands in the user message before the provider call) — it is a superset of
191
- // ephemeral, so "contextual dominates" loses nothing, mirroring retract-over-followup on the
192
- // agent surface. Markers are collected regardless — they persist whichever path runs.
193
- const ephemeralBodies: string[] = [];
194
- const ephemeralSources: FiredSource[] = [];
195
- const durableBodies: string[] = [];
196
- const durableSources: FiredSource[] = [];
197
- const markers: MessageContent[] = [];
198
- for (const { trigger, kinds, matched } of fired) {
199
- const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) });
200
- if (out.hadResult) {
201
- if (kinds.includes('contextual')) {
202
- durableBodies.push(renderResult(out.result));
203
- durableSources.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'contextual') });
204
- } else {
205
- ephemeralBodies.push(renderResult(out.result));
206
- ephemeralSources.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'ephemeral') });
207
- }
285
+ // No traceId ⇒ can't correlate to the followup hook that consumes the verdict; skip racing (a
286
+ // direct runSession caller without a traceId simply gets no user-phase triggers). In the pump —
287
+ // the only real entry point — traceId is always set.
288
+ const traceId = ctx.config.traceId;
289
+ if (traceId === undefined) return;
290
+
291
+ // Kick off classify+dispatch; do not await. The record's `ready`/`fenced` let the runner poll
292
+ // synchronously; `.catch` neutralises an aborted-turn rejection (the classifier shares the turn's
293
+ // signal) so it never surfaces as an unhandled rejection.
294
+ const rec: UserPhaseInFlight = { verdict: undefined as unknown as Promise<UserPhaseOutcome>, ready: false, correction: {}, claimed: false };
295
+ rec.verdict = runUserPhase({ session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) })
296
+ .then(out => { rec.ready = true; rec.correction = fenceCorrection(out); return out; })
297
+ .catch((): UserPhaseOutcome => { rec.ready = true; return { ephemeralBodies: [], durableBodies: [], sources: [], markers: [] }; });
298
+ pendingUserPhase.set(traceId, rec);
299
+
300
+ // Optional grace: wait up to `classifierGraceMs` for the verdict before racing. If it lands with a
301
+ // result-fire, deliver it as ordinary ephemeral (injected before the first token — the clean path)
302
+ // and latch `claimed` so neither the runner nor followup re-delivers it. At 0 (default) this is
303
+ // skipped entirely — pure race, no added latency.
304
+ const graceMs = await resolveGraceMs();
305
+ if (graceMs > 0) {
306
+ await Promise.race([rec.verdict, new Promise(r => setTimeout(r, graceMs))]);
307
+ if (rec.ready && hasCorrection(rec.correction) && !rec.claimed) {
308
+ rec.claimed = true;
309
+ return {
310
+ ...(rec.correction.ephemeral ? { ephemeral: rec.correction.ephemeral } : {}),
311
+ ...(rec.correction.durable ? { durable: rec.correction.durable } : {}),
312
+ };
208
313
  }
209
- markers.push(...out.markers);
210
314
  }
211
- // Trace each injection. The ephemeral text is never otherwise persisted, so the marker carries it;
212
- // the durable text rides the user message itself, so its marker records only the firing sources.
213
- if (ephemeralBodies.length > 0) markers.push(injectionMarker('ephemeral-inject', ephemeralSources, ephemeralBodies.join(JOIN)));
214
- if (durableBodies.length > 0) markers.push(injectionMarker('durable-inject', durableSources));
215
- if (ephemeralBodies.length === 0 && durableBodies.length === 0 && markers.length === 0) return;
216
315
 
316
+ // Race path: hand the runner a deferred that claims the correction exactly once for an in-situ
317
+ // restart (or a pre-generation fold). Not settled / no result / already claimed ⇒ undefined.
217
318
  return {
218
- // The dispatcher appends markers to the session AND emits them live (consistent draw/reload).
219
- // `ephemeral` informs only this turn; `durable` folds onto the user turn (origin: 'robo'),
220
- // persists, and is carried live as a robo-user event.
221
- ...(markers.length > 0 ? { markers } : {}),
222
- ...(ephemeralBodies.length > 0 ? { ephemeral: [{ type: 'text', text: fence(ephemeralBodies.join(JOIN)) }] } : {}),
223
- ...(durableBodies.length > 0 ? { durable: [{ type: 'text', text: fence(durableBodies.join(JOIN)), origin: 'robo' as const }] } : {}),
319
+ deferred: {
320
+ claim: (): DeferredCorrection | undefined => {
321
+ if (!rec.ready || rec.claimed || !hasCorrection(rec.correction)) return undefined;
322
+ rec.claimed = true;
323
+ return rec.correction;
324
+ },
325
+ },
224
326
  };
225
327
  },
226
328
  });
@@ -234,8 +336,20 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
234
336
  services.hooks.register({
235
337
  on: 'followup',
236
338
  async handler(ctx) {
339
+ // Consume the raced user-phase record kicked off in `screen` (present only for a genuine, non-redo
340
+ // user turn). Delete-on-read keeps the map bounded; an aborted turn (followup skipped entirely)
341
+ // leaves at most one orphan whose promise already .catch'd itself into a harmless value.
342
+ const traceId = ctx.config.traceId;
343
+ const rec = traceId !== undefined ? pendingUserPhase.get(traceId) : undefined;
344
+ if (traceId !== undefined) pendingUserPhase.delete(traceId);
345
+
237
346
  if (ctx.resubmitDepth > 0) return;
238
347
 
348
+ const userOut: UserPhaseOutcome = rec ? await rec.verdict : { ephemeralBodies: [], durableBodies: [], sources: [], markers: [] };
349
+ // `claimed` ⇒ the correction was already delivered on the clean path (the runner's in-situ restart
350
+ // or the screen grace inject), so it must NOT also enter the retract context below.
351
+ const insitu = rec?.claimed ?? false;
352
+
239
353
  const fired = await manager.evaluate(
240
354
  'agent',
241
355
  { label: 'assistant response', text: textOf(ctx.session.messages.findLast(
@@ -245,23 +359,24 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
245
359
  ctx.signal,
246
360
  ctx.config.provider,
247
361
  );
248
- if (fired.length === 0) return;
249
362
 
250
363
  // Convergence guard: a retract rule that already retracted on the PREVIOUS turn and is about to
251
364
  // again has not converged (the redo didn't dissolve its condition). Hold those rules off so a
252
365
  // mis-tuned retract can't pop-and-redo every turn forever. A well-behaved rule self-terminates
253
- // (its redo cures the defect, so it won't fire next turn) and never lands here.
366
+ // (its redo cures the defect, so it won't fire next turn) and never lands here. (User-phase fires
367
+ // need no such guard: `screen` skips them on a redo, so they fire at most once per user message.)
254
368
  const heldOff = retractActiveLastTurn(ctx.session.messages);
255
369
 
256
- // Partition fired triggers' output by their kind. A trigger can carry both retract and followup
257
- // conditions; if any retract condition fired, the trigger is treated as retract (a wrong answer
258
- // can't be merely steered). Markers are collected regardless — they persist whichever path runs.
370
+ // Partition agent fired triggers' output by their kind. A trigger can carry both retract and
371
+ // followup conditions; if any retract condition fired, the trigger is treated as retract (a wrong
372
+ // answer can't be merely steered). Markers are collected regardless — they persist whichever path
373
+ // runs. The raced user-phase markers ride alongside from the start.
259
374
  const retractBodies: string[] = [];
260
375
  const retractSources: FiredSource[] = [];
261
376
  const followupBodies: string[] = [];
262
377
  const followupSources: FiredSource[] = [];
263
378
  const suppressed: FiredSource[] = [];
264
- const markers: MessageContent[] = [];
379
+ const markers: MessageContent[] = [...userOut.markers];
265
380
  for (const { trigger, kinds, matched } of fired) {
266
381
  if (kinds.includes('retract') && heldOff.has(trigger.id)) {
267
382
  suppressed.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'retract') });
@@ -284,26 +399,43 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
284
399
  markers.push(suppressedMarker('retract-convergence', 'retract held off: this rule was active on the previous turn and is still matching (non-converging)', suppressed));
285
400
  }
286
401
 
287
- // Retract dominates: if any trigger judged the response WRONG, the response is discarded, so a
288
- // steer that critiques it has lost its subject — skip the followup bodies this turn. That skip is
289
- // a suppression too, so it leaves a `suppressed` marker (not just a log): a followup that "didn't
290
- // fire" because a retract preempted it is answerable from the session. The redo's injected context
291
- // is recorded by the core retraction marker; the `retract-fired` marker lets the next turn's
292
- // convergence guard see this firing.
293
- if (retractBodies.length > 0) {
402
+ // A user-phase result-fire already delivered on the clean path (in-situ / grace) is traced but does
403
+ // NOT re-enter the turn — it's already there. Only an UNclaimed one lands here, meaning the verdict
404
+ // arrived after commit: it supersedes the answer via retract. Its ephemeral bodies lead the context
405
+ // (they address the QUESTION; an agent retract addresses the ANSWER); its durable bodies (a
406
+ // contextual fire) fold onto the re-run's user message so the correction persists, not just informs.
407
+ const userHadFire = userOut.ephemeralBodies.length > 0 || userOut.durableBodies.length > 0;
408
+ if (insitu && userHadFire) markers.push(userInsituFiredMarker(userOut.sources));
409
+ const userEph = insitu ? [] : userOut.ephemeralBodies;
410
+ const userDur = insitu ? [] : userOut.durableBodies;
411
+ const retractContext = [...userEph, ...retractBodies];
412
+ const retractDurable = userDur;
413
+
414
+ // Retract dominates: if ANY trigger (user- or agent-phase) judged the exchange wrong, the response
415
+ // is discarded, so a steer that critiques it has lost its subject — skip the followup bodies this
416
+ // turn. That skip is a suppression too, so it leaves a `suppressed` marker (answerable from the
417
+ // session). The redo's injected context is recorded by the core retraction marker; the phase-tagged
418
+ // `retract-fired` markers let a post-mortem (and, for agent, the next turn's convergence guard)
419
+ // see which side fired.
420
+ if (retractContext.length > 0 || retractDurable.length > 0) {
294
421
  if (followupBodies.length > 0) {
295
422
  markers.push(suppressedMarker('followup-shadowed', 'followup steer skipped: a retract on the same turn supersedes the response it would critique', followupSources));
296
423
  }
297
- markers.push(retractFiredMarker(retractSources));
424
+ if (userEph.length > 0 || userDur.length > 0) markers.push(userRetractFiredMarker(userOut.sources));
425
+ if (retractBodies.length > 0) markers.push(retractFiredMarker(retractSources));
298
426
  return {
299
- retractAndRerun: { context: [{ type: 'text', text: fence(retractBodies.join(JOIN)) }] },
427
+ retractAndRerun: {
428
+ ...(retractContext.length > 0 ? { context: [{ type: 'text', text: fence(retractContext.join(JOIN)) }] } : {}),
429
+ ...(retractDurable.length > 0 ? { durable: [{ type: 'text', text: fence(retractDurable.join(JOIN)), origin: 'robo' as const }] } : {}),
430
+ },
300
431
  markers,
301
432
  };
302
433
  }
303
434
  if (followupBodies.length > 0) {
435
+ markers.push(followupFiredMarker(followupSources));
304
436
  return {
305
437
  resubmit: { content: [{ type: 'text', origin: 'robo', text: fence(followupBodies.join(JOIN)) }] },
306
- ...(markers.length > 0 ? { markers } : {}),
438
+ markers,
307
439
  };
308
440
  }
309
441
  // No model-facing result from any trigger — only markers (silent side-effects), if any.