bytechefhq / bytechefhq/bytechef

[bug] Sub-workflow suspension (Request Approval / Wait) is silently swallowed when a workflow is called as an AI agent tool

Open
#5,055 1 comment 0 reactions 1 assignee Claimed by @ivicac View on GitHub
ai backend bug
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.