@matatbread/matbot-triggers 0.4.12 → 0.4.16

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.12",
7
+ "version": "0.4.16",
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.12"
36
+ "@matatbread/matbot-plugin-api": "^0.4.16"
37
37
  },
38
38
  "devDependencies": {
39
39
  "typescript": "^6.0.3",
40
- "@matatbread/matbot-plugin-api": "^0.4.12"
40
+ "@matatbread/matbot-plugin-api": "^0.4.16"
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,26 +1,27 @@
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 type { TriggerCondition, TriggerCooldown, TriggerKind, Trigger } from './types.js';
3
+ import { checkInvocation } from './dispatch.js';
4
+ import type { TriggerCondition, TriggerCooldown, TriggerKind, Trigger, ReportedTrigger } from './types.js';
4
5
 
5
6
  declare module '@matatbread/matbot-plugin-api' {
6
7
  interface ToolContracts {
7
8
  // One arm per action: a caller of `invokeTool(machine, 'trigger_action', { action: '…' })` gets the
8
9
  // matching result narrowed by the `action` it passed (see ToolContract / the multi-action note on ToolContracts).
9
10
  trigger_action:
10
- | ToolContract<{ triggers: Trigger[] }, { action: 'list' }>
11
- | ToolContract<{ triggers: Trigger[] }, { action: 'query'; tool?: string; params?: object }>
12
- | ToolContract<Trigger, { action: 'get'; id: string }>
11
+ | ToolContract<{ triggers: ReportedTrigger[] }, { action: 'list' }>
12
+ | ToolContract<{ triggers: ReportedTrigger[] }, { action: 'query'; tool?: string; params?: object }>
13
+ | ToolContract<ReportedTrigger, { action: 'get'; id: string }>
13
14
  | ToolContract<{ id: string }, { action: 'add'; conditions: TriggerCondition[]; tool: string; params?: object; enabled?: boolean; cooldown?: TriggerCooldown }>
14
15
  // `cooldown: null` CLEARS every limit, which an omitted `cooldown` deliberately does not (it
15
16
  // leaves the stored one untouched). The executor has always accepted it and the web UI has always
16
17
  // sent it; only this arm omitted it, so a documented call was untypeable — invisible until the
17
18
  // params type was actually enforced. `add` takes no `null`: there is no prior limit to clear.
18
- | ToolContract<Trigger, { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: object; enabled?: boolean; cooldown?: TriggerCooldown | null }>
19
- | ToolContract<Trigger, { action: 'disable'; id: string }>
20
- | ToolContract<Trigger, { action: 'enable'; id: string }>
21
- | ToolContract<{ id: string }, { action: 'remove'; id: string }>
22
- | ToolContract<{ triggers: Trigger[] }, { action: 'move'; tool?: string; params?: object; toTool: string; toParams?: object }>
23
- | ToolContract<{ triggers: Trigger[] }, { action: 'copy'; tool?: string; params?: object; toTool: string; toParams?: object }>;
19
+ | ToolContract<ReportedTrigger, { action: 'update'; id: string; conditions?: TriggerCondition[]; tool?: string; params?: object; enabled?: boolean; cooldown?: TriggerCooldown | null }>
20
+ | ToolContract<ReportedTrigger, { action: 'disable'; id: string }>
21
+ | ToolContract<ReportedTrigger, { action: 'enable'; id: string }>
22
+ | ToolContract<{ id: string }, { action: 'remove'; id: string }>
23
+ | ToolContract<{ triggers: ReportedTrigger[] }, { action: 'move'; tool?: string; params?: object; toTool: string; toParams?: object }>
24
+ | ToolContract<{ triggers: ReportedTrigger[] }, { action: 'copy'; tool?: string; params?: object; toTool: string; toParams?: object }>;
24
25
  triggers_config:
25
26
  | ToolContract<{ classifierProvider: string | null; available: string[] }, { action: 'get' }>
26
27
  | ToolContract<{ classifierProvider: string }, { action: 'set'; provider: string }>
@@ -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. 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 ' +
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` ' +
@@ -80,6 +84,11 @@ type TriggerActionInput =
80
84
  | { action: 'move'; tool?: string; params?: unknown; toTool: string; toParams?: unknown }
81
85
  | { action: 'copy'; tool?: string; params?: unknown; toTool: string; toParams?: unknown };
82
86
 
87
+ // `enabled` resolved for reporting (see ReportedTrigger): it is stored only when explicitly written, so
88
+ // a listing otherwise carried it for some triggers and not others, leaving a reader to know "absent ⇒
89
+ // enabled" rather than see it. Applied at the yield sites only — the stored document is untouched.
90
+ const reported = (t: Trigger): ReportedTrigger => ({ ...t, enabled: t.enabled !== false });
91
+
83
92
  // The query selector shared by query/move/copy: `tool`/`params` narrow the set by what a trigger invokes.
84
93
  function queryFilter(a: { tool?: unknown; params?: unknown }): { tool?: string; params?: unknown } {
85
94
  return {
@@ -113,20 +122,20 @@ function validConditions(x: unknown): x is TriggerCondition[] {
113
122
  typeof (c as { rule?: unknown }).rule === 'string');
114
123
  }
115
124
 
116
- export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResultOf<'trigger_action'>> {
125
+ export function createTriggerActionTool(manager: TriggerManager, services: MatbotMachine): Tool<ToolResultOf<'trigger_action'>> {
117
126
  const executor: ToolExecutor<ToolResultOf<'trigger_action'>> = {
118
127
  async *execute(input: unknown, _ctx: ToolContext) {
119
128
  const args = input as Partial<TriggerActionInput> & { action?: string };
120
129
 
121
130
  switch (args.action) {
122
131
  case 'list': {
123
- yield { type: 'result', value: { triggers: await manager.all() } };
132
+ yield { type: 'result', value: { triggers: (await manager.all()).map(reported) } };
124
133
  return;
125
134
  }
126
135
 
127
136
  case 'query': {
128
137
  const a = args as Extract<TriggerActionInput, { action: 'query' }>;
129
- yield { type: 'result', value: { triggers: await manager.query(queryFilter(a)) } };
138
+ yield { type: 'result', value: { triggers: (await manager.query(queryFilter(a))).map(reported) } };
130
139
  return;
131
140
  }
132
141
 
@@ -135,7 +144,7 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
135
144
  if (!id) { yield { type: 'error', message: 'action "get" requires "id".' }; return; }
136
145
  const t = await manager.get(id);
137
146
  if (!t) { yield { type: 'error', message: `Trigger not found: "${id}"` }; return; }
138
- yield { type: 'result', value: t };
147
+ yield { type: 'result', value: reported(t) };
139
148
  return;
140
149
  }
141
150
 
@@ -145,9 +154,12 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
145
154
  if (a.conditions.length === 0) { yield { type: 'error', message: 'action "add" requires at least one condition.' }; return; }
146
155
  if (typeof a.tool !== 'string') { yield { type: 'error', message: 'action "add" requires a string "tool" to invoke.' }; return; }
147
156
  if (a.cooldown !== undefined && !validCooldown(a.cooldown)) { yield { type: 'error', message: COOLDOWN_ERR }; return; }
157
+ const invoke = { tool: a.tool, ...(a.params !== undefined ? { params: a.params } : {}) };
158
+ const refused = await checkInvocation(services, invoke);
159
+ if (refused !== undefined) { yield { type: 'error', message: `Trigger not added — ${refused}.` }; return; }
148
160
  const t = await manager.add({
149
161
  conditions: a.conditions,
150
- invoke: { tool: a.tool, ...(a.params !== undefined ? { params: a.params } : {}) },
162
+ invoke,
151
163
  ...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
152
164
  ...(a.cooldown !== undefined ? { cooldown: a.cooldown as TriggerCooldown } : {}),
153
165
  });
@@ -161,20 +173,22 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
161
173
  if (a.conditions !== undefined && !validConditions(a.conditions)) { yield { type: 'error', message: '"conditions" must be [{ kind, rule }].' }; return; }
162
174
  if (a.tool !== undefined && typeof a.tool !== 'string') { yield { type: 'error', message: '"tool" must be a string.' }; return; }
163
175
  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 priorTool = (a.tool !== undefined || a.params !== undefined)
165
- ? (await manager.get(a.id))?.invoke.tool ?? ''
166
- : '';
176
+ const invoke = a.tool !== undefined || a.params !== undefined
177
+ ? { tool: a.tool ?? (await manager.get(a.id))?.invoke.tool ?? '', ...(a.params !== undefined ? { params: a.params } : {}) }
178
+ : undefined;
179
+ // Only an edit to the invocation is checked, so a broken trigger can still be disabled or have
180
+ // its conditions changed without first being repaired.
181
+ const refused = invoke !== undefined ? await checkInvocation(services, invoke) : undefined;
182
+ if (refused !== undefined) { yield { type: 'error', message: `Trigger not updated — ${refused}.` }; return; }
167
183
  const t = await manager.update(a.id, {
168
184
  ...(a.conditions !== undefined ? { conditions: a.conditions } : {}),
169
- ...(a.tool !== undefined || a.params !== undefined
170
- ? { invoke: { tool: a.tool ?? priorTool, ...(a.params !== undefined ? { params: a.params } : {}) } }
171
- : {}),
185
+ ...(invoke !== undefined ? { invoke } : {}),
172
186
  ...(a.enabled !== undefined ? { enabled: a.enabled } : {}),
173
187
  // null clears every limit; an omitted `cooldown` leaves the stored one untouched.
174
188
  ...(a.cooldown !== undefined ? { cooldown: (a.cooldown === null ? {} : a.cooldown) as TriggerCooldown } : {}),
175
189
  });
176
190
  if (t === undefined) { yield { type: 'error', message: `Trigger not found: "${a.id}"` }; return; }
177
- yield { type: 'result', value: t };
191
+ yield { type: 'result', value: reported(t) };
178
192
  return;
179
193
  }
180
194
 
@@ -187,7 +201,7 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
187
201
  if (!id) { yield { type: 'error', message: `action "${args.action}" requires "id".` }; return; }
188
202
  const t = await manager.update(id, { enabled: args.action === 'enable' });
189
203
  if (t === undefined) { yield { type: 'error', message: `Trigger not found: "${id}"` }; return; }
190
- yield { type: 'result', value: t };
204
+ yield { type: 'result', value: reported(t) };
191
205
  return;
192
206
  }
193
207
 
@@ -205,10 +219,12 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
205
219
  if (typeof a.toTool !== 'string') { yield { type: 'error', message: 'action "move" requires a string "toTool" to re-target the selected triggers onto.' }; return; }
206
220
  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
221
  const invoke = { tool: a.toTool, ...(a.toParams !== undefined ? { params: a.toParams } : {}) };
208
- const triggers: Trigger[] = [];
222
+ const refused = await checkInvocation(services, invoke);
223
+ if (refused !== undefined) { yield { type: 'error', message: `No triggers moved — ${refused}.` }; return; }
224
+ const triggers: ReportedTrigger[] = [];
209
225
  for (const t of await manager.query(queryFilter(a))) {
210
226
  const updated = await manager.update(t.id, { invoke });
211
- if (updated !== undefined) triggers.push(updated);
227
+ if (updated !== undefined) triggers.push(reported(updated));
212
228
  }
213
229
  yield { type: 'result', value: { triggers } };
214
230
  return;
@@ -219,14 +235,16 @@ export function createTriggerActionTool(manager: TriggerManager): Tool<ToolResul
219
235
  if (typeof a.toTool !== 'string') { yield { type: 'error', message: 'action "copy" requires a string "toTool" to point the duplicates at.' }; return; }
220
236
  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
237
  const invoke = { tool: a.toTool, ...(a.toParams !== undefined ? { params: a.toParams } : {}) };
222
- const triggers: Trigger[] = [];
238
+ const refused = await checkInvocation(services, invoke);
239
+ if (refused !== undefined) { yield { type: 'error', message: `No triggers copied — ${refused}.` }; return; }
240
+ const triggers: ReportedTrigger[] = [];
223
241
  for (const t of await manager.query(queryFilter(a))) {
224
- triggers.push(await manager.add({
242
+ triggers.push(reported(await manager.add({
225
243
  conditions: t.conditions,
226
244
  invoke,
227
245
  ...(t.enabled !== undefined ? { enabled: t.enabled } : {}),
228
246
  ...(t.cooldown !== undefined ? { cooldown: t.cooldown } : {}),
229
- }));
247
+ })));
230
248
  }
231
249
  yield { type: 'result', value: { triggers } };
232
250
  return;
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;
@@ -106,6 +107,16 @@ export interface Trigger {
106
107
  updatedAt: string;
107
108
  }
108
109
 
110
+ /**
111
+ * A trigger as `trigger_action` REPORTS it: `enabled` resolved rather than omitted.
112
+ *
113
+ * Stored documents carry `enabled` only when it was explicitly written, so a listing showed it for some
114
+ * triggers and not others — and "absent ⇒ enabled" is a rule a reader has to know rather than see. The
115
+ * resolution happens at the tool boundary only: stored shape and {@link Triggers} consumers are
116
+ * untouched, so no write can persist a value nobody asked for.
117
+ */
118
+ export type ReportedTrigger = Omit<Trigger, 'enabled'> & { enabled: boolean };
119
+
109
120
  /** Fields a caller supplies when creating or replacing a trigger; identity/versioning is the store's. */
110
121
  export interface TriggerSpec {
111
122
  conditions: TriggerCondition[];