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 +6 -0
- package/package.json +2 -1
- package/rules/camunda-cloud/agent-fromai-contract.js +374 -0
- package/rules/camunda-cloud/agent-tool-documentation.js +41 -0
- package/rules/camunda-cloud/agent-tool-output-key.js +279 -0
- package/rules/camunda-cloud/utils/feel.js +57 -1
- package/rules/utils/element.js +89 -0
- package/rules/utils/error-types.js +16 -2
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.
|
|
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
|
};
|
package/rules/utils/element.js
CHANGED
|
@@ -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
|
+
});
|