bytechefhq / bytechefhq/bytechef
[bug] Sub-workflow suspension (Request Approval / Wait) is silently swallowed when a workflow is called as an AI agent tool
- Dominant language
- Java
- Stars
- 1k
- Forks
- 170
- Avg merge
- 11h 25m
- Merged PRs (30d)
- 115
Description
## Summary
When an AI agent calls another workflow as a tool (`workflow` component → **Call Workflow** cluster element), and that sub-workflow contains a **suspending** step such as **Request Approval** (`approval.requestApproval`) or any **Wait** action, the suspension is silently swallowed. The sub-workflow job is marked `COMPLETED` immediately and the agent's tool call returns right away with a bogus payload — **the human approval is never actually awaited**.
This is a silent correctness bug: no error, no timeout. The agent continues its reasoning as if a human had responded.
## Affected code
- `WorkflowCallWorkflowTool` — `server/libs/modules/components/workflow/src/main/java/com/bytechef/component/workflow/cluster/WorkflowCallWorkflowTool.java`
- `SubflowSyncExecutor` / `WorkflowSubflowSyncExecutorConfiguration` — `server/libs/modules/components/workflow/src/main/java/com/bytechef/component/workflow/subflow/sync/...`
- `ApprovalRequestApprovalAction` — `server/libs/modules/components/approval/src/main/java/com/bytechef/component/approval/action/ApprovalRequestApprovalAction.java`
## Steps to reproduce
1. Create a sub-workflow containing a **Request Approval** step.
2. In an AI agent workflow, add a **Call Workflow** tool pointing at that sub-workflow.
3. Run the agent so the LLM invokes the tool.
**Expected:** the agent pauses until a human approves/rejects, then continues with the approval result.
**Actual:** the tool returns immediately; the agent proceeds with an incomplete/garbage result; the approval form (if a notification channel is configured) becomes an orphaned dead link.
## Root cause
`WorkflowCallWorkflowTool.getToolFunction` runs the sub-workflow through `SubflowSyncExecutor`, which wraps a `JobSyncExecutor` — a single blocking call that waits on a `CountDownLatch` released only on `COMPLETED` / `FAILED` / `STOPPED`.
`ApprovalRequestApprovalAction.perform` calls `context.suspend(...)`, which in a normal (async) run is turned into a `STOPPED` job by **`SuspendTaskCompletionHandler`** (it persists the `JobResumeId` + `Suspend` state and calls `jobService.setStatusToStopped`).
But `JobSyncExecutor` builds its **own isolated coordinator**, and `WorkflowSubflowSyncExecutorConfiguration.getTaskCompletionHandlerFactories()` registers Branch / Condition / Each / ForkJoin / Loop / Map / Parallel handlers — **`SuspendTaskCompletionHandler` is not in the list**.
Consequently:
1. `requestApproval` runs, dispatches approval-channel notifications, calls `context.suspend(...)`.
2. `SuspendTaskExecutionPostOutputProcessor` stamps `JOB_RESUME_ID` / `SUSPEND` metadata onto the **task execution** (and, since `triggerScheduler` is null here, skips expiry scheduling).
3. `TaskWorker` unconditionally publishes `TaskExecutionCompleteEvent`.
4. With no `SuspendTaskCompletionHandler`, the chain falls through to `DefaultTaskCompletionHandler`, which treats `requestApproval` as an ordinary completed task and marks the **sub-workflow job `COMPLETED`**.
5. The latch fires, `SubflowSyncExecutor.execute()` returns, the agent's tool call returns instantly — with the raw `Suspend` payload (form URL / expiry) instead of the declared flat `{approved, }` schema (that shape is only produced by `resumePerform`, which never runs).
The notification email/Slack message points at `/resume/`, but the sub-workflow job is already `COMPLETED` and no `TaskState` was persisted, so the resume is orphaned and can never reach the agent.
## Related: #5056
`ApprovalRequestApprovalTool` (`server/libs/modules/components/approval/src/main/java/com/bytechef/component/approval/cluster/tool/ApprovalRequestApprovalTool.java`) lets the agent call **Request Approval** directly as a tool. Its `object()` lambda calls `performFunction.apply(...)` directly, bypassing `ActionDefinitionServiceImpl.checkSuspend()` — so `context.suspend()` writes to a throwaway `ActionContext` that nobody reads, and the LLM receives `null`.
Note that the nested-agent tool path already guards against this correctly: `AiAgentChatTool`'s context wrapper throws `UnsupportedOperationException` from `suspend()`. The two paths above are missing the equivalent guard.
## Suggested fix (fail fast)
Convert the silent bug into an honest, LLM-readable error and prevent the broken combination:
1. **Runtime guard** in `WorkflowSubflowSyncExecutorConfiguration` — after `jobSyncExecutor.execute(...)`, inspect the last task execution; if its metadata contains `JOB_RESUME_ID`, throw a clear `IllegalStateException` ("Sub-workflow contains a suspending step (Request Approval / Wait) and cannot be called synchronously as an agent tool."). This also catches conditional and nested suspends.
2. **Dropdown filter** in `WorkflowCallWorkflowTool.getWorkflowOptionsFunction` — exclude (or disable) sub-workflows whose tasks include a suspendable action. A suspendable action is statically identifiable as one declaring a `resumePerform` (`ActionDefinition.getResumePerform().isPresent()`).
3. For `ApprovalRequestApprovalTool` — either add the same suspend guard or remove the direct tool (approval is inherently asynchronous).
A proper "suspend-aware / durable agent" solution (propagating the suspension up to the agent's own job and resuming the LLM turn after approval) is a larger feature and would warrant a separate design.
Contributor guide
Assessment
This issue has not been assessed yet.