Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions grapharc/planner/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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

Expand Down
69 changes: 69 additions & 0 deletions tests/test_planner_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading