diff --git a/ariadne/services/hermes_autotriage_decision.py b/ariadne/services/hermes_autotriage_decision.py index 7744401..e1f2807 100644 --- a/ariadne/services/hermes_autotriage_decision.py +++ b/ariadne/services/hermes_autotriage_decision.py @@ -4,6 +4,8 @@ from dataclasses import dataclass import json from typing import Any +from ariadne.services import hermes_suggested_remediation as suggestion_field + ALLOWED_FACT_SOURCES = {"jenkins", "opensearch", "victoriametrics", "kubernetes", "flux", "gitea"} RUN_ARIADNE_JOB_ACTION = "run_ariadne_job" @@ -20,6 +22,9 @@ _TOP_LEVEL_KEYS = { "human_required", "reason", } +# Optional because the field only applies when nothing in the allowlist fit, and +# because a response written before it existed must keep validating unchanged. +_OPTIONAL_TOP_LEVEL_KEYS = {suggestion_field.SUGGESTION_KEY} _STRING_FIELDS = ("incident_id", "classification", "first_failed_gate", "reason") _FACT_KEYS = {"statement", "source", "reference"} _ACTION_KEYS = {"type", "id"} @@ -73,6 +78,7 @@ class TriageDecision: requested_action: RequestedAction | None human_required: bool reason: str + suggested_remediation: suggestion_field.SuggestedRemediation | None = None @dataclass(frozen=True) @@ -137,6 +143,10 @@ def authorize_action( # noqa: PLR0913 - gate signature is part of the frozen mo allowlisted). Without it the single `expected_classification` is enforced. Outputs: (allowed, reason); reason is "authorized" only when every gate passes, otherwise it names the first failing gate. + + `decision.suggested_remediation` is deliberately absent from every gate. A + remediation the model names for itself must never widen what it may run; + the suggestion reaches a person and nothing else. """ if not outcome.valid or outcome.decision is None: @@ -238,7 +248,7 @@ def _validate_payload(payload: dict[str, Any], expected_incident_id: str) -> str missing = sorted(_TOP_LEVEL_KEYS - keys) if missing: return "missing_keys: " + ", ".join(missing) - extra = sorted(keys - _TOP_LEVEL_KEYS) + extra = sorted(keys - _TOP_LEVEL_KEYS - _OPTIONAL_TOP_LEVEL_KEYS) if extra: return "unexpected_keys: " + ", ".join(extra) type_error = ( @@ -246,6 +256,7 @@ def _validate_payload(payload: dict[str, Any], expected_incident_id: str) -> str or _validate_confidence(payload["confidence"]) or _validate_facts(payload["facts"]) or _validate_requested_action(payload["requested_action"]) + or suggestion_field.validate(payload) ) if type_error: return type_error @@ -317,6 +328,7 @@ def _decision_from_payload(payload: dict[str, Any]) -> TriageDecision: requested_action=None if action is None else RequestedAction(type=action["type"], id=action["id"]), human_required=payload["human_required"], reason=payload["reason"], + suggested_remediation=suggestion_field.from_payload(payload), ) diff --git a/ariadne/services/hermes_autotriage_events.py b/ariadne/services/hermes_autotriage_events.py index 594b773..c6f9ab0 100644 --- a/ariadne/services/hermes_autotriage_events.py +++ b/ariadne/services/hermes_autotriage_events.py @@ -12,7 +12,12 @@ from dataclasses import dataclass import json from typing import Any -from .hermes_autotriage_metrics import HERMES_TRIAGE_ACTION_TOTAL, set_incident_gauge +from . import hermes_suggested_remediation as suggestion_field +from .hermes_autotriage_metrics import ( + HERMES_TRIAGE_ACTION_TOTAL, + HERMES_TRIAGE_SUGGESTION_TOTAL, + set_incident_gauge, +) INCIDENT_EVENT_TYPE = "hermes_autotriage_incident" @@ -159,8 +164,15 @@ def record_diagnosis( Inputs: storage, the incident identity fields, the Hermes run result, the parsed DecisionOutcome (or None when the run never completed), and the Authorization verdict. Outputs: none. + + Counts a proposed remediation here rather than at render time: this runs + exactly once per diagnosis, so the counter measures how often the allowlist + fell short rather than how often an issue body was formatted. """ + decision = getattr(outcome, "decision", None) + if getattr(decision, "suggested_remediation", None) is not None: + HERMES_TRIAGE_SUGGESTION_TOTAL.inc() storage.record_event( DIAGNOSIS_EVENT_TYPE, { @@ -198,6 +210,9 @@ def outcome_phase(outcome: Any) -> dict[str, Any]: "first_failed_gate": decision.first_failed_gate, "human_required": decision.human_required, "requested_action": None if decision.requested_action is None else decision.requested_action.id, + "suggested_remediation": suggestion_field.as_detail( + getattr(decision, "suggested_remediation", None) + ), } diff --git a/ariadne/services/hermes_autotriage_metrics.py b/ariadne/services/hermes_autotriage_metrics.py index c282cff..e516300 100644 --- a/ariadne/services/hermes_autotriage_metrics.py +++ b/ariadne/services/hermes_autotriage_metrics.py @@ -29,9 +29,20 @@ PROPOSE_CODE_FIX_ACTION = "propose_code_fix" # The bounded `action` label set for HERMES_TRIAGE_ACTION_TOTAL. Anything the # model asks for that is neither deployed in hermes_allowed_actions nor listed # here collapses to "unknown", so a hallucinated action id can never mint a new -# metric series. `propose_code_fix` is the orchestrator's own additive step -# rather than a model-requested remediation, so it has to be named here. -KNOWN_ACTION_LABELS = ("repair_demo_fixture", "retry_transient_infra", PROPOSE_CODE_FIX_ACTION) +# metric series. `propose_code_fix` and `abort_hung_build` are the orchestrator's +# own additive steps rather than model-requested remediations, so they have to be +# named here. The remaining ids are named so a deployment that has not enabled +# one of them still reports it under its own name rather than as "unknown". +ABORT_HUNG_BUILD_ACTION = "abort_hung_build" + +KNOWN_ACTION_LABELS = ( + "repair_demo_fixture", + "retry_transient_infra", + "reclaim_workspace_storage", + "clear_stuck_agent_pods", + ABORT_HUNG_BUILD_ACTION, + PROPOSE_CODE_FIX_ACTION, +) HERMES_TRIAGE_INCIDENT = Gauge( "ariadne_hermes_triage_incident", @@ -43,6 +54,10 @@ HERMES_TRIAGE_ACTION_TOTAL = Counter( "Hermes auto-triage remediation actions by result", ["action", "result"], ) +HERMES_TRIAGE_SUGGESTION_TOTAL = Counter( + "ariadne_hermes_triage_suggestion_total", + "Remediations Hermes proposed for failures no allowlisted action covers", +) HERMES_TRIAGE_LAST_SUCCESS_TS = Gauge( "ariadne_hermes_triage_last_success_timestamp_seconds", "Last Hermes auto-triage incident resolution timestamp", diff --git a/ariadne/services/hermes_incident_body.py b/ariadne/services/hermes_incident_body.py index 100efac..661fa6e 100644 --- a/ariadne/services/hermes_incident_body.py +++ b/ariadne/services/hermes_incident_body.py @@ -15,6 +15,8 @@ from __future__ import annotations import re from typing import Any +from ariadne.services import hermes_suggested_remediation as suggestion_field + DEFAULT_MAX_BODY_CHARS = 8000 UNDIAGNOSED = "undiagnosed" @@ -118,6 +120,7 @@ def issue_body(context: dict, max_chars: int = DEFAULT_MAX_BODY_CHARS) -> str: _human_section(context), _facts_section(context), _inferences_section(context), + suggestion_field.issue_section(context.get("suggested_remediation")), _links_section(context), _footer(context), ] diff --git a/ariadne/services/hermes_incident_issue.py b/ariadne/services/hermes_incident_issue.py index 92a260d..1597984 100644 --- a/ariadne/services/hermes_incident_issue.py +++ b/ariadne/services/hermes_incident_issue.py @@ -202,6 +202,7 @@ def issue_context(base: dict[str, Any], diagnosis: dict[str, Any]) -> dict[str, "reason": str(getattr(decision, "reason", "") or authorize_reason), "facts": [body.fact_fields(fact) for fact in getattr(decision, "facts", None) or []], "inferences": list(getattr(decision, "inferences", None) or []), + "suggested_remediation": getattr(decision, "suggested_remediation", None), "authorize_reason": authorize_reason, # An escalation that never reached a model has no cited facts, so its # console text is the whole explanation and must not be dropped. diff --git a/ariadne/services/hermes_suggested_remediation.py b/ariadne/services/hermes_suggested_remediation.py new file mode 100644 index 0000000..ab3710e --- /dev/null +++ b/ariadne/services/hermes_suggested_remediation.py @@ -0,0 +1,163 @@ +"""Let Hermes describe a remediation Ariadne cannot yet perform. + +Every action in the allowlist exists because a person hit the failure by hand, +recognised the pattern, and wired a job for it. That is a slow loop, and it +only closes when someone happens to read enough issues to notice the same +failure recurring. A failure nobody reviews twice never earns an action. + +This is the other half of that loop. When no allowlisted action fits, Hermes +may describe what it believes *would* fix the failure and what evidence should +have to be present before that fix is safe. The description is recorded on the +incident and printed in the issue, so a suggestion that keeps reappearing +across unrelated builds becomes visible as a pattern rather than staying buried +in one issue nobody re-reads. + +The suggestion is inert by construction, and deliberately so. It carries no +authority: the authorization gates never read it, and an id that is not already +in the allowlist fails `action_not_allowlisted` exactly as it always has. Its +only destination is prose a person reads. That separation is what makes it safe +to let the model name its own remediation - naming one and being granted one +are different things, and only the second requires a deployment change made by +a human. + +A suggestion is accepted only when `requested_action` is null. If an existing +action already fit, the gap this field exists to report is not there, and +allowing both would invite the model to attach a rationale to a request the +gates are supposed to judge on evidence alone. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Any + + +SUGGESTION_KEY = "suggested_remediation" + +_KEYS = {"action_id", "summary", "evidence_required"} +# The same shape as a real action id, so a suggestion that graduates can be +# deployed under the name it was proposed with. +_ACTION_ID = re.compile(r"^[a-z][a-z0-9_]{2,63}$") +_MAX_TEXT = 400 + + +@dataclass(frozen=True) +class SuggestedRemediation: + """Represent a remediation Hermes proposes but Ariadne cannot perform. + + Inputs: a validated `suggested_remediation` object from the response + schema. Outputs: the proposed action id, what it would do, and the evidence + that should be required before it runs. + + Holds no authority of any kind. Nothing in the gate chain reads this. + """ + + action_id: str + summary: str + evidence_required: str + + +def validate(payload: dict[str, Any]) -> str | None: + """Validate the optional suggested_remediation field of a response. + + Inputs: the whole parsed response payload. Outputs: a specific reject + reason, or None when the field is absent, null, or well-formed. + + Rejects a suggestion offered alongside a requested action: the field + reports that nothing fit, so having something fit contradicts it. + """ + + suggestion = payload.get(SUGGESTION_KEY) + if suggestion is None: + return None + if payload.get("requested_action") is not None: + return "suggested_remediation_invalid: must be null when an action is requested" + if not isinstance(suggestion, dict): + return "suggested_remediation_invalid: must be an object or null" + if set(suggestion) != _KEYS: + return ( + "suggested_remediation_invalid: must have exactly action_id, summary, evidence_required" + ) + return _validate_fields(suggestion) + + +def _validate_fields(suggestion: dict[str, Any]) -> str | None: + """Check each field of a suggestion whose key set is already correct.""" + + for key in sorted(_KEYS): + if not isinstance(suggestion[key], str) or not suggestion[key].strip(): + return f"suggested_remediation_invalid: {key} must be a non-empty string" + if not _ACTION_ID.match(suggestion["action_id"]): + return f"suggested_remediation_invalid: action_id {suggestion['action_id']!r} is not snake_case" + return None + + +def from_payload(payload: dict[str, Any]) -> SuggestedRemediation | None: + """Build the typed suggestion from an already-validated payload. + + Inputs: the parsed response payload. Outputs: a SuggestedRemediation, or + None when the field is absent or null. Text is clipped here so a long + proposal cannot crowd the diagnosis out of a bounded issue body. + """ + + suggestion = payload.get(SUGGESTION_KEY) + if not isinstance(suggestion, dict): + return None + return SuggestedRemediation( + action_id=suggestion["action_id"], + summary=_clip(suggestion["summary"]), + evidence_required=_clip(suggestion["evidence_required"]), + ) + + +def as_detail(suggestion: Any) -> dict[str, str] | None: + """Summarize a suggestion for the audit event detail. + + Inputs: a SuggestedRemediation or None. Outputs: the three fields as a + plain dict for the event store, or None. Recording it durably is the point: + the same proposal recurring across unrelated incidents is the evidence that + a real action is worth building. + """ + + if suggestion is None: + return None + return { + "action_id": suggestion.action_id, + "summary": suggestion.summary, + "evidence_required": suggestion.evidence_required, + } + + +def issue_section(suggestion: Any) -> str: + """Render the suggestion as a markdown section for the incident issue. + + Inputs: a SuggestedRemediation or None. Outputs: the section, or "" when + there is no suggestion. + + The heading states plainly that this remediation does not exist, so nobody + reads it as something that was attempted, declined, or is about to happen. + """ + + if suggestion is None: + return "" + return "\n".join( + [ + "## Suggested remediation (not available)", + "No automated remediation exists for this failure. Hermes proposes one; " + "it was not performed, and Ariadne cannot perform it until a person builds " + "and deploys it.", + f"- Proposed action: `{suggestion.action_id}`", + f"- What it would do: {suggestion.summary}", + f"- Evidence that should be required first: {suggestion.evidence_required}", + ] + ) + + +def _clip(value: str) -> str: + """Clip one proposal field so it cannot crowd out the diagnosis.""" + + text = " ".join(str(value).split()) + if len(text) <= _MAX_TEXT: + return text + return text[: _MAX_TEXT - 3] + "..." diff --git a/ariadne/services/hermes_triage_prompt.py b/ariadne/services/hermes_triage_prompt.py index 2796db7..1600ba0 100644 --- a/ariadne/services/hermes_triage_prompt.py +++ b/ariadne/services/hermes_triage_prompt.py @@ -26,7 +26,7 @@ jenkins.first_failed_stage names the pipeline stage that failed when the build r The jenkins.console_failures array holds excerpts around detected failure markers in chronological order; the earliest region usually contains the first enforced failure, and jenkins.console_tail is the end of the build which often only shows downstream noise. Distinguish facts from inference. Return ONLY a single JSON object with exactly these keys and no others: -{"incident_id": "", "classification": "", "confidence": <0..1>, "facts": [{"statement": "...", "source": "jenkins|opensearch|victoriametrics|kubernetes|flux|gitea", "reference": "..."}], "inferences": ["..."], "first_failed_gate": "", "requested_action": , "human_required": , "reason": ""} +{"incident_id": "", "classification": "", "confidence": <0..1>, "facts": [{"statement": "...", "source": "jenkins|opensearch|victoriametrics|kubernetes|flux|gitea", "reference": "..."}], "inferences": ["..."], "first_failed_gate": "", "requested_action": , "suggested_remediation": , "human_required": , "reason": ""} You are diagnosing only; you do not execute anything. Ariadne separately validates and executes any requested action under its own authorization policy, and will refuse anything its own reading of the evidence does not support. Write the reason and the inferences for an engineer who maintains this service and who has no knowledge of how this triage system is configured. Describe the failure and what a fix would involve. Do not discuss classifications, actions, policies, or which of them are permitted; those are Ariadne's concern and are meaningless in the service's issue tracker. Three classifications have a predefined remediation. Use one only when the evidence plainly shows that failure; otherwise leave requested_action null. @@ -34,6 +34,7 @@ Use transient_infra_failure with requested_action {"type": "run_ariadne_job", "i Use workspace_storage_exhausted with requested_action {"type": "run_ariadne_job", "id": "reclaim_workspace_storage"} when the evidence shows the build ran out of disk on its workspace volume (no space left on device, disk quota exceeded) while writing under the agent workspace. Do not classify this as transient_infra_failure: a plain rebuild lands on the same full volume, so the remediation must reclaim the stale workspace storage first. Use jenkins_agent_provisioning_failure with requested_action {"type": "run_ariadne_job", "id": "clear_stuck_agent_pods"} when the evidence shows the build never got an agent (all nodes of a label offline, an agent pod stuck ContainerCreating or Pending, or an error in provisioning) rather than failing once it was running. Otherwise leave requested_action null. +When you leave requested_action null because none of the remediations above fits, and you can name a remediation that a maintainer could reasonably automate for this failure, set suggested_remediation to {"action_id": "", "summary": "", "evidence_required": ""}; otherwise set it to null. This proposes work for a maintainer to build and does not request anything: nothing you name here can be executed, and it must be null whenever requested_action is set. Propose one only when the same remediation would be correct for any build failing this way, not merely for this build. Set human_required to true when decisive evidence is missing or the failure needs a judgement only a maintainer can make; otherwise set it false and say plainly what you believe is wrong. Do not perform mutations.""" diff --git a/tests/test_hermes_suggested_remediation.py b/tests/test_hermes_suggested_remediation.py new file mode 100644 index 0000000..b211bb1 --- /dev/null +++ b/tests/test_hermes_suggested_remediation.py @@ -0,0 +1,211 @@ +"""Tests for remediations Hermes proposes but Ariadne cannot perform.""" + +from __future__ import annotations + +import json + +import pytest + +from ariadne.services import hermes_autotriage_decision as decision_module +from ariadne.services import hermes_incident_body as body +from ariadne.services import hermes_suggested_remediation as module + + +SUGGESTION = { + "action_id": "restart_stuck_flux_reconciler", + "summary": "Suspend and resume the Flux Kustomization so the stalled reconcile is retried.", + "evidence_required": "A Kustomization reporting the same revision as not-ready for over an hour.", +} + + +def _payload(**overrides): + payload = { + "incident_id": "lesavka/9", + "classification": "flux_reconcile_stalled", + "confidence": 0.9, + "facts": [], + "inferences": [], + "first_failed_gate": "deploy", + "requested_action": None, + "human_required": True, + "reason": "the reconcile never converged", + } + payload.update(overrides) + return payload + + +def _parse(payload): + return decision_module.parse_triage_response(json.dumps(payload), "lesavka/9") + + +def test_a_response_without_the_field_still_validates() -> None: + """The field is optional, so responses predating it must keep parsing.""" + + outcome = _parse(_payload()) + + assert outcome.valid + assert outcome.decision.suggested_remediation is None + + +def test_a_null_suggestion_is_accepted() -> None: + outcome = _parse(_payload(suggested_remediation=None)) + + assert outcome.valid + assert outcome.decision.suggested_remediation is None + + +def test_a_well_formed_suggestion_is_parsed() -> None: + outcome = _parse(_payload(suggested_remediation=dict(SUGGESTION))) + + suggestion = outcome.decision.suggested_remediation + assert suggestion.action_id == "restart_stuck_flux_reconciler" + assert suggestion.summary.startswith("Suspend and resume") + assert "not-ready" in suggestion.evidence_required + + +def test_a_suggestion_alongside_a_requested_action_is_rejected() -> None: + """The field reports that nothing fit, so something fitting contradicts it.""" + + outcome = _parse( + _payload( + requested_action={"type": "run_ariadne_job", "id": "retry_transient_infra"}, + suggested_remediation=dict(SUGGESTION), + ) + ) + + assert not outcome.valid + assert "must be null when an action is requested" in outcome.reject_reason + + +@pytest.mark.parametrize( + ("suggestion", "expected"), + [ + ("not-an-object", "must be an object or null"), + ({"action_id": "a_b_c"}, "must have exactly action_id"), + ({**SUGGESTION, "summary": " "}, "summary must be a non-empty string"), + ({**SUGGESTION, "evidence_required": 7}, "evidence_required must be a non-empty string"), + ({**SUGGESTION, "action_id": "Restart Flux"}, "is not snake_case"), + ({**SUGGESTION, "action_id": "ab"}, "is not snake_case"), + ], +) +def test_a_malformed_suggestion_is_rejected(suggestion, expected) -> None: + outcome = _parse(_payload(suggested_remediation=suggestion)) + + assert not outcome.valid + assert expected in outcome.reject_reason + + +def test_an_unknown_key_is_still_rejected() -> None: + """Making one key optional must not make the schema open-ended.""" + + outcome = _parse(_payload(something_else=1)) + + assert not outcome.valid + assert "unexpected_keys: something_else" in outcome.reject_reason + + +def test_a_long_proposal_is_clipped() -> None: + outcome = _parse( + _payload(suggested_remediation={**SUGGESTION, "summary": "word " * 400}) + ) + + assert len(outcome.decision.suggested_remediation.summary) <= 400 + + +def test_a_suggestion_never_widens_what_ariadne_may_run() -> None: + """The proposed id is not an allowlist entry, and the gates must say so.""" + + outcome = _parse(_payload(suggested_remediation=dict(SUGGESTION), human_required=False)) + allowed, reason = decision_module.authorize_action( + outcome, + { + "allowed_actions": ["retry_transient_infra"], + "action_classifications": {"flux_reconcile_stalled": "restart_stuck_flux_reconciler"}, + "autoremediation_enabled": True, + "min_confidence": 0.5, + }, + prior_action_count=0, + build_is_terminal_failure=True, + job_allowlisted=True, + evidence_has_signature=True, + ) + + assert not allowed + assert reason == "requested_action_missing" + + +def test_the_detail_recorded_for_the_audit_trail() -> None: + suggestion = module.from_payload({"suggested_remediation": dict(SUGGESTION)}) + + assert module.as_detail(suggestion) == { + "action_id": "restart_stuck_flux_reconciler", + "summary": SUGGESTION["summary"], + "evidence_required": SUGGESTION["evidence_required"], + } + assert module.as_detail(None) is None + + +def test_the_issue_section_says_plainly_that_it_did_not_happen() -> None: + """A reader must not mistake a proposal for something that was attempted.""" + + section = module.issue_section(module.from_payload({"suggested_remediation": dict(SUGGESTION)})) + + assert "## Suggested remediation (not available)" in section + assert "it was not performed" in section + assert "`restart_stuck_flux_reconciler`" in section + assert module.issue_section(None) == "" + + +def test_the_issue_body_carries_the_proposal() -> None: + rendered = body.issue_body( + { + "incident_id": "lesavka/9", + "job": "lesavka", + "build_number": 9, + "classification": "flux_reconcile_stalled", + "reason": "the reconcile never converged", + "run_id": "run-1", + "suggested_remediation": module.from_payload( + {"suggested_remediation": dict(SUGGESTION)} + ), + } + ) + + assert "Suggested remediation (not available)" in rendered + assert "restart_stuck_flux_reconciler" in rendered + assert rendered.rstrip().endswith("-->") + + +def test_a_proposal_is_counted_when_the_diagnosis_is_recorded() -> None: + """The counter measures how often the allowlist fell short, once per diagnosis.""" + + from ariadne.services import hermes_autotriage_events as events + from ariadne.services.hermes_autotriage_metrics import HERMES_TRIAGE_SUGGESTION_TOTAL + + class _Storage: + def __init__(self): + self.events = [] + + def record_event(self, event_type, detail): + self.events.append((event_type, detail)) + + run = type( + "Run", + (), + { + "status": "completed", "run_id": "r1", "session_id": "s1", "error": None, + "duration_seconds": 1.0, "denied_approvals": 0, + }, + )() + authorization = events.Authorization(allowed=False, reason="requested_action_missing", evidence_marker=None) + before = HERMES_TRIAGE_SUGGESTION_TOTAL._value.get() + storage = _Storage() + events.record_diagnosis(storage, {"incident_id": "lesavka/9"}, run, _parse(_payload()), authorization) + assert HERMES_TRIAGE_SUGGESTION_TOTAL._value.get() == before + + outcome = _parse(_payload(suggested_remediation=dict(SUGGESTION))) + events.record_diagnosis(storage, {"incident_id": "lesavka/9"}, run, outcome, authorization) + + assert HERMES_TRIAGE_SUGGESTION_TOTAL._value.get() == before + 1 + detail = storage.events[-1][1] + assert detail["outcome"]["suggested_remediation"]["action_id"] == "restart_stuck_flux_reconciler" diff --git a/tests/test_hermes_triage_prompt.py b/tests/test_hermes_triage_prompt.py index b00e5f2..8827c90 100644 --- a/tests/test_hermes_triage_prompt.py +++ b/tests/test_hermes_triage_prompt.py @@ -61,3 +61,14 @@ def test_structured_test_evidence_is_pointed_at_first() -> None: prompt = hermes_triage_prompt.build_prompt("metis/272", "metis", BUNDLE) assert prompt.index("jenkins.failed_tests") < prompt.index("jenkins.console_failures") + + +def test_prompt_offers_a_way_to_propose_a_remediation_that_does_not_exist() -> None: + """Hermes cannot report a gap in the allowlist unless it is told it may.""" + + prompt = hermes_triage_prompt.build_prompt("lesavka/9", "lesavka", {}) + + assert '"suggested_remediation": ' in prompt + assert "evidence_required" in prompt + assert "must be null whenever requested_action is set" in prompt + assert "does not request anything" in prompt