Skip to content

Commit 64a9698

Browse files
An empty plan against an unmet goal gets one nudge, then is believed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 75efbd0 commit 64a9698

2 files changed

Lines changed: 114 additions & 2 deletions

File tree

grapharc/planner/loop.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,10 @@ def run(
392392
unplanned_in_a_row = 0
393393
failed_in_a_row = 0
394394
stalled_in_a_row = 0
395+
# One nudge per run when an empty proposal contradicts an unmet goal
396+
# check; `confirm_pending` marks the single round right after it.
397+
empty_nudged = False
398+
confirm_pending = False
395399

396400
stop, detail = self._precheck(current)
397401
round_number = 0
@@ -441,6 +445,19 @@ def close(**fields: Any) -> None:
441445
break
442446

443447
if not outcome.ok or outcome.proposal is None:
448+
if confirm_pending:
449+
# The planner already said "nothing more"; the nudge asked
450+
# it to confirm and the follow-up produced nothing usable.
451+
# Read that as the confirmation it is, not as a planning
452+
# failure to burn the allowance on — a scripted planner
453+
# whose replies simply ran out lands here too.
454+
stop = LoopStop.NO_FURTHER_WORK
455+
detail = (
456+
"the planner proposed no further work, and the "
457+
"follow-up produced nothing usable"
458+
)
459+
close(planner_error=outcome.error, tokens=outcome.tokens)
460+
break
444461
unplanned_in_a_row += 1
445462
# The retry note shows the model what it actually said and what
446463
# was wanted. The bare error string alone ("no JSON object
@@ -466,6 +483,7 @@ def close(**fields: Any) -> None:
466483
close(planner_error=outcome.error, tokens=outcome.tokens)
467484
continue
468485
unplanned_in_a_row = 0
486+
confirm_pending = False
469487

470488
proposal = outcome.proposal
471489
verdict = self.checker.check(
@@ -490,11 +508,36 @@ def close(**fields: Any) -> None:
490508

491509
if not proposal.nodes:
492510
# Admitted, and it authorises nothing: the planner is saying
493-
# there is no further work. Admitting that is the right answer.
511+
# there is no further work. With no goal check, or a satisfied
512+
# one, admitting that is the right answer. With an UNMET goal
513+
# check it contradicts the operator's own definition of done,
514+
# so it gets one nudge naming the contradiction — one, not a
515+
# counter, because a planner that says "nothing more" twice is
516+
# answering, not failing.
517+
if (
518+
self.goal_reached is not None
519+
and not self._goal_met(current)
520+
and not empty_nudged
521+
):
522+
empty_nudged = True
523+
confirm_pending = True
524+
note = (
525+
"Your previous proposal was empty, but the run's goal "
526+
"check is not yet satisfied. Propose the remaining "
527+
"work, or reply with an empty proposal again to "
528+
"confirm there is nothing more this catalog can do."
529+
)
530+
close(**judged)
531+
continue
494532
stop = (
495533
LoopStop.GOAL_MET if self._goal_met(current) else LoopStop.NO_FURTHER_WORK
496534
)
497-
detail = "the planner proposed no further work"
535+
detail = (
536+
"the planner confirmed no further work; the goal check is "
537+
"still unsatisfied"
538+
if empty_nudged and not self._goal_met(current)
539+
else "the planner proposed no further work"
540+
)
498541
close(**judged)
499542
break
500543

tests/test_planner_loop.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,3 +1661,72 @@ def test_the_incident_example_state_merges_parallel_writers():
16611661
result = loop.run("triage, patch and verify at once", IncidentState())
16621662
assert result.stop.value == "goal_met"
16631663
assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"]
1664+
1665+
1666+
def test_an_empty_proposal_against_an_unmet_goal_gets_one_nudge():
1667+
"""Empty plan, goal check unsatisfied: the contradiction is named once.
1668+
1669+
The planner is told the goal check is not met and asked to either propose
1670+
the remaining work or repeat the empty proposal. Here it proposes the
1671+
work, and the run finishes on the goal — where before the nudge existed,
1672+
round 1's empty reply ended the run as `no_further_work` with the goal
1673+
never mentioned to the model.
1674+
"""
1675+
loop, model, bodies = build_loop(
1676+
[NOTHING_MORE, plan(("write", "summarise"))], goal_reached=goal_is_done
1677+
)
1678+
1679+
result = loop.run("summarise the findings", LoopState())
1680+
1681+
assert result.stop is LoopStop.GOAL_MET
1682+
assert bodies.ran == ["write"]
1683+
assert len(result.rounds) == 2
1684+
assert not result.rounds[0].executed and result.rounds[0].admitted
1685+
assert any(
1686+
"goal check is not yet satisfied" in str(message.content)
1687+
for message in model.calls[1]
1688+
)
1689+
1690+
1691+
def test_a_second_empty_proposal_is_believed():
1692+
"""The nudge is one round, not a counter: a repeat empty plan is an answer."""
1693+
loop, model, bodies = build_loop(
1694+
[NOTHING_MORE, NOTHING_MORE], goal_reached=goal_is_done
1695+
)
1696+
1697+
result = loop.run("summarise the findings", LoopState())
1698+
1699+
assert result.stop is LoopStop.NO_FURTHER_WORK
1700+
assert "confirmed no further work" in result.detail
1701+
assert bodies.ran == []
1702+
assert len(result.rounds) == 2
1703+
assert model.call_count == 2
1704+
1705+
1706+
def test_an_unusable_reply_after_the_nudge_confirms_no_further_work():
1707+
"""A planner with nothing left to say after the nudge is not a failure.
1708+
1709+
The scripted stand-ins end their reply lists with an empty proposal; the
1710+
nudge asks one more question than the script answers. Exhaustion there
1711+
must read as the confirmation it is — never as `planning_failed` burning
1712+
the failure allowance on a planner that already said "nothing more".
1713+
"""
1714+
loop, model, bodies = build_loop([NOTHING_MORE], goal_reached=goal_is_done)
1715+
1716+
result = loop.run("summarise the findings", LoopState())
1717+
1718+
assert result.stop is LoopStop.NO_FURTHER_WORK
1719+
assert "nothing usable" in result.detail
1720+
assert bodies.ran == []
1721+
assert result.rounds[-1].planner_error
1722+
1723+
1724+
def test_an_empty_proposal_with_no_goal_check_stops_without_a_nudge():
1725+
"""No goal check means nothing to contradict: one round, one clean stop."""
1726+
loop, model, bodies = build_loop([NOTHING_MORE])
1727+
1728+
result = loop.run("nothing needs doing", LoopState())
1729+
1730+
assert result.stop is LoopStop.NO_FURTHER_WORK
1731+
assert len(result.rounds) == 1
1732+
assert model.call_count == 1

0 commit comments

Comments
 (0)