fix(dify): merge HITL resume streaming into in-tree plugin

This commit is contained in:
fdc310
2026-10-30 12:49:15 +08:00
parent 67115d431b
commit 72068e41fa
6 changed files with 368 additions and 73 deletions
+5 -4
View File
@@ -54,10 +54,11 @@ events, which are used only when no explicit answer was produced.
## Workflow Human Input
When Dify emits `workflow_paused` with a `human_input_required` reason, the
runner asks LangBot to deliver a structured interaction. A validated
`interaction.submitted` event resumes Dify through
`/form/human_input/{form_token}` and `/workflow/{workflow_run_id}/events`.
When Dify emits `workflow_paused` with a `human_input_required` reason, or a
direct `human_input_required` event, the runner asks LangBot to deliver a
structured interaction. A validated `interaction.submitted` event resumes Dify
through `/form/human_input/{form_token}`. The submission response may itself
stream events; otherwise the runner reads `/workflow/{workflow_run_id}/events`.
Workflows that pause again produce a new interaction and can be resumed again.
Forms are emitted as atomic steps: the runner collects one field at a time and
then emits the action buttons. This matches LangBot delivery adapters that
@@ -247,17 +247,68 @@ def _usage_from_payload(payload: typing.Any) -> dict[str, typing.Any] | None:
return normalized or None
def _mapping_value(mapping: typing.Any, *keys: str) -> typing.Any:
"""Return a protocol value using explicit aliases with case-insensitive matching."""
if not isinstance(mapping, dict):
return None
for key in keys:
if key in mapping:
return mapping[key]
lowered = {str(key).lower(): value for key, value in mapping.items()}
for key in keys:
if key.lower() in lowered:
return lowered[key.lower()]
return None
def _protocol_name(value: typing.Any) -> str:
if not isinstance(value, str):
return ""
return re.sub(r"[-\s]+", "_", value.strip().lower())
def _workflow_run_id(event: dict[str, typing.Any], fallback: str = "") -> str:
data = event.get("data") if isinstance(event.get("data"), dict) else {}
value = _mapping_value(data, "workflow_run_id", "workflowRunId")
if value in (None, ""):
value = _mapping_value(event, "workflow_run_id", "workflowRunId")
return str(value or fallback).strip()
def _human_input_reason(event: dict[str, typing.Any]) -> dict[str, typing.Any] | None:
data = event.get("data") if isinstance(event.get("data"), dict) else {}
if _protocol_name(event.get("event")) == "human_input_required":
return data
raw_reasons = _mapping_value(data, "reasons", "pause_reasons", "pauseReasons")
if isinstance(raw_reasons, dict):
reasons = [raw_reasons]
elif isinstance(raw_reasons, list):
reasons = raw_reasons
else:
raw_reason = _mapping_value(data, "reason", "pause_reason", "pauseReason")
reasons = [raw_reason] if isinstance(raw_reason, dict) else []
for reason in reasons:
if not isinstance(reason, dict):
continue
reason_type = _mapping_value(reason, "TYPE", "type", "reason_type", "reasonType")
if _protocol_name(reason_type) == "human_input_required":
return reason
return None
def _form_field_name(field: dict[str, typing.Any]) -> str:
return str(
field.get("output_variable_name") or field.get("variable") or field.get("name") or field.get("id") or ""
_mapping_value(field, "output_variable_name", "outputVariableName", "variable", "name", "id") or ""
).strip()
def _form_field_options(field: dict[str, typing.Any]) -> list[str]:
source = field.get("option_source")
raw_options = source.get("value") if isinstance(source, dict) else None
source = _mapping_value(field, "option_source", "optionSource")
raw_options = _mapping_value(source, "value") if isinstance(source, dict) else None
if raw_options is None:
raw_options = field.get("options")
raw_options = _mapping_value(field, "options")
if isinstance(raw_options, str):
raw_options = [line.strip() for line in raw_options.splitlines() if line.strip()]
if not isinstance(raw_options, list):
@@ -266,7 +317,7 @@ def _form_field_options(field: dict[str, typing.Any]) -> list[str]:
options: list[str] = []
for option in raw_options:
if isinstance(option, dict):
value = option.get("value") or option.get("label") or option.get("name")
value = _mapping_value(option, "value", "label", "name")
else:
value = option
if value is not None and str(value).strip():
@@ -275,9 +326,9 @@ def _form_field_options(field: dict[str, typing.Any]) -> list[str]:
def _form_field_default(field: dict[str, typing.Any]) -> typing.Any:
default = field.get("default")
if isinstance(default, dict) and default.get("type") == "constant":
return default.get("value")
default = _mapping_value(field, "default", "default_value", "defaultValue")
if isinstance(default, dict) and _protocol_name(_mapping_value(default, "type")) == "constant":
return _mapping_value(default, "value")
return default
@@ -725,7 +776,7 @@ class DefaultRunner(Runner):
workflow_run_id: str,
user: str,
) -> tuple[InteractionRequest, dict[str, typing.Any]]:
form_token = str(reason.get("form_token") or "").strip()
form_token = str(_mapping_value(reason, "form_token", "formToken") or "").strip()
if not form_token or not workflow_run_id:
raise DifyAPIError(
"Dify human-input event is missing continuation identifiers",
@@ -735,9 +786,10 @@ class DefaultRunner(Runner):
interaction_id = f"dify-{uuid.uuid4().hex}"
fields: list[InteractionField] = []
field_map: dict[str, str] = {}
resolved_defaults = reason.get("resolved_default_values")
resolved_defaults = _mapping_value(reason, "resolved_default_values", "resolvedDefaultValues")
default_inputs = dict(resolved_defaults) if isinstance(resolved_defaults, dict) else {}
raw_fields = reason.get("inputs") if isinstance(reason.get("inputs"), list) else []
reason_inputs = _mapping_value(reason, "inputs", "fields")
raw_fields = reason_inputs if isinstance(reason_inputs, list) else []
for index, raw_field in enumerate(raw_fields):
if not isinstance(raw_field, dict):
continue
@@ -746,7 +798,7 @@ class DefaultRunner(Runner):
continue
field_id = f"field_{index + 1}"
field_map[field_id] = provider_name
provider_type = str(raw_field.get("type") or "text").lower()
provider_type = _protocol_name(_mapping_value(raw_field, "type") or "text")
field_type = {
"paragraph": "textarea",
"textarea": "textarea",
@@ -755,7 +807,7 @@ class DefaultRunner(Runner):
"checkbox": "boolean",
"boolean": "boolean",
"file": "file",
"file-list": "file",
"file_list": "file",
}.get(provider_type, "text")
option_values = _form_field_options(raw_field) if field_type == "select" else []
if field_type == "select" and not option_values:
@@ -766,22 +818,23 @@ class DefaultRunner(Runner):
fields.append(
InteractionField(
id=field_id,
label=str(raw_field.get("label") or raw_field.get("title") or provider_name),
label=str(_mapping_value(raw_field, "label", "title") or provider_name),
type=field_type,
required=bool(raw_field.get("required", True)),
required=_to_bool(_mapping_value(raw_field, "required", "is_required", "isRequired"), True),
options=[InteractionOption(value=value, label=value) for value in option_values],
placeholder=raw_field.get("placeholder"),
placeholder=_mapping_value(raw_field, "placeholder"),
default=default,
)
)
actions: list[InteractionAction] = []
action_map: dict[str, str] = {}
raw_actions = reason.get("actions") if isinstance(reason.get("actions"), list) else []
reason_actions = _mapping_value(reason, "actions", "user_actions", "userActions")
raw_actions = reason_actions if isinstance(reason_actions, list) else []
for index, raw_action in enumerate(raw_actions):
if not isinstance(raw_action, dict):
continue
provider_action_id = str(raw_action.get("id") or "").strip()
provider_action_id = str(_mapping_value(raw_action, "id", "action_id", "actionId") or "").strip()
if not provider_action_id:
continue
action_id = f"action_{index + 1}"
@@ -789,15 +842,15 @@ class DefaultRunner(Runner):
actions.append(
InteractionAction(
id=action_id,
label=str(raw_action.get("title") or raw_action.get("label") or provider_action_id),
label=str(_mapping_value(raw_action, "title", "label", "text") or provider_action_id),
)
)
if not actions:
action_map["continue"] = ""
actions.append(InteractionAction(id="continue", label="Continue", style="primary"))
node_title = str(reason.get("node_title") or "Human input required")
form_content = str(reason.get("form_content") or "")
node_title = str(_mapping_value(reason, "node_title", "nodeTitle", "title") or "Human input required")
form_content = str(_mapping_value(reason, "form_content", "formContent", "content") or "")
provider_names = [field_map[field.id] for field in fields if field.id in field_map]
provider_descriptions, action_description = _form_step_descriptions(form_content, provider_names)
field_descriptions = {
@@ -846,13 +899,8 @@ class DefaultRunner(Runner):
event: dict[str, typing.Any],
user: str,
) -> RunnerResult | None:
data = event.get("data") if isinstance(event.get("data"), dict) else {}
workflow_run_id = str(data.get("workflow_run_id") or "").strip()
reasons = data.get("reasons") if isinstance(data.get("reasons"), list) else []
reason = next(
(item for item in reasons if isinstance(item, dict) and item.get("TYPE") == "human_input_required"),
None,
)
workflow_run_id = _workflow_run_id(event)
reason = _human_input_reason(event)
if reason is None:
return None
request, continuation = self._build_interaction_request(reason, workflow_run_id, user)
@@ -985,11 +1033,11 @@ class DefaultRunner(Runner):
"node_finished",
"workflow_finished",
"workflow_paused",
"human_input_required",
}:
mode = "workflow"
if event_type == "workflow_started":
data = event.get("data") if isinstance(event.get("data"), dict) else {}
workflow_run_id = str(data.get("workflow_run_id") or workflow_run_id)
workflow_run_id = _workflow_run_id(event, workflow_run_id)
if event_type == "error":
raise DifyAPIError(
@@ -1011,9 +1059,10 @@ class DefaultRunner(Runner):
has_response = True
yield RunnerResult.message_delta(ctx.run_id, MessageChunk(role="assistant", content=content))
elif mode == "workflow" and event_type == "workflow_paused":
elif mode == "workflow" and event_type in {"workflow_paused", "human_input_required"}:
data = event.get("data") if isinstance(event.get("data"), dict) else {}
if workflow_run_id and not data.get("workflow_run_id"):
workflow_run_id = _workflow_run_id(event, workflow_run_id)
if workflow_run_id and not _workflow_run_id(event):
event = dict(event)
event["data"] = {**data, "workflow_run_id": workflow_run_id}
if pending_content:
@@ -1224,8 +1273,7 @@ class DefaultRunner(Runner):
usage = _usage_from_payload(event) or usage
if event_type == "workflow_started":
data = event.get("data") if isinstance(event.get("data"), dict) else {}
workflow_run_id = str(data.get("workflow_run_id") or workflow_run_id)
workflow_run_id = _workflow_run_id(event, workflow_run_id)
if event_type == "error":
raise DifyAPIError(
@@ -1267,9 +1315,10 @@ class DefaultRunner(Runner):
MessageChunk(role="assistant", content=content, is_final=True),
)
elif event_type == "workflow_paused":
elif event_type in {"workflow_paused", "human_input_required"}:
data = event.get("data") if isinstance(event.get("data"), dict) else {}
if workflow_run_id and not data.get("workflow_run_id"):
workflow_run_id = _workflow_run_id(event, workflow_run_id)
if workflow_run_id and not _workflow_run_id(event):
event = dict(event)
event["data"] = {**data, "workflow_run_id": workflow_run_id}
if pending_content:
@@ -1403,7 +1452,10 @@ class DefaultRunner(Runner):
await self._store_interaction_continuation(ctx, continuation)
pending_content = ""
streamed_content = ""
text_event_count = 0
has_response = False
final_response = False
terminal = False
usage: dict[str, typing.Any] | None = None
async for event in client.workflow_submit(
@@ -1426,30 +1478,49 @@ class DefaultRunner(Runner):
if event_type != "text_chunk"
else str(event.get("data", {}).get("text") or "")
)
if len(pending_content) + len(chunk) > 1024 * 1024:
if event_type != "text_chunk" and pending_content and chunk.startswith(pending_content):
next_content = chunk
else:
next_content = pending_content + chunk
if len(next_content) > 1024 * 1024:
raise DifyAPIError("Dify response exceeds the runtime limit", code="dify.response_limit")
pending_content += chunk
pending_content = next_content
text_event_count += 1
if text_event_count % 8 == 0:
content, _ = process_thinking_content(pending_content, remove_think)
if content and content != streamed_content:
streamed_content = content
has_response = True
yield RunnerResult.message_delta(
ctx.run_id,
MessageChunk(role="assistant", content=content, is_final=False),
)
elif event_type == "node_finished":
data = event.get("data", {})
if data.get("node_type") == "answer":
answer = extract_text_from_output(data.get("outputs", {}).get("answer"))
if answer:
content, _ = process_thinking_content(answer, remove_think)
has_response = True
yield RunnerResult.message_delta(
ctx.run_id,
MessageChunk(role="assistant", content=content, is_final=True),
)
elif event_type == "workflow_paused":
final_response = bool(content)
if content and content != streamed_content:
has_response = True
streamed_content = content
yield RunnerResult.message_delta(
ctx.run_id,
MessageChunk(role="assistant", content=content, is_final=True),
)
elif event_type in {"workflow_paused", "human_input_required"}:
if pending_content:
content, _ = process_thinking_content(pending_content, remove_think)
if content:
if content and content != streamed_content:
has_response = True
streamed_content = content
yield RunnerResult.message_delta(
ctx.run_id,
MessageChunk(role="assistant", content=content, is_final=True),
)
data = event.get("data") if isinstance(event.get("data"), dict) else {}
if not data.get("workflow_run_id"):
if not _workflow_run_id(event):
event = dict(event)
event["data"] = {
**data,
@@ -1475,26 +1546,29 @@ class DefaultRunner(Runner):
summary = extract_text_from_output(data.get("outputs", {}).get("summary", ""))
if summary:
content, _ = process_thinking_content(summary, remove_think)
has_response = True
yield RunnerResult.message_delta(
ctx.run_id,
MessageChunk(role="assistant", content=content, is_final=True),
)
final_response = bool(content)
if content and content != streamed_content:
has_response = True
streamed_content = content
yield RunnerResult.message_delta(
ctx.run_id,
MessageChunk(role="assistant", content=content, is_final=True),
)
terminal = True
if pending_content and not has_response:
content, _ = process_thinking_content(pending_content, remove_think)
if content:
has_response = True
yield RunnerResult.message_delta(
ctx.run_id,
MessageChunk(role="assistant", content=content, is_final=True),
)
if not terminal:
raise DifyAPIError(
"Dify workflow resume ended before a terminal event",
code="dify.response_invalid",
)
if pending_content and not final_response:
content, _ = process_thinking_content(pending_content, remove_think)
if content and content != streamed_content:
has_response = True
yield RunnerResult.message_delta(
ctx.run_id,
MessageChunk(role="assistant", content=content, is_final=True),
)
if not has_response:
raise DifyAPIError(
"Dify workflow returned no response after human input",
+16 -9
View File
@@ -208,18 +208,25 @@ class AsyncDifyClient:
timeout=self.timeout,
trust_env=False,
) as client:
response = await limited_post(
client,
async with client.stream(
"POST",
f"/form/human_input/{form_token}",
DifyAPIError,
headers=headers,
json={"inputs": inputs, "user": user, "action": action},
)
if not response.is_success:
raise DifyAPIError(
f"Dify API error: {response.status_code} - {response.text[:200]}",
code="dify.http_error",
)
) as response:
if not response.is_success:
body = await limited_body(response, DifyAPIError)
raise DifyAPIError(
f"Dify API error: {response.status_code} - {body.decode('utf-8', errors='replace')[:200]}",
code="dify.http_error",
)
content_type = response.headers.get("content-type", "").lower()
if content_type.split(";", 1)[0].strip() == "text/event-stream":
async for event in _iter_sse_json(response):
yield event
return
await limited_body(response, DifyAPIError)
async with client.stream(
"GET",
+1 -1
View File
@@ -13,7 +13,7 @@ MAX_STREAM_BYTES = 16 * 1024 * 1024
async def limited_bytes(response, error):
total = 0
async for chunk in response.aiter_bytes(chunk_size=8192):
async for chunk in response.aiter_bytes():
total += len(chunk)
if total > MAX_STREAM_BYTES:
raise error("Provider stream exceeds the runtime limit")
@@ -0,0 +1,61 @@
"""Streaming and deduplication for resumed Dify workflows."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from components.runner.default import DefaultRunner
async def _resume(events):
continuation = {
"phase": "submit",
"field_map": {},
"action_map": {"approve": "approve"},
"default_inputs": {},
"form_token": "form",
"workflow_run_id": "workflow",
"user": "user",
}
runner = SimpleNamespace(
_load_interaction_continuation=AsyncMock(return_value=continuation),
_store_interaction_continuation=AsyncMock(),
_delete_interaction_continuation=AsyncMock(),
)
class Client:
async def workflow_submit(self, **kwargs):
for event in events:
yield event
submission = SimpleNamespace(interaction_id="interaction", action_id="approve", values={})
result = [
item
async for item in DefaultRunner._resume_workflow_once(
runner, SimpleNamespace(run_id="run"), Client(), submission, False
)
]
return [item.data["chunk"] for item in result if item.type == "message.delta"], result
@pytest.mark.asyncio
async def test_resume_emits_intermediate_snapshots_without_repeating_final_answer():
events = [{"event": "text_chunk", "data": {"text": char}} for char in "abcdefghij"]
events += [
{"event": "node_finished", "data": {"node_type": "answer", "outputs": {"answer": "abcdefghij"}}},
{"event": "workflow_finished", "data": {"outputs": {"summary": "abcdefghij"}}},
]
chunks, results = await _resume(events)
assert [(chunk["content"], chunk["is_final"]) for chunk in chunks] == [
("abcdefgh", False),
("abcdefghij", True),
]
assert results[-1].type == "run.completed"
@pytest.mark.asyncio
async def test_resume_accepts_cumulative_message_snapshots():
events = [{"event": "message", "answer": "abcdefgh"[:length]} for length in range(1, 9)]
events.append({"event": "workflow_finished", "data": {"outputs": {}}})
chunks, _ = await _resume(events)
assert [chunk["content"] for chunk in chunks] == ["abcdefgh"]
@@ -252,6 +252,87 @@ def test_dify_workflow_pause_requests_host_interaction_and_hides_provider_tokens
assert all(_type(result) != "run.completed" for result in results)
def test_dify_current_human_input_event_requests_host_interaction() -> None:
module = _load_runner_module("dify-agent")
runner = object.__new__(module.DefaultRunner)
storage = _FakePluginStorage()
runner.get_run_api = lambda ctx: storage
class FakeClient:
async def workflow_run(self, *, inputs, user, files):
yield {
"event": "human_input_required",
"workflow_run_id": "workflow-private",
"data": {
"form_token": "form-private",
"node_title": "Manual review",
"form_content": "Choose a priority\n{{#$output.priority#}}",
"inputs": [
{
"output_variable_name": "priority",
"type": "select",
"option_source": {"type": "constant", "value": ["high", "low"]},
}
],
"actions": [{"id": "approve-private", "title": "Approve"}],
},
}
results = asyncio.run(_collect_async(runner._run_workflow(_ctx(), FakeClient(), {}, "hello", "user_1", [], False)))
assert _type(results[-1]) == "action.requested"
assert all(_type(result) != "run.completed" for result in results)
request = results[-1].data["payload"]
assert request["title"] == "Manual review"
assert request["fields"][0]["options"][0]["value"] == "high"
assert "form-private" not in str(request)
assert "workflow-private" not in str(request)
continuation = json.loads(next(iter(storage.values.values())))
assert continuation["form_token"] == "form-private"
assert continuation["workflow_run_id"] == "workflow-private"
def test_dify_pause_accepts_single_reason_and_camel_case_fields() -> None:
module = _load_runner_module("dify-agent")
runner = object.__new__(module.DefaultRunner)
storage = _FakePluginStorage()
runner.get_run_api = lambda ctx: storage
event = {
"event": "workflow_paused",
"data": {
"workflowRunId": "workflow-private",
"reason": {
"type": "human-input-required",
"formToken": "form-private",
"nodeTitle": "Manual review",
"formContent": "Choose a priority\n{{#$output.priority#}}",
"resolvedDefaultValues": {"priority": "low"},
"fields": [
{
"outputVariableName": "priority",
"type": "select",
"optionSource": {"type": "constant", "value": [{"label": "High", "value": "high"}]},
}
],
"userActions": [{"actionId": "approve-private", "label": "Approve"}],
},
},
}
result = asyncio.run(runner._interaction_result_for_pause(_ctx(), event, "user_1"))
assert result is not None
request = result.data["payload"]
assert request["title"] == "Manual review"
assert request["fields"][0]["default"] == "low"
assert request["fields"][0]["options"][0]["value"] == "high"
assert request["fields"][0]["options"][0]["label"] == "high"
continuation = json.loads(next(iter(storage.values.values())))
assert continuation["action_map"] == {"action_1": "approve-private"}
assert "form-private" not in str(request)
assert "workflow-private" not in str(request)
def test_dify_interaction_resume_submits_mapped_values_and_clears_continuation() -> None:
module = _load_runner_module("dify-agent")
runner = object.__new__(module.DefaultRunner)
@@ -558,6 +639,77 @@ def test_dify_client_submits_form_then_streams_resumed_events(monkeypatch) -> No
assert event_body.consumed and event_body.closed
def test_dify_client_consumes_sse_returned_by_form_submission(monkeypatch) -> None:
module = _load_runner_module("dify-agent")
client_module = sys.modules["pkg.dify_client"]
real_async_client = httpx.AsyncClient
calls: list[httpx.Request] = []
expected_event = {"event": "workflow_finished", "data": {"outputs": {"summary": "done"}}}
def handle_request(request):
calls.append(request)
return httpx.Response(
200,
headers={"content-type": "text/event-stream; charset=utf-8"},
content=b"data: " + json.dumps(expected_event).encode() + b"\n\n",
)
transport = httpx.MockTransport(handle_request)
monkeypatch.setattr(
client_module.httpx,
"AsyncClient",
lambda **kwargs: real_async_client(transport=transport, **kwargs),
)
client = module.AsyncDifyClient(api_key="key", base_url="https://dify.example/v1")
events = asyncio.run(
_collect_async(
client.workflow_submit(
form_token="form-token",
workflow_run_id="workflow-run",
inputs={"priority": "high"},
user="user-1",
action="approve",
)
)
)
assert events == [expected_event]
assert [(request.method, request.url.path) for request in calls] == [("POST", "/v1/form/human_input/form-token")]
def test_dify_client_yields_submission_sse_before_stream_ends(monkeypatch) -> None:
module = _load_runner_module("dify-agent")
client_module = sys.modules["pkg.dify_client"]
real_async_client = httpx.AsyncClient
first_event = {"event": "text_chunk", "data": {"text": "ready"}}
class FirstEventStream(httpx.AsyncByteStream):
async def __aiter__(self):
yield b"data: " + json.dumps(first_event).encode() + b"\n\n"
raise AssertionError("submission response was buffered before the first event")
def handle_request(request):
assert request.method == "POST"
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=FirstEventStream())
monkeypatch.setattr(
client_module.httpx,
"AsyncClient",
lambda **kwargs: real_async_client(transport=httpx.MockTransport(handle_request), **kwargs),
)
client = module.AsyncDifyClient(api_key="key", base_url="https://dify.example/v1")
async def read_first():
stream = client.workflow_submit("form-token", "workflow-run", {}, "user-1", "approve")
try:
return await anext(stream)
finally:
await stream.aclose()
assert asyncio.run(read_first()) == first_event
def test_dify_text_image_upload_failure_is_input_error() -> None:
module = _load_runner_module("dify-agent")