Runs that go in circles end (M7.8)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 1m50s
CI / e2e (pull_request) Skipped

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
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-26 16:30:07 +00:00
1 parent 516a328618
commit 74bc68db1c
20 files changed
+506 -20

No files matched your search

+10
View File
@@ -146,6 +146,16 @@ def parse_tuning(
return tuning(**sections)
def check_circles(repeats: int, similar: float, where: str, errors: list[str]) -> None:
"""A run ends going in circles (L2, M7.8) at ``repeats`` alike turns or
corrections: 0 turns that off, and 1 would end every run at its first
turn. ``similar`` is how alike two replies are, at most 1."""
if repeats == 1:
errors.append(f"{where}.circle_repeats: must be 0 (off) or 2 or more")
if similar > 1:
errors.append(f"{where}.circle_similar: must be at most 1")
def llm_numbers(values: Mapping[str, Any], errors: list[str]) -> tuple[int, float]:
"""`llm.max_output_tokens` and `llm.request_timeout_s`, or their
defaults."""
+45 -7
View File
@@ -1,8 +1,8 @@
"""Runs going in circles (L2, X6): turns that repeat near-identically, one
correction given again and again, or apologies over and over.
A pure function over a run's turns, so the status page can flag a loop now
and ``Driver.run`` can end one later (M7.8) from the same rules.
A pure function over a run's turns, so the status page flags a loop and
``Driver.run`` ends one (M7.8) from the same rules.
"""
import re
@@ -29,6 +29,10 @@ class Turn:
text: str = ""
calls: tuple[tuple[str, str], ...] = () # (tool, arguments as sent)
told: tuple[str, ...] = () # corrections given since the turn before
# What its calls got back. Two turns alike in their calls but not in
# what they got back, exactly (reading sibling files), are progress, not
# a circle; the driver gives them, the page doesn't yet.
results: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -39,10 +43,17 @@ class Circle:
def find_circles(
turns: Sequence[Turn], *, similar: float = SIMILAR, repeats: int = REPEATS
turns: Sequence[Turn],
*,
similar: float = SIMILAR,
repeats: int = REPEATS,
exact_calls: bool = False,
) -> list[Circle]:
"""Every circle in a run, in the order they started."""
found = _repeated_replies(turns, similar, repeats)
"""Every circle in a run, in the order they started. ``exact_calls``:
replies repeat only when their tool calls are exactly the same, as the
driver judges before it ends a run; near-identical is enough for the
page to flag one (``git add a.py`` then ``b.py`` are near-identical)."""
found = _repeated_replies(turns, similar, repeats, exact_calls)
found += _repeated_corrections(turns, repeats)
apologies = tuple(i for i, t in enumerate(turns) if _APOLOGY.search(t.text))
if len(apologies) >= repeats:
@@ -61,6 +72,33 @@ def _signature(turn: Turn) -> str:
return f"{turn.text.strip()}\n{calls}"[:_COMPARED]
def _same_turn(a: Turn, b: Turn, similar: float, exact_calls: bool) -> bool:
"""The same reply getting the same back. A model repeating itself gets
much the same answer each time ("noted (2 in the draft)"); alike calls
that got different answers are progress (two files of near-identical
contents are still two files)."""
if exact_calls:
joined = ("\n".join(t.results)[:_COMPARED] for t in (a, b))
return (
a.calls == b.calls
and _alike(*joined, similar)
and _alike(a.text.strip(), b.text.strip(), similar)
)
return a.results == b.results and _alike(_signature(a), _signature(b), similar)
def apologies_in_a_row(turns: Sequence[Turn], repeats: int = REPEATS) -> Circle | None:
"""The last ``repeats`` turns all apologising: a model saying sorry
again and again instead of moving on, as the driver ends a run for.
Apologies spread over a run are only flagged (``find_circles``)."""
last = range(len(turns) - repeats, len(turns))
if repeats <= 0 or last.start < 0:
return None
if all(_APOLOGY.search(_signature(turns[i])) for i in last):
return Circle("repeated apology", tuple(last), f"{repeats} apologies in a row")
return None
def _alike(a: str, b: str, similar: float) -> bool:
if a == b:
return True
@@ -69,7 +107,7 @@ def _alike(a: str, b: str, similar: float) -> bool:
def _repeated_replies(
turns: Sequence[Turn], similar: float, repeats: int
turns: Sequence[Turn], similar: float, repeats: int, exact_calls: bool
) -> list[Circle]:
"""Stretches of consecutive turns each alike the one before."""
found: list[Circle] = []
@@ -79,7 +117,7 @@ def _repeated_replies(
if (
i < len(turns)
and signatures[i].strip()
and _alike(signatures[i - 1], signatures[i], similar)
and _same_turn(turns[i - 1], turns[i], similar, exact_calls)
):
continue
if i - start >= repeats:
+74 -3
View File
@@ -1,5 +1,5 @@
"""A driver run (L2): one model with tools, looping until it calls a waiting
tool, runs out of turns, or is stopped.
tool, runs out of turns, goes in circles, or is stopped.
Everything the run does is written to the item's transcript as the driver
(L7), and every model call sees the driver's view of it (L6), compacted
@@ -9,11 +9,13 @@ here, and a waiting one ends the run, so the scheduler can put it to the
operator. Which tools wait is the agent's: each run is told.
"""
import json
from collections.abc import Callable, Collection, Mapping
from dataclasses import dataclass
from typing import Any, Literal, Protocol
from nisse_core.log.canonical import AssistantText, Fact, ToolCall, ToolResult
from nisse_core.loop import circles
from nisse_core.loop.tools import names
from nisse_core.models import calls
from nisse_core.models.compact import Compactor
@@ -47,8 +49,12 @@ class Executor(Protocol):
@dataclass(frozen=True)
class RunOutcome:
# stuck: failed commands in a row hit the failure limit (L9's trigger);
# unreachable: the tool VM went out of reach (#137), noted in ``note``
kind: Literal["wait", "capped", "stuck", "stopped", "failed", "unreachable"]
# circling: the run repeated itself, or was told the same thing too often
# (L2, M7.8); unreachable: the tool VM went out of reach (#137). What
# happened is in ``note``.
kind: Literal[
"wait", "capped", "stuck", "circling", "stopped", "failed", "unreachable"
]
tool: str = "" # the waiting tool called
arguments: dict[str, Any] | None = None
note: str = ""
@@ -86,7 +92,11 @@ class Driver:
cancel: Cancel | None = None,
failure_limit: int = 0, # 0: no limit
compactor: Compactor | None = None,
circle_repeats: int = circles.REPEATS, # 0: runs never end as circling
circle_similar: float = circles.SIMILAR,
) -> None:
self._circle_repeats = circle_repeats
self._circle_similar = circle_similar
self._client = client
self._compactor = compactor
self._model = model
@@ -140,6 +150,9 @@ class Driver:
offered = names(tools)
nudged = False
cap = max_turns or self._max_turns
# Each turn as circles judge it (L2): the reply, what its calls got
# back, and the corrections the harness gave in answer.
turns: list[circles.Turn] = []
for turn in range(cap):
# L2: stop takes effect at the next tool boundary.
if self._cancel.requested:
@@ -175,7 +188,10 @@ class Driver:
# the user's side (models refuse assistant prefill), and the
# nudge is then in the log as what it was.
transcript.as_actor("orchestrator").append(Fact(text=_PROSE_NUDGE))
turns.append(circles.Turn(text=said, told=(_PROSE_NUDGE,)))
continue
results: list[str] = []
told: list[str] = [] # results the harness gave, not the tools
for index, call in enumerate(calls):
if self._cancel.requested:
skip_calls(transcript, calls[index:])
@@ -189,6 +205,8 @@ class Driver:
transcript.append(
ToolResult(call_id=call.call_id, result=problem, success=False)
)
results.append(problem)
told.append(problem)
skip_calls(
transcript, calls[index + 1 :], "not run: fix the call above"
)
@@ -233,6 +251,7 @@ class Driver:
+ ", ".join(sorted(offered)),
False,
)
told.append(result)
else:
screening = screen(call) if screen else Screening("run")
if screening.decision == "ask":
@@ -262,6 +281,8 @@ class Driver:
success=False,
)
)
results.append(f"not run: {screening.text}")
told.append(f"not run: {screening.text}")
skip_calls(
transcript,
calls[index + 1 :],
@@ -281,6 +302,7 @@ class Driver:
transcript.append(
ToolResult(call_id=call.call_id, result=result, success=ok)
)
results.append(result)
if call.tool == "run_command":
failures = [] if ok else [*failures, _brief(call, result)]
if self._failure_limit and len(failures) >= self._failure_limit:
@@ -292,8 +314,53 @@ class Driver:
note=f"{len(failures)} commands failed in a row: "
+ "; ".join(failures),
)
turns.append(
circles.Turn(
text=said,
calls=tuple((c.tool, _args(c)) for c in calls),
told=tuple(told),
results=tuple(results),
)
)
circling = self._circling(turns)
if circling is not None:
# In the log as the harness's, for whoever picks it up next.
transcript.as_actor("orchestrator").append(
Fact(
text=f"The run was ended: it was going in circles ({circling})."
)
)
return RunOutcome("circling", note=circling)
return RunOutcome("capped", note=f"hit the {cap}-turn cap")
def _circling(self, turns: list[circles.Turn]) -> str | None:
"""What the run is going in circles on, if it is (L2): the same
calls getting the same back, one correction given again and again,
or apologies in a row. Near-identical calls and scattered apologies
are only flagged, on the status page: ``git add a.py`` then
``b.py`` is progress."""
if self._circle_repeats <= 0:
return None
found = [
c
for c in circles.find_circles(
turns,
similar=self._circle_similar,
repeats=self._circle_repeats,
exact_calls=True,
)
if c.kind != "repeated apology"
]
found.append(circles.apologies_in_a_row(turns, self._circle_repeats))
for circle in found:
if circle is None:
continue
last = turns[circle.turns[-1]]
doing = "; ".join(f"{tool}({args})" for tool, args in last.calls)
doing = doing or last.text
return f"{circle.detail}; the last: {doing[:300]}"
return None
@staticmethod
def _unreachable(
transcript: Transcript, calls: list[ToolCall], index: int, said: str
@@ -309,6 +376,10 @@ class Driver:
return RunOutcome("unreachable", note=said)
def _args(call: ToolCall) -> str:
return json.dumps(dict(call.arguments), sort_keys=True, ensure_ascii=False)
def _brief(call: ToolCall, result: str) -> str:
"""A failed command and the end of what it said, for a helper's brief."""
command = str(call.arguments.get("command", ""))[:120]
+44 -1
View File
@@ -1,6 +1,6 @@
"""Runs going in circles (L2, X6), judged from the turns alone."""
from nisse_core.loop.circles import Turn, find_circles
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."
@@ -71,3 +71,46 @@ 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) == []
+160 -1
View File
@@ -5,7 +5,13 @@ import io
from pathlib import Path
from typing import Any
from nisse_core.log.canonical import CanonicalEvent, ToolCall, ToolResult
from nisse_core.log.canonical import (
AssistantText,
CanonicalEvent,
Fact,
ToolCall,
ToolResult,
)
from nisse_core.loop.driver import Driver, ToolOutcome
from nisse_core.loop.tools import tool
from nisse_core.oplog import OpLog
@@ -150,3 +156,156 @@ def test_without_due_a_settled_point_leaves_the_run_going(tmp_path: Path) -> Non
handlers={"note": lambda args: ("noted", True)},
)
assert (ran.tool, ran.arguments) == ("post_review", {"body": "b"})
# --- runs going in circles (L2, M7.8) ------------------------------------------
COMMENT = tool("post_comment", "Post a comment.", body="The comment.")
def facts(log: Any) -> list[str]:
return [e.event.text for e in log.read() if isinstance(e.event, Fact)]
def test_a_run_posting_the_same_comment_ends_as_circling(tmp_path: Path) -> None:
# Flash-Next on 2026-10-24: the same apology, numbered, about 20 times
# until the turn cap.
posted: list[str] = []
model = Scripted(
*[
[call("post_comment", body=f"Duplicate, please disregard ({n}).")]
for n in range(20)
]
)
log = transcript(tmp_path)
ran = Driver(model, "m", max_turns=30).run(
log,
system="s",
tools=[COMMENT, POST],
waiting={"post_review"},
workspace=None,
handlers={
"post_comment": lambda a: (posted.append(a["body"]) or "posted", True)
},
)
assert ran.kind == "circling"
assert len(posted) == 3 # ended at the third, not the thirtieth
assert ran.note.startswith("3 apologies in a row")
assert "Duplicate, please disregard (2)" in ran.note # what it was doing
assert facts(log) == [f"The run was ended: it was going in circles ({ran.note})."]
def test_an_invented_tool_told_again_and_again_ends_the_run(tmp_path: Path) -> None:
# A different reply each time, but the same correction every time.
model = Scripted(
*[[call("search_code", query=f"attempt {n} " * n)] for n in range(1, 10)]
)
ran = Driver(model, "m").run(
transcript(tmp_path),
system="s",
tools=[READ, POST],
waiting={"post_review"},
workspace=Reader(),
)
assert ran.kind == "circling"
assert ran.note.startswith("told 3 times: no such tool: search_code")
def test_reading_file_after_file_is_progress_not_a_circle(tmp_path: Path) -> None:
class Files:
def dispatch(self, tool: str, arguments: dict[str, Any]) -> ToolOutcome:
# Near-identical contents: still a different file each time.
return ToolOutcome(f"{arguments['path']}\n" + "x = 1\n" * 200, True)
model = Scripted(
*[[call("read_file", path=f"src/test_{n}.py")] for n in range(6)],
[call("post_review", body="ok")],
)
ran = Driver(model, "m").run(
transcript(tmp_path),
system="s",
tools=[READ, POST],
waiting={"post_review"},
workspace=Files(),
)
assert ran.kind == "wait"
def test_the_same_call_getting_the_same_back_ends_the_run(tmp_path: Path) -> None:
model = Scripted(*[[call("read_file", path="a.py")] for _ in range(9)])
ran = Driver(model, "m").run(
transcript(tmp_path),
system="s",
tools=[READ, POST],
waiting={"post_review"},
workspace=Reader(),
)
assert ran.kind == "circling"
assert ran.note.startswith("3 near-identical replies in a row")
assert 'read_file({"path": "a.py"})' in ran.note
def test_alike_commands_are_progress_not_a_circle(tmp_path: Path) -> None:
# git add a.py, b.py, c.py: alike, each answered the same, each its own.
class Git:
def dispatch(self, tool: str, arguments: dict[str, Any]) -> ToolOutcome:
return ToolOutcome("", True)
model = Scripted(
*[[call("run_command", command=f"git add {f}.py")] for f in "abcdef"],
[call("post_review", body="ok")],
)
ran = Driver(model, "m").run(
transcript(tmp_path),
system="s",
tools=[tool("run_command", "Run.", command="It."), POST],
waiting={"post_review"},
workspace=Git(),
)
assert ran.kind == "wait"
def test_circles_can_be_turned_off(tmp_path: Path) -> None:
model = Scripted(*[[call("post_comment", body="same")] for _ in range(5)])
ran = Driver(model, "m", max_turns=5, circle_repeats=0).run(
transcript(tmp_path),
system="s",
tools=[COMMENT, POST],
waiting={"post_review"},
workspace=None,
handlers={"post_comment": lambda a: ("posted", True)},
)
assert ran.kind == "capped"
def test_repeats_and_likeness_are_the_agents_to_set(tmp_path: Path) -> None:
model = Scripted(*[[call("post_comment", body="same")] for _ in range(5)])
ran = Driver(model, "m", circle_repeats=5).run(
transcript(tmp_path),
system="s",
tools=[COMMENT, POST],
waiting={"post_review"},
workspace=None,
handlers={"post_comment": lambda a: ("posted", True)},
)
assert ran.kind == "circling" and ran.note.startswith("5 near-identical")
def test_apologies_spread_over_a_run_dont_end_it(tmp_path: Path) -> None:
# Only flagged on the status page: sorry now and then is not a loop.
def turn(n: int, sorry: bool) -> list[CanonicalEvent]:
said = "Sorry, wrong file." if sorry else "Reading on."
return [AssistantText(text=said, model="m"), call("read_file", path=f"f{n}")]
model = Scripted(
*[turn(n, sorry=n % 2 == 0) for n in range(7)],
[call("post_review", body="ok")],
)
ran = Driver(model, "m").run(
transcript(tmp_path),
system="s",
tools=[READ, POST],
waiting={"post_review"},
workspace=Reader(),
)
assert ran.kind == "wait"