From b7acf91b8c85972b8e200d0bc1efb5336fa33d09 Mon Sep 17 00:00:00 2001 From: Charles Yao Date: Fri, 6 Nov 2026 05:00:54 -0800 Subject: [PATCH] fix(builder): share Code failure policy with diagnosis Reuse the existing repair policy inside the authoritative Code source context so diagnosis cannot prescribe grouping that violates the known invalid-record contract. Preserve source binding, privacy and omission budgets, and keep proposal vetting unchanged. Validate actual diagnosis/proposal prompt delivery with six boundary cases and update the strict CI inventory. Focused tests: 373 passed. Ruff, scoped Pyrefly, diff checks and all seven exact guards passed. Independent audit: GO, 304 tests passed. --- .../scripts/builder-confirmed-tool-units.py | 1 + api/services/dify_builder/agent/fix.py | 5 +- .../services/dify_builder/test_fix_context.py | 59 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/.github/scripts/builder-confirmed-tool-units.py b/.github/scripts/builder-confirmed-tool-units.py index a4ce0fc322d..12ba7f384dd 100644 --- a/.github/scripts/builder-confirmed-tool-units.py +++ b/.github/scripts/builder-confirmed-tool-units.py @@ -2930,6 +2930,7 @@ a4_expected.update( "test_diagnosis_does_not_expand_unbound_or_mismatched_source": 6, "test_diagnosis_expanded_source_redacts_credentials_and_excludes_neighbors": 1, "test_diagnosis_oversized_source_is_explicitly_omitted_without_a_prefix": 1, + "test_code_diagnosis_and_proposal_share_failure_policy_at_source_boundary": 6, }, "tests.unit_tests.services.dify_builder.test_testdata_code_refresh": { "test_initial_file_submission_refreshes_low_risk_code_and_saves_inputs_once": 6, diff --git a/api/services/dify_builder/agent/fix.py b/api/services/dify_builder/agent/fix.py index 0dabfaea83b..6605edb94d6 100644 --- a/api/services/dify_builder/agent/fix.py +++ b/api/services/dify_builder/agent/fix.py @@ -148,7 +148,8 @@ def diagnose( """Diagnose owned failure evidence without changing the graph or Run. Only a unique historically bound Code target with format facts may expose - its complete redacted current source, under the existing repair budget. + its complete redacted current source, under the existing repair budget, + and receive the same failure-policy contract used by repair proposals. Missing/oversized context remains explicit; model failures still degrade. """ if evidence is None: @@ -200,6 +201,8 @@ def diagnose( safe_graph = diagnostic_context.redact_diagnostic_value(graph, failed_run, graph, node_outputs) targets = [node for node in safe_graph.get("nodes", []) if node.get("id") == target] if len(targets) == 1 and targets[0].get("data", {}).get("type") == "code": + # Diagnose against the same contract before suggesting a remedy. + system += CODE_FAILURE_POLICY_RULES fields, _omitted = _culprit_repair_fields(target, safe_graph) source_context = ( f"\n\nCURRENT CULPRIT SOURCE CONTEXT (native graph relation: {relation}):\n" diff --git a/api/tests/unit_tests/services/dify_builder/test_fix_context.py b/api/tests/unit_tests/services/dify_builder/test_fix_context.py index ed1698cbdd2..223cc95108b 100644 --- a/api/tests/unit_tests/services/dify_builder/test_fix_context.py +++ b/api/tests/unit_tests/services/dify_builder/test_fix_context.py @@ -386,3 +386,62 @@ def test_diagnosis_oversized_source_is_explicitly_omitted_without_a_prefix(monke assert '"outputs": {"result": {"type": "string"}}' in prompt assert "Do not reconstruct or replace omitted fields" in prompt assert len(prompt.encode()) < 40_000 + + +@pytest.mark.parametrize( + "boundary", ["bound", "source-omitted", "unbound", "non-code", "changed-binding", "code-drift"] +) +def test_code_diagnosis_and_proposal_share_failure_policy_at_source_boundary( + monkeypatch: pytest.MonkeyPatch, boundary: str +) -> None: + """A diagnosis must not prescribe identity grouping that the repair policy forbids.""" + from core.workflow.generator.prompts.builder_prompts import CODE_FAILURE_POLICY_RULES + + source = ( + "def classify(record, input_index):\n" + " identity = record.get('record_key')\n" + " if not isinstance(identity, str) or not identity.strip():\n" + " return {'status': 'invalid', 'record': record, 'input_index': input_index}\n" + " return {'status': 'valid', 'record': record}\n" + ) + if boundary == "source-omitted": + source = "# SOURCE_REQUIRES_MANUAL_REVIEW\n" * 2000 + source + graph: Graph = { + "nodes": [{"id": "culprit", "data": {"type": "code", "code": source, "variables": []}}], + "edges": [], + } + historical = deepcopy(graph) + if boundary == "non-code": + graph["nodes"][0]["data"]["type"] = "tool" + historical = deepcopy(graph) + elif boundary == "changed-binding": + graph["nodes"][0]["data"]["variables"] = [{"variable": "different", "value_selector": ["start", "new"]}] + elif boundary == "code-drift": + historical["nodes"][0]["data"]["code"] = "def classify(record, input_index): return record" + outputs = [NodeOutput(node_id="culprit", status="failed", inputs={"text": '[{"record_key":[]}]'})] + run = Run(status="failed") + evidence = build_evidence(run, graph, None if boundary == "unbound" else historical, outputs)[3] + prompts: list[dict[str, str]] = [] + + def answer(_model: object, *, system: str, user: str, **_kwargs: object) -> dict[str, object]: + prompts.append({"system": system, "user": user}) + if len(prompts) == 1: + return {"culprit_node_id": "culprit", "root_cause": "Review invalid-record contract", "severity": "high"} + return {"intents": []} + + monkeypatch.setattr(fix.llm, "invoke_json", answer) + model = make_model_instance(provider="openai", model="test-model") + diagnosis = fix.diagnose(model, run, graph, outputs, evidence=evidence) + diagnosis.condition_evidence = evidence + fix.propose_repair(model, diagnosis, graph) + + assert len(prompts) == 2 + assert CODE_FAILURE_POLICY_RULES in prompts[1]["system"] + bound_code = boundary in {"bound", "source-omitted", "code-drift"} + assert (CODE_FAILURE_POLICY_RULES in prompts[0]["system"]) is bound_code + assert ("CURRENT CULPRIT SOURCE CONTEXT" in prompts[0]["user"]) is bound_code + if boundary in {"bound", "code-drift"}: + assert json.dumps(source, ensure_ascii=False) in prompts[0]["user"] + elif boundary == "source-omitted": + assert "SOURCE_REQUIRES_MANUAL_REVIEW" not in prompts[0]["user"] + assert "Omitted source requires manual review" in prompts[0]["user"]