Runs that go in circles end (M7.8)
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:
1 parent
516a328618
commit
74bc68db1c
20 files changed
+506
-20
No files matched your search
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user