@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 +3 -3
- package/src/manager.ts +1 -1
- package/src/plugin.ts +218 -86
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.3.
|
|
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.
|
|
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.
|
|
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": "<
|
|
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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* the
|
|
133
|
-
*
|
|
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
|
|
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
|
|
159
|
-
//
|
|
160
|
-
//
|
|
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
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
257
|
-
// conditions; if any retract condition fired, the trigger is treated as retract (a wrong
|
|
258
|
-
// can't be merely steered). Markers are collected regardless — they persist whichever path
|
|
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
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
if (
|
|
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(
|
|
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: {
|
|
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
|
-
|
|
438
|
+
markers,
|
|
307
439
|
};
|
|
308
440
|
}
|
|
309
441
|
// No model-facing result from any trigger — only markers (silent side-effects), if any.
|