Driver.run checks each turn with nisse_core.loop.circles and ends the run as "circling" when the same calls get much the same back, one harness correction is given again and again, or the model apologises, for circle_repeats turns in a row (default 3). Near-identical calls (git add a.py, b.py) and scattered apologies stay flags on the status page. A fact in the transcript says why the run ended. nisse-dev calls in a helper as for a stuck run, then asks the operator; nisse-review counts a failed run and posts none of its drafts. Tuning: tuning.driver / tuning.review circle_repeats (0 off) and circle_similar. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
117 lines
4.8 KiB
Python
117 lines
4.8 KiB
Python
"""Runs going in circles (L2, X6), judged from the turns alone."""
|
|
|
|
from nisse_core.loop.circles import Turn, apologies_in_a_row, find_circles
|
|
|
|
NUDGE = "Your last reply was prose. Every reply must be a tool call."
|
|
|
|
|
|
def comment(n: int) -> Turn:
|
|
return Turn(
|
|
text="",
|
|
calls=(("post_comment", f'{{"body": "Duplicate, please disregard. ({n})"}}'),),
|
|
)
|
|
|
|
|
|
def test_near_identical_replies_in_a_row_are_a_circle() -> None:
|
|
# The Flash-Next review loop of 2026-10-24: the same comment posted over
|
|
# and over, differing only in a counter.
|
|
turns = [Turn(calls=(("read_file", '{"path": "a.py"}'),))]
|
|
turns += [comment(n) for n in range(6)]
|
|
turns += [Turn(calls=(("finish", "{}"),))]
|
|
[circle] = find_circles(turns)
|
|
assert circle.kind == "repeated reply"
|
|
assert circle.turns == (1, 2, 3, 4, 5, 6)
|
|
|
|
|
|
def test_work_that_moves_on_is_no_circle() -> None:
|
|
turns = [
|
|
Turn(calls=(("read_file", '{"path": "a.py"}'),)),
|
|
Turn(calls=(("run_command", '{"command": "pytest -q"}'),)),
|
|
Turn(calls=(("edit_file", '{"path": "a.py", "old": "x", "new": "y"}'),)),
|
|
Turn(calls=(("run_command", '{"command": "pytest -q"}'),)),
|
|
Turn(text="Done.", calls=(("finish", "{}"),)),
|
|
]
|
|
assert find_circles(turns) == []
|
|
|
|
|
|
def test_two_alike_is_not_yet_a_circle() -> None:
|
|
assert find_circles([comment(1), comment(2), Turn(text="done")]) == []
|
|
|
|
|
|
def test_one_correction_given_again_and_again_is_a_circle() -> None:
|
|
# Invented tools met with the prose reminder turn after turn, the replies
|
|
# themselves all different.
|
|
replies = [
|
|
"I'll run the linter first: <tool_call>lint_fix</tool_call>",
|
|
"<call>read_file src/review.py</call>",
|
|
"Installing the dependencies with install(requirements.txt) now.",
|
|
"Let me use the skill tool to summarise the diff for the review.",
|
|
]
|
|
turns = [Turn(text=r, told=(NUDGE,) if i else ()) for i, r in enumerate(replies)]
|
|
[circle] = find_circles(turns)
|
|
assert circle.kind == "repeated correction"
|
|
assert circle.turns == (1, 2, 3)
|
|
assert NUDGE[:40] in circle.detail
|
|
|
|
|
|
def test_apologies_over_and_over_are_a_circle() -> None:
|
|
turns = [
|
|
Turn(text="Sorry, I made an error reading the file."),
|
|
Turn(calls=(("read_file", '{"path": "b.py"}'),)),
|
|
Turn(text="My apologies, that was the wrong path."),
|
|
Turn(calls=(("list_files", "{}"),)),
|
|
Turn(text="Please disregard my previous comment."),
|
|
]
|
|
[circle] = find_circles(turns)
|
|
assert circle.kind == "repeated apology"
|
|
assert circle.turns == (0, 2, 4)
|
|
|
|
|
|
def test_the_limits_are_tuning() -> None:
|
|
turns = [comment(1), comment(2)]
|
|
assert [c.kind for c in find_circles(turns, repeats=2)] == ["repeated reply"]
|
|
assert find_circles([comment(1), comment(22), comment(333)], similar=1.0) == []
|
|
|
|
|
|
def test_alike_replies_that_got_different_results_are_no_circle() -> None:
|
|
read = [("read_file", '{"path": "src/a.py"}')]
|
|
turns = [Turn(calls=tuple(read), results=(f"file {n}",)) for n in range(4)]
|
|
assert find_circles(turns) == []
|
|
same = [Turn(calls=tuple(read), results=("posted",)) for _ in range(4)]
|
|
assert [c.kind for c in find_circles(same)] == ["repeated reply"]
|
|
# The page gives no results: alike replies alone are a circle there.
|
|
bare = [Turn(calls=tuple(read)) for _ in range(3)]
|
|
assert [c.kind for c in find_circles(bare)] == ["repeated reply"]
|
|
|
|
|
|
def test_the_driver_counts_only_exactly_repeated_calls() -> None:
|
|
adds = [
|
|
Turn(calls=(("run_command", f'{{"command": "git add {f}.py"}}'),))
|
|
for f in "abc"
|
|
]
|
|
assert [c.kind for c in find_circles(adds)] == ["repeated reply"] # flagged
|
|
assert find_circles(adds, exact_calls=True) == [] # never ended for it
|
|
same = [Turn(calls=(("read_file", '{"path": "a"}'),))] * 3
|
|
assert len(find_circles(same, exact_calls=True)) == 1
|
|
|
|
|
|
def test_apologies_end_a_run_only_in_a_row() -> None:
|
|
sorry, work = Turn(text="Sorry, my mistake."), Turn(text="Reading the file.")
|
|
assert apologies_in_a_row([work, sorry, sorry, sorry]) is not None
|
|
assert apologies_in_a_row([sorry, work, sorry, sorry]) is None
|
|
assert apologies_in_a_row([sorry, sorry]) is None
|
|
# In what it posted, too: an apology comment is an apology.
|
|
posted = Turn(calls=(("post_comment", '{"body": "Please disregard (1)."}'),))
|
|
assert apologies_in_a_row([posted] * 3) is not None
|
|
|
|
|
|
def test_the_same_call_answered_with_a_count_is_still_a_circle() -> None:
|
|
note = (("comment", '{"line": 2, "text": "Same point."}'),)
|
|
turns = [Turn(calls=note, results=(f"noted ({n} in the draft)",)) for n in range(3)]
|
|
assert len(find_circles(turns, exact_calls=True)) == 1
|
|
files = [
|
|
Turn(calls=(("read_file", f'{{"path": "{f}"}}'),), results=(f"{f}: x",))
|
|
for f in "abc"
|
|
]
|
|
assert find_circles(files, exact_calls=True) == []
|