diff --git a/ariadne/services/hermes_code_defects.py b/ariadne/services/hermes_code_defects.py index 0a29962..c6aa02f 100644 --- a/ariadne/services/hermes_code_defects.py +++ b/ariadne/services/hermes_code_defects.py @@ -342,7 +342,16 @@ def defect_instruction(defects: list[dict[str, Any]]) -> str: if not defects: return "" - lines: list[str] = ["The build reported these specific defects; address exactly these:"] + # A static-analysis sweep runs against a green build. Saying "the build + # reported" there would be a plain falsehood in the first line the model + # reads, and the instruction two lines later already contradicts it. + from_build = any(defect["category"] != SONARQUBE_ISSUE for defect in defects) + lead = ( + "The build reported these specific defects; address exactly these:" + if from_build + else "Static analysis reported these findings; address exactly these:" + ) + lines: list[str] = [lead] for defect in defects: lines.append(_defect_line(defect)) if any(d["category"] == LINT_VIOLATION for d in defects): diff --git a/ariadne/services/hermes_code_prompt.py b/ariadne/services/hermes_code_prompt.py index 58c7d1d..8d31a94 100644 --- a/ariadne/services/hermes_code_prompt.py +++ b/ariadne/services/hermes_code_prompt.py @@ -12,7 +12,7 @@ import json from . import hermes_code_defects -_PATCH_PROMPT_TEMPLATE = """Use $triage-titan-test-failures. +_PATCH_PROMPT_TEMPLATE = """__PREAMBLE__ You are proposing a MINIMAL source fix for incident __INCIDENT_ID__. The repository is __OWNER__/__REPO__ branch __BASE_BRANCH__. Return ONLY a single JSON object with exactly these keys and no others: @@ -24,18 +24,55 @@ Change as few lines as possible; do not reformat; do not add dependencies. Ariadne validates and pushes the change — you do not execute anything. Set human_required to true if the fix is not a small localized source change. __DEFECT_INSTRUCTION__ -Failing test evidence bundle: +__BUNDLE_LABEL__ __BUNDLE__ __FILE_SECTIONS__""" _FILE_SECTION_TEMPLATE = "Current content of the candidate file {path}:\n{contents}" +# A build-driven repair starts from a failure worth investigating, so the +# triage skill earns its cost. A quality sweep does not: no build failed, there +# is no console to read, and the file is already inlined below. Sending Hermes +# to investigate a green build would waste a tool call and invite it to explain +# a failure that does not exist. +_BUILD_PREAMBLE = "Use $triage-titan-test-failures." +_SWEEP_PREAMBLE = ( + "A static-analysis finding, not a build failure. Nothing is broken and no " + "investigation is needed: the finding and the file are below." +) +_BUILD_BUNDLE_LABEL = "Failing test evidence bundle:" +_SWEEP_BUNDLE_LABEL = "Static analysis evidence bundle:" + + +def is_quality_sweep(bundle: dict) -> bool: + """Report whether a bundle came from a quality sweep rather than a build. + + Inputs: the evidence bundle. Outputs: True when it carries SonarQube + findings and no build evidence. A bundle with both is treated as + build-driven, since a real failure is the more urgent framing. + """ + + if not isinstance(bundle, dict): + return False + sonar = bundle.get("sonarqube") + if not isinstance(sonar, dict) or not sonar.get("issues"): + return False + jenkins = bundle.get("jenkins") if isinstance(bundle.get("jenkins"), dict) else {} + return not ( + jenkins.get("console_failures") or jenkins.get("console_tail") or jenkins.get("failed_tests") + ) + def build_patch_prompt( incident_id: str, bundle: dict, code_cfg: dict, fetched: dict[str, str] ) -> str: - """Render the frozen patch prompt with incident, bundle, and file context.""" + """Render the frozen patch prompt with incident, bundle, and file context. + + The framing follows the bundle's origin. A prompt that opens by telling the + model it is looking at a failing test, when the build is green and the + evidence is a static-analysis finding, is wrong in the first line it reads. + """ compact = json.dumps(bundle, separators=(",", ":"), ensure_ascii=True) listing = "\n".join(f"- {path}" for path in fetched) @@ -46,8 +83,13 @@ def build_patch_prompt( _FILE_SECTION_TEMPLATE.format(path=path, contents=contents) for path, contents in fetched.items() ) + sweep = is_quality_sweep(bundle) return ( - _PATCH_PROMPT_TEMPLATE.replace("__INCIDENT_ID__", incident_id) + _PATCH_PROMPT_TEMPLATE.replace( + "__PREAMBLE__", _SWEEP_PREAMBLE if sweep else _BUILD_PREAMBLE + ) + .replace("__BUNDLE_LABEL__", _SWEEP_BUNDLE_LABEL if sweep else _BUILD_BUNDLE_LABEL) + .replace("__INCIDENT_ID__", incident_id) .replace("__OWNER__", str(code_cfg.get("owner") or "")) .replace("__REPO__", str(code_cfg.get("repo") or "")) .replace("__BASE_BRANCH__", str(code_cfg.get("base_branch") or "")) diff --git a/tests/test_hermes_sonar_sweep.py b/tests/test_hermes_sonar_sweep.py index b49d9db..c823261 100644 --- a/tests/test_hermes_sonar_sweep.py +++ b/tests/test_hermes_sonar_sweep.py @@ -307,3 +307,56 @@ def test_the_scheduled_entry_point_runs_the_sweep_when_enabled(monkeypatch) -> N "api_key": "k", "total_timeout_seconds": 420.0, } + + +def test_the_sweep_prompt_never_claims_a_build_failed() -> None: + """A green build framed as a failing test is wrong in the first line read.""" + + from ariadne.services import hermes_code_prompt + + cfg = {"owner": "bstein", "repo": "ariadne", "base_branch": "master"} + prompt = hermes_code_prompt.build_patch_prompt( + "sonar/ariadne/AZ-1", + module.bundle_for("ariadne", _issue()), + {**cfg, "allowed_path_prefixes": ["ariadne/"], "allowed_suffixes": [".py"]}, + {"ariadne/services/hermes_code_defects.py": "x"}, + ) + + assert prompt.startswith("A static-analysis finding, not a build failure.") + assert "Failing test evidence bundle" not in prompt + assert "Static analysis evidence bundle:" in prompt + assert "The build reported these specific defects" not in prompt + # The triage skill investigates a failed build; there isn't one. + assert "$triage-titan-test-failures" not in prompt + + +def test_a_build_driven_prompt_is_unchanged() -> None: + from ariadne.services import hermes_code_prompt + + bundle = {"jenkins": {"console_tail": "AssertionError", "console_failures": [], "failed_tests": []}} + prompt = hermes_code_prompt.build_patch_prompt( + "ariadne/9", bundle, {"owner": "bstein", "repo": "ariadne"}, {"ariadne/a.py": "x"} + ) + + assert prompt.startswith("Use $triage-titan-test-failures.") + assert "Failing test evidence bundle:" in prompt + + +@pytest.mark.parametrize( + ("bundle", "expected"), + [ + ({"sonarqube": {"issues": [{"path": "a.py"}]}, "jenkins": {}}, True), + ({"sonarqube": {"issues": []}, "jenkins": {}}, False), + ({"jenkins": {"console_tail": "boom"}}, False), + # Both present: a real failure is the more urgent framing. + ( + {"sonarqube": {"issues": [{"path": "a.py"}]}, "jenkins": {"console_tail": "boom"}}, + False, + ), + ("not-a-dict", False), + ], +) +def test_a_bundles_origin_decides_the_framing(bundle, expected) -> None: + from ariadne.services import hermes_code_prompt + + assert hermes_code_prompt.is_quality_sweep(bundle) is expected