Read a default-filled author disposition as no disposition

A model that fills every optional schema key sent
{"author_disposition": {"disposition": "accepted", "rationale": ""}, "items": [],
"review_fingerprint": ""} beside its FIRST plan_task envelope and looped on
PLAN_REVIEW_DISPOSITION_MIXED_ENVELOPE (seen live on the E2E stand, 17 calls).
An empty fingerprint names no wave and an empty rationale states nothing, so the
value carries no disposition: it is ignored beside a plan exactly like the
already-vacuous {fingerprint: "", items: []}. A rationale, an item or a
fingerprint is meaning and keeps the existing refusal.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-15 18:16:50 +03:00
parent 2cf5b5108e
commit 1eca97ddaf
2 changed files with 36 additions and 3 deletions

View file

@ -300,10 +300,20 @@ def _vacuous(name: str, value: object) -> bool:
def _vacuous_disposition(value: object) -> bool:
"""A schema-shaped but empty disposition (models fill optional objects with defaults).
An UNKNOWN key or a non-empty items list is never vacuous: refused, not ignored."""
if not isinstance(value, dict) or set(value) - {"review_fingerprint", "items"}:
An UNKNOWN key or a non-empty items list is never vacuous: refused, not ignored.
A default-filled ``author_disposition`` ({"disposition": "accepted", "rationale": ""})
beside an EMPTY fingerprint names no wave and answers no finding, so it carries
nothing either: without this a model that fills every schema key sent it with
its first plan and looped on PLAN_REVIEW_DISPOSITION_MIXED_ENVELOPE (seen live)."""
if not isinstance(value, dict) or set(value) - {"review_fingerprint", "items", "author_disposition"}:
return False
return not str(value.get("review_fingerprint") or "").strip() and not value.get("items")
author = value.get("author_disposition")
author_vacuous = author is None or (
isinstance(author, dict) and set(author) <= {"disposition", "rationale"}
and not str(author.get("rationale") or "").strip()
)
return (author_vacuous and not str(value.get("review_fingerprint") or "").strip()
and not value.get("items"))
def _typed_refusal(ctx: ToolContext, code: str, text: str) -> str:
"""Publish a refusal the producer ALREADY knows about (D02). The text ABI is

View file

@ -752,6 +752,29 @@ class TestPlanReviewDispositionEnvelope(unittest.TestCase):
self.assertEqual(out, "reviewed")
run.assert_called_once()
def test_default_filled_author_disposition_beside_a_plan_is_ignored(self):
"""Seen live: a model that fills every schema key sent
{"author_disposition": {"disposition": "accepted", "rationale": ""},
"items": [], "review_fingerprint": ""} with its FIRST plan and looped on
MIXED_ENVELOPE. No fingerprint names no wave, so it carries nothing."""
import ouroboros.tools.plan_review as pr
from ouroboros.tools.registry import ToolContext
ctx = ToolContext(repo_dir=pathlib.Path("."), drive_root=pathlib.Path("."))
ctx.task_id = "parent"
filler = {"author_disposition": {"disposition": "accepted", "rationale": ""},
"items": [], "review_fingerprint": ""}
with patch.object(pr, "_run_plan_review_async", return_value="reviewed") as run:
out = pr._handle_plan_task(ctx, plan="P", goal="G", spec={}, review_disposition=filler)
self.assertEqual(out, "reviewed")
run.assert_called_once()
# A rationale is a statement; with it the disposition is real and still refused beside a plan.
spoken = {**filler, "author_disposition": {"disposition": "rejected", "rationale": "no"}}
with patch.object(pr, "_run_plan_review_async") as run:
out = pr._handle_plan_task(ctx, plan="P", goal="G", spec={}, review_disposition=spoken)
self.assertIn("PLAN_REVIEW_DISPOSITION_MIXED_ENVELOPE", out)
run.assert_not_called()
def test_duplicate_plan_calls_use_existing_sequential_tool_lane(self):
from ouroboros.loop_tool_execution import tool_calls_can_run_parallel