From d47adfe027b6c955e5dc6618e35a2863b11749f9 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 4 Jun 2026 06:01:06 +0200 Subject: [PATCH] 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. --- roboco/services/gateway/envelope.py | 24 ++++++++++++++++++++++-- tests/unit/gateway/test_envelope.py | 6 ++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/roboco/services/gateway/envelope.py b/roboco/services/gateway/envelope.py index b881ca5f..fc9bc460 100644 --- a/roboco/services/gateway/envelope.py +++ b/roboco/services/gateway/envelope.py @@ -67,6 +67,21 @@ class Envelope: 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 def tracing_gap( cls, @@ -77,6 +92,7 @@ class Envelope: ) -> Envelope: return cls( error="tracing_gap", + message=cls._missing_message("required tracing", missing, remediate), missing=missing, remediate=remediate, context_briefing=context_briefing or {}, @@ -99,6 +115,7 @@ class Envelope: """ return cls( error="incomplete_input", + message=cls._missing_message("required input fields", missing, remediate), missing=missing, field_hints=field_hints, remediate=remediate, @@ -181,10 +198,13 @@ class Envelope: ctx = briefing or {} kind = decision.rejection_kind if kind == "tracing_gap": + missing = list(decision.missing) + remediate = decision.remediate or "" return cls( error="tracing_gap", - missing=list(decision.missing), - remediate=decision.remediate or "", + message=cls._missing_message("required tracing", missing, remediate), + missing=missing, + remediate=remediate, context_briefing=ctx, ) if kind == "self_review": diff --git a/tests/unit/gateway/test_envelope.py b/tests/unit/gateway/test_envelope.py index 1ffd5c41..6899d1d6 100644 --- a/tests/unit/gateway/test_envelope.py +++ b/tests/unit/gateway/test_envelope.py @@ -37,6 +37,12 @@ class TestEnvelopeError: assert body["error"] == "tracing_gap" assert body["missing"] == ["progress>=1", "journal:reflect"] 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: env = Envelope.invalid_state(