@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 +3 -3
- package/src/manager.ts +25 -29
- package/src/plugin.ts +220 -91
- package/src/tools.ts +9 -6
- package/src/types.ts +3 -3
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.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.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
this.triggers.set(t.id, t);
|
|
65
|
-
}
|
|
61
|
+
return items;
|
|
66
62
|
}
|
|
67
63
|
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
101
|
-
if (cur ===
|
|
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.
|
|
114
|
-
if (cur ===
|
|
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();
|
|
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
|
|
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": "<
|
|
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 =
|
|
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)
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
152
|
-
//
|
|
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
|
|
162
|
-
//
|
|
163
|
-
//
|
|
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
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
260
|
-
// conditions; if any retract condition fired, the trigger is treated as retract (a wrong
|
|
261
|
-
// 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.
|
|
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
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
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) {
|
|
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(
|
|
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: {
|
|
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
|
-
|
|
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 ??
|
|
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>;
|