@matatbread/matbot-triggers 0.3.3 → 0.3.5

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.3",
7
+ "version": "0.3.5",
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.3"
36
+ "@matatbread/matbot-plugin-api": "^0.3.5"
37
37
  },
38
38
  "devDependencies": {
39
39
  "typescript": "^6.0.3",
40
- "@matatbread/matbot-plugin-api": "^0.3.3"
40
+ "@matatbread/matbot-plugin-api": "^0.3.5"
41
41
  },
42
42
  "scripts": {
43
43
  "typecheck": "tsc --noEmit"
package/src/manager.ts CHANGED
@@ -27,7 +27,6 @@ function invokeKey(t: { invoke: Trigger['invoke'] }): string {
27
27
  * Constructed only with web-platform primitives, so it runs in the browser too.
28
28
  */
29
29
  export class TriggerManager implements Triggers {
30
- private readonly triggers = new Map<string, Trigger>();
31
30
  private readonly store: Store<Trigger>;
32
31
  private readonly services: MatbotMachine;
33
32
  // Aborts on teardown (clear()), ending the mounted-swap subscription set up in setupTriggers.
@@ -38,8 +37,7 @@ export class TriggerManager implements Triggers {
38
37
  this.services = services;
39
38
  }
40
39
 
41
- /** Ends with the manager (teardown). Hand to `services.mounted.consume` so a StorageBackend swap
42
- * re-reads the new backend's triggers, and the loop stops when the plugin unloads. */
40
+ /** Aborts on teardown (clear()) — ends any in-flight classifier call the manager owns. */
43
41
  get signal(): AbortSignal { return this.lifecycle.signal; }
44
42
 
45
43
  // The classifier provider, resolved live per evaluation (so a triggers_config change takes effect on
@@ -53,26 +51,25 @@ export class TriggerManager implements Triggers {
53
51
  return turnProvider;
54
52
  }
55
53
 
56
- /** (Re)load persisted triggers into memory. Re-runnable: the initial boot load and every later
57
- * StorageBackend swap funnel through here. Reading `this.store` (a swap-following proxy) always hits
58
- * the live backend, so a swap re-reads the new backend's triggers. Clears first — the old in-memory
59
- * set belongs to the displaced backend. */
60
- async load(): Promise<void> {
61
- this.triggers.clear();
54
+ /** Read straight through the backing store on every call — no in-memory snapshot. The store is a
55
+ * swap-following proxy, so this always reflects the live backend AND the current principal's
56
+ * partition, and a shared backend's out-of-band writes are seen too. A snapshot here would be
57
+ * principal-blind and go stale under any second writer; caching, if a slow backend needs it,
58
+ * belongs in the StorageBackend, not in this consumer. */
59
+ async all(): Promise<Trigger[]> {
62
60
  const { items } = await this.store.query({});
63
- for (const t of items) {
64
- this.triggers.set(t.id, t);
65
- }
61
+ return items;
66
62
  }
67
63
 
68
- all(): Trigger[] { return [...this.triggers.values()]; }
69
- get(id: string): Trigger | undefined { return this.triggers.get(id); }
64
+ async get(id: string): Promise<Trigger | undefined> {
65
+ return (await this.store.get(id)) ?? undefined;
66
+ }
70
67
 
71
68
  /** Triggers whose invocation matches the filter: `tool` (if given) must equal `invoke.tool`, and
72
69
  * `params` (if given) must deep-equal `invoke.params`. The natural "which trigger(s) fire tool X
73
70
  * (with these args)" lookup — e.g. the one that loads a given skill. */
74
- query(filter: { tool?: string; params?: unknown }): Trigger[] {
75
- return this.all().filter(t => {
71
+ async query(filter: { tool?: string; params?: unknown }): Promise<Trigger[]> {
72
+ return (await this.all()).filter(t => {
76
73
  if (filter.tool !== undefined && t.invoke.tool !== filter.tool) return false;
77
74
  if (filter.params !== undefined &&
78
75
  JSON.stringify(t.invoke.params ?? null) !== JSON.stringify(filter.params)) return false;
@@ -92,13 +89,12 @@ export class TriggerManager implements Triggers {
92
89
  updatedAt: now,
93
90
  };
94
91
  await this.store.set(doc.id, doc);
95
- this.triggers.set(doc.id, doc);
96
92
  return doc;
97
93
  }
98
94
 
99
95
  async update(id: string, patch: Partial<TriggerSpec>): Promise<Trigger | undefined> {
100
- const cur = this.triggers.get(id);
101
- if (cur === undefined) return undefined;
96
+ const cur = await this.store.get(id);
97
+ if (cur === null) return undefined;
102
98
  return this.casMutate(cur, prev => ({
103
99
  ...prev,
104
100
  ...(patch.conditions !== undefined ? { conditions: patch.conditions } : {}),
@@ -110,21 +106,20 @@ export class TriggerManager implements Triggers {
110
106
  }
111
107
 
112
108
  async remove(id: string): Promise<boolean> {
113
- const cur = this.triggers.get(id);
114
- if (cur === undefined) return false;
109
+ const cur = await this.store.get(id);
110
+ if (cur === null) return false;
115
111
  await this.store.delete(id, cur.version);
116
- this.triggers.delete(id);
117
112
  return true;
118
113
  }
119
114
 
120
115
  async importIfAbsent(spec: TriggerSpec): Promise<Trigger> {
121
116
  const key = invokeKey(spec);
122
- const existing = this.all().find(t => invokeKey(t) === key);
117
+ const existing = (await this.all()).find(t => invokeKey(t) === key);
123
118
  if (existing !== undefined) return existing;
124
119
  return this.add(spec);
125
120
  }
126
121
 
127
- clear(): void { this.lifecycle.abort(); this.triggers.clear(); }
122
+ clear(): void { this.lifecycle.abort(); }
128
123
 
129
124
  /**
130
125
  * LLM-judge every enabled condition on `surface` against the current turn and return the distinct
@@ -148,7 +143,8 @@ export class TriggerManager implements Triggers {
148
143
  // Candidate key is `${triggerId}#${conditionIndex}` — addressing conditions by index is fine
149
144
  // because evaluation is per-turn and the trigger set is stable for its duration. The surface a
150
145
  // condition belongs to is derived from its `kind` (ephemeral/contextual→user, retract/followup→agent).
151
- const candidates = this.all()
146
+ const triggers = await this.all();
147
+ const candidates = triggers
152
148
  .filter(t => t.enabled !== false)
153
149
  .flatMap(t => t.conditions
154
150
  .map((c, i) => ({ triggerId: t.id, key: `${t.id}#${i}`, index: i, kind: c.kind, rule: c.rule }))
@@ -166,7 +162,7 @@ export class TriggerManager implements Triggers {
166
162
  'relational (refers to what was asked, answered, disputed, or repeated). Fire a condition when, ' +
167
163
  `reading the "${subject.label}" in light of the "${context.label}", it holds. Return ONLY a JSON ` +
168
164
  'object mapping each condition id (the bracketed value) to an object {"match": true|false, ' +
169
- '"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.',
170
166
  prompt:
171
167
  `${context.label} (earlier):\n${context.text === '' ? '(none)' : clip(context.text)}\n\n` +
172
168
  `${subject.label} (later — evaluate the conditions against THIS):\n${clip(subject.text)}\n\n` +
@@ -195,8 +191,9 @@ export class TriggerManager implements Triggers {
195
191
  list.push({ index: c.index, kind: c.kind, rule: c.rule, ...(why !== undefined ? { why } : {}) });
196
192
  firedByTrigger.set(c.triggerId, list);
197
193
  }
194
+ const byId = new Map(triggers.map(t => [t.id, t]));
198
195
  return [...firedByTrigger].flatMap(([id, matched]) => {
199
- const trigger = this.triggers.get(id);
196
+ const trigger = byId.get(id);
200
197
  return trigger ? [{ trigger, kinds: [...new Set(matched.map(m => m.kind))], matched }] : [];
201
198
  });
202
199
  }
@@ -206,11 +203,10 @@ export class TriggerManager implements Triggers {
206
203
  for (;;) {
207
204
  const next = mutate(cur);
208
205
  const r = await this.store.cas(cur.id, cur.version, next);
209
- if (r.ok) { this.triggers.set(next.id, next); return next; }
206
+ if (r.ok) return next;
210
207
  const fresh = await this.store.get(cur.id);
211
208
  if (fresh === null) {
212
209
  await this.store.set(next.id, next);
213
- this.triggers.set(next.id, next);
214
210
  return next;
215
211
  }
216
212
  cur = fresh;
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.
@@ -148,19 +205,68 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
148
205
 
149
206
  const store = services.createStore<Trigger>('triggers') as Store<Trigger>;
150
207
  const manager = new TriggerManager(store, services);
151
- await manager.load();
152
- // Re-read after a deferred StorageBackend swap lands: the new backend's `triggers` namespace replaces
153
- // the old in-memory set. No `replay` — the initial load is the boot load above; this reacts only to
154
- // future swaps. Ends with the manager (teardown aborts manager.signal).
155
- services.mounted.consume({ key: 'StorageBackend', signal: manager.signal }, () => void manager.load());
208
+ // No boot load and no StorageBackend-swap re-read: the manager reads straight through `store` (a
209
+ // swap-following proxy) on every call, so it always reflects the live backend and current partition.
156
210
  await services.register('Triggers', manager);
157
211
 
158
212
  services.tools.register(createTriggerActionTool(manager));
159
213
  services.tools.register(createTriggersConfigTool(services));
160
214
 
161
- // user phase — pre-response. Judges the incoming user message and delivers each fired trigger's tool
162
- // result by its kind: `ephemeral` informs this response only; `contextual` folds durably onto the
163
- // 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.
164
270
  services.hooks.register({
165
271
  on: 'screen',
166
272
  async handler(ctx) {
@@ -168,62 +274,55 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
168
274
  // Skip our own agent-phase robo resubmissions — they are not real user turns.
169
275
  if (!lastUser || lastUser.content.every(c => c.origin === 'robo')) return;
170
276
 
171
- // Guard: a retract-redo re-runs this same user turn. The user-phase (ephemeral/contextual)
172
- // triggers already fired — and their side effects (e.g. remember_fact's store write, or a
173
- // contextual fold persisted onto the user message) already happened — on the original attempt,
174
- // so re-firing here double-applies them. Hold off, and record why (suppression is never silent).
175
- // 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).
176
281
  if (isRetractRedo(ctx.session.messages)) {
177
282
  return { markers: [suppressedMarker('user-redo', 'user-phase triggers held off: retract-redo (user message already processed on the original attempt)', [])] };
178
283
  }
179
284
 
180
- const fired = await manager.evaluate(
181
- 'user',
182
- { label: 'latest user message', text: textOf(lastUser) },
183
- { label: 'preceding assistant message', text: textOf(ctx.session.messages.findLast(
184
- l => l.role === 'assistant' && l.content.some(c => c.type === 'text'),
185
- )) },
186
- ctx.signal,
187
- ctx.config.provider,
188
- );
189
- if (fired.length === 0) return;
190
-
191
- // Partition fired triggers' output by delivery. A trigger can carry both kinds on this surface;
192
- // if any `contextual` condition fired the output goes durable, since a durable fold is also sent
193
- // on this very turn (it lands in the user message before the provider call) — it is a superset of
194
- // ephemeral, so "contextual dominates" loses nothing, mirroring retract-over-followup on the
195
- // agent surface. Markers are collected regardless — they persist whichever path runs.
196
- const ephemeralBodies: string[] = [];
197
- const ephemeralSources: FiredSource[] = [];
198
- const durableBodies: string[] = [];
199
- const durableSources: FiredSource[] = [];
200
- const markers: MessageContent[] = [];
201
- for (const { trigger, kinds, matched } of fired) {
202
- const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) });
203
- if (out.hadResult) {
204
- if (kinds.includes('contextual')) {
205
- durableBodies.push(renderResult(out.result));
206
- durableSources.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'contextual') });
207
- } else {
208
- ephemeralBodies.push(renderResult(out.result));
209
- ephemeralSources.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'ephemeral') });
210
- }
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
+ };
211
313
  }
212
- markers.push(...out.markers);
213
314
  }
214
- // Trace each injection. The ephemeral text is never otherwise persisted, so the marker carries it;
215
- // the durable text rides the user message itself, so its marker records only the firing sources.
216
- if (ephemeralBodies.length > 0) markers.push(injectionMarker('ephemeral-inject', ephemeralSources, ephemeralBodies.join(JOIN)));
217
- if (durableBodies.length > 0) markers.push(injectionMarker('durable-inject', durableSources));
218
- if (ephemeralBodies.length === 0 && durableBodies.length === 0 && markers.length === 0) return;
219
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.
220
318
  return {
221
- // The dispatcher appends markers to the session AND emits them live (consistent draw/reload).
222
- // `ephemeral` informs only this turn; `durable` folds onto the user turn (origin: 'robo'),
223
- // persists, and is carried live as a robo-user event.
224
- ...(markers.length > 0 ? { markers } : {}),
225
- ...(ephemeralBodies.length > 0 ? { ephemeral: [{ type: 'text', text: fence(ephemeralBodies.join(JOIN)) }] } : {}),
226
- ...(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
+ },
227
326
  };
228
327
  },
229
328
  });
@@ -237,8 +336,20 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
237
336
  services.hooks.register({
238
337
  on: 'followup',
239
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
+
240
346
  if (ctx.resubmitDepth > 0) return;
241
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
+
242
353
  const fired = await manager.evaluate(
243
354
  'agent',
244
355
  { label: 'assistant response', text: textOf(ctx.session.messages.findLast(
@@ -248,23 +359,24 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
248
359
  ctx.signal,
249
360
  ctx.config.provider,
250
361
  );
251
- if (fired.length === 0) return;
252
362
 
253
363
  // Convergence guard: a retract rule that already retracted on the PREVIOUS turn and is about to
254
364
  // again has not converged (the redo didn't dissolve its condition). Hold those rules off so a
255
365
  // mis-tuned retract can't pop-and-redo every turn forever. A well-behaved rule self-terminates
256
- // (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.)
257
368
  const heldOff = retractActiveLastTurn(ctx.session.messages);
258
369
 
259
- // Partition fired triggers' output by their kind. A trigger can carry both retract and followup
260
- // conditions; if any retract condition fired, the trigger is treated as retract (a wrong answer
261
- // 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.
262
374
  const retractBodies: string[] = [];
263
375
  const retractSources: FiredSource[] = [];
264
376
  const followupBodies: string[] = [];
265
377
  const followupSources: FiredSource[] = [];
266
378
  const suppressed: FiredSource[] = [];
267
- const markers: MessageContent[] = [];
379
+ const markers: MessageContent[] = [...userOut.markers];
268
380
  for (const { trigger, kinds, matched } of fired) {
269
381
  if (kinds.includes('retract') && heldOff.has(trigger.id)) {
270
382
  suppressed.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'retract') });
@@ -287,26 +399,43 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
287
399
  markers.push(suppressedMarker('retract-convergence', 'retract held off: this rule was active on the previous turn and is still matching (non-converging)', suppressed));
288
400
  }
289
401
 
290
- // Retract dominates: if any trigger judged the response WRONG, the response is discarded, so a
291
- // steer that critiques it has lost its subject — skip the followup bodies this turn. That skip is
292
- // a suppression too, so it leaves a `suppressed` marker (not just a log): a followup that "didn't
293
- // fire" because a retract preempted it is answerable from the session. The redo's injected context
294
- // is recorded by the core retraction marker; the `retract-fired` marker lets the next turn's
295
- // convergence guard see this firing.
296
- 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) {
297
421
  if (followupBodies.length > 0) {
298
422
  markers.push(suppressedMarker('followup-shadowed', 'followup steer skipped: a retract on the same turn supersedes the response it would critique', followupSources));
299
423
  }
300
- 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));
301
426
  return {
302
- 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
+ },
303
431
  markers,
304
432
  };
305
433
  }
306
434
  if (followupBodies.length > 0) {
435
+ markers.push(followupFiredMarker(followupSources));
307
436
  return {
308
437
  resubmit: { content: [{ type: 'text', origin: 'robo', text: fence(followupBodies.join(JOIN)) }] },
309
- ...(markers.length > 0 ? { markers } : {}),
438
+ markers,
310
439
  };
311
440
  }
312
441
  // No model-facing result from any trigger — only markers (silent side-effects), if any.
package/src/tools.ts CHANGED
@@ -90,20 +90,20 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
90
90
 
91
91
  switch (args.action) {
92
92
  case 'list': {
93
- yield { type: 'result', value: { triggers: manager.all() } };
93
+ yield { type: 'result', value: { triggers: await manager.all() } };
94
94
  return;
95
95
  }
96
96
 
97
97
  case 'query': {
98
98
  const a = args as Extract<TriggerActionInput, { action: 'query' }>;
99
- yield { type: 'result', value: { triggers: manager.query(queryFilter(a)) } };
99
+ yield { type: 'result', value: { triggers: await manager.query(queryFilter(a)) } };
100
100
  return;
101
101
  }
102
102
 
103
103
  case 'get': {
104
104
  const { id } = args as Extract<TriggerActionInput, { action: 'get' }>;
105
105
  if (!id) { yield { type: 'error', message: 'action "get" requires "id".' }; return; }
106
- const t = manager.get(id);
106
+ const t = await manager.get(id);
107
107
  if (!t) { yield { type: 'error', message: `Trigger not found: "${id}"` }; return; }
108
108
  yield { type: 'result', value: t };
109
109
  return;
@@ -128,10 +128,13 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
128
128
  if (!a.id) { yield { type: 'error', message: 'action "update" requires "id".' }; return; }
129
129
  if (a.conditions !== undefined && !validConditions(a.conditions)) { yield { type: 'error', message: '"conditions" must be [{ kind, rule }].' }; return; }
130
130
  if (a.tool !== undefined && typeof a.tool !== 'string') { yield { type: 'error', message: '"tool" must be a string.' }; return; }
131
+ const priorTool = (a.tool !== undefined || a.params !== undefined)
132
+ ? (await manager.get(a.id))?.invoke.tool ?? ''
133
+ : '';
131
134
  const t = await manager.update(a.id, {
132
135
  ...(a.conditions !== undefined ? { conditions: a.conditions } : {}),
133
136
  ...(a.tool !== undefined || a.params !== undefined
134
- ? { invoke: { tool: a.tool ?? manager.get(a.id)?.invoke.tool ?? '', ...(a.params !== undefined ? { params: a.params } : {}) } }
137
+ ? { invoke: { tool: a.tool ?? priorTool, ...(a.params !== undefined ? { params: a.params } : {}) } }
135
138
  : {}),
136
139
  ...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
137
140
  });
@@ -168,7 +171,7 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
168
171
  if (a.tool === undefined && a.params === undefined) { yield { type: 'error', message: 'action "move" requires a filter ("tool" and/or "params") selecting which triggers to re-target — refusing to move every trigger.' }; return; }
169
172
  const invoke = { tool: a.toTool, ...(a.toParams !== undefined ? { params: a.toParams } : {}) };
170
173
  const triggers: Trigger[] = [];
171
- for (const t of manager.query(queryFilter(a))) {
174
+ for (const t of await manager.query(queryFilter(a))) {
172
175
  const updated = await manager.update(t.id, { invoke });
173
176
  if (updated !== undefined) triggers.push(updated);
174
177
  }
@@ -182,7 +185,7 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
182
185
  if (a.tool === undefined && a.params === undefined) { yield { type: 'error', message: 'action "copy" requires a filter ("tool" and/or "params") selecting which triggers to duplicate — refusing to duplicate every trigger.' }; return; }
183
186
  const invoke = { tool: a.toTool, ...(a.toParams !== undefined ? { params: a.toParams } : {}) };
184
187
  const triggers: Trigger[] = [];
185
- for (const t of manager.query(queryFilter(a))) {
188
+ for (const t of await manager.query(queryFilter(a))) {
186
189
  triggers.push(await manager.add({
187
190
  conditions: t.conditions,
188
191
  invoke,
package/src/types.ts CHANGED
@@ -92,11 +92,11 @@ export interface TriggerSpec {
92
92
  * triggers plugin, which owns the hooks that drive them.
93
93
  */
94
94
  export interface Triggers {
95
- all(): Trigger[];
96
- get(id: string): Trigger | undefined;
95
+ all(): Promise<Trigger[]>;
96
+ get(id: string): Promise<Trigger | undefined>;
97
97
  /** Triggers whose invocation matches the filter — `tool` (if given) equals `invoke.tool`, `params`
98
98
  * (if given) deep-equals `invoke.params`. The "which trigger(s) fire tool X" lookup. */
99
- query(filter: { tool?: string; params?: unknown }): Trigger[];
99
+ query(filter: { tool?: string; params?: unknown }): Promise<Trigger[]>;
100
100
  add(spec: TriggerSpec): Promise<Trigger>;
101
101
  update(id: string, patch: Partial<TriggerSpec>): Promise<Trigger | undefined>;
102
102
  remove(id: string): Promise<boolean>;