From 64a96982f6aefe80fafeb97b6b3e7907c1145bd8 Mon Sep 17 00:00:00 2001 From: Shashank Shekhar Singh Date: Thu, 6 Aug 2026 01:09:11 +0530 Subject: [PATCH] An empty plan against an unmet goal gets one nudge, then is believed Co-Authored-By: Claude Fable 5 --- grapharc/planner/loop.py | 47 ++++++++++++++++++++++++-- tests/test_planner_loop.py | 69 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/grapharc/planner/loop.py b/grapharc/planner/loop.py index 3cc4ebc..c7a8516 100644 --- a/grapharc/planner/loop.py +++ b/grapharc/planner/loop.py @@ -392,6 +392,10 @@ def run( unplanned_in_a_row = 0 failed_in_a_row = 0 stalled_in_a_row = 0 + # One nudge per run when an empty proposal contradicts an unmet goal + # check; `confirm_pending` marks the single round right after it. + empty_nudged = False + confirm_pending = False stop, detail = self._precheck(current) round_number = 0 @@ -441,6 +445,19 @@ def close(**fields: Any) -> None: break if not outcome.ok or outcome.proposal is None: + if confirm_pending: + # The planner already said "nothing more"; the nudge asked + # it to confirm and the follow-up produced nothing usable. + # Read that as the confirmation it is, not as a planning + # failure to burn the allowance on — a scripted planner + # whose replies simply ran out lands here too. + stop = LoopStop.NO_FURTHER_WORK + detail = ( + "the planner proposed no further work, and the " + "follow-up produced nothing usable" + ) + close(planner_error=outcome.error, tokens=outcome.tokens) + break unplanned_in_a_row += 1 # The retry note shows the model what it actually said and what # was wanted. The bare error string alone ("no JSON object @@ -466,6 +483,7 @@ def close(**fields: Any) -> None: close(planner_error=outcome.error, tokens=outcome.tokens) continue unplanned_in_a_row = 0 + confirm_pending = False proposal = outcome.proposal verdict = self.checker.check( @@ -490,11 +508,36 @@ def close(**fields: Any) -> None: if not proposal.nodes: # Admitted, and it authorises nothing: the planner is saying - # there is no further work. Admitting that is the right answer. + # there is no further work. With no goal check, or a satisfied + # one, admitting that is the right answer. With an UNMET goal + # check it contradicts the operator's own definition of done, + # so it gets one nudge naming the contradiction — one, not a + # counter, because a planner that says "nothing more" twice is + # answering, not failing. + if ( + self.goal_reached is not None + and not self._goal_met(current) + and not empty_nudged + ): + empty_nudged = True + confirm_pending = True + note = ( + "Your previous proposal was empty, but the run's goal " + "check is not yet satisfied. Propose the remaining " + "work, or reply with an empty proposal again to " + "confirm there is nothing more this catalog can do." + ) + close(**judged) + continue stop = ( LoopStop.GOAL_MET if self._goal_met(current) else LoopStop.NO_FURTHER_WORK ) - detail = "the planner proposed no further work" + detail = ( + "the planner confirmed no further work; the goal check is " + "still unsatisfied" + if empty_nudged and not self._goal_met(current) + else "the planner proposed no further work" + ) close(**judged) break diff --git a/tests/test_planner_loop.py b/tests/test_planner_loop.py index c06783f..ecdcbfe 100644 --- a/tests/test_planner_loop.py +++ b/tests/test_planner_loop.py @@ -1661,3 +1661,72 @@ def test_the_incident_example_state_merges_parallel_writers(): result = loop.run("triage, patch and verify at once", IncidentState()) assert result.stop.value == "goal_met" assert sorted(result.state.notes) == ["patch ran", "triage ran", "verify ran"] + + +def test_an_empty_proposal_against_an_unmet_goal_gets_one_nudge(): + """Empty plan, goal check unsatisfied: the contradiction is named once. + + The planner is told the goal check is not met and asked to either propose + the remaining work or repeat the empty proposal. Here it proposes the + work, and the run finishes on the goal — where before the nudge existed, + round 1's empty reply ended the run as `no_further_work` with the goal + never mentioned to the model. + """ + loop, model, bodies = build_loop( + [NOTHING_MORE, plan(("write", "summarise"))], goal_reached=goal_is_done + ) + + result = loop.run("summarise the findings", LoopState()) + + assert result.stop is LoopStop.GOAL_MET + assert bodies.ran == ["write"] + assert len(result.rounds) == 2 + assert not result.rounds[0].executed and result.rounds[0].admitted + assert any( + "goal check is not yet satisfied" in str(message.content) + for message in model.calls[1] + ) + + +def test_a_second_empty_proposal_is_believed(): + """The nudge is one round, not a counter: a repeat empty plan is an answer.""" + loop, model, bodies = build_loop( + [NOTHING_MORE, NOTHING_MORE], goal_reached=goal_is_done + ) + + result = loop.run("summarise the findings", LoopState()) + + assert result.stop is LoopStop.NO_FURTHER_WORK + assert "confirmed no further work" in result.detail + assert bodies.ran == [] + assert len(result.rounds) == 2 + assert model.call_count == 2 + + +def test_an_unusable_reply_after_the_nudge_confirms_no_further_work(): + """A planner with nothing left to say after the nudge is not a failure. + + The scripted stand-ins end their reply lists with an empty proposal; the + nudge asks one more question than the script answers. Exhaustion there + must read as the confirmation it is — never as `planning_failed` burning + the failure allowance on a planner that already said "nothing more". + """ + loop, model, bodies = build_loop([NOTHING_MORE], goal_reached=goal_is_done) + + result = loop.run("summarise the findings", LoopState()) + + assert result.stop is LoopStop.NO_FURTHER_WORK + assert "nothing usable" in result.detail + assert bodies.ran == [] + assert result.rounds[-1].planner_error + + +def test_an_empty_proposal_with_no_goal_check_stops_without_a_nudge(): + """No goal check means nothing to contradict: one round, one clean stop.""" + loop, model, bodies = build_loop([NOTHING_MORE]) + + result = loop.run("nothing needs doing", LoopState()) + + assert result.stop is LoopStop.NO_FURTHER_WORK + assert len(result.rounds) == 1 + assert model.call_count == 1