mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(dify-builder): carry generation diagnostics into the exported debug log
api pods lose their logs on restart, so a failed build's real cause
("Workflow generator: structural validation failed: ...") could only be read
from a pod log that may already be gone. Session 01a08fd0 hit exactly that.
Carry the generator's own diagnostics back with the result instead:
BuildNodesResult and ErrorCard gain a ``diagnostics`` list, and build_nodes
records one entry per failed generation attempt -- initial AND the corrective
retry, so an error that changes between attempts stays visible -- plus the
all-rejected and exception paths.
Each entry holds a server-side UTC ``at`` (so the surviving pod-log window can
be searched around it), ``source``, ``attempt``, the generator's ``message``
(the same text the server logs), its ``codes``, and the full structured
``errors`` including ``node_id``. That is strictly more than the log line
carries: for an UNRESOLVED_REFERENCE the export now names the offending node.
The list rides in the ErrorCard's item payload, which is what the streamed
conversation item -- and therefore the exported debug log -- preserves. It is
not rendered in the UI. Retries that eventually succeed keep their breadcrumbs.
Deliberately not mirroring the "structural validation failed" prefix: build.py
cannot tell structural failures from the rest without duplicating the
generator's code taxonomy, which would go stale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
77924d3470
commit
cc9ca8b1dd
6 files changed
+158
-5
No files matched your search
@@ -588,6 +588,10 @@ class ErrorCard(_Card):
|
||||
body: str
|
||||
tone: str = "danger"
|
||||
node_id: str | None = None
|
||||
# Machine-readable failure breadcrumbs (server timestamp + generator codes /
|
||||
# node ids). Not rendered: it rides along in the item payload so the exported
|
||||
# debug log retains what would otherwise only exist in a pod log.
|
||||
diagnostics: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -395,7 +395,7 @@ def handle_plan_approval(env: Env, turn: Turn, s: Session, fc: DifyBuilderContex
|
||||
)
|
||||
error_items = append_card(
|
||||
fc,
|
||||
ErrorCard(title="Couldn't build the workflow", body=body),
|
||||
ErrorCard(title="Couldn't build the workflow", body=body, diagnostics=build_result.diagnostics),
|
||||
)
|
||||
progress.fail_step("build-generate-graph")
|
||||
execution = progress.finish(status="error")
|
||||
|
||||
@@ -225,6 +225,11 @@ class BuildNodesResult:
|
||||
|
||||
intents: list[MutationIntent] = field(default_factory=list)
|
||||
error: str = ""
|
||||
# Structured, timestamped breadcrumbs for the exported debug log. Server logs
|
||||
# are lost when the api pod restarts, so each failed generation attempt travels
|
||||
# back here (server UTC ``at``, generator ``code``/``node_id``/``detail``) and is
|
||||
# stamped onto the ErrorCard -> conversation item -> exported trace.
|
||||
diagnostics: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
|
||||
@@ -7,6 +7,7 @@ rather than crashing the advance. build_nodes lives in the same module
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
@@ -256,6 +257,31 @@ def _generation_error_text(result: dict[str, Any]) -> str:
|
||||
return "the generator returned no usable graph"
|
||||
|
||||
|
||||
def _diagnostic(**fields: Any) -> dict[str, Any]:
|
||||
"""One debug-log breadcrumb, stamped with the server's UTC time.
|
||||
|
||||
``at`` is the correlation key: pod logs are lost on restart, but the exported
|
||||
debug log keeps this timestamp so the surviving log window can be searched
|
||||
around it (and matched to the same failure the server logged).
|
||||
"""
|
||||
return {"at": datetime.now(UTC).isoformat(), **fields}
|
||||
|
||||
|
||||
def _generation_diagnostic(result: dict[str, Any], *, attempt: int) -> dict[str, Any]:
|
||||
"""Capture one failed generation attempt: the generator's structured errors
|
||||
(``code`` / ``detail`` / ``node_id`` -- e.g. UNRESOLVED_REFERENCE on node2),
|
||||
which is strictly more than the server-side log line carries."""
|
||||
errors = result.get("errors")
|
||||
errors = [e for e in errors if isinstance(e, dict)] if isinstance(errors, list) else []
|
||||
return _diagnostic(
|
||||
source="workflow-generator",
|
||||
attempt=attempt,
|
||||
message=_generation_error_text(result),
|
||||
codes=[str(e.get("code")) for e in errors if e.get("code")],
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
def build_nodes(
|
||||
tenant_id: str,
|
||||
model_config: dict[str, Any],
|
||||
@@ -275,9 +301,11 @@ def build_nodes(
|
||||
current_graph=None,
|
||||
)
|
||||
|
||||
diagnostics: list[dict[str, Any]] = []
|
||||
result = _generate(base_instruction)
|
||||
graph = result.get("graph") or {}
|
||||
if result.get("error") or not graph.get("nodes"):
|
||||
diagnostics.append(_generation_diagnostic(result, attempt=1))
|
||||
# The generator's own retry only covers invalid-JSON / bad-schema, NOT a
|
||||
# structurally-valid graph that fails topology validation (e.g. no 'end'
|
||||
# node). Retry ONCE with the specific error fed back as a corrective nudge.
|
||||
@@ -285,6 +313,7 @@ def build_nodes(
|
||||
result = _generate(_terminal_retry_instruction(base_instruction, retry_error))
|
||||
graph = result.get("graph") or {}
|
||||
if result.get("error") or not graph.get("nodes"):
|
||||
diagnostics.append(_generation_diagnostic(result, attempt=len(diagnostics) + 1))
|
||||
error = _generation_error_text(result)
|
||||
logger.warning(
|
||||
"Dify Builder: build_nodes produced no graph for tenant %s (%d plan items): error=%s",
|
||||
@@ -292,7 +321,7 @@ def build_nodes(
|
||||
len(plan_items),
|
||||
error,
|
||||
)
|
||||
return BuildNodesResult(intents=[], error=error)
|
||||
return BuildNodesResult(intents=[], error=error, diagnostics=diagnostics)
|
||||
intents = graph_translate.to_intents(graph)
|
||||
# Ground node model blocks to the user-SELECTED model (resource confirmation),
|
||||
# falling back to the generation/session model when none was selected. The
|
||||
@@ -305,8 +334,16 @@ def build_nodes(
|
||||
reason = rejected[0][1] if rejected else "no applicable node intents"
|
||||
error = f"the generated nodes were rejected by validation: {reason}"
|
||||
logger.warning("Dify Builder: build_nodes rejected all intents for tenant %s: %s", tenant_id, error)
|
||||
return BuildNodesResult(intents=[], error=error)
|
||||
return BuildNodesResult(intents=applicable)
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
source="build_nodes",
|
||||
message=error,
|
||||
rejected=[{"reason": str(r), "intent": str(i.op)} for i, r in rejected],
|
||||
)
|
||||
)
|
||||
return BuildNodesResult(intents=[], error=error, diagnostics=diagnostics)
|
||||
# Retries that eventually succeeded still leave their breadcrumbs behind.
|
||||
return BuildNodesResult(intents=applicable, diagnostics=diagnostics)
|
||||
except Exception as exc: # any generation/translation failure -> honest empty build
|
||||
logger.exception(
|
||||
"Dify Builder: build_nodes generation failed for tenant %s (%d plan items); returning empty build",
|
||||
@@ -315,7 +352,12 @@ def build_nodes(
|
||||
)
|
||||
# Surface the exception text (e.g. a provider error like credit_balance_exhausted)
|
||||
# so the user sees WHY, not a hardcoded 'couldn't build' message.
|
||||
return BuildNodesResult(intents=[], error=str(exc).strip() or type(exc).__name__)
|
||||
message = str(exc).strip() or type(exc).__name__
|
||||
return BuildNodesResult(
|
||||
intents=[],
|
||||
error=message,
|
||||
diagnostics=[_diagnostic(source="build_nodes", message=message, exception=type(exc).__name__)],
|
||||
)
|
||||
|
||||
|
||||
def _ground(intents: list[MutationIntent], mc: ModelConfig, tenant_id: str, plan_items: list[str]) -> None:
|
||||
|
||||
@@ -284,6 +284,44 @@ def test_plan_approval_empty_build_surfaces_error_and_keeps_canvas():
|
||||
assert assistant.payload["reply_text"] != "Workflow built on the canvas." # no false success claim
|
||||
|
||||
|
||||
def test_plan_approval_error_card_carries_diagnostics_into_the_item_payload():
|
||||
"""Pod logs vanish on restart, so the generator's structured diagnostics must
|
||||
ride in the ErrorCard's item payload -- that payload is what the streamed
|
||||
conversation item (and therefore the exported debug log) preserves."""
|
||||
from core.dify_builder.handlers_build import handle_plan_approval
|
||||
from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort
|
||||
|
||||
env, _ = _new_env()
|
||||
env.dify = FakeBuildDifyPort()
|
||||
env.dify.graph = {
|
||||
"nodes": [{"id": "start", "data": {"type": "start", "title": "Old", "variables": []}}],
|
||||
"edges": [],
|
||||
}
|
||||
diag = [
|
||||
{
|
||||
"at": "2026-09-17T20:22:45.123456+00:00",
|
||||
"source": "workflow-generator",
|
||||
"attempt": 1,
|
||||
"message": "Reference {#node2.response#} not declared on node 'node2'",
|
||||
"codes": ["UNRESOLVED_REFERENCE"],
|
||||
"errors": [{"code": "UNRESOLVED_REFERENCE", "detail": "...", "node_id": "node2"}],
|
||||
}
|
||||
]
|
||||
env.agent.build_nodes = lambda _plan, _rids=None: BuildNodesResult(
|
||||
intents=[], error="Reference {#node2.response#} not declared on node 'node2'", diagnostics=diag
|
||||
)
|
||||
s = _session(entry_mode=EntryMode.BUILD, current_state=PcState.BUILD_PLAN_APPROVAL)
|
||||
fc = DifyBuilderContext(plan_items=["x"])
|
||||
|
||||
res = handle_plan_approval(env, Turn(actor=_actor(), action=Action(kind="approve_repair")), s, fc)
|
||||
|
||||
error = next(i for i in res.items if i.kind == "error")
|
||||
payload_diag = error.payload["diagnostics"]
|
||||
assert payload_diag == diag # verbatim: server timestamp, codes and node ids all survive
|
||||
assert payload_diag[0]["errors"][0]["node_id"] == "node2" # the offending node is identifiable
|
||||
assert payload_diag[0]["at"].endswith("+00:00") # UTC, so pod logs can be searched around it
|
||||
|
||||
|
||||
def test_plan_approval_deletes_pre_existing_start_on_from_scratch_build():
|
||||
from core.dify_builder.handlers_build import handle_plan_approval
|
||||
from core.dify_builder.models import MutationIntent
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.dify_builder.agent import build, resources
|
||||
@@ -378,3 +379,66 @@ def test_build_nodes_error_reason_joins_errors_list_when_no_top_level_error():
|
||||
|
||||
assert result.intents == []
|
||||
assert "top-level value was a list" in result.error
|
||||
|
||||
|
||||
def test_build_nodes_records_structured_diagnostics_for_the_debug_export():
|
||||
"""Server logs are lost when the api pod restarts, so the generator's own
|
||||
diagnostic ("structural validation failed") must travel back with the result:
|
||||
structured code/detail/node_id plus a server timestamp, so the exported debug
|
||||
log carries it and can be correlated with pod logs."""
|
||||
failing = {
|
||||
"graph": {"nodes": [], "edges": []},
|
||||
"error": "UNRESOLVED_REFERENCE: Reference {#node2.response#} not declared on node 'node2'",
|
||||
"errors": [
|
||||
{
|
||||
"code": "UNRESOLVED_REFERENCE",
|
||||
"detail": "Reference {#node2.response#} not declared on node 'node2'",
|
||||
"node_id": "node2",
|
||||
}
|
||||
],
|
||||
}
|
||||
with (
|
||||
patch.object(build, "_generator_model_config", return_value=_fake_mc("anthropic", "x")),
|
||||
patch(
|
||||
"services.dify_builder.agent.build.WorkflowGeneratorService.generate_workflow_graph",
|
||||
return_value=failing,
|
||||
),
|
||||
):
|
||||
result = build.build_nodes("t1", {}, ["call the GitHub API and summarize"])
|
||||
|
||||
assert result.intents == []
|
||||
# one diagnostic per generation attempt (initial + the single corrective retry)
|
||||
assert len(result.diagnostics) == 2
|
||||
first = result.diagnostics[0]
|
||||
assert first["source"] == "workflow-generator"
|
||||
# the same text the server logged as the "%s" of "structural validation failed: %s"
|
||||
assert "Reference {#node2.response#} not declared" in first["message"]
|
||||
assert first["codes"] == ["UNRESOLVED_REFERENCE"]
|
||||
assert first["attempt"] == 1
|
||||
assert result.diagnostics[1]["attempt"] == 2
|
||||
# structured detail survives -- code AND the offending node id
|
||||
assert first["errors"][0]["code"] == "UNRESOLVED_REFERENCE"
|
||||
assert first["errors"][0]["node_id"] == "node2"
|
||||
# a server timestamp to locate the corresponding pod-log lines
|
||||
datetime.fromisoformat(first["at"]) # parses -> real ISO-8601
|
||||
assert first["at"].endswith("+00:00") # UTC, unambiguous across pods
|
||||
|
||||
|
||||
def test_build_nodes_records_diagnostic_when_generation_raises():
|
||||
"""A provider/runtime failure must also leave a timestamped breadcrumb."""
|
||||
with (
|
||||
patch.object(build, "_generator_model_config", return_value=_fake_mc("anthropic", "x")),
|
||||
patch(
|
||||
"services.dify_builder.agent.build.WorkflowGeneratorService.generate_workflow_graph",
|
||||
side_effect=RuntimeError("credit_balance_exhausted"),
|
||||
),
|
||||
):
|
||||
result = build.build_nodes("t1", {}, ["x"])
|
||||
|
||||
assert result.intents == []
|
||||
assert len(result.diagnostics) == 1
|
||||
d = result.diagnostics[0]
|
||||
assert d["source"] == "build_nodes"
|
||||
assert d["exception"] == "RuntimeError"
|
||||
assert "credit_balance_exhausted" in d["message"]
|
||||
datetime.fromisoformat(d["at"])
|
||||
Reference in new issue
Block a user