fix(hitl): planen et menneske signerer er TEKST, aldri en objekt-repr (BLOCKER-1, ORDRE 20260902T151931Z)
Fire steder gjorde `str(review.plan)` paa en MAF `Message` som ikke har noen `__str__` (maalt: `type(Message).__str__ is object.__str__`), saa BEGGE HITL-doerene viste og lagret `<agent_framework._types.Message object at 0x…>`: terminalen F4 spoer ved (`:1395`), de to opptakene i `plan_reviews` (`:1404` approve, `:1418` revise) og den PARKERTE spoersmaalsfila U12 (`:1478`) som er det ENESTE som krysser prosessgrensen. En ekspert som svarte `approve` signerte blindt. Fiksen er den eksisterende `_plan_text` (`:966`) paa alle fire; ingen ny hjelper. Fire asserts var gronne mot defekten fordi de var TRUTHINESS eller selv-sammenligning: `reviews[1]["plan"] != ""`, `waiting[0].plan`, og `reviews[0]["plan"][:40] in out` — den siste sammenlignet den samme repr-en med seg selv, saa de to flatene var enige mens begge var uleselige. Diskriminatoren er innhold som KUN kan komme av `Message.text`: MAF komponerer plan-meldingen rundt managerens svar ordrett (maalt), saa en sentinel i det skriptede svaret er til stede naar teksten ble tatt og fravaerende naar repr-en ble det. Sentinelen bor i et `reason`-felt fordi ingenting leser dem — aa nokle den til en ANSWER ville endret kjoringen den maaler. I tillegg en NEGATIV assert (` object at 0x` finnes ingen steder i stdout, artefaktet eller spoersmaalsfila), som fanger hele defektklassen og ikke bare denne ene. MAALT: fire mutasjoner, EN PER LINJE (ordrens ene samlede revert underteste — `:1418` er revise-grenens egen kopi og hadde ellers ikke noe roedt vitne): :1395 -> 1 roed (T2 stdout) · :1404 -> 2 roede · :1418 -> 1 roed (T1 alene) · :1478 -> 1 roed (async T9). Suite 2 failed (F15-diffen, KJENT) / 1078 passed / 5 skipped — uendret fra baseline. Golden `demo-transcript.stdout` BYTE-UENDRET (ea8c534773acdbe41ae68f2c55724d69aaf8be4f). Aerlighetsgrense, uttalt: `str(review.current_progress)` (`:1396`/`:1479`) er IKKE roert. Den er en `MagenticProgressLedger | None`, ikke en `Message`, og maalt renderer pydantic den lesbart — men i disse kjoringene er den `None`, saa terminalen skriver «progress so far: None». Det er en annen defekt og en annen ordre. Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
parent
e2d26c50ed
commit
4075b46a00
3 changed files with 84 additions and 11 deletions
|
|
@ -1392,7 +1392,7 @@ async def _drive(
|
||||||
decision = plan_reviewer(
|
decision = plan_reviewer(
|
||||||
PlanReviewRequest(
|
PlanReviewRequest(
|
||||||
index=len(plan_reviews),
|
index=len(plan_reviews),
|
||||||
plan=str(review.plan),
|
plan=_plan_text(review.plan),
|
||||||
current_progress=str(review.current_progress),
|
current_progress=str(review.current_progress),
|
||||||
is_stalled=_truthy(review.is_stalled),
|
is_stalled=_truthy(review.is_stalled),
|
||||||
)
|
)
|
||||||
|
|
@ -1401,7 +1401,7 @@ async def _drive(
|
||||||
plan_reviews.append(
|
plan_reviews.append(
|
||||||
PlanReview(
|
PlanReview(
|
||||||
index=len(plan_reviews),
|
index=len(plan_reviews),
|
||||||
plan=str(review.plan),
|
plan=_plan_text(review.plan),
|
||||||
is_stalled=_truthy(review.is_stalled),
|
is_stalled=_truthy(review.is_stalled),
|
||||||
decision="approve",
|
decision="approve",
|
||||||
)
|
)
|
||||||
|
|
@ -1415,7 +1415,7 @@ async def _drive(
|
||||||
plan_reviews.append(
|
plan_reviews.append(
|
||||||
PlanReview(
|
PlanReview(
|
||||||
index=len(plan_reviews),
|
index=len(plan_reviews),
|
||||||
plan=str(review.plan),
|
plan=_plan_text(review.plan),
|
||||||
is_stalled=_truthy(review.is_stalled),
|
is_stalled=_truthy(review.is_stalled),
|
||||||
decision="revise",
|
decision="revise",
|
||||||
feedback=decision.feedback,
|
feedback=decision.feedback,
|
||||||
|
|
@ -1475,7 +1475,7 @@ async def _park(
|
||||||
request_id=str(request.request_id),
|
request_id=str(request.request_id),
|
||||||
checkpoint_id=str(latest.checkpoint_id),
|
checkpoint_id=str(latest.checkpoint_id),
|
||||||
index=len(trace.plan_reviews),
|
index=len(trace.plan_reviews),
|
||||||
plan=str(review.plan),
|
plan=_plan_text(review.plan),
|
||||||
current_progress=str(review.current_progress),
|
current_progress=str(review.current_progress),
|
||||||
is_stalled=_truthy(review.is_stalled),
|
is_stalled=_truthy(review.is_stalled),
|
||||||
bundle_dirs=tuple(bundle_dirs),
|
bundle_dirs=tuple(bundle_dirs),
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,18 @@ _BUNDLE_DIR = _REPO / "shared" / "examples" / "bygg-energi-mikro"
|
||||||
_PID = "BYGG-KONTOR-NORD"
|
_PID = "BYGG-KONTOR-NORD"
|
||||||
_RUN_ID = "async-review"
|
_RUN_ID = "async-review"
|
||||||
|
|
||||||
|
#: See ``tests/test_plan_review_cli_door_loadbearing.py`` for the measurement behind this: MAF
|
||||||
|
#: composes the task-ledger plan ``Message`` around the manager's answer verbatim, and ``Message``
|
||||||
|
#: has no ``__str__``, so ``str(review.plan)`` renders ``<…Message object at 0x…>``. The sentinel
|
||||||
|
#: can therefore appear only where the plan's TEXT was taken. Restated here rather than imported,
|
||||||
|
#: because this file already carries its own copy of the whole scripted-reply block: the two
|
||||||
|
#: scenarios park and resume differently and are meant to be readable apart.
|
||||||
|
_PLAN_SENTINEL = "PLAN-TEXT-SENTINEL-4b1e"
|
||||||
|
|
||||||
|
#: The defect asserted NEGATIVELY — any default object repr on a surface an expert reads, not only
|
||||||
|
#: this one.
|
||||||
|
_REPR_LEAK = " object at 0x"
|
||||||
|
|
||||||
_PROPOSER_REPLY = json.dumps(
|
_PROPOSER_REPLY = json.dumps(
|
||||||
{
|
{
|
||||||
"measure": "LED-retrofit",
|
"measure": "LED-retrofit",
|
||||||
|
|
@ -52,7 +64,7 @@ _PROPOSER_REPLY = json.dumps(
|
||||||
)
|
)
|
||||||
_MANAGER_REPLY = json.dumps(
|
_MANAGER_REPLY = json.dumps(
|
||||||
{
|
{
|
||||||
"is_request_satisfied": {"reason": "r", "answer": True},
|
"is_request_satisfied": {"reason": _PLAN_SENTINEL, "answer": True},
|
||||||
"is_in_loop": {"reason": "r", "answer": False},
|
"is_in_loop": {"reason": "r", "answer": False},
|
||||||
"is_progress_being_made": {"reason": "r", "answer": True},
|
"is_progress_being_made": {"reason": "r", "answer": True},
|
||||||
"next_speaker": {"reason": "r", "answer": "hypothesiser"},
|
"next_speaker": {"reason": "r", "answer": "hypothesiser"},
|
||||||
|
|
@ -405,7 +417,23 @@ def test_a_parked_review_is_pending_until_its_own_answer_lands(tmp_path, capsys)
|
||||||
|
|
||||||
waiting = hitl.pending_plan_reviews(outbox, inbox)
|
waiting = hitl.pending_plan_reviews(outbox, inbox)
|
||||||
assert [p.run_id for p in waiting] == [_RUN_ID]
|
assert [p.run_id for p in waiting] == [_RUN_ID]
|
||||||
assert waiting[0].plan, "an expert cannot answer a review that does not show them the plan"
|
|
||||||
|
# BLOCKER-1, on the ASYNCHRONOUS door. ``assert waiting[0].plan`` was truthiness, and a repr is
|
||||||
|
# truthy: the register said an expert could answer while what it showed them was
|
||||||
|
# ``<…Message object at 0x…>``. Both surfaces the expert actually reads are asserted — the
|
||||||
|
# question file ``explore.py:1478`` writes, and the register that joins it to the inbox.
|
||||||
|
question = _question(tmp_path)
|
||||||
|
assert _PLAN_SENTINEL in question["plan"], (
|
||||||
|
"the parked question is the ONLY thing crossing the process boundary; an expert answering "
|
||||||
|
"days later can read nothing else, so it must carry the plan as text"
|
||||||
|
)
|
||||||
|
assert _REPR_LEAK not in question["plan"], (
|
||||||
|
f"no default object repr may stand in for the plan: {question['plan'][:80]!r}"
|
||||||
|
)
|
||||||
|
assert _PLAN_SENTINEL in waiting[0].plan, (
|
||||||
|
"an expert cannot answer a review that does not show them the plan — and a repr shows "
|
||||||
|
"them nothing they can judge"
|
||||||
|
)
|
||||||
|
|
||||||
_answer(tmp_path, decision="approve")
|
_answer(tmp_path, decision="approve")
|
||||||
inbox_file = tmp_path / "review-inbox" / f"{_RUN_ID}-plan-review-answer.json"
|
inbox_file = tmp_path / "review-inbox" / f"{_RUN_ID}-plan-review-answer.json"
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,22 @@ _PROPOSER_REPLY = json.dumps(
|
||||||
"claimed_saving_nok": 30000,
|
"claimed_saving_nok": 30000,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
#: A string that exists ONLY inside the manager's scripted reply, so it reaches a surface exactly
|
||||||
|
#: when that surface rendered the plan's TEXT. MAF composes the task-ledger plan ``Message`` around
|
||||||
|
#: the manager's answer verbatim (measured), and ``Message`` has no ``__str__``
|
||||||
|
#: (``type(Message).__str__ is object.__str__``, measured) — so ``str(review.plan)`` yields
|
||||||
|
#: ``<agent_framework._types.Message object at 0x…>``, in which the sentinel cannot appear. It sits
|
||||||
|
#: in a ``reason`` field because nothing reads those: the ledger's ANSWERS drive the loop and are
|
||||||
|
#: asserted elsewhere, so keying the sentinel to them would change the run it is measuring.
|
||||||
|
_PLAN_SENTINEL = "PLAN-TEXT-SENTINEL-4b1e"
|
||||||
|
|
||||||
|
#: The shape of the defect itself, asserted NEGATIVELY. Stronger than the sentinel's absence: it
|
||||||
|
#: catches ANY object rendered by its default repr onto a surface a human reads, not only this one.
|
||||||
|
_REPR_LEAK = " object at 0x"
|
||||||
|
|
||||||
_MANAGER_REPLY = json.dumps(
|
_MANAGER_REPLY = json.dumps(
|
||||||
{
|
{
|
||||||
"is_request_satisfied": {"reason": "r", "answer": True},
|
"is_request_satisfied": {"reason": _PLAN_SENTINEL, "answer": True},
|
||||||
"is_in_loop": {"reason": "r", "answer": False},
|
"is_in_loop": {"reason": "r", "answer": False},
|
||||||
"is_progress_being_made": {"reason": "r", "answer": True},
|
"is_progress_being_made": {"reason": "r", "answer": True},
|
||||||
"next_speaker": {"reason": "r", "answer": "hypothesiser"},
|
"next_speaker": {"reason": "r", "answer": "hypothesiser"},
|
||||||
|
|
@ -158,28 +171,60 @@ def test_an_operator_answer_typed_at_the_cli_reaches_the_manager_and_is_asked_ag
|
||||||
assert reviews[0]["feedback"] == _FEEDBACK, (
|
assert reviews[0]["feedback"] == _FEEDBACK, (
|
||||||
"what a human told the loop is worth nothing paraphrased"
|
"what a human told the loop is worth nothing paraphrased"
|
||||||
)
|
)
|
||||||
assert reviews[1]["plan"] != "", "the second review must show the replanned plan"
|
# BLOCKER-1: the two recorded plans come from two DIFFERENT lines — the revise branch writes
|
||||||
|
# its own copy and the approve branch writes another. ``!= ""`` was green against a repr, so
|
||||||
|
# reverting either line alone stayed green; the sentinel is what separates them.
|
||||||
|
assert _PLAN_SENTINEL in reviews[0]["plan"], (
|
||||||
|
"the REVISE record must carry the plan as text — an expert reading the artefact to see "
|
||||||
|
"what they revised cannot use an object repr"
|
||||||
|
)
|
||||||
|
assert _PLAN_SENTINEL in reviews[1]["plan"], (
|
||||||
|
"the second review must show the replanned plan as TEXT, not as a repr of the message "
|
||||||
|
"that carried it"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_the_operator_is_shown_the_plan_and_the_answer_vocabulary(
|
def test_the_operator_is_shown_the_plan_and_the_answer_vocabulary(
|
||||||
tmp_path, capsys, monkeypatch
|
tmp_path, capsys, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
"""T2: a review nobody can read is a review nobody can answer. The prompt carries the plan,
|
"""T2: a review nobody can read is a review nobody can answer. The prompt carries the plan
|
||||||
the progress and the two words that answer it.
|
AS TEXT, the progress and the two words that answer it.
|
||||||
|
|
||||||
Detach point: print only "plan review?" → RED. The control for T1: T1 proves the answer is
|
Detach point: print only "plan review?" → RED. The control for T1: T1 proves the answer is
|
||||||
used, this proves the question was askable.
|
used, this proves the question was askable.
|
||||||
|
|
||||||
|
**Sharpened for BLOCKER-1.** The old form asserted ``reviews[0]["plan"][:40] in out``, which
|
||||||
|
an object repr satisfies just as well as the plan does — the same string was shown and stored,
|
||||||
|
so the two agreed while both were unreadable. What discriminates is content that can only come
|
||||||
|
from ``Message.text``: the sentinel, plus the negative that no default repr reached either
|
||||||
|
surface. Mutation: revert ``explore.py:1395`` → the operator is shown a repr (stdout RED);
|
||||||
|
revert ``:1404`` → the artefact stores one (artefact RED).
|
||||||
"""
|
"""
|
||||||
_stdin(monkeypatch, "approve\napprove\napprove\n")
|
_stdin(monkeypatch, "approve\napprove\napprove\n")
|
||||||
|
|
||||||
run.main(_argv(tmp_path))
|
run.main(_argv(tmp_path))
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
reviews = _artefact(tmp_path)["plan_reviews"]
|
artefact = _artefact(tmp_path)
|
||||||
|
reviews = artefact["plan_reviews"]
|
||||||
assert reviews, "the run must actually have reached a plan review"
|
assert reviews, "the run must actually have reached a plan review"
|
||||||
assert reviews[0]["plan"][:40] in out, (
|
assert reviews[0]["plan"][:40] in out, (
|
||||||
"the operator must be shown the plan they are signing off"
|
"the operator must be shown the plan they are signing off"
|
||||||
)
|
)
|
||||||
|
assert _PLAN_SENTINEL in out, (
|
||||||
|
"an operator asked to sign a plan must be shown the PLAN; a repr of the message that "
|
||||||
|
"carried it is a signature given blind"
|
||||||
|
)
|
||||||
|
assert _PLAN_SENTINEL in reviews[0]["plan"], (
|
||||||
|
"the artefact is the record of what was approved, so it must hold the plan as text too"
|
||||||
|
)
|
||||||
|
assert _REPR_LEAK not in out, (
|
||||||
|
f"no default object repr may reach a surface a human reads; found in stdout: "
|
||||||
|
f"{out[max(0, out.find(_REPR_LEAK) - 60) : out.find(_REPR_LEAK) + 20]!r}"
|
||||||
|
)
|
||||||
|
assert _REPR_LEAK not in json.dumps(artefact), (
|
||||||
|
"nor may one be written into the artefact that outlives the run"
|
||||||
|
)
|
||||||
assert "approve" in out and "revise" in out, "the prompt must name the vocabulary it accepts"
|
assert "approve" in out and "revise" in out, "the prompt must name the vocabulary it accepts"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue