fix(gateway): rejection envelopes always carry a human-readable message

tracing_gap and incomplete_input rejections set missing and remediate but
left message null. The audit log records message (not remediate) and agents
keyed on message, so a rejected agent saw a null reason and retried the same
verb until it burned out instead of reading remediate and self-correcting.

Both builders (and from_decision) now derive a non-null message that folds
the missing tokens and the actionable remediate into one line, so the agent
and the audit trail always see what was missing and how to fix it.
This commit is contained in:
Renn F
2026-06-04 06:01:06 +02:00
parent 3cfac2e503
commit d47adfe027
2 changed files with 28 additions and 2 deletions
+22 -2
View File
@@ -67,6 +67,21 @@ class Envelope:
context_briefing=context_briefing or {}, context_briefing=context_briefing or {},
) )
@staticmethod
def _missing_message(label: str, missing: list[str], remediate: str) -> str:
"""Human-readable rejection summary so `message` is never null.
`tracing_gap`/`incomplete_input` historically left `message` unset, so
the agent (and the audit log, which records `message`) saw `null` and
could not see what to do — the agent then retried the same verb until it
burned out. This folds the missing tokens + the actionable `remediate`
into one line carried by `message`, so a client reading only `message`
still gets the full picture.
"""
joined = ", ".join(missing) if missing else "(unspecified)"
base = f"blocked: {label} missing — {joined}"
return f"{base}. {remediate}" if remediate else base
@classmethod @classmethod
def tracing_gap( def tracing_gap(
cls, cls,
@@ -77,6 +92,7 @@ class Envelope:
) -> Envelope: ) -> Envelope:
return cls( return cls(
error="tracing_gap", error="tracing_gap",
message=cls._missing_message("required tracing", missing, remediate),
missing=missing, missing=missing,
remediate=remediate, remediate=remediate,
context_briefing=context_briefing or {}, context_briefing=context_briefing or {},
@@ -99,6 +115,7 @@ class Envelope:
""" """
return cls( return cls(
error="incomplete_input", error="incomplete_input",
message=cls._missing_message("required input fields", missing, remediate),
missing=missing, missing=missing,
field_hints=field_hints, field_hints=field_hints,
remediate=remediate, remediate=remediate,
@@ -181,10 +198,13 @@ class Envelope:
ctx = briefing or {} ctx = briefing or {}
kind = decision.rejection_kind kind = decision.rejection_kind
if kind == "tracing_gap": if kind == "tracing_gap":
missing = list(decision.missing)
remediate = decision.remediate or ""
return cls( return cls(
error="tracing_gap", error="tracing_gap",
missing=list(decision.missing), message=cls._missing_message("required tracing", missing, remediate),
remediate=decision.remediate or "", missing=missing,
remediate=remediate,
context_briefing=ctx, context_briefing=ctx,
) )
if kind == "self_review": if kind == "self_review":
+6
View File
@@ -37,6 +37,12 @@ class TestEnvelopeError:
assert body["error"] == "tracing_gap" assert body["error"] == "tracing_gap"
assert body["missing"] == ["progress>=1", "journal:reflect"] assert body["missing"] == ["progress>=1", "journal:reflect"]
assert "note(scope='reflect'" in body["remediate"] assert "note(scope='reflect'" in body["remediate"]
# message must NOT be null — it carries the missing tokens + remediate so
# the agent and the audit log can see what to do (no more silent flailing).
assert body["message"] is not None
assert "progress>=1" in body["message"]
assert "journal:reflect" in body["message"]
assert "note(scope='reflect'" in body["message"]
def test_invalid_state(self) -> None: def test_invalid_state(self) -> None:
env = Envelope.invalid_state( env = Envelope.invalid_state(