Conversation on the new loop (M2.7)
- Questions never change state (L5, #77): a message ending in "?" to a waiting project item starts a question run (reply and look-only tools, an instruction to change nothing), and the item goes back to the very wait it was in: a restatement up stays up and a go binds to it; the agent's own question stays open. - Explanations: a restatement gives its reasons and the main alternative; a revision says what changed (restate(changed), sent back without it); a milestone's summary says what was done, verified and left open. - Progress in prose (#42): the driver's and a helper's own words for a turn are posted, trimmed, at most once a minute per item; `details` shows the last tool calls; a message arriving mid-run is acknowledged at once ("Noted; ..."). - CI: the e2e job's comments and the plan no longer claim a newer push cancels the run it supersedes (Gitea here doesn't); the concurrency block goes. Tests: conversation unit tests (questions in a go-ahead and an answer wait, an unanswerable question, revisions, progress rate and trimming, details, mid-run notes), the helper's progress, the walk asking questions in every state with a questions_change_nothing property (routing questions the old way fails 32 of 48 seeds), and an e2e question while a restatement is up. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
5967d022b6
commit
8994b3fb4b
14 files changed
+456
-30
No files matched your search
@@ -214,7 +214,9 @@ def test_go_starts_the_work_and_close_item_closes_the_issue(h: Harness) -> None:
|
||||
|
||||
|
||||
def test_a_go_for_an_earlier_revision_is_not_applied(h: Harness) -> None:
|
||||
h.model.turns.append([call("restate", text="revised: in a new module")])
|
||||
h.model.turns.append(
|
||||
[call("restate", text="revised: in a new module", changed="a new module")]
|
||||
)
|
||||
h.agent.intake()
|
||||
h.say("put it in its own module") # changes, so the driver restates rev-2
|
||||
assert loop_of(h.item()).rev == 2
|
||||
|
||||
@@ -301,3 +301,26 @@ def test_a_delegation_cut_off_by_a_crash_is_discarded_at_boot(
|
||||
]
|
||||
assert any("cut off by a restart and discarded" in f for f in facts)
|
||||
assert git
|
||||
|
||||
|
||||
def test_a_helpers_progress_is_posted(world: dict[str, Any]) -> None:
|
||||
helper = Spy(
|
||||
[
|
||||
AssistantText(text="Writing helped.py with the constant.", model="hlp"),
|
||||
call("write_file", path="helped.py", content="H = 1\n"),
|
||||
],
|
||||
[hand_back()],
|
||||
)
|
||||
model = ScriptedModel(
|
||||
RESTATE,
|
||||
[
|
||||
call("start_milestone", name="words"),
|
||||
call("delegate", task="write helped.py"),
|
||||
call("ask_human", question="done?"),
|
||||
],
|
||||
)
|
||||
agent, transport = loop_agent(world, model, helpers=helpers(helper))
|
||||
agent.intake()
|
||||
say(transport, world, "go")
|
||||
said = [m.text for m in transport.outbound]
|
||||
assert "helper: Writing helped.py with the constant." in said
|
||||
@@ -118,7 +118,7 @@ def test_a_plain_yes_is_a_go_ahead_said_so_first(tmp_path: Path) -> None:
|
||||
def test_anything_else_goes_to_the_driver(tmp_path: Path) -> None:
|
||||
h = Harness(
|
||||
tmp_path,
|
||||
ScriptedModel(RESTATED, [call("restate", text="v2")]),
|
||||
ScriptedModel(RESTATED, [call("restate", text="v2", changed="as you asked")]),
|
||||
FakeGitea(issue()),
|
||||
reader=Reader(),
|
||||
)
|
||||
@@ -132,7 +132,7 @@ def test_a_question_never_reaches_the_reader(tmp_path: Path) -> None:
|
||||
reader = Reader("sounds good?")
|
||||
h = Harness(
|
||||
tmp_path,
|
||||
ScriptedModel(RESTATED, [call("restate", text="v2")]),
|
||||
ScriptedModel(RESTATED, [call("restate", text="v2", changed="as you asked")]),
|
||||
FakeGitea(issue()),
|
||||
reader=reader,
|
||||
)
|
||||
@@ -145,7 +145,9 @@ def test_a_plain_yes_to_an_older_revision_is_not_a_go(tmp_path: Path) -> None:
|
||||
reader = Reader("yes")
|
||||
h = Harness(
|
||||
tmp_path,
|
||||
ScriptedModel(RESTATED, [call("restate", text="v2")], STARTED),
|
||||
ScriptedModel(
|
||||
RESTATED, [call("restate", text="v2", changed="as you asked")], STARTED
|
||||
),
|
||||
FakeGitea(issue()),
|
||||
reader=reader,
|
||||
)
|
||||
@@ -224,7 +226,12 @@ def test_a_reply_stops_the_bugs_countdown(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_a_new_revision_of_a_bug_gets_its_own_countdown(tmp_path: Path) -> None:
|
||||
h, clock = bug(tmp_path, RESTATED, [call("restate", text="v2")], STARTED)
|
||||
h, clock = bug(
|
||||
tmp_path,
|
||||
RESTATED,
|
||||
[call("restate", text="v2", changed="as you asked")],
|
||||
STARTED,
|
||||
)
|
||||
h.agent.intake()
|
||||
clock.now += 300
|
||||
h.say("use the other file")
|
||||
|
||||
@@ -95,7 +95,7 @@ def test_the_next_milestone_waits_for_the_merge(world: dict[str, Any]) -> None:
|
||||
agent, transport = loop_agent(world, model)
|
||||
agent.intake()
|
||||
say(transport, world, "go")
|
||||
say(transport, world, "how's it going?")
|
||||
say(transport, world, "carry on")
|
||||
assert "hasn't merged yet" in result_of(world, "start_milestone")
|
||||
assert (item(world).position, loop_of(item(world)).wait) == (WAITING, MERGE)
|
||||
assert len(forge.prs) == 1
|
||||
|
||||
@@ -76,6 +76,8 @@ class PolicyModel:
|
||||
return [call("ask_human", question=f"question {self.n}?")]
|
||||
if roll < 0.12:
|
||||
return [ToolCall(call_id=f"p{self.n}", tool="nonsense", arguments={})]
|
||||
if "reply" in offered and "propose_work" not in offered:
|
||||
return [call("reply", text=f"because {self.n}")] # a question run
|
||||
if "reply" in offered: # a conversation: talk, or propose work
|
||||
if self.rng.random() < 0.1:
|
||||
return [
|
||||
@@ -99,7 +101,7 @@ class PolicyModel:
|
||||
if "restate" not in offered and "finish_milestone" not in offered:
|
||||
return [call("ask_human", question=f"talk first {self.n}?")] # discuss
|
||||
if "finish_milestone" not in offered:
|
||||
return [call("restate", text=f"restatement {self.n}")]
|
||||
return [call("restate", text=f"restatement {self.n}", changed=f"c{self.n}")]
|
||||
if "delegate" in offered and self.rng.random() < 0.1:
|
||||
return [call("delegate", task=f"help {self.n}")]
|
||||
if "wait_for_review" in offered and self.rng.random() < 0.2:
|
||||
@@ -248,7 +250,7 @@ class Walk:
|
||||
forge: Forge = self.worlds[project]["forge"]
|
||||
if item and loop:
|
||||
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
|
||||
weights.update({"plain-yes": 2, "wait": 2})
|
||||
weights.update({"plain-yes": 2, "wait": 2, "question": 2})
|
||||
if loop.new_project:
|
||||
weights.update({"done": 2})
|
||||
if item.scope != "general": # conversations aren't named by number
|
||||
@@ -264,6 +266,28 @@ class Walk:
|
||||
elif action == "done" and loop:
|
||||
# The operator says a new project is ready; no entry was added.
|
||||
self.transport.receive(DON, "done", thread=loop.thread)
|
||||
elif action == "question" and loop:
|
||||
# L5: a question never changes the item it's asked about.
|
||||
before = (item.position, loop.wait, loop.rev, loop.pr, loop.branch)
|
||||
self.transport.receive(DON, f"why {len(self.steps)}?", thread=loop.thread)
|
||||
after_item = self.world["store"].work_item(item.id)
|
||||
after = loop_of(after_item)
|
||||
if (
|
||||
item.scope != "general"
|
||||
and item.position == WAITING
|
||||
and loop.wait in (GO_AHEAD, ANSWER, MERGE)
|
||||
):
|
||||
now = (
|
||||
after_item.position,
|
||||
after.wait,
|
||||
after.rev,
|
||||
after.pr,
|
||||
after.branch,
|
||||
)
|
||||
assert now == before, (
|
||||
f"questions_change_nothing: {before} -> {now}: "
|
||||
f"seed {self.seed}, steps {self.steps}"
|
||||
)
|
||||
elif action == "chat":
|
||||
# A top-level message: a conversation of its own (L1, L5).
|
||||
self.transport.receive(DON, f"thinking aloud {len(self.steps)}")
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Conversation on the new loop (M2.7, L5, #42, #77): questions answered
|
||||
without changing the item, revisions that say what changed, progress in
|
||||
prose at a readable rate, raw tool activity on request, and a note for a
|
||||
message that can't be read yet."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from test_loop import DON, RESTATED, FakeGitea, Harness, ScriptedModel, call, issue
|
||||
|
||||
from nisse_dev.canonical import AssistantText
|
||||
from nisse_dev.loop.items import ANSWER, GO_AHEAD, WAITING, loop_of
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 1000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
def harness(tmp_path: Path, *turns: list[Any], **kw: Any) -> Harness:
|
||||
return Harness(tmp_path, ScriptedModel(*turns), FakeGitea(issue()), **kw)
|
||||
|
||||
|
||||
# --- questions never change state (#77) -------------------------------------
|
||||
|
||||
|
||||
def test_a_question_about_a_restatement_leaves_it_up(tmp_path: Path) -> None:
|
||||
h = harness(
|
||||
tmp_path,
|
||||
RESTATED,
|
||||
[call("reply", text="Because text.py already holds the string helpers.")],
|
||||
[call("ask_human", question="started; anything else?")],
|
||||
)
|
||||
h.agent.intake()
|
||||
h.say("Why text.py?")
|
||||
loop = loop_of(h.item())
|
||||
assert (h.item().position, loop.wait, loop.rev) == (WAITING, GO_AHEAD, 1)
|
||||
assert not loop.question and not loop.started
|
||||
assert h.posted()[-1] == "Because text.py already holds the string helpers."
|
||||
h.say("go") # still bound to rev-1
|
||||
assert loop_of(h.item()).started
|
||||
|
||||
|
||||
def test_a_question_back_keeps_the_agents_own_question_open(tmp_path: Path) -> None:
|
||||
h = harness(
|
||||
tmp_path,
|
||||
[call("ask_human", question="One function or two?")],
|
||||
[call("reply", text="One handles words, two would split lines too.")],
|
||||
)
|
||||
h.agent.intake()
|
||||
h.say("What would the second one do?")
|
||||
loop = loop_of(h.item())
|
||||
assert (loop.wait, loop.note) == (ANSWER, "One function or two?")
|
||||
|
||||
|
||||
def test_a_question_that_isnt_answered_still_goes_back(tmp_path: Path) -> None:
|
||||
h = harness(
|
||||
tmp_path,
|
||||
RESTATED,
|
||||
[call("restate", text="sneaky v2", changed="x")], # not offered
|
||||
[AssistantText(text="hmm", model="m")],
|
||||
[AssistantText(text="hmm", model="m")], # prose twice: the run fails
|
||||
)
|
||||
h.agent.intake()
|
||||
h.say("why?")
|
||||
loop = loop_of(h.item())
|
||||
# restate isn't offered in a question run: no new revision.
|
||||
assert (loop.wait, loop.rev) == (GO_AHEAD, 1)
|
||||
assert h.posted()[-1].startswith("I couldn't answer that")
|
||||
|
||||
|
||||
# --- explanations ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_revision_says_what_changed(tmp_path: Path) -> None:
|
||||
h = harness(
|
||||
tmp_path,
|
||||
RESTATED,
|
||||
[call("restate", text="count_words in words.py", changed="")],
|
||||
[
|
||||
call(
|
||||
"restate",
|
||||
text="count_words in words.py",
|
||||
changed="a module of its own, as you asked",
|
||||
)
|
||||
],
|
||||
)
|
||||
h.agent.intake()
|
||||
h.say("put it in its own module")
|
||||
loop = loop_of(h.item())
|
||||
assert loop.rev == 2
|
||||
assert "Changed from rev-1: a module of its own, as you asked" in h.posted()[-1]
|
||||
|
||||
|
||||
# --- progress and details (#42) ----------------------------------------------
|
||||
|
||||
|
||||
def test_progress_is_posted_at_a_readable_rate(tmp_path: Path) -> None:
|
||||
clock = Clock()
|
||||
|
||||
class Ticking(ScriptedModel):
|
||||
def complete(self, events, tools, model, system=None): # type: ignore[no-untyped-def]
|
||||
clock.now += 30 # each turn takes a while
|
||||
return super().complete(events, tools, model, system)
|
||||
|
||||
h = Harness(
|
||||
tmp_path,
|
||||
Ticking(
|
||||
[
|
||||
AssistantText(text="Looking at text.py first.", model="m"),
|
||||
call("list_dir", path="."),
|
||||
],
|
||||
[
|
||||
AssistantText(text="Reading the tests too.", model="m"),
|
||||
call("list_dir", path="tests"),
|
||||
],
|
||||
[
|
||||
AssistantText(text="x" * 1000, model="m"),
|
||||
call("list_dir", path="."),
|
||||
],
|
||||
[call("restate", text="I'll add count_words.", changed="")],
|
||||
),
|
||||
FakeGitea(issue()),
|
||||
clock=clock,
|
||||
)
|
||||
h.agent.intake()
|
||||
said = h.posted()
|
||||
assert "Looking at text.py first." in said
|
||||
assert "Reading the tests too." not in said # 30 s later: folded away
|
||||
long = [p for p in said if p.startswith("xxx")]
|
||||
assert long and len(long[0]) < 420 and long[0].endswith(" …") # 60 s on
|
||||
|
||||
|
||||
def test_details_shows_the_last_tool_calls(tmp_path: Path) -> None:
|
||||
h = harness(
|
||||
tmp_path,
|
||||
[
|
||||
call("list_dir", path="src"),
|
||||
call("read_file", path="text.py"),
|
||||
call("restate", text="I'll add count_words.", changed=""),
|
||||
],
|
||||
)
|
||||
h.agent.intake()
|
||||
h.say("details")
|
||||
shown = h.posted()[-1]
|
||||
assert shown.startswith("The last tool calls on #14:")
|
||||
assert "list_dir(src)" in shown and "read_file(text.py)" in shown
|
||||
assert loop_of(h.item()).wait == GO_AHEAD # nothing changed
|
||||
|
||||
|
||||
def test_a_message_mid_run_is_acknowledged(tmp_path: Path) -> None:
|
||||
h = harness(tmp_path, RESTATED)
|
||||
h.agent.intake()
|
||||
h.agent._running = True # as if a driver run were under way
|
||||
h.agent._active_thread = "elsewhere"
|
||||
h.agent._active_name = "#15"
|
||||
h.transport.receive(DON, "one more thing", thread=h.thread())
|
||||
assert "Noted; I'm in the middle of #15" in h.posted()[-1]
|
||||
h.transport.receive(DON, "and another", thread="elsewhere")
|
||||
assert h.posted()[-1] == "Noted; I'll read it when this turn ends."
|
||||
Reference in new issue
Block a user