Skip to content

Builder: adHocSubProcess() auto-chains its children — no way to declare unconnected siblings (breaks the Camunda 8 agentic-AI tool pattern) #149

Description

@vobu

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

  • Sequential activity calls inside .adHocSubProcess() content emit no sequence flow, no
    bpmn:incoming / bpmn:outgoing, and no <bpmndi:BPMNEdge>.
  • Explicit s.connectTo(...) inside the content still creates a flow (internal sub-flows stay
    expressible).
  • withAutoLayout() still emits isExpanded="true" with container bounds enclosing all
    children when they are unconnected (currently it does — guard against regression).
  • Nested .adHocSubProcess() inside SubProcessContentBuilder behaves the same.
  • Round-trips through parse → export unchanged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions