@matatbread/matbot-triggers 0.4.7 → 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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.7",
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.7"
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.7"
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
- const fail = (error: string): void => {
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.params, {
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 ? { ephemeral: 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' {
@@ -11,7 +12,11 @@ declare module '@matatbread/matbot-plugin-api' {
11
12
  | ToolContract<{ triggers: Trigger[] }, { action: 'query'; tool?: string; params?: object }>
12
13
  | ToolContract<Trigger, { action: 'get'; id: string }>
13
14
  | ToolContract<{ id: string }, { action: 'add'; conditions: TriggerCondition[]; tool: string; params?: object; enabled?: boolean; cooldown?: TriggerCooldown }>
14
- | ToolContract<Trigger, { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: object; enabled?: boolean; cooldown?: TriggerCooldown }>
15
+ // `cooldown: null` CLEARS every limit, which an omitted `cooldown` deliberately does not (it
16
+ // leaves the stored one untouched). The executor has always accepted it and the web UI has always
17
+ // sent it; only this arm omitted it, so a documented call was untypeable — invisible until the
18
+ // params type was actually enforced. `add` takes no `null`: there is no prior limit to clear.
19
+ | ToolContract<Trigger, { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: object; enabled?: boolean; cooldown?: TriggerCooldown | null }>
15
20
  | ToolContract<Trigger, { action: 'disable'; id: string }>
16
21
  | ToolContract<Trigger, { action: 'enable'; id: string }>
17
22
  | ToolContract<{ id: string }, { action: 'remove'; id: string }>
@@ -48,7 +53,10 @@ const GUIDANCE =
48
53
  'verification, so keep it and add a follow-up turn carrying the output (e.g. critique/verify the ' +
49
54
  'existing answer — it must remain in context). Choose retract vs followup by whether a match means ' +
50
55
  '"this is wrong" vs "look at this again".\n\n' +
51
- '`invoke` names a tool and its params, run verbatim when the trigger fires. If the tool produces ' +
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 ' +
52
60
  'a result, the model is woken with it; a pure side-effect tool (no result) runs silently. An ' +
53
61
  'invoke naming a tool that is not present fails soft — the trigger simply does nothing until it ' +
54
62
  'is. To fire a skill on a condition, invoke `skill_action` with `{ action: "use", name }` — `use` ' +
@@ -61,7 +69,8 @@ const GUIDANCE =
61
69
  'consecutive turns). Absent ⇒ unlimited, which is right for most triggers: a trigger that captures ' +
62
70
  'what the user just said SHOULD fire every turn, and rate-limiting it silently loses data. Use a ' +
63
71
  'cool-down for triggers whose consequence is a self-directed steer (critique, verify, reconsider), ' +
64
- 'where matching every turn is correct but acting every turn is a spin.';
72
+ 'where matching every turn is correct but acting every turn is a spin. On "update", omitting ' +
73
+ '`cooldown` leaves the stored limits untouched; pass `null` to clear them.';
65
74
 
66
75
  type TriggerActionInput =
67
76
  | { action: 'list' }
@@ -108,7 +117,7 @@ function validConditions(x: unknown): x is TriggerCondition[] {
108
117
  typeof (c as { rule?: unknown }).rule === 'string');
109
118
  }
110
119
 
111
- export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResultOf<'trigger_action'>> {
120
+ export function createTriggerActionTool(manager: TriggerManager, services: MatbotMachine): Tool<ToolResultOf<'trigger_action'>> {
112
121
  const executor: ToolExecutor<ToolResultOf<'trigger_action'>> = {
113
122
  async *execute(input: unknown, _ctx: ToolContext) {
114
123
  const args = input as Partial<TriggerActionInput> & { action?: string };
@@ -140,9 +149,12 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
140
149
  if (a.conditions.length === 0) { yield { type: 'error', message: 'action "add" requires at least one condition.' }; return; }
141
150
  if (typeof a.tool !== 'string') { yield { type: 'error', message: 'action "add" requires a string "tool" to invoke.' }; return; }
142
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; }
143
155
  const t = await manager.add({
144
156
  conditions: a.conditions,
145
- invoke: { tool: a.tool, ...(a.params !== undefined ? { params: a.params } : {}) },
157
+ invoke,
146
158
  ...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
147
159
  ...(a.cooldown !== undefined ? { cooldown: a.cooldown as TriggerCooldown } : {}),
148
160
  });
@@ -156,14 +168,16 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
156
168
  if (a.conditions !== undefined && !validConditions(a.conditions)) { yield { type: 'error', message: '"conditions" must be [{ kind, rule }].' }; return; }
157
169
  if (a.tool !== undefined && typeof a.tool !== 'string') { yield { type: 'error', message: '"tool" must be a string.' }; return; }
158
170
  if (a.cooldown !== undefined && a.cooldown !== null && !validCooldown(a.cooldown)) { yield { type: 'error', message: COOLDOWN_ERR + ' Pass null to remove every limit.' }; return; }
159
- const priorTool = (a.tool !== undefined || a.params !== undefined)
160
- ? (await manager.get(a.id))?.invoke.tool ?? ''
161
- : '';
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; }
162
178
  const t = await manager.update(a.id, {
163
179
  ...(a.conditions !== undefined ? { conditions: a.conditions } : {}),
164
- ...(a.tool !== undefined || a.params !== undefined
165
- ? { invoke: { tool: a.tool ?? priorTool, ...(a.params !== undefined ? { params: a.params } : {}) } }
166
- : {}),
180
+ ...(invoke !== undefined ? { invoke } : {}),
167
181
  ...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
168
182
  // null clears every limit; an omitted `cooldown` leaves the stored one untouched.
169
183
  ...(a.cooldown !== undefined ? { cooldown: (a.cooldown === null ? {} : a.cooldown) as TriggerCooldown } : {}),
@@ -200,6 +214,8 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
200
214
  if (typeof a.toTool !== 'string') { yield { type: 'error', message: 'action "move" requires a string "toTool" to re-target the selected triggers onto.' }; return; }
201
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; }
202
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; }
203
219
  const triggers: Trigger[] = [];
204
220
  for (const t of await manager.query(queryFilter(a))) {
205
221
  const updated = await manager.update(t.id, { invoke });
@@ -214,6 +230,8 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
214
230
  if (typeof a.toTool !== 'string') { yield { type: 'error', message: 'action "copy" requires a string "toTool" to point the duplicates at.' }; return; }
215
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; }
216
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; }
217
235
  const triggers: Trigger[] = [];
218
236
  for (const t of await manager.query(queryFilter(a))) {
219
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;