feat(hermes): let a diagnosis propose a remediation that does not exist yet
All checks were successful
Tests / Declarative: Post Actions passed: 1275

Every action in the allowlist got there because someone hit the failure by
hand, recognised the pattern, and wired a job for it. That loop only closes if
a person happens to read enough issues to notice the same failure recurring,
so a failure nobody reviews twice never earns an action. Until now a diagnosis
that fit nothing could only say a human was needed; it could not say what the
human should build.

An optional suggested_remediation field closes the other half of the loop.
When nothing in the allowlist fits, Hermes may name the remediation it
believes would work and the evidence that should be required before running it
is safe. It lands in the incident issue under a heading that states plainly
the remediation does not exist and was not performed, and in the audit event,
where the same proposal recurring across unrelated incidents is the evidence
that building it is worth the effort.

The field is inert by construction. No gate reads it, and an id that is not
already allowlisted still fails action_not_allowlisted exactly as before -
naming a remediation and being granted one stay different things, and only the
second needs a human to change a deployment. It is rejected outright alongside
a requested action: the field reports that nothing fit, so something fitting
contradicts it, and allowing both would invite a rationale to be attached to a
request the gates must judge on evidence alone. The key is optional rather
than required so a response written before today still validates unchanged,
and unknown keys are still refused.

Also names reclaim_workspace_storage, clear_stuck_agent_pods and
abort_hung_build in KNOWN_ACTION_LABELS. They were reporting as "unknown" in
metrics on any deployment that had not enabled them, which is the one case
where you most want to see the real name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
codex 2026-08-06 21:15:08 -03:00
parent ee7b243106
commit 9f24f8d999
9 changed files with 438 additions and 6 deletions

View File

@ -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),
)

View File

@ -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)
),
}

View File

@ -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",

View File

@ -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),
]

View File

@ -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.

View File

@ -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] + "..."

View File

@ -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": "<must equal __INCIDENT_ID__>", "classification": "<short snake_case name for what actually failed>", "confidence": <0..1>, "facts": [{"statement": "...", "source": "jenkins|opensearch|victoriametrics|kubernetes|flux|gitea", "reference": "..."}], "inferences": ["..."], "first_failed_gate": "<string>", "requested_action": <object or null>, "human_required": <bool>, "reason": "<string>"}
{"incident_id": "<must equal __INCIDENT_ID__>", "classification": "<short snake_case name for what actually failed>", "confidence": <0..1>, "facts": [{"statement": "...", "source": "jenkins|opensearch|victoriametrics|kubernetes|flux|gitea", "reference": "..."}], "inferences": ["..."], "first_failed_gate": "<string>", "requested_action": <object or null>, "suggested_remediation": <object or null>, "human_required": <bool>, "reason": "<string>"}
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": "<snake_case name for the remediation you are proposing>", "summary": "<what it would do, in one or two sentences>", "evidence_required": "<what would have to be present in the evidence before running it is safe>"}; 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."""

View File

@ -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"

View File

@ -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": <object or null>' 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