@matatbread/matbot-triggers 0.4.21 → 0.4.22
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/dispatch.ts +13 -6
- package/src/plugin.ts +31 -12
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.22",
|
|
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.4.
|
|
36
|
+
"@matatbread/matbot-plugin-api": "^0.4.22"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"typescript": "^6.0.3",
|
|
40
|
-
"@matatbread/matbot-plugin-api": "^0.4.
|
|
40
|
+
"@matatbread/matbot-plugin-api": "^0.4.22"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
43
|
"typecheck": "tsc --noEmit"
|
package/src/dispatch.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { MatbotMachine, Session, PromptFn, MessageContent } from '@matatbread/matbot-plugin-api';
|
|
2
2
|
import { invokeTool } from '@matatbread/matbot-plugin-api';
|
|
3
|
-
import type { Trigger, TriggerInvoke } from './types.js';
|
|
3
|
+
import type { Trigger, TriggerInvoke, FiredCondition } from './types.js';
|
|
4
4
|
|
|
5
5
|
export interface DispatchOutcome {
|
|
6
6
|
/** Whether the tool yielded a result — i.e. whether the model should be woken with it (inject). */
|
|
7
7
|
hadResult: boolean;
|
|
8
8
|
/** The yielded result (meaningful only when `hadResult`). */
|
|
9
9
|
result: unknown;
|
|
10
|
+
/** The tool did not complete — absent, errored, threw, or was cut off — and an error marker says so. */
|
|
11
|
+
failed: boolean;
|
|
10
12
|
/** Durable markers to persist: any the tool emitted, plus a synthesised error marker if it failed.
|
|
11
13
|
* These outlive the silent firing, so a post-mortem can see what each trigger actually did. */
|
|
12
14
|
markers: MessageContent[];
|
|
@@ -48,14 +50,17 @@ export async function checkInvocation(services: MatbotMachine, invoke: TriggerIn
|
|
|
48
50
|
* caller injects it); a tool that yields none is a silent side-effect (the model never wakes). Any
|
|
49
51
|
* `marker` events the tool emits are collected for the caller to persist, and a tool that errors or
|
|
50
52
|
* throws — or names an absent tool — is recorded as an error marker rather than vanishing into a log;
|
|
51
|
-
* one cut off by an abort is recorded as interrupted, not as a failure.
|
|
53
|
+
* one cut off by an abort is recorded as interrupted, not as a failure. `matched` rides on that marker,
|
|
54
|
+
* since a failed fire leaves no fire marker of its own to say why it fired.
|
|
52
55
|
*/
|
|
53
56
|
export async function dispatchTrigger(
|
|
54
57
|
services: MatbotMachine,
|
|
55
58
|
trigger: Trigger,
|
|
56
59
|
ctx: { session: Session; signal: AbortSignal; provider: string; prompt?: PromptFn },
|
|
60
|
+
matched?: FiredCondition[],
|
|
57
61
|
): Promise<DispatchOutcome> {
|
|
58
62
|
const markers: MessageContent[] = [];
|
|
63
|
+
let failed = false;
|
|
59
64
|
// An aborted signal (a mid-turn steer, a user cancel) means the tool was cut off, not that it faulted:
|
|
60
65
|
// the raw abort reason is an internal token ("steer") that reads as a real failure in a durable marker.
|
|
61
66
|
// The runner reframes this for the tools it runs itself (INTERRUPTED_TOOL_RESULT); a trigger's tool runs
|
|
@@ -63,9 +68,11 @@ export async function dispatchTrigger(
|
|
|
63
68
|
// that was interrupted did nothing, and a post-mortem wants to know that — just not as an error.
|
|
64
69
|
// `code` rides along so the next turn can tell a malformed invocation from an ordinary tool failure.
|
|
65
70
|
const fail = (error: string, code?: number): void => {
|
|
71
|
+
failed = true;
|
|
72
|
+
const why = matched !== undefined ? { matched } : {};
|
|
66
73
|
const data = ctx.signal.aborted
|
|
67
|
-
? { triggerId: trigger.id, tool: trigger.invoke.tool, interrupted: true }
|
|
68
|
-
: { triggerId: trigger.id, tool: trigger.invoke.tool, error, ...(code !== undefined ? { code } : {}) };
|
|
74
|
+
? { triggerId: trigger.id, tool: trigger.invoke.tool, interrupted: true, ...why }
|
|
75
|
+
: { triggerId: trigger.id, tool: trigger.invoke.tool, error, ...(code !== undefined ? { code } : {}), ...why };
|
|
69
76
|
markers.push({ type: 'marker', creator: 'triggers', data });
|
|
70
77
|
};
|
|
71
78
|
|
|
@@ -79,7 +86,7 @@ export async function dispatchTrigger(
|
|
|
79
86
|
if (services.tools.resolve(trigger.invoke.tool) === null) {
|
|
80
87
|
console.warn(`[triggers] trigger ${trigger.id} invokes unknown tool "${trigger.invoke.tool}"; skipped.`);
|
|
81
88
|
fail(`tool "${trigger.invoke.tool}" not registered`);
|
|
82
|
-
return { hadResult: false, result: undefined, markers };
|
|
89
|
+
return { hadResult: false, result: undefined, failed, markers };
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
// The prompt (when the firing hook carries one — a live interactive session behind this turn) is
|
|
@@ -105,7 +112,7 @@ export async function dispatchTrigger(
|
|
|
105
112
|
fail(e instanceof Error ? e.message : String(e));
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
return { hadResult, result, markers };
|
|
115
|
+
return { hadResult, result, failed, markers };
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
/**
|
package/src/plugin.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { MatbotPluginSpec, MatbotMachine, Store, Message, MessageContent, S
|
|
|
3
3
|
import { TriggerManager } from './manager.js';
|
|
4
4
|
import { dispatchTrigger, renderResult, TOOL_INPUT_INVALID } from './dispatch.js';
|
|
5
5
|
import { createTriggerActionTool, createTriggersConfigTool } from './tools.js';
|
|
6
|
-
import type { Trigger, Triggers, FiredCondition, TriggerCooldown } from './types.js';
|
|
6
|
+
import type { Trigger, Triggers, FiredCondition, TriggerCooldown, TriggerSurface } from './types.js';
|
|
7
7
|
|
|
8
8
|
/** A firing trigger's id plus the specific condition(s) that matched — the unit markers trace, so a
|
|
9
9
|
* post-mortem can see not just that a trigger fired but which rubric the classifier judged true and
|
|
@@ -16,15 +16,19 @@ interface FiredSource {
|
|
|
16
16
|
/** The outcome of the concurrent user-phase evaluation (see the racing note in {@link setupTriggers}):
|
|
17
17
|
* the rendered result bodies split by delivery — `ephemeral` (a `ephemeral`-kind fire, informs one
|
|
18
18
|
* answer) vs `durable` (a `contextual`-kind fire, folds onto the user turn and persists) — plus the
|
|
19
|
-
* firing sources
|
|
20
|
-
* nothing fired / nothing
|
|
19
|
+
* firing sources, the silent fires (already traced in `markers`, kept apart only so the cool-down can
|
|
20
|
+
* count them before those markers are persisted) and any markers. All empty ⇒ nothing fired / nothing
|
|
21
|
+
* to do. */
|
|
21
22
|
interface UserPhaseOutcome {
|
|
22
23
|
ephemeralBodies: string[];
|
|
23
24
|
durableBodies: string[];
|
|
24
25
|
sources: FiredSource[];
|
|
26
|
+
silent: FiredSource[];
|
|
25
27
|
markers: MessageContent[];
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
const NO_USER_PHASE = (): UserPhaseOutcome => ({ ephemeralBodies: [], durableBodies: [], sources: [], silent: [], markers: [] });
|
|
31
|
+
|
|
28
32
|
/** A user-phase evaluation in flight for one turn, shared between the three places that may deliver its
|
|
29
33
|
* correction: the runner's in-situ restart (via the `DeferredScreen.claim()` the screen hook returns),
|
|
30
34
|
* an optional pre-first-token grace inject (screen, when `classifierGraceMs > 0`), and the post-commit
|
|
@@ -161,6 +165,14 @@ function followupFiredMarker(triggers: FiredSource[]): MessageContent {
|
|
|
161
165
|
return { type: 'marker', creator: 'triggers', data: { event: 'followup-fired', surface: 'agent', triggers } };
|
|
162
166
|
}
|
|
163
167
|
|
|
168
|
+
// A fire whose tool completed without a result (remember_fact, say) delivers nothing, so no other
|
|
169
|
+
// marker carries its `matched` — and a silent side-effect is exactly where a false positive goes
|
|
170
|
+
// unnoticed, writing a fact or a file nobody asked for. The tool's own markers say what it did; this
|
|
171
|
+
// says which rule fired it and why. A failed fire is traced by its error marker instead.
|
|
172
|
+
function silentFiredMarker(surface: TriggerSurface, triggers: FiredSource[]): MessageContent {
|
|
173
|
+
return { type: 'marker', creator: 'triggers', data: { event: 'silent-fired', surface, triggers } };
|
|
174
|
+
}
|
|
175
|
+
|
|
164
176
|
// True when the latest turn is a retract-redo: a `matbot-retraction` marker sits after the last genuine
|
|
165
177
|
// (non-robo) user message — i.e. this user message was already processed on the original attempt, so
|
|
166
178
|
// user-phase (ephemeral/contextual) triggers and their side effects already fired. Re-firing them on the
|
|
@@ -201,9 +213,10 @@ function retractActiveLastTurn(messages: Message[]): Set<string> {
|
|
|
201
213
|
}
|
|
202
214
|
|
|
203
215
|
// Every marker event that records a trigger ACTUALLY firing (as opposed to being held off), across both
|
|
204
|
-
// surfaces
|
|
205
|
-
// there is no in-memory counter, so a cool-down holds across restart, reload
|
|
206
|
-
|
|
216
|
+
// surfaces, all three delivery paths and the silent side-effect. This is the durable fire log the
|
|
217
|
+
// cool-down is counted from — there is no in-memory counter, so a cool-down holds across restart, reload
|
|
218
|
+
// and a backend swap.
|
|
219
|
+
const FIRE_EVENTS = new Set(['retract-fired', 'followup-fired', 'user-insitu-fired', 'user-retract-fired', 'silent-fired']);
|
|
207
220
|
|
|
208
221
|
/** Per-trigger firing history, in turns. `thisTurn` counts fires inside the current turn region (since
|
|
209
222
|
* the last genuine user message; a redo or robo resubmit belongs to that same turn). `since` is how
|
|
@@ -364,13 +377,15 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
364
377
|
const ephemeralBodies: string[] = [];
|
|
365
378
|
const durableBodies: string[] = [];
|
|
366
379
|
const sources: FiredSource[] = [];
|
|
380
|
+
const silent: FiredSource[] = [];
|
|
367
381
|
const markers: MessageContent[] = [];
|
|
368
382
|
// A per-trigger cool-down applies to both surfaces: matching says the trigger is relevant, the
|
|
369
383
|
// cool-down says whether it may act again this soon. Held-off triggers are traced, never silent.
|
|
370
384
|
const cooled = applyCooldown(fired, fireHistory(ctx.session.messages));
|
|
371
385
|
markers.push(...cooled.markers);
|
|
372
386
|
for (const { trigger, kinds, matched } of cooled.kept) {
|
|
373
|
-
const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) });
|
|
387
|
+
const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) }, matched);
|
|
388
|
+
if (!out.hadResult && !out.failed) silent.push({ id: trigger.id, matched });
|
|
374
389
|
if (out.hadResult) {
|
|
375
390
|
// Contextual dominates: a trigger carrying both kinds folds durable (a superset of ephemeral —
|
|
376
391
|
// it persists AND informs this answer), mirroring the agent surface's retract-over-followup.
|
|
@@ -380,7 +395,8 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
380
395
|
}
|
|
381
396
|
markers.push(...out.markers);
|
|
382
397
|
}
|
|
383
|
-
|
|
398
|
+
if (silent.length > 0) markers.push(silentFiredMarker('user', silent));
|
|
399
|
+
return { ephemeralBodies, durableBodies, sources, silent, markers };
|
|
384
400
|
};
|
|
385
401
|
|
|
386
402
|
// user phase — kicked off pre-response, NOT awaited. Judging the user message concurrently with the
|
|
@@ -421,7 +437,7 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
421
437
|
const rec: UserPhaseInFlight = { verdict: undefined as unknown as Promise<UserPhaseOutcome>, ready: false, correction: {}, claimed: false };
|
|
422
438
|
rec.verdict = runUserPhase({ session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) })
|
|
423
439
|
.then(out => { rec.ready = true; rec.correction = fenceCorrection(out); return out; })
|
|
424
|
-
.catch((): UserPhaseOutcome => { rec.ready = true; return
|
|
440
|
+
.catch((): UserPhaseOutcome => { rec.ready = true; return NO_USER_PHASE(); });
|
|
425
441
|
pendingUserPhase.set(traceId, rec);
|
|
426
442
|
|
|
427
443
|
// Optional grace: wait up to `classifierGraceMs` for the verdict before racing. If it lands with a
|
|
@@ -473,7 +489,7 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
473
489
|
|
|
474
490
|
if (ctx.resubmitDepth > 0) return;
|
|
475
491
|
|
|
476
|
-
const userOut: UserPhaseOutcome = rec ? await rec.verdict :
|
|
492
|
+
const userOut: UserPhaseOutcome = rec ? await rec.verdict : NO_USER_PHASE();
|
|
477
493
|
// `claimed` ⇒ the correction was already delivered on the clean path (the runner's in-situ restart
|
|
478
494
|
// or the screen grace inject), so it must NOT also enter the retract context below.
|
|
479
495
|
const insitu = rec?.claimed ?? false;
|
|
@@ -503,13 +519,14 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
503
519
|
const retractSources: FiredSource[] = [];
|
|
504
520
|
const followupBodies: string[] = [];
|
|
505
521
|
const followupSources: FiredSource[] = [];
|
|
522
|
+
const silentSources: FiredSource[] = [];
|
|
506
523
|
const suppressed: FiredSource[] = [];
|
|
507
524
|
const markers: MessageContent[] = [...userOut.markers];
|
|
508
525
|
// The raced user-phase fires belong to THIS turn but aren't in the committed messages yet (their
|
|
509
526
|
// markers ride out on this hook's return), so fold them in before counting — otherwise a trigger
|
|
510
527
|
// that fired on the user surface would look untouched to its own per-turn cap.
|
|
511
528
|
const hist = fireHistory(ctx.session.messages);
|
|
512
|
-
for (const s of userOut.sources) {
|
|
529
|
+
for (const s of [...userOut.sources, ...userOut.silent]) {
|
|
513
530
|
hist.thisTurn.set(s.id, (hist.thisTurn.get(s.id) ?? 0) + 1);
|
|
514
531
|
hist.since.set(s.id, 0);
|
|
515
532
|
}
|
|
@@ -520,7 +537,8 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
520
537
|
suppressed.push({ id: trigger.id, matched: matched.filter(m => m.kind === 'retract') });
|
|
521
538
|
continue;
|
|
522
539
|
}
|
|
523
|
-
const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) });
|
|
540
|
+
const out = await dispatchTrigger(services, trigger, { session: ctx.session, signal: ctx.signal, provider: ctx.config.provider, ...(ctx.prompt !== undefined ? { prompt: ctx.prompt } : {}) }, matched);
|
|
541
|
+
if (!out.hadResult && !out.failed) silentSources.push({ id: trigger.id, matched });
|
|
524
542
|
if (out.hadResult) {
|
|
525
543
|
if (kinds.includes('retract')) {
|
|
526
544
|
retractBodies.push(renderResult(out.result));
|
|
@@ -532,6 +550,7 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
532
550
|
}
|
|
533
551
|
markers.push(...out.markers);
|
|
534
552
|
}
|
|
553
|
+
if (silentSources.length > 0) markers.push(silentFiredMarker('agent', silentSources));
|
|
535
554
|
// Suppression is never silent — record which rules were held off and why.
|
|
536
555
|
if (suppressed.length > 0) {
|
|
537
556
|
markers.push(suppressedMarker('retract-convergence', 'retract held off: this rule was active on the previous turn and is still matching (non-converging)', suppressed));
|