Summary
.adHocSubProcess(id, content) auto-chains its children with sequence flows, exactly like the
top-level builder does. For an ad-hoc sub-process that is semantically wrong: its children are an
unordered set of independently-invocable activities, not a sequence. There is no way to add
unconnected siblings inside the content callback.
This breaks the Camunda 8 agentic-AI tool pattern, where the ad-hoc sub-process is the agent and
each child activity is a tool the LLM may call. Camunda's rule is structural:
- child activity with no
<bpmn:incoming> → an LLM-invocable tool
- child activity with
<bpmn:incoming> → part of an internal sub-flow, not a tool
So declaring three tools yields one tool plus a two-step sub-flow. The file lints clean
(c8ctl bpmn lint → no issues), which makes the failure silent.
Independently corroborated by Camunda's own tooling: a compatibility check that reports one
warning per tool flags only Tool_ListUsers for the three-tool model below, and flags all
three once the fabricated flows are stripped.
Verified against @bpmnkit/core@0.1.1.
Reproduction
import { Bpmn } from "@bpmnkit/core";
const defs = Bpmn.createProcess("P")
.startEvent("S", { name: "Request received" })
.adHocSubProcess("AIAgent_H", (s) => {
s.serviceTask("Tool_ListUsers", { name: "List users", taskType: "io.camunda:http-json:1" });
s.serviceTask("Tool_LoadUser", { name: "Load user by ID", taskType: "io.camunda:http-json:1" });
s.serviceTask("Tool_CreateUser", { name: "Create user", taskType: "io.camunda:http-json:1" });
}, { name: "Handle request" })
.endEvent("E", { name: "Request handled" })
.executable(true)
.withAutoLayout()
.build();
const ahsp = defs.processes[0].flowElements.find((e) => e.id === "AIAgent_H");
console.log(ahsp.sequenceFlows.map((f) => `${f.sourceRef} -> ${f.targetRef}`));
// → [ 'Tool_ListUsers -> Tool_LoadUser', 'Tool_LoadUser -> Tool_CreateUser' ] ← should be []
Exported XML:
<bpmn:adHocSubProcess id="AIAgent_H" name="Handle request">
<bpmn:incoming>Flow_Mh0pZloR</bpmn:incoming>
<bpmn:outgoing>Flow_bmq7ARUQ</bpmn:outgoing>
<bpmn:serviceTask id="Tool_ListUsers" name="List users">
<bpmn:outgoing>Flow_vZG1C8Mg</bpmn:outgoing> <!-- fabricated -->
</bpmn:serviceTask>
<bpmn:serviceTask id="Tool_LoadUser" name="Load user by ID">
<bpmn:incoming>Flow_vZG1C8Mg</bpmn:incoming> <!-- fabricated → no longer a tool -->
</bpmn:serviceTask>
<bpmn:sequenceFlow id="Flow_vZG1C8Mg" sourceRef="Tool_ListUsers" targetRef="Tool_LoadUser"/>
</bpmn:adHocSubProcess>
withAutoLayout() also emits a <bpmndi:BPMNEdge> for each fabricated flow, so a workaround must
clean the DI plane too or leave orphaned edges behind.
Positive note: the DI for this shape is otherwise correct and was the reason we adopted
.adHocSubProcess() — the container comes out isExpanded="true" with bounds enclosing every
child shape. Only the flow fabrication is wrong.
Current workaround
Post-build strip of the intra-container flows, their refs, and their DI edges:
const ahsp = defs.processes[0].flowElements.find((e) => e.id === "AIAgent_H");
const dropped = new Set(ahsp.sequenceFlows.map((f) => f.id));
ahsp.sequenceFlows.length = 0;
for (const t of ahsp.flowElements) { t.incoming = []; t.outgoing = []; }
for (const d of defs.diagrams) d.plane.edges = d.plane.edges.filter((e) => !dropped.has(e.bpmnElement));
Verified: after the strip the file lints clean, DI is complete, and all three children register as
tools. But every caller has to know to do this, and has to reach into the model and the DI plane —
which defeats a typed builder.
Proposed API
The correct default is no auto-chaining inside an ad-hoc sub-process — BPMN ad-hoc
sub-processes are unordered by definition, so sequential s.serviceTask(...) calls should produce
siblings, not a chain:
.adHocSubProcess("AIAgent_H", (s) => {
s.serviceTask("Tool_ListUsers", { ... }); // → siblings, no sequence flow
s.serviceTask("Tool_LoadUser", { ... });
}, { name: "Handle request" })
Internal sub-flows inside an ad-hoc sub-process stay expressible via explicit .connectTo(...),
which is how a caller signals ordering it actually wants.
If changing the default is considered breaking, an opt-out is acceptable:
{ autoConnect: false } on AdHocSubProcessOptions.
Acceptance criteria
Summary
.adHocSubProcess(id, content)auto-chains its children with sequence flows, exactly like thetop-level builder does. For an ad-hoc sub-process that is semantically wrong: its children are an
unordered set of independently-invocable activities, not a sequence. There is no way to add
unconnected siblings inside the content callback.
This breaks the Camunda 8 agentic-AI tool pattern, where the ad-hoc sub-process is the agent and
each child activity is a tool the LLM may call. Camunda's rule is structural:
<bpmn:incoming>→ an LLM-invocable tool<bpmn:incoming>→ part of an internal sub-flow, not a toolSo declaring three tools yields one tool plus a two-step sub-flow. The file lints clean
(
c8ctl bpmn lint→ no issues), which makes the failure silent.Independently corroborated by Camunda's own tooling: a compatibility check that reports one
warning per tool flags only
Tool_ListUsersfor the three-tool model below, and flags allthree once the fabricated flows are stripped.
Verified against
@bpmnkit/core@0.1.1.Reproduction
Exported XML:
withAutoLayout()also emits a<bpmndi:BPMNEdge>for each fabricated flow, so a workaround mustclean the DI plane too or leave orphaned edges behind.
Positive note: the DI for this shape is otherwise correct and was the reason we adopted
.adHocSubProcess()— the container comes outisExpanded="true"with bounds enclosing everychild shape. Only the flow fabrication is wrong.
Current workaround
Post-build strip of the intra-container flows, their refs, and their DI edges:
Verified: after the strip the file lints clean, DI is complete, and all three children register as
tools. But every caller has to know to do this, and has to reach into the model and the DI plane —
which defeats a typed builder.
Proposed API
The correct default is no auto-chaining inside an ad-hoc sub-process — BPMN ad-hoc
sub-processes are unordered by definition, so sequential
s.serviceTask(...)calls should producesiblings, not a chain:
Internal sub-flows inside an ad-hoc sub-process stay expressible via explicit
.connectTo(...),which is how a caller signals ordering it actually wants.
If changing the default is considered breaking, an opt-out is acceptable:
{ autoConnect: false }onAdHocSubProcessOptions.Acceptance criteria
.adHocSubProcess()content emit no sequence flow, nobpmn:incoming/bpmn:outgoing, and no<bpmndi:BPMNEdge>.s.connectTo(...)inside the content still creates a flow (internal sub-flows stayexpressible).
withAutoLayout()still emitsisExpanded="true"with container bounds enclosing allchildren when they are unconnected (currently it does — guard against regression).
.adHocSubProcess()insideSubProcessContentBuilderbehaves the same.parse → exportunchanged.