@matatbread/matbot-triggers 0.4.12 → 0.4.14
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 +42 -5
- package/src/plugin.ts +42 -4
- package/src/tools.ts +22 -9
- package/src/types.ts +2 -1
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.14",
|
|
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.14"
|
|
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.14"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
43
|
"typecheck": "tsc --noEmit"
|
package/src/dispatch.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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 } from './types.js';
|
|
3
|
+
import type { Trigger, TriggerInvoke } 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). */
|
|
@@ -12,6 +12,42 @@ export interface DispatchOutcome {
|
|
|
12
12
|
markers: MessageContent[];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** core's `TOOL_INPUT_INVALID`, the `code` on the error event a validator's rejection yields. Spelled
|
|
16
|
+
* here rather than imported because triggers depends on plugin-api alone; it is the one error a stored
|
|
17
|
+
* trigger can only recover from by being edited, which is why it is reported to the model. */
|
|
18
|
+
export const TOOL_INPUT_INVALID = 422;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The input a trigger's tool is actually called with. An omitted `params` is `{}`, never `undefined`:
|
|
22
|
+
* every tool input is an object (the model's own call of a parameterless tool sends `{}`), so passing
|
|
23
|
+
* `undefined` made every such trigger fail validation — `remember_fact` and a `tool_function` with no
|
|
24
|
+
* arguments among them — while reading as "no params needed" to whoever wrote it.
|
|
25
|
+
*/
|
|
26
|
+
export function invocationParams(invoke: TriggerInvoke): unknown {
|
|
27
|
+
return invoke.params ?? {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// core declares `ToolCallValidator` on MatbotServices, and triggers does not depend on core, so the key
|
|
31
|
+
// is read structurally. Same shape as core's `ToolInputValidator`.
|
|
32
|
+
interface InvocationValidator {
|
|
33
|
+
validateToolCall(tool: string, parameters: unknown): Promise<{ path: string; message: string }[] | undefined>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check an invocation against the tool's declared input BEFORE it is stored, with the same validator the
|
|
38
|
+
* executor applies when the trigger fires — so a trigger that could never run is refused while its author
|
|
39
|
+
* is present to fix it, rather than failing silently on some later turn. Returns the rejection as text,
|
|
40
|
+
* or `undefined` to accept. A tool that is not registered, or no validator, is accepted: a trigger
|
|
41
|
+
* naming an absent tool fails soft by design, and nothing here can check what nothing declares.
|
|
42
|
+
*/
|
|
43
|
+
export async function checkInvocation(services: MatbotMachine, invoke: TriggerInvoke): Promise<string | undefined> {
|
|
44
|
+
if (services.tools.resolve(invoke.tool) === null) return undefined;
|
|
45
|
+
const validator = (services as MatbotMachine & { ToolCallValidator?: InvocationValidator }).ToolCallValidator;
|
|
46
|
+
const errs = await validator?.validateToolCall(invoke.tool, invocationParams(invoke));
|
|
47
|
+
if (errs === undefined || errs.length === 0) return undefined;
|
|
48
|
+
return `params do not match tool "${invoke.tool}": ${errs.map(e => `${e.path}: ${e.message}`).join('; ')}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
15
51
|
/**
|
|
16
52
|
* Invoke a fired trigger's tool, observing its output. The observational rule decides whether the
|
|
17
53
|
* model wakes: a tool that yields a `result` is producing model-facing content (`hadResult` → the
|
|
@@ -31,10 +67,11 @@ export async function dispatchTrigger(
|
|
|
31
67
|
// The runner reframes this for the tools it runs itself (INTERRUPTED_TOOL_RESULT); a trigger's tool runs
|
|
32
68
|
// outside that loop, so the same reframing has to happen here. The trace is still recorded — a trigger
|
|
33
69
|
// that was interrupted did nothing, and a post-mortem wants to know that — just not as an error.
|
|
34
|
-
|
|
70
|
+
// `code` rides along so the next turn can tell a malformed invocation from an ordinary tool failure.
|
|
71
|
+
const fail = (error: string, code?: number): void => {
|
|
35
72
|
const data = ctx.signal.aborted
|
|
36
73
|
? { triggerId: trigger.id, tool: trigger.invoke.tool, interrupted: true }
|
|
37
|
-
: { triggerId: trigger.id, tool: trigger.invoke.tool, error };
|
|
74
|
+
: { triggerId: trigger.id, tool: trigger.invoke.tool, error, ...(code !== undefined ? { code } : {}) };
|
|
38
75
|
markers.push({ type: 'marker', creator: 'triggers', data });
|
|
39
76
|
};
|
|
40
77
|
|
|
@@ -54,7 +91,7 @@ export async function dispatchTrigger(
|
|
|
54
91
|
// The prompt (when the firing hook carries one — a live interactive session behind this turn) is
|
|
55
92
|
// forwarded so a trigger can invoke an interactive tool (e.g. `ask_user`) for real; absent, the
|
|
56
93
|
// tool runs non-interactively and a prompt attempt surfaces as a normal tool error.
|
|
57
|
-
const events = invokeTool(services, trigger.invoke.tool, trigger.invoke
|
|
94
|
+
const events = invokeTool(services, trigger.invoke.tool, invocationParams(trigger.invoke), {
|
|
58
95
|
session: ctx.session,
|
|
59
96
|
signal: ctx.signal,
|
|
60
97
|
provider: ctx.provider,
|
|
@@ -66,7 +103,7 @@ export async function dispatchTrigger(
|
|
|
66
103
|
else if (ev.type === 'marker') { markers.push({ type: 'marker', creator: ev.creator, data: ev.data }); }
|
|
67
104
|
else if (ev.type === 'error') {
|
|
68
105
|
if (!ctx.signal.aborted) console.warn(`[triggers] trigger ${trigger.id} tool "${trigger.invoke.tool}" errored: ${ev.message}`);
|
|
69
|
-
fail(ev.message);
|
|
106
|
+
fail(ev.message, ev.code);
|
|
70
107
|
}
|
|
71
108
|
}
|
|
72
109
|
} catch (e) {
|
package/src/plugin.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { PLUGIN_API_VERSION } from '@matatbread/matbot-plugin-api';
|
|
2
2
|
import type { MatbotPluginSpec, MatbotMachine, Store, Message, MessageContent, Session, PromptFn, DeferredCorrection } from '@matatbread/matbot-plugin-api';
|
|
3
3
|
import { TriggerManager } from './manager.js';
|
|
4
|
-
import { dispatchTrigger, renderResult } from './dispatch.js';
|
|
4
|
+
import { dispatchTrigger, renderResult, TOOL_INPUT_INVALID } from './dispatch.js';
|
|
5
5
|
import { createTriggerActionTool, createTriggersConfigTool } from './tools.js';
|
|
6
6
|
import type { Trigger, Triggers, FiredCondition, TriggerCooldown } from './types.js';
|
|
7
7
|
|
|
@@ -102,6 +102,36 @@ function hasCorrection(c: DeferredCorrection): boolean {
|
|
|
102
102
|
return (c.ephemeral?.length ?? 0) > 0 || (c.durable?.length ?? 0) > 0;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The fired triggers whose call the tool REJECTED as malformed, rendered for the model. Such a trigger
|
|
107
|
+
* is rejected every time it fires — its stored invocation is wrong, not the turn — and the only trace
|
|
108
|
+
* was an LLM-invisible marker and a server-log warning, so nobody learned of it until someone went
|
|
109
|
+
* looking. Reported on the next genuine user turn (`from` is that user message's index), where the model
|
|
110
|
+
* can say so and repair it with `trigger_action update`. The window is the markers since the PREVIOUS
|
|
111
|
+
* genuine user message, robo resubmits included, so each firing is reported exactly once.
|
|
112
|
+
*/
|
|
113
|
+
export function invalidInvocationReport(messages: readonly Message[], from: number): MessageContent[] | undefined {
|
|
114
|
+
const lines: string[] = [];
|
|
115
|
+
for (let i = from - 1; i >= 0; i--) {
|
|
116
|
+
const m = messages[i]!;
|
|
117
|
+
if (m.role === 'user' && !m.content.every(c => c.origin === 'robo')) break;
|
|
118
|
+
for (const c of m.content) {
|
|
119
|
+
if (c.type !== 'marker' || c.creator !== 'triggers') continue;
|
|
120
|
+
const d = c.data as { triggerId?: unknown; tool?: unknown; error?: unknown; code?: unknown };
|
|
121
|
+
if (d.code !== TOOL_INPUT_INVALID || typeof d.triggerId !== 'string') continue;
|
|
122
|
+
lines.unshift(`- trigger ${d.triggerId} (invokes "${String(d.tool)}"): ${String(d.error)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (lines.length === 0) return undefined;
|
|
126
|
+
return [{ type: 'text', text: fence(
|
|
127
|
+
'On the previous turn, these triggers fired but their tool rejected the call, so they did nothing. ' +
|
|
128
|
+
'The stored `invoke.params` do not match what the tool accepts, and will fail the same way every time ' +
|
|
129
|
+
'the trigger fires:\n' + lines.join('\n') + '\n\n' +
|
|
130
|
+
'Tell the user. If the correct params are clear from the error and the tool\'s parameters, fix the ' +
|
|
131
|
+
'trigger with `trigger_action` (action "update", with its id and the corrected "params").',
|
|
132
|
+
) }];
|
|
133
|
+
}
|
|
134
|
+
|
|
105
135
|
// The core retraction marker's creator (core/src/session-runner.ts). Hardcoded as a
|
|
106
136
|
// documented cross-package string contract — markers are keyed by a creator string, and triggers
|
|
107
137
|
// depends only on plugin-api, not core. Keep in sync with RETRACTION_CREATOR there.
|
|
@@ -295,7 +325,7 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
295
325
|
// swap-following proxy) on every call, so it always reflects the live backend and current partition.
|
|
296
326
|
await services.register('Triggers', manager);
|
|
297
327
|
|
|
298
|
-
services.tools.register(createTriggerActionTool(manager));
|
|
328
|
+
services.tools.register(createTriggerActionTool(manager, services));
|
|
299
329
|
services.tools.register(createTriggersConfigTool(services));
|
|
300
330
|
|
|
301
331
|
// In-flight user-phase evaluations, keyed by the turn's traceId. The `screen` hook kicks one off
|
|
@@ -375,8 +405,15 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
375
405
|
// No traceId ⇒ can't correlate to the followup hook that consumes the verdict; skip racing (a
|
|
376
406
|
// direct runSession caller without a traceId simply gets no user-phase triggers). In the pump —
|
|
377
407
|
// the only real entry point — traceId is always set.
|
|
408
|
+
// Rejections from the previous turn ride out ephemerally on whichever return this hook takes.
|
|
409
|
+
const report = invalidInvocationReport(ctx.session.messages, ctx.session.messages.lastIndexOf(lastUser));
|
|
410
|
+
const withReport = (eph?: MessageContent[]): { ephemeral?: MessageContent[] } => {
|
|
411
|
+
const all = [...(report ?? []), ...(eph ?? [])];
|
|
412
|
+
return all.length > 0 ? { ephemeral: all } : {};
|
|
413
|
+
};
|
|
414
|
+
|
|
378
415
|
const traceId = ctx.config.traceId;
|
|
379
|
-
if (traceId === undefined) return;
|
|
416
|
+
if (traceId === undefined) return report ? withReport() : undefined;
|
|
380
417
|
|
|
381
418
|
// Kick off classify+dispatch; do not await. The record's `ready`/`fenced` let the runner poll
|
|
382
419
|
// synchronously; `.catch` neutralises an aborted-turn rejection (the classifier shares the turn's
|
|
@@ -397,7 +434,7 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
397
434
|
if (rec.ready && hasCorrection(rec.correction) && !rec.claimed) {
|
|
398
435
|
rec.claimed = true;
|
|
399
436
|
return {
|
|
400
|
-
...(rec.correction.ephemeral
|
|
437
|
+
...withReport(rec.correction.ephemeral),
|
|
401
438
|
...(rec.correction.durable ? { durable: rec.correction.durable } : {}),
|
|
402
439
|
};
|
|
403
440
|
}
|
|
@@ -406,6 +443,7 @@ export async function setupTriggers(services: MatbotMachine): Promise<TriggerMan
|
|
|
406
443
|
// Race path: hand the runner a deferred that claims the correction exactly once for an in-situ
|
|
407
444
|
// restart (or a pre-generation fold). Not settled / no result / already claimed ⇒ undefined.
|
|
408
445
|
return {
|
|
446
|
+
...withReport(),
|
|
409
447
|
deferred: {
|
|
410
448
|
claim: (): DeferredCorrection | undefined => {
|
|
411
449
|
if (!rec.ready || rec.claimed || !hasCorrection(rec.correction)) return undefined;
|
package/src/tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext, MatbotMachine } from '@matatbread/matbot-plugin-api';
|
|
2
2
|
import type { TriggerManager } from './manager.js';
|
|
3
|
+
import { checkInvocation } from './dispatch.js';
|
|
3
4
|
import type { TriggerCondition, TriggerCooldown, TriggerKind, Trigger } from './types.js';
|
|
4
5
|
|
|
5
6
|
declare module '@matatbread/matbot-plugin-api' {
|
|
@@ -52,7 +53,10 @@ const GUIDANCE =
|
|
|
52
53
|
'verification, so keep it and add a follow-up turn carrying the output (e.g. critique/verify the ' +
|
|
53
54
|
'existing answer — it must remain in context). Choose retract vs followup by whether a match means ' +
|
|
54
55
|
'"this is wrong" vs "look at this again".\n\n' +
|
|
55
|
-
'`invoke` names a tool and its params, run verbatim when the trigger fires.
|
|
56
|
+
'`invoke` names a tool and its params (omitted ⇒ `{}`), run verbatim when the trigger fires. The ' +
|
|
57
|
+
'params are checked against the tool\'s declared parameters when the trigger is saved, so match them ' +
|
|
58
|
+
'exactly; a trigger whose call is rejected when it fires is reported on the next turn — fix it with ' +
|
|
59
|
+
'"update". If the tool produces ' +
|
|
56
60
|
'a result, the model is woken with it; a pure side-effect tool (no result) runs silently. An ' +
|
|
57
61
|
'invoke naming a tool that is not present fails soft — the trigger simply does nothing until it ' +
|
|
58
62
|
'is. To fire a skill on a condition, invoke `skill_action` with `{ action: "use", name }` — `use` ' +
|
|
@@ -113,7 +117,7 @@ function validConditions(x: unknown): x is TriggerCondition[] {
|
|
|
113
117
|
typeof (c as { rule?: unknown }).rule === 'string');
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResultOf<'trigger_action'>> {
|
|
120
|
+
export function createTriggerActionTool(manager: TriggerManager, services: MatbotMachine): Tool<ToolResultOf<'trigger_action'>> {
|
|
117
121
|
const executor: ToolExecutor<ToolResultOf<'trigger_action'>> = {
|
|
118
122
|
async *execute(input: unknown, _ctx: ToolContext) {
|
|
119
123
|
const args = input as Partial<TriggerActionInput> & { action?: string };
|
|
@@ -145,9 +149,12 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
|
|
|
145
149
|
if (a.conditions.length === 0) { yield { type: 'error', message: 'action "add" requires at least one condition.' }; return; }
|
|
146
150
|
if (typeof a.tool !== 'string') { yield { type: 'error', message: 'action "add" requires a string "tool" to invoke.' }; return; }
|
|
147
151
|
if (a.cooldown !== undefined && !validCooldown(a.cooldown)) { yield { type: 'error', message: COOLDOWN_ERR }; return; }
|
|
152
|
+
const invoke = { tool: a.tool, ...(a.params !== undefined ? { params: a.params } : {}) };
|
|
153
|
+
const refused = await checkInvocation(services, invoke);
|
|
154
|
+
if (refused !== undefined) { yield { type: 'error', message: `Trigger not added — ${refused}.` }; return; }
|
|
148
155
|
const t = await manager.add({
|
|
149
156
|
conditions: a.conditions,
|
|
150
|
-
invoke
|
|
157
|
+
invoke,
|
|
151
158
|
...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
|
|
152
159
|
...(a.cooldown !== undefined ? { cooldown: a.cooldown as TriggerCooldown } : {}),
|
|
153
160
|
});
|
|
@@ -161,14 +168,16 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
|
|
|
161
168
|
if (a.conditions !== undefined && !validConditions(a.conditions)) { yield { type: 'error', message: '"conditions" must be [{ kind, rule }].' }; return; }
|
|
162
169
|
if (a.tool !== undefined && typeof a.tool !== 'string') { yield { type: 'error', message: '"tool" must be a string.' }; return; }
|
|
163
170
|
if (a.cooldown !== undefined && a.cooldown !== null && !validCooldown(a.cooldown)) { yield { type: 'error', message: COOLDOWN_ERR + ' Pass null to remove every limit.' }; return; }
|
|
164
|
-
const
|
|
165
|
-
? (await manager.get(a.id))?.invoke.tool ?? ''
|
|
166
|
-
:
|
|
171
|
+
const invoke = a.tool !== undefined || a.params !== undefined
|
|
172
|
+
? { tool: a.tool ?? (await manager.get(a.id))?.invoke.tool ?? '', ...(a.params !== undefined ? { params: a.params } : {}) }
|
|
173
|
+
: undefined;
|
|
174
|
+
// Only an edit to the invocation is checked, so a broken trigger can still be disabled or have
|
|
175
|
+
// its conditions changed without first being repaired.
|
|
176
|
+
const refused = invoke !== undefined ? await checkInvocation(services, invoke) : undefined;
|
|
177
|
+
if (refused !== undefined) { yield { type: 'error', message: `Trigger not updated — ${refused}.` }; return; }
|
|
167
178
|
const t = await manager.update(a.id, {
|
|
168
179
|
...(a.conditions !== undefined ? { conditions: a.conditions } : {}),
|
|
169
|
-
...(
|
|
170
|
-
? { invoke: { tool: a.tool ?? priorTool, ...(a.params !== undefined ? { params: a.params } : {}) } }
|
|
171
|
-
: {}),
|
|
180
|
+
...(invoke !== undefined ? { invoke } : {}),
|
|
172
181
|
...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
|
|
173
182
|
// null clears every limit; an omitted `cooldown` leaves the stored one untouched.
|
|
174
183
|
...(a.cooldown !== undefined ? { cooldown: (a.cooldown === null ? {} : a.cooldown) as TriggerCooldown } : {}),
|
|
@@ -205,6 +214,8 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
|
|
|
205
214
|
if (typeof a.toTool !== 'string') { yield { type: 'error', message: 'action "move" requires a string "toTool" to re-target the selected triggers onto.' }; return; }
|
|
206
215
|
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; }
|
|
207
216
|
const invoke = { tool: a.toTool, ...(a.toParams !== undefined ? { params: a.toParams } : {}) };
|
|
217
|
+
const refused = await checkInvocation(services, invoke);
|
|
218
|
+
if (refused !== undefined) { yield { type: 'error', message: `No triggers moved — ${refused}.` }; return; }
|
|
208
219
|
const triggers: Trigger[] = [];
|
|
209
220
|
for (const t of await manager.query(queryFilter(a))) {
|
|
210
221
|
const updated = await manager.update(t.id, { invoke });
|
|
@@ -219,6 +230,8 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
|
|
|
219
230
|
if (typeof a.toTool !== 'string') { yield { type: 'error', message: 'action "copy" requires a string "toTool" to point the duplicates at.' }; return; }
|
|
220
231
|
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; }
|
|
221
232
|
const invoke = { tool: a.toTool, ...(a.toParams !== undefined ? { params: a.toParams } : {}) };
|
|
233
|
+
const refused = await checkInvocation(services, invoke);
|
|
234
|
+
if (refused !== undefined) { yield { type: 'error', message: `No triggers copied — ${refused}.` }; return; }
|
|
222
235
|
const triggers: Trigger[] = [];
|
|
223
236
|
for (const t of await manager.query(queryFilter(a))) {
|
|
224
237
|
triggers.push(await manager.add({
|
package/src/types.ts
CHANGED
|
@@ -62,7 +62,8 @@ export interface FiredCondition {
|
|
|
62
62
|
why?: string;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/** The tool call a matched trigger makes. `params` is passed verbatim as the tool's input
|
|
65
|
+
/** The tool call a matched trigger makes. `params` is passed verbatim as the tool's input; absent, the
|
|
66
|
+
* tool is called with `{}`, as the model calls a tool that takes no parameters. */
|
|
66
67
|
export interface TriggerInvoke {
|
|
67
68
|
tool: string;
|
|
68
69
|
params?: unknown;
|