bpmnlint-plugin-camunda-compat 2.55.0 → 2.56.0

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/index.js CHANGED
@@ -111,6 +111,9 @@ const camundaCloud87Rules = withConfig({
111
111
 
112
112
  const camundaCloud88Rules = withConfig({
113
113
  ...omit(camundaCloud87Rules, [ 'no-task-listeners' ]),
114
+ 'agent-tool-documentation': 'warn',
115
+ 'agent-tool-output-key': 'warn',
116
+ 'agent-fromai-contract': 'error',
114
117
  'task-listener': 'error'
115
118
  }, { version: '8.8' });
116
119
 
@@ -169,6 +172,9 @@ const bpmnlintRules = [
169
172
 
170
173
  const rules = {
171
174
  'ad-hoc-sub-process': './rules/camunda-cloud/ad-hoc-sub-process',
175
+ 'agent-fromai-contract': './rules/camunda-cloud/agent-fromai-contract',
176
+ 'agent-tool-documentation': './rules/camunda-cloud/agent-tool-documentation',
177
+ 'agent-tool-output-key': './rules/camunda-cloud/agent-tool-output-key',
172
178
  'before-all-execution-listener': './rules/camunda-cloud/before-all-execution-listener',
173
179
  'element-type': './rules/camunda-cloud/element-type',
174
180
  'cancel-execution-listener': './rules/camunda-cloud/cancel-execution-listener',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bpmnlint-plugin-camunda-compat",
3
- "version": "2.55.0",
3
+ "version": "2.56.0",
4
4
  "description": "A bpmnlint plug-in for Camunda compatibility",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -38,6 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bpmn-io/feel-analyzer": "^0.3.0",
41
+ "@bpmn-io/lezer-feel": "^2.5.0",
41
42
  "@bpmn-io/moddle-utils": "^0.3.0",
42
43
  "@bpmn-io/semver-compat": "^0.1.0",
43
44
  "@camunda/feel-builtins": "^1.2.0",
@@ -0,0 +1,374 @@
1
+ const { is } = require('bpmnlint-utils');
2
+
3
+ const { findExtensionElement, findAncestorAdHocSubProcess, isAgenticAdHocSubProcess } = require('../utils/element');
4
+ const { CORRECT_NAME, NAME_ALIASES, findFunctionInvocations, getPositionalArgs } = require('./utils/feel');
5
+ const { reportErrors } = require('../utils/reporter');
6
+ const { ERROR_TYPES } = require('../utils/error-types');
7
+ const { skipInNonExecutableProcess } = require('../utils/rule');
8
+ const { annotateRule } = require('../helper');
9
+
10
+ /**
11
+ * Validates the parts of fromAi() calls that have no legitimate reading: the
12
+ * call silently resolves to nothing at runtime, with no error, and there is
13
+ * no plausible intent behind the violation. Covers wrong key type (including
14
+ * a conditional/if-expression key), missing/misplaced toolCall. prefix,
15
+ * multi-segment keys, duplicate keys within one tool, function name casing,
16
+ * a description argument that is not a string literal, and using fromAi()
17
+ * where the connector never populates toolCall (non-entry elements, output
18
+ * mappings, sequence flow conditions, non-agentic or non-AHSP contexts).
19
+ * Reports once per violation.
20
+ *
21
+ * Both the key and description arguments are parsed to FEEL AST nodes at
22
+ * tool-schema resolution time, from the deployed process definition, not
23
+ * evaluated against a process instance's variables (confirmed against
24
+ * FromAiTaggedParameterExtractor in camunda/camunda: `parameterName()`
25
+ * requires a `Ref` node and `asString()` requires a `ConstString` node,
26
+ * throwing otherwise; `throwsExceptionWhenValueIsNotAReference` in that
27
+ * repo's test suite confirms this for every non-reference shape it exercises,
28
+ * including a nested function call). A conditional key can never resolve
29
+ * this way, regardless of which branch would be "correct" at runtime, so it
30
+ * has no more legitimate reading than a bare number or null literal used as
31
+ * the key or description.
32
+ *
33
+ * A description that is simply absent (no argument, or an empty string) is
34
+ * valid: the fromAi() description is optional, so neither rule reports it.
35
+ */
36
+ // ─── Constraint validators ────────────────────────────────────────────────────
37
+
38
+ function validateKeyArg(arg) {
39
+ switch (arg.type) {
40
+ case 'StringLiteral':
41
+ return {
42
+ message: `fromAi() key must be a FEEL path, not a string literal. Remove the quotes around ${arg.text}.`,
43
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_TYPE_INVALID },
44
+ };
45
+ case 'null':
46
+ return {
47
+ message: 'fromAi() key must be a FEEL path starting with "toolCall.", not null.',
48
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_TYPE_INVALID },
49
+ };
50
+ case 'NumericLiteral':
51
+ return {
52
+ message: 'fromAi() key must be a FEEL path starting with "toolCall.", not a number.',
53
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_TYPE_INVALID },
54
+ };
55
+ case 'ArithmeticExpression':
56
+ return {
57
+ message: 'fromAi() key must be a FEEL path starting with "toolCall.", not an arithmetic expression.',
58
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_TYPE_INVALID },
59
+ };
60
+ case 'FilterExpression':
61
+ return {
62
+ message: 'fromAi() key must use dot notation, not bracket notation. Use toolCall.name instead of toolCall["name"].',
63
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_TYPE_INVALID },
64
+ };
65
+ case 'VariableName':
66
+ return {
67
+ message: `fromAi() key must start with "toolCall.". Use toolCall.${arg.text} instead of a bare name.`,
68
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_PREFIX_MISSING },
69
+ };
70
+ case 'PathExpression': {
71
+ if (!arg.text.startsWith('toolCall.')) {
72
+ return {
73
+ message: `fromAi() key must start with "toolCall.". Got ${arg.text}.`,
74
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_PREFIX_MISSING },
75
+ };
76
+ }
77
+
78
+ // The connector uses the LAST path segment as the parameter name, so a
79
+ // nested key like toolCall.input.filter reads a path it never populates.
80
+ const segments = arg.text.split('.');
81
+ if (segments.length > 2) {
82
+ return {
83
+ message: `fromAi() key must be a single name under toolCall. Use toolCall.${ segments[ segments.length - 1 ] } instead of ${ arg.text }.`,
84
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_SEGMENTS_INVALID },
85
+ };
86
+ }
87
+ return null;
88
+ }
89
+ case 'IfExpression':
90
+ return {
91
+ message: 'fromAi() key must be a FEEL path starting with "toolCall.", not a conditional expression. The connector requires a plain reference regardless of which branch would apply at runtime.',
92
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_TYPE_INVALID },
93
+ };
94
+ default:
95
+ return {
96
+ message: `fromAi() key must be a FEEL path starting with "toolCall.", not a ${arg.type}.`,
97
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_TYPE_INVALID },
98
+ };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Any non-string-literal description (a bare number, null, a variable
104
+ * reference, or any other expression) has no legitimate reading: the
105
+ * connector requires a literal string to build the tool schema and throws
106
+ * otherwise, so there is no case where a non-literal description works.
107
+ */
108
+ function validateDescriptionTypeInvalid(arg) {
109
+ if (arg.type !== 'StringLiteral') {
110
+ return {
111
+ message: 'fromAi() description must be a string literal: a quoted string describing what the agent should provide.',
112
+ data: { type: ERROR_TYPES.AGENT_FEEL_DESCRIPTION_TYPE_INVALID },
113
+ };
114
+ }
115
+ return null;
116
+ }
117
+
118
+ // ─── Rule ─────────────────────────────────────────────────────────────────────
119
+
120
+ module.exports = skipInNonExecutableProcess(function(config = {}) {
121
+ const { version } = config;
122
+ function check(node, reporter) {
123
+ if (is(node, 'bpmn:Activity')) {
124
+ checkDuplicateKeys(node, reporter);
125
+ return;
126
+ }
127
+
128
+ if (is(node, 'zeebe:Output')) {
129
+ checkOutputSurface(node, reporter);
130
+ return;
131
+ }
132
+
133
+ if (is(node, 'bpmn:SequenceFlow')) {
134
+ checkConditionSurface(node, reporter);
135
+ return;
136
+ }
137
+
138
+ if (!is(node, 'zeebe:Input')) {
139
+ return;
140
+ }
141
+
142
+ const source = node.get('source');
143
+ if (!source || !source.startsWith('=')) {
144
+ return;
145
+ }
146
+
147
+ const expr = source.substring(1).trim();
148
+ const invocations = findFunctionInvocations(expr);
149
+ if (invocations.length === 0) {
150
+ return;
151
+ }
152
+
153
+ // Walk from zeebe:Input → zeebe:IoMapping → bpmn:ExtensionElements → task
154
+ const task = node.$parent && node.$parent.$parent && node.$parent.$parent.$parent;
155
+ if (!task || !is(task, 'bpmn:FlowNode')) {
156
+ return;
157
+ }
158
+
159
+ // Properties-panel entry for this input parameter, so clicking the report
160
+ // opens the right mapping (id convention: {elementId}-input-{index}-source).
161
+ const inputIndex = (node.$parent.get('inputParameters') || []).indexOf(node);
162
+ const propertiesPanel = {
163
+ entryIds: [ `${ task.get('id') }-input-${ inputIndex }-source` ]
164
+ };
165
+
166
+ const ahsp = findAncestorAdHocSubProcess(task);
167
+
168
+ if (!ahsp) {
169
+ reportErrors(task, reporter, invocations.map(() => ({
170
+ message: 'fromAi() should only be used inside an agentic sub-process.',
171
+ data: { type: ERROR_TYPES.AGENT_FEEL_WRONG_CONTEXT },
172
+ propertiesPanel,
173
+ })));
174
+ return;
175
+ }
176
+
177
+ if (!isAgenticAdHocSubProcess(ahsp, version)) {
178
+ const ahspLabel = ahsp.get('name') || ahsp.get('id');
179
+ reportErrors(task, reporter, invocations.map(() => ({
180
+ message: `The "${ahspLabel}" sub-process is not marked as agentic, so fromAi() has no effect. Add an Extension property "io.camunda.agenticai.toolContainer" with value "true" to "${ahspLabel}".`,
181
+ data: { type: ERROR_TYPES.AGENT_FEEL_WRONG_CONTEXT },
182
+ propertiesPanel,
183
+ })));
184
+ return;
185
+ }
186
+
187
+ // The connector resolves fromAi() only on the tool's entry element: the
188
+ // root node sitting DIRECTLY inside the AHSP. A call is ignored at runtime
189
+ // either when the element has an incoming flow (it is downstream of the
190
+ // root) or when it is nested below the AHSP (e.g. a task inside a
191
+ // sub-process tool, whose parent is the sub-process, not the AHSP).
192
+ const incoming = task.get('incoming') || [];
193
+ if (incoming.length > 0 || task.$parent !== ahsp) {
194
+ reportErrors(task, reporter, invocations.map(() => ({
195
+ message: 'fromAi() is ignored here: only the tool\'s entry element defines AI inputs. Define it there and read the toolCall variable directly.',
196
+ data: { type: ERROR_TYPES.AGENT_FEEL_NON_ENTRY_ELEMENT },
197
+ propertiesPanel,
198
+ })));
199
+ return;
200
+ }
201
+
202
+ // Inside agentic AHSP, on the entry element: run structural checks.
203
+ const errors = [];
204
+
205
+ for (const inv of invocations) {
206
+
207
+ // Check for casing typo in function name.
208
+ if (inv.name !== CORRECT_NAME && NAME_ALIASES.includes(inv.name.toLowerCase())) {
209
+ errors.push({
210
+ message: `Wrong function name "${inv.name}". Use ${CORRECT_NAME} (case-sensitive).`,
211
+ data: { type: ERROR_TYPES.AGENT_FEEL_FUNCTION_NAME_INVALID },
212
+ });
213
+ continue;
214
+ }
215
+
216
+ const args = getPositionalArgs(inv.node, expr);
217
+
218
+ if (args.length === 0) {
219
+ errors.push({
220
+ message: 'fromAi() requires a key argument: a FEEL path like toolCall.url.',
221
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_MISSING },
222
+ });
223
+ continue;
224
+ }
225
+
226
+ const keyError = validateKeyArg(args[0]);
227
+ if (keyError) {
228
+ errors.push(keyError);
229
+ }
230
+
231
+ if (args.length >= 2) {
232
+ const descriptionError = validateDescriptionTypeInvalid(args[1]);
233
+ if (descriptionError) {
234
+ errors.push(descriptionError);
235
+ }
236
+ }
237
+ }
238
+
239
+ if (errors.length) {
240
+ reportErrors(task, reporter, errors.map(error => ({ ...error, propertiesPanel })));
241
+ }
242
+ }
243
+
244
+ return annotateRule('agent-fromai-contract', {
245
+ check
246
+ });
247
+
248
+ /**
249
+ * The connector combines all fromAi() definitions of the tool's entry
250
+ * element into one input schema, so a key declared twice collides. Reports
251
+ * once per duplicated key on the entry activity.
252
+ */
253
+ function checkDuplicateKeys(task, reporter) {
254
+ const incoming = task.get('incoming') || [];
255
+ if (incoming.length > 0) {
256
+ return;
257
+ }
258
+
259
+ const ahsp = findAncestorAdHocSubProcess(task);
260
+ if (!ahsp || !isAgenticAdHocSubProcess(ahsp, version)) {
261
+ return;
262
+ }
263
+
264
+ // Only the tool's entry element (directly inside the AHSP) defines inputs;
265
+ // a nested task's fromAi() is ignored at runtime, so its keys are moot and
266
+ // the main check already reports it as a non-entry call.
267
+ if (task.$parent !== ahsp) {
268
+ return;
269
+ }
270
+
271
+ const ioMapping = findExtensionElement(task, 'zeebe:IoMapping');
272
+ if (!ioMapping) {
273
+ return;
274
+ }
275
+
276
+ const keyCounts = {};
277
+
278
+ for (const input of ioMapping.get('inputParameters') || []) {
279
+ const source = input.get('source');
280
+ if (!source || !source.startsWith('=')) {
281
+ continue;
282
+ }
283
+
284
+ const expr = source.substring(1).trim();
285
+ for (const inv of findFunctionInvocations(expr)) {
286
+ const args = getPositionalArgs(inv.node, expr);
287
+ const key = args[ 0 ];
288
+ if (key && key.type === 'PathExpression' && key.text.startsWith('toolCall.')) {
289
+ keyCounts[ key.text ] = (keyCounts[ key.text ] || 0) + 1;
290
+ }
291
+ }
292
+ }
293
+
294
+ const duplicates = Object.keys(keyCounts).filter(key => keyCounts[ key ] > 1);
295
+
296
+ if (duplicates.length) {
297
+ reportErrors(task, reporter, duplicates.map(key => ({
298
+ message: `fromAi() key ${ key } is declared more than once in this tool. Declare it once and reference it directly elsewhere.`,
299
+ data: { type: ERROR_TYPES.AGENT_FEEL_KEY_DUPLICATE },
300
+ propertiesPanel: { entryIds: [ 'inputs' ] },
301
+ })));
302
+ }
303
+ }
304
+
305
+ /**
306
+ * fromAi() only defines tool inputs; the connector reads it from input
307
+ * mappings and never populates toolCall on the output side, so a fromAi()
308
+ * call in an output source silently resolves to null. Scoped to agentic
309
+ * ad-hoc sub-processes to avoid firing on unrelated diagrams.
310
+ */
311
+ function checkOutputSurface(node, reporter) {
312
+ const source = node.get('source');
313
+ if (!source || !source.startsWith('=')) {
314
+ return;
315
+ }
316
+
317
+ const expr = source.substring(1).trim();
318
+ const invocations = findFunctionInvocations(expr);
319
+ if (!invocations.length) {
320
+ return;
321
+ }
322
+
323
+ const task = node.$parent && node.$parent.$parent && node.$parent.$parent.$parent;
324
+ if (!task || !is(task, 'bpmn:FlowNode')) {
325
+ return;
326
+ }
327
+
328
+ const ahsp = findAncestorAdHocSubProcess(task);
329
+ if (!ahsp || !isAgenticAdHocSubProcess(ahsp, version)) {
330
+ return;
331
+ }
332
+
333
+ const outputIndex = (node.$parent.get('outputParameters') || []).indexOf(node);
334
+ const propertiesPanel = {
335
+ entryIds: [ `${ task.get('id') }-output-${ outputIndex }-source` ]
336
+ };
337
+
338
+ reportErrors(task, reporter, invocations.map(() => ({
339
+ message: 'fromAi() defines a tool input and has no effect in an output mapping. Define it in an input mapping on the tool\'s entry element.',
340
+ data: { type: ERROR_TYPES.AGENT_FEEL_WRONG_CONTEXT },
341
+ propertiesPanel,
342
+ })));
343
+ }
344
+
345
+ /**
346
+ * The toolCall context is only populated for a tool's inputs, so fromAi() in
347
+ * a sequence flow condition resolves to null and the branch never behaves as
348
+ * intended. Scoped to agentic ad-hoc sub-processes.
349
+ */
350
+ function checkConditionSurface(node, reporter) {
351
+ const condition = node.get('conditionExpression');
352
+ const body = condition && condition.get('body');
353
+ if (!body || !body.startsWith('=')) {
354
+ return;
355
+ }
356
+
357
+ const expr = body.substring(1).trim();
358
+ const invocations = findFunctionInvocations(expr);
359
+ if (!invocations.length) {
360
+ return;
361
+ }
362
+
363
+ const ahsp = findAncestorAdHocSubProcess(node);
364
+ if (!ahsp || !isAgenticAdHocSubProcess(ahsp, version)) {
365
+ return;
366
+ }
367
+
368
+ reportErrors(node, reporter, invocations.map(() => ({
369
+ message: 'fromAi() defines a tool input and cannot be used in a sequence flow condition. Define it in an input mapping on the tool\'s entry element.',
370
+ data: { type: ERROR_TYPES.AGENT_FEEL_WRONG_CONTEXT },
371
+ propertiesPanel: { entryIds: [ 'conditionExpression' ] },
372
+ })));
373
+ }
374
+ });
@@ -0,0 +1,41 @@
1
+ const { isAgenticToolElement } = require('../utils/element');
2
+ const { reportErrors } = require('../utils/reporter');
3
+ const { ERROR_TYPES } = require('../utils/error-types');
4
+ const { skipInNonExecutableProcess } = require('../utils/rule');
5
+ const { annotateRule } = require('../helper');
6
+
7
+ /**
8
+ * The AI agent reads a tool's element documentation to decide which tool to
9
+ * call; without it the LLM falls back to the element name, which is
10
+ * underspecified. This rule warns when a tool entry activity (no incoming
11
+ * sequence flows, not an event sub-process) inside an agentic ad-hoc
12
+ * sub-process has no documentation text.
13
+ */
14
+ module.exports = skipInNonExecutableProcess(function(config = {}) {
15
+ const { version } = config;
16
+ function check(node, reporter) {
17
+
18
+ // Only a tool (a root activity directly inside an agentic AHSP) needs
19
+ // documentation; steps nested inside a tool are not separate tools.
20
+ if (!isAgenticToolElement(node, version)) {
21
+ return;
22
+ }
23
+
24
+ const docs = node.get('documentation') || [];
25
+ const text = docs.map(d => d.get('text') || '').join('').trim();
26
+
27
+ if (!text) {
28
+ reportErrors(node, reporter, [
29
+ {
30
+ message: 'Tool documentation is missing.',
31
+ data: { type: ERROR_TYPES.AGENT_TOOL_DOCUMENTATION_MISSING },
32
+ propertiesPanel: { entryIds: [ 'documentation' ] },
33
+ },
34
+ ]);
35
+ }
36
+ }
37
+
38
+ return annotateRule('agent-tool-documentation', {
39
+ check
40
+ });
41
+ });
@@ -0,0 +1,279 @@
1
+ const { is } = require('bpmnlint-utils');
2
+
3
+ const { isAgenticToolElement } = require('../utils/element');
4
+ const { reportErrors, getName } = require('../utils/reporter');
5
+ const { ERROR_TYPES } = require('../utils/error-types');
6
+ const { skipInNonExecutableProcess } = require('../utils/rule');
7
+ const { annotateRule } = require('../helper');
8
+
9
+ /**
10
+ * A tool inside an agentic ad-hoc sub-process returns its result through the
11
+ * `toolCallResult` variable. The result can be set at any point in the tool's
12
+ * flow and through several channels: an output mapping (target `toolCallResult`
13
+ * or a part like `toolCallResult.statusCode`), a script task or called decision
14
+ * `resultVariable`, or a connector `resultVariable`/`resultExpression` header.
15
+ *
16
+ * The rule warns once per tool: on the element that actually miswrote a
17
+ * result-shaped variable, when the tool flow writes some but none of them is
18
+ * `toolCallResult` (misdirection or wrong casing); on the entry activity when
19
+ * the flow writes none at all, since there's no single offending element to
20
+ * point to (the agent gets no completion signal and may retry or hallucinate
21
+ * an outcome). Results written from arbitrary FEEL expressions are not
22
+ * statically detectable.
23
+ */
24
+ module.exports = skipInNonExecutableProcess(function(config = {}) {
25
+ const { version } = config;
26
+ function check(node, reporter) {
27
+
28
+ // Only a tool entry (a root activity directly inside an agentic AHSP) is a
29
+ // tool; the whole tool flow is then inspected from here. Elements nested
30
+ // inside a tool are not separate tools, so they are not entry points.
31
+ if (!isAgenticToolElement(node, version)) {
32
+ return;
33
+ }
34
+
35
+ const { channels, linear } = collectResultChannels(node);
36
+
37
+ if (!channels.length) {
38
+ reportErrors(node, reporter, {
39
+ message: 'Tool returns nothing to the agent. Set a "toolCallResult" (at minimum, note the task completed).',
40
+ data: { type: ERROR_TYPES.AGENT_TOOL_RESULT_MISSING },
41
+ propertiesPanel: { entryIds: [ 'outputs' ] },
42
+ });
43
+ return;
44
+ }
45
+
46
+ const hasResult = channels.some(isToolCallResultChannel);
47
+ if (!hasResult) {
48
+ const casingMismatch = channels.find(isToolCallResultCasingMismatch);
49
+
50
+ if (casingMismatch) {
51
+ reportErrors(casingMismatch.element, reporter, {
52
+ message: `Wrong casing "${getCasingMismatchText(casingMismatch)}": use toolCallResult (case-sensitive).`,
53
+ data: { type: ERROR_TYPES.AGENT_TOOL_OUTPUT_KEY_CASING_INVALID },
54
+ propertiesPanel: { entryIds: [ 'outputs' ] },
55
+ });
56
+ return;
57
+ }
58
+
59
+ // Every channel here is a miswrite (none matched toolCallResult), so
60
+ // the first one is a real misdirected write; report on the element
61
+ // that actually wrote it, not the tool's entry.
62
+ const misdirected = channels[ 0 ];
63
+ reportErrors(misdirected.element, reporter, {
64
+ message: '"toolCallResult" output is not mapped.',
65
+ data: { type: ERROR_TYPES.AGENT_TOOL_OUTPUT_KEY_INVALID },
66
+ propertiesPanel: { entryIds: [ 'outputs' ] },
67
+ });
68
+ return;
69
+ }
70
+
71
+ // toolCallResult is set somewhere, but assigning it more than once
72
+ // overwrites the earlier value. Part contributions (toolCallResult.part)
73
+ // and the context put(toolCallResult, ...) accumulation pattern are
74
+ // exempt; every other full write after the first one is flagged.
75
+ //
76
+ // Only flag overwrites on a strictly linear tool flow, where the writes
77
+ // are guaranteed to run one after another. If the flow branches (an
78
+ // exclusive/parallel split, a join, or a boundary event), two writes may
79
+ // sit on alternative paths that never both run in one execution, so
80
+ // flagging them would be a false positive. We conservatively suppress the
81
+ // check for any non-linear flow.
82
+ //
83
+ // Future improvement: instead of suppressing, walk backward from the
84
+ // flow's leaf nodes and reason about which writes can actually co-execute.
85
+ // That has to account for inclusive-OR splits and other BPMN
86
+ // diverge/converge shapes: the common inner-orchestration case is one
87
+ // entry with multiple leaf nodes, so naive flattening over-reports.
88
+ if (linear) {
89
+ const fullWrites = channels.filter(isToolCallResultChannel).filter(isFullOverwriteChannel);
90
+
91
+ for (let i = 1; i < fullWrites.length; i++) {
92
+ const overwriter = fullWrites[ i ];
93
+ const overwritten = fullWrites[ i - 1 ];
94
+ const overwrittenLabel = getName(overwritten.element) || overwritten.element.get('id');
95
+
96
+ reportErrors(overwriter.element, reporter, {
97
+ message: `This overwrites the "toolCallResult" value set on "${overwrittenLabel}".`,
98
+ data: { type: ERROR_TYPES.AGENT_TOOL_OUTPUT_KEY_OVERWRITE },
99
+ propertiesPanel: { entryIds: [ 'outputs' ] },
100
+ });
101
+ }
102
+ }
103
+ }
104
+
105
+ return annotateRule('agent-tool-output-key', {
106
+ check
107
+ });
108
+ });
109
+
110
+ /**
111
+ * Collect all result-channel writes across the tool flow: the entry element,
112
+ * everything reachable through outgoing sequence flows, and the contents of
113
+ * embedded sub-processes.
114
+ *
115
+ * Also reports whether the flow is strictly linear: a single chain with no
116
+ * branching. A flow is non-linear if any traversed element splits (more than
117
+ * one outgoing sequence flow), joins (more than one incoming), or has a
118
+ * boundary event attached. Non-linear flows can place two writes on
119
+ * alternative paths, so the caller uses this to avoid false overwrite reports.
120
+ *
121
+ * @param {ModdleElement} entry
122
+ *
123
+ * @returns {Object} { channels, linear } — channels as { kind, value, element }
124
+ * (element being whichever element in the flow actually wrote this channel),
125
+ * and linear being true when the flow is a single non-branching chain
126
+ */
127
+ function collectResultChannels(entry) {
128
+ const channels = [],
129
+ visited = new Set(),
130
+ queue = [ entry ];
131
+
132
+ let linear = true;
133
+
134
+ while (queue.length) {
135
+ const element = queue.shift();
136
+
137
+ const id = element.get('id');
138
+ if (id && visited.has(id)) {
139
+ continue;
140
+ }
141
+ visited.add(id);
142
+
143
+ collectElementChannels(element, channels);
144
+
145
+ const outgoing = element.get('outgoing') || [];
146
+ const incoming = element.get('incoming') || [];
147
+
148
+ // A split (>1 outgoing) or a join (>1 incoming) means the flow is not a
149
+ // single guaranteed-sequential chain, so writes may live on paths that
150
+ // never both run.
151
+ if (outgoing.length > 1 || incoming.length > 1) {
152
+ linear = false;
153
+ }
154
+
155
+ for (const flow of outgoing) {
156
+ const target = flow.get('targetRef');
157
+ if (target) {
158
+ queue.push(target);
159
+ }
160
+ }
161
+
162
+ // Boundary events can define alternate tool paths that still contribute
163
+ // outputs; an alternate path is a branch, so the flow is not linear.
164
+ const parent = element.$parent;
165
+ const siblings = parent && parent.get && parent.get('flowElements');
166
+ for (const boundary of siblings || []) {
167
+ if (is(boundary, 'bpmn:BoundaryEvent') && boundary.get('attachedToRef') === element) {
168
+ linear = false;
169
+ queue.push(boundary);
170
+ }
171
+ }
172
+
173
+ if (is(element, 'bpmn:SubProcess')) {
174
+ for (const child of element.get('flowElements') || []) {
175
+ if (is(child, 'bpmn:FlowNode')) {
176
+ queue.push(child);
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ return { channels, linear };
183
+ }
184
+
185
+ function collectElementChannels(element, channels) {
186
+ const extensionElements = element.get('extensionElements');
187
+ if (!extensionElements) {
188
+ return;
189
+ }
190
+
191
+ for (const value of extensionElements.get('values')) {
192
+ if (is(value, 'zeebe:IoMapping')) {
193
+ for (const output of value.get('outputParameters') || []) {
194
+ channels.push({ kind: 'output', value: output.get('target') || '', source: output.get('source'), element });
195
+ }
196
+ }
197
+
198
+ if (is(value, 'zeebe:Script') || is(value, 'zeebe:CalledDecision')) {
199
+ const resultVariable = value.get('resultVariable');
200
+ if (resultVariable) {
201
+ channels.push({ kind: 'resultVariable', value: resultVariable, element });
202
+ }
203
+ }
204
+
205
+ // resultVariable / resultExpression task headers are treated as output
206
+ // channels for ANY task, not only recognized connector tasks. This breadth
207
+ // is intentional: the goal is to capture every way a tool writes its
208
+ // output. resultVariable/resultExpression is the convention connectors use,
209
+ // but custom element templates can follow the same convention, so gating on
210
+ // connector-ness would miss those and let a real miswrite through.
211
+ if (is(value, 'zeebe:TaskHeaders')) {
212
+ for (const header of value.get('values') || []) {
213
+ const key = header.get('key');
214
+ if (key === 'resultVariable') {
215
+ channels.push({ kind: 'resultVariable', value: header.get('value') || '', element });
216
+ }
217
+ if (key === 'resultExpression') {
218
+ channels.push({ kind: 'resultExpression', value: header.get('value') || '', element });
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ function isToolCallResultChannel({ kind, value }) {
226
+ if (kind === 'resultExpression') {
227
+ return /\btoolCallResult\b/.test(value);
228
+ }
229
+
230
+ return value === 'toolCallResult' || value.startsWith('toolCallResult.');
231
+ }
232
+
233
+ // A same-letters, wrong-case variant of toolCallResult (e.g. toolcallresult,
234
+ // TOOLCALLRESULT), not any other typo. Mirrors agent-fromai-contract's
235
+ // case-insensitive name matching for fromAi().
236
+ function isToolCallResultCasingMismatch({ kind, value }) {
237
+ if (kind === 'resultExpression') {
238
+ return /\btoolcallresult\b/i.test(value) && !/\btoolCallResult\b/.test(value);
239
+ }
240
+
241
+ const lower = value.toLowerCase();
242
+ const isCasingVariant = lower === 'toolcallresult' || lower.startsWith('toolcallresult.');
243
+
244
+ return isCasingVariant && !isToolCallResultChannel({ kind, value });
245
+ }
246
+
247
+ // The mismatched word itself, not the whole expression, for resultExpression
248
+ // channels (a connector header value is a full FEEL expression, e.g.
249
+ // `={toolcallresult: response.body}`).
250
+ function getCasingMismatchText({ kind, value }) {
251
+ if (kind === 'resultExpression') {
252
+ const match = value.match(/\btoolcallresult\b/i);
253
+ return match ? match[0] : value;
254
+ }
255
+
256
+ return value;
257
+ }
258
+
259
+ // The documented safe-accumulation pattern for combining several elements'
260
+ // contributions into one toolCallResult: `context put(toolCallResult, ...)`.
261
+ // Only meaningful for output mappings; connectors and script/decision result
262
+ // variables can't read the prior value, so they can never accumulate this way.
263
+ const CONTEXT_PUT_TOOL_CALL_RESULT = /^=?\s*context\s+put\s*\(\s*toolCallResult\s*,/;
264
+
265
+ // Whether this channel fully replaces toolCallResult, as opposed to
266
+ // contributing one field (toolCallResult.part) or safely appending to the
267
+ // existing value via context put(). Only meaningful for channels that
268
+ // already passed isToolCallResultChannel.
269
+ function isFullOverwriteChannel({ kind, value, source }) {
270
+ if (kind === 'resultExpression' || kind === 'resultVariable') {
271
+ return true;
272
+ }
273
+
274
+ if (value !== 'toolCallResult') {
275
+ return false;
276
+ }
277
+
278
+ return !CONTEXT_PUT_TOOL_CALL_RESULT.test(source || '');
279
+ }
@@ -1,5 +1,7 @@
1
1
  const { isString } = require('min-dash');
2
2
 
3
+ const { parser } = require('@bpmn-io/lezer-feel');
4
+
3
5
  // Properties ignored globally
4
6
  const IGNORED_PROPERTIES = [
5
7
  'name'
@@ -30,6 +32,60 @@ const isFeelProperty = (node, propertyName, value) => {
30
32
  return !isIgnoredProperty(node, propertyName) && isString(value) && value.startsWith('=');
31
33
  };
32
34
 
35
+ // ─── fromAi() lezer helpers ─────────────────────────────────────────────────
36
+ // Used by agent-fromai-contract to locate fromAi() calls and read their
37
+ // positional arguments.
38
+
39
+ const CORRECT_NAME = 'fromAi';
40
+ const NAME_ALIASES = [ 'fromai', 'fromAI' ];
41
+
42
+ function findFunctionInvocations(expr) {
43
+ const tree = parser.parse(expr);
44
+ const result = [];
45
+
46
+ function visit(node) {
47
+ if (node.type.name === 'FunctionInvocation') {
48
+ const nameNode = node.firstChild;
49
+ if (nameNode && nameNode.type.name === 'VariableName') {
50
+ const name = expr.slice(nameNode.from, nameNode.to);
51
+ const nameLower = name.toLowerCase();
52
+ if (nameLower === CORRECT_NAME.toLowerCase()) {
53
+ result.push({ name, node });
54
+ }
55
+ }
56
+ }
57
+ let child = node.firstChild;
58
+ while (child) {
59
+ visit(child);
60
+ child = child.nextSibling;
61
+ }
62
+ }
63
+
64
+ visit(tree.topNode);
65
+ return result;
66
+ }
67
+
68
+ function getPositionalArgs(invocationNode, expr) {
69
+ let child = invocationNode.firstChild;
70
+ while (child) {
71
+ if (child.type.name === 'PositionalParameters') {
72
+ const args = [];
73
+ let arg = child.firstChild;
74
+ while (arg) {
75
+ args.push({ type: arg.type.name, text: expr.slice(arg.from, arg.to) });
76
+ arg = arg.nextSibling;
77
+ }
78
+ return args;
79
+ }
80
+ child = child.nextSibling;
81
+ }
82
+ return [];
83
+ }
84
+
33
85
  module.exports = {
34
- isFeelProperty
86
+ isFeelProperty,
87
+ CORRECT_NAME,
88
+ NAME_ALIASES,
89
+ findFunctionInvocations,
90
+ getPositionalArgs
35
91
  };
@@ -20,6 +20,8 @@ const { getPath } = require('@bpmn-io/moddle-utils');
20
20
 
21
21
  const { ERROR_TYPES } = require('./error-types');
22
22
 
23
+ const { greaterOrEqual } = require('./version');
24
+
23
25
  module.exports.ERROR_TYPES = ERROR_TYPES;
24
26
 
25
27
  function getEventDefinition(node) {
@@ -505,6 +507,93 @@ function addAllowedVersion(data, allowedVersion) {
505
507
  };
506
508
  }
507
509
 
510
+ function findAncestorAdHocSubProcess(node) {
511
+ let el = node.$parent;
512
+ while (el) {
513
+ if (is(el, 'bpmn:AdHocSubProcess')) return el;
514
+ el = el.$parent;
515
+ }
516
+ return null;
517
+ }
518
+
519
+ module.exports.findAncestorAdHocSubProcess = findAncestorAdHocSubProcess;
520
+
521
+ // Property-based agentic detection (solution 1, marker shape revised
522
+ // 2026-07-13). The AI Agent element templates (and external-agent templates,
523
+ // e.g. Bedrock, Hugging Face) apply a generic zeebe:property to mark a
524
+ // detached tools ad-hoc sub-process as a tool container to lint. This is
525
+ // read-only and has no execution effect; it parses on the current moddle with
526
+ // no schema change. The marker is an independent boolean, not a single-valued
527
+ // role enum, because a Camunda agent element can carry both the
528
+ // `toolContainer` role (hosts tools) and the `agent` role at once; a
529
+ // single-valued `io.camunda.agenticai.role` property could not represent
530
+ // both roles on the same element.
531
+ const TOOL_CONTAINER_PROPERTY = 'io.camunda.agenticai.toolContainer';
532
+
533
+ function hasToolContainerRoleProperty(node) {
534
+ const properties = findExtensionElement(node, 'zeebe:Properties');
535
+
536
+ if (!properties) {
537
+ return false;
538
+ }
539
+
540
+ return (properties.get('properties') || []).some(
541
+ property => property.get('name') === TOOL_CONTAINER_PROPERTY
542
+ && property.get('value') === 'true'
543
+ );
544
+ }
545
+
546
+ module.exports.hasToolContainerRoleProperty = hasToolContainerRoleProperty;
547
+
548
+ // Whether an ad-hoc sub-process should have agent tool contracts linted.
549
+ //
550
+ // The `io.camunda.agenticai.toolContainer=true` property marker is honored at
551
+ // every version. From 8.10, a `zeebe:agentDefinition` marker on the AHSP
552
+ // (Camunda-provided template) is an additional signal, tracked in
553
+ // connectors#7842; that marker doesn't exist in this plugin's pinned
554
+ // zeebe-bpmn-moddle yet, so the check below is inert until it ships.
555
+ //
556
+ // Deliberately does not fall back to a bare `zeebe:AdHoc` extension: that
557
+ // extension is also carried by plain ad-hoc sub-processes using output
558
+ // collection, so it can't reliably distinguish agentic from non-agentic ones.
559
+ function isAgenticAdHocSubProcess(ahsp, version) {
560
+ if (hasToolContainerRoleProperty(ahsp)) {
561
+ return true;
562
+ }
563
+
564
+ return !!version
565
+ && greaterOrEqual(version, '8.10')
566
+ && !!findExtensionElement(ahsp, 'zeebe:AgentDefinition');
567
+ }
568
+
569
+ module.exports.isAgenticAdHocSubProcess = isAgenticAdHocSubProcess;
570
+
571
+ // Whether a node is an agent tool, i.e. the root node of a tool sub-flow that
572
+ // sits directly inside an agentic ad-hoc sub-process. This is the single "is
573
+ // tool" contract every agent rule gates on, so lint scope matches how the
574
+ // agent connector actually resolves tools: only at these roots. Anything
575
+ // nested below a root (a step inside a sub-process tool, or a downstream
576
+ // element reached by a sequence flow) is PART of a tool, not a tool itself.
577
+ function isAgenticToolElement(node, version) {
578
+
579
+ // a tool is an activity (task or sub-process)
580
+ return is(node, 'bpmn:Activity')
581
+
582
+ // an event sub-process is not a tool
583
+ && !(is(node, 'bpmn:SubProcess') && node.get('triggeredByEvent'))
584
+
585
+ // tool root: nothing flows into it
586
+ && (node.get('incoming') || []).length === 0
587
+
588
+ // it must live DIRECTLY in the AHSP, not nested
589
+ && is(node.$parent, 'bpmn:AdHocSubProcess')
590
+
591
+ // and that AHSP must be an agent tool container
592
+ && isAgenticAdHocSubProcess(node.$parent, version);
593
+ }
594
+
595
+ module.exports.isAgenticToolElement = isAgenticToolElement;
596
+
508
597
  function findParent(node, type) {
509
598
  if (!node) {
510
599
  return null;
@@ -25,5 +25,19 @@ module.exports.ERROR_TYPES = Object.freeze({
25
25
  PROPERTY_VALUES_DUPLICATED: 'camunda.propertiesValuesDuplicated',
26
26
  PROPERTY_VALUE_NOT_ALLOWED: 'camunda.propertyValueNotAllowed',
27
27
  PROPERTY_VALUE_REQUIRED: 'camunda.propertyValueRequired',
28
- SECRET_EXPRESSION_FORMAT_DEPRECATED: 'camunda.secretExpressionFormatDeprecated'
29
- });
28
+ SECRET_EXPRESSION_FORMAT_DEPRECATED: 'camunda.secretExpressionFormatDeprecated',
29
+ AGENT_FEEL_FUNCTION_NAME_INVALID: 'camunda.agentFeelFunctionNameInvalid',
30
+ AGENT_FEEL_KEY_MISSING: 'camunda.agentFeelKeyMissing',
31
+ AGENT_FEEL_KEY_TYPE_INVALID: 'camunda.agentFeelKeyTypeInvalid',
32
+ AGENT_FEEL_KEY_PREFIX_MISSING: 'camunda.agentFeelKeyPrefixMissing',
33
+ AGENT_FEEL_DESCRIPTION_TYPE_INVALID: 'camunda.agentFeelDescriptionTypeInvalid',
34
+ AGENT_FEEL_WRONG_CONTEXT: 'camunda.agentFeelWrongContext',
35
+ AGENT_FEEL_KEY_SEGMENTS_INVALID: 'camunda.agentFeelKeySegmentsInvalid',
36
+ AGENT_FEEL_KEY_DUPLICATE: 'camunda.agentFeelKeyDuplicate',
37
+ AGENT_FEEL_NON_ENTRY_ELEMENT: 'camunda.agentFeelNonEntryElement',
38
+ AGENT_TOOL_OUTPUT_KEY_INVALID: 'camunda.agentToolOutputKeyInvalid',
39
+ AGENT_TOOL_OUTPUT_KEY_CASING_INVALID: 'camunda.agentToolOutputKeyCasingInvalid',
40
+ AGENT_TOOL_OUTPUT_KEY_OVERWRITE: 'camunda.agentToolOutputKeyOverwrite',
41
+ AGENT_TOOL_RESULT_MISSING: 'camunda.agentToolResultMissing',
42
+ AGENT_TOOL_DOCUMENTATION_MISSING: 'camunda.agentToolDocumentationMissing',
43
+ });