Conversations on the new loop (M2.5, PR A)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 5m14s

- A top-level message that isn't a command is a conversation: an item in
  the `general` scope (L1), threaded under the operator's message, with
  `reply`, `ask_human` and `propose_work` and no workspace or execution.
  `general` is reserved as a project name.
- propose_work(project, title, summary) goes up as a revision for a
  go-ahead (or a plain yes). On go, under a file_issue intent (L8), the
  record issue is opened in that project, assigned to the agent and
  carrying the new item's ID; its item starts agreed, with the summary as
  its brief. Intake skips any issue carrying an ID (D1); boot adopts a
  filed issue or puts the proposal up again.
- A conversation closes after tuning.loop.general_idle_s (default a day);
  a reply in its thread then starts a new conversation that continues it.

Tests: conversation unit tests (threading, filing, the marker, an unknown
project, changes, idle close and continuation, a plain yes, both crash
cases, commands at top level); the walk with conversations, proposals and
idle time, and records_have_one_item / conversations_do_no_work; an e2e
conversation that becomes filed work.

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:
dongarandClaude Opus 5.5 committed 2026-10-06 04:26:40 +00:00
1 parent d70d43a8cc
commit 3f54896f65
14 files changed
+811 -35

No files matched your search

+6
View File
@@ -68,6 +68,7 @@ PROJECT_OPTIONAL = (
# never in a command the model chose.
VISIBILITY = ("commands", "harness")
_PROJECT_NAME = re.compile(r"[a-z0-9][a-z0-9-]*")
GENERAL = "general" # the scope of conversation not about a project (L1)
_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
@@ -116,6 +117,8 @@ class LoopTuning:
# A restatement on a `bug` issue proceeds after this long without a
# reply (D1).
bug_grace_s: float = 600.0
# A `general` conversation closes after this long without a message (L1).
general_idle_s: float = 86400.0
@dataclass(frozen=True)
@@ -247,6 +250,9 @@ def _parse_projects(
if not isinstance(name, str) or not _PROJECT_NAME.fullmatch(name):
errors.append(f"{where}: a project name is lowercase letters, digits, -")
continue
if name == GENERAL:
errors.append(f"{where}: `{GENERAL}` is the scope of conversations (L1)")
continue
if not isinstance(entry, dict):
errors.append(f"{where}: must be a mapping")
continue
+5
View File
@@ -261,6 +261,11 @@ class GiteaClient:
who = (event.get("user") or {}).get("login")
return who
def assign_issue(self, repo: str, index: int, assignee: str) -> None:
self._request(
"PATCH", f"/repos/{repo}/issues/{index}", json={"assignees": [assignee]}
)
def close_issue(self, repo: str, index: int) -> None:
self._request(
"PATCH", f"/repos/{repo}/issues/{index}", json={"state": "closed"}
+351 -16
View File
@@ -24,6 +24,7 @@ from dataclasses import dataclass, replace
from nisse_dev.cancel import Cancel
from nisse_dev.canonical import Fact, PlanSubmitted, UserMessage
from nisse_dev.config import GENERAL
from nisse_dev.gates import command_form
from nisse_dev.git import GitError
from nisse_dev.gitea import GiteaClient, GiteaError, Issue
@@ -31,6 +32,7 @@ from nisse_dev.loop.driver import Driver, RunOutcome
from nisse_dev.loop.items import (
ANSWER,
BLOCKED,
CHAT,
CLOSED,
GO_AHEAD,
MERGE,
@@ -49,6 +51,8 @@ from nisse_dev.loop.tools import (
ASK_HUMAN,
CLOSE_ITEM,
FINISH_MILESTONE,
PROPOSE_WORK,
REPLY,
RESTATE,
START_MILESTONE,
WAIT_FOR_REVIEW,
@@ -69,6 +73,11 @@ _NAMED = re.compile(r"(go|approve|abandon|stop) (?:([a-z0-9][a-z0-9-]*))?#(\d+)"
# At top level these need an item; without one they are ambiguous (L5).
_NEEDS_ITEM = frozenset({"go", "approve", "abandon"})
BUG_GRACE_S = 600.0
GENERAL_IDLE_S = 86400.0
FILE_ISSUE = "file_issue" # the intent of opening a record issue (L8)
# Carried by a record issue the agent opens for work started in conversation:
# intake never takes it for new work, even before its item exists (D1).
_MARKER = re.compile(r"<!-- nisse-item: ([A-Za-z0-9-]+) -->")
# Before the go-ahead the driver may look around (read, list, run a command
# to reproduce a bug) but not change anything (D1); after it, everything.
@@ -111,6 +120,23 @@ Keep ARCHITECTURE.md current: a design change updates it in the same PR.
{workspace}"""
CONVERSATION = """You are nisse-dev, a software agent, in a conversation with
the operator in a Matrix thread. It isn't about a project yet: it may be a
question, thoughts on feasibility or hardware, or the start of some work.
Answer with reply; ask_human only for a question you need answered. You have
no workspace and can't run anything here; if an experiment is needed, say so.
If the conversation turns into work on one of these projects, use
propose_work, with a summary of what was decided (not the conversation): it
is the new issue's body and the work's whole brief.
{projects}
Setting up a new project from here isn't available yet: if that's where the
conversation goes, say so, and that the operator can create the repository
and its project entry."""
# What an issue's labels add to the driver's instructions (D1).
LABELS = {
"bug": "This issue is labelled bug. Before restating, reproduce it with "
@@ -177,6 +203,7 @@ class LoopAgent:
milestones: Milestones | None = None,
reader: GoAheadReader | None = None,
bug_grace_s: float = BUG_GRACE_S,
general_idle_s: float = GENERAL_IDLE_S,
clock: Callable[[], float] = time.time,
intake_interval_s: float = INTAKE_INTERVAL_S,
) -> None:
@@ -196,6 +223,7 @@ class LoopAgent:
self._roles = roles or {}
self._reader = reader
self._grace = bug_grace_s
self._idle = general_idle_s
self._clock = clock
self._interval = intake_interval_s
# thread root -> revision on screen there, read on the transport's
@@ -214,11 +242,16 @@ class LoopAgent:
# -- projects (D8) ----------------------------------------------------------
def _at(self, item: WorkItem) -> Project:
if item.scope == GENERAL:
# A conversation: no repository, workspace or build VM (L1).
return Project(GENERAL, "")
return self._projects[item.scope or ""]
def _label(self, item: WorkItem) -> str:
"""How an item's issue is named to the operator: #14 with one
project, sandbox#14 with more, where #14 alone could be either."""
if item.scope == GENERAL:
return f'the conversation "{item.title}"'
issue = loop_of(item).issue
return f"#{issue}" if len(self._projects) == 1 else f"{item.scope}#{issue}"
@@ -227,7 +260,9 @@ class LoopAgent:
return [
i
for i in self._store.work_items("open")
if loop_of(i).issue == issue and (project is None or i.scope == project)
if i.scope != GENERAL
and loop_of(i).issue == issue
and (project is None or i.scope == project)
]
def _repos(self) -> str:
@@ -273,6 +308,7 @@ class LoopAgent:
work blindly: an item cut off mid-run waits on the operator."""
self._store.set_active(None, cause="boot")
self._reconcile_publishing()
self._reconcile_filing()
for item in self._store.work_items("open"):
loop = loop_of(item)
self._threads[(item.scope or "", loop.issue)] = loop.thread
@@ -287,12 +323,61 @@ class LoopAgent:
"to start, or reply with changes.",
thread=loop.thread,
)
else:
elif loop.wait != CHAT: # a conversation just goes on
self._transport.post(
f"(after a restart) still waiting: {loop.note}",
thread=loop.thread,
)
def _reconcile_filing(self) -> None:
"""L8: a record issue cut off by a crash. An issue carrying the new
item's ID was opened: its item is started now. Otherwise nothing was
opened, and the conversation's proposal is up again."""
for intent in self._store.pending_intents():
if intent.kind != FILE_ISSUE or not intent.work_item:
continue
conversation = self._store.work_item(intent.work_item)
repo = str(intent.payload.get("repo", ""))
new_id = str(intent.payload.get("item", ""))
project = next((p for p in self._projects.values() if p.repo == repo), None)
if self._store.work_item(new_id) is not None:
self._store.commit_intent(intent.id, cause="its item exists")
continue
if conversation is None or project is None:
self._store.abort_intent(
intent.id, cause="conversation or project gone"
)
continue
try:
issues = self._gitea.assigned_issues(repo, self._account)
except GiteaError:
continue # unverifiable now; settled at the next boot
filed = [
i
for i in issues
if (m := _MARKER.search(i.body or "")) and m.group(1) == new_id
]
if filed:
self._start_filed(
conversation,
project,
new_id,
filed[0].number,
filed[0].title,
self._latest_proposal(conversation),
)
self._store.commit_intent(
intent.id, cause=f"adopted {repo}#{filed[0].number}"
)
else:
self._store.abort_intent(intent.id, cause="no issue was opened")
loop = loop_of(conversation)
note = (
"opening the issue was cut off by a restart; say go to try again."
)
self._shown[loop.thread] = loop.rev
self._wait(conversation, replace(loop, wait=GO_AHEAD, note=note), note)
def _reconcile_publishing(self) -> None:
"""L8 step 2: a publish cut off by a crash is settled against Gitea.
An open PR for the branch is adopted; otherwise the intent is aborted
@@ -337,6 +422,7 @@ class LoopAgent:
self.watch()
self.check_blocked()
self.check_grace()
self.check_idle()
self._schedule()
def _intake(self, project: Project) -> None:
@@ -349,6 +435,8 @@ class LoopAgent:
for issue in issues:
if item_for_issue(self._store, issue.number, project.name) is not None:
continue
if _MARKER.search(issue.body or ""):
continue # D1: a record of work started in conversation
if not self._from_an_operator(project, issue):
self._oplog.emit(
"loop.intake_ignored",
@@ -441,7 +529,7 @@ class LoopAgent:
waiting on the operator, and an agent that finds a blocker mid-work
asks before recording it."""
for item in self._store.work_items("open"):
if item.position not in (READY, BLOCKED):
if item.position not in (READY, BLOCKED) or item.scope == GENERAL:
continue
loop = loop_of(item)
repo = self._at(item).repo
@@ -469,6 +557,32 @@ class LoopAgent:
cause=said,
)
def check_idle(self) -> None:
"""L1: a conversation left without a message for a day closes; a
later reply in its thread starts a new one, linked to it."""
now = self._clock()
for item in self._store.work_items("open"):
loop = loop_of(item)
if (
item.scope == GENERAL
and item.position == WAITING
and loop.idle_since
and now - loop.idle_since >= self._idle
):
said = sum(
1
for e in self._store.transcript(item.id, "operator").read()
if e.actor == "operator"
)
became = f"; it became {', '.join(loop.became)}" if loop.became else ""
self._close(
item,
loop,
"idle",
f"no message for {round(self._idle / 3600)} h, after {said} "
f"from the operator{became}",
)
def check_grace(self) -> None:
"""D1: a `bug` restatement left unanswered for the grace period is
taken as agreed, bound to the revision that was up all along."""
@@ -613,6 +727,11 @@ class LoopAgent:
)
return
[item] = found
if item is None and command not in ("status", "stop", *_NEEDS_ITEM):
# L5: any other message is a conversation, from its first message.
self._converse(msg, text)
self._schedule()
return
if item is None:
self._store.transcript(None, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
@@ -621,16 +740,8 @@ class LoopAgent:
self._transport.post(self.status(), thread=msg.thread)
elif command in _NEEDS_ITEM:
self._transport.post(self._which(command), thread=msg.thread)
elif command == "stop":
if not self._store.active_item():
self._transport.post("Nothing is running.", thread=msg.thread)
else:
self._transport.post(
f"I take work from issues assigned to me in {self._repos()}: "
"assign one and I'll pick it up. Reply in its thread to "
"talk about it.",
thread=msg.thread,
)
elif not self._store.active_item():
self._transport.post("Nothing is running.", thread=msg.thread)
return
self._store.transcript(item.id, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
@@ -643,6 +754,7 @@ class LoopAgent:
asked_text="",
grace_until=0.0,
talked=True,
idle_since=self._clock(),
)
if loop != loop_of(item):
save(self._store, item.id, item.position, loop, cause="operator spoke")
@@ -694,8 +806,53 @@ class LoopAgent:
)
self._schedule()
def _converse(self, msg: InboundMessage, text: str) -> None:
"""A conversation (L1): its own item in the `general` scope, in a
thread under the operator's message, or in the thread of one that
closed, which it continues."""
thread = msg.thread or msg.event_id
before = (
item_for_thread(self._store, thread, status=None) if msg.thread else None
)
title = text.splitlines()[0][:60] if text else "conversation"
item_id = f"{GENERAL}-{uuid.uuid4().hex[:8]}"
loop = Loop(
thread=thread,
idle_since=self._clock(),
talked=True,
follows=before.id if before else "",
)
self._store.open_work_item(
item_id,
title=title,
scope=GENERAL,
position=READY,
data={"loop": loop.to_data(), "roles": self._roles},
cause="the operator started a conversation",
)
log = self._store.transcript(item_id, "orchestrator")
if before is not None:
closing = [
e.event.text
for e in self._store.transcript(before.id, "orchestrator").read()
if isinstance(e.event, Fact) and e.event.text.startswith("work item ")
]
log.append(
Fact(
text=f'this continues the conversation "{before.title}" '
f"({before.id}), "
+ (closing[-1] if closing else "which is still open")
)
)
log.as_actor("operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
)
def _go(self, item: WorkItem, loop: Loop, by: str) -> None:
self._fact(item, f"go-ahead on rev-{loop.rev} from {by}")
if item.scope == GENERAL:
self._file_work(item, loop)
return
save(
self._store,
item.id,
@@ -704,6 +861,102 @@ class LoopAgent:
cause=f"go-ahead on rev-{loop.rev}",
)
def _file_work(self, item: WorkItem, loop: Loop) -> None:
"""D1: agreed work from a conversation gets a record issue in its
project, carrying the new item's ID from the moment it exists, and
an item of its own that starts as agreed. Under an intent (L8): a
crash between the issue and the item is settled at boot."""
project = self._projects.get(loop.proposal_project)
summary = self._latest_proposal(item)
if project is None or not summary:
note = "that proposal can't be filed any more; what next?"
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
return
new_id = f"{project.name}-c{uuid.uuid4().hex[:6]}"
body = (
f"{summary}\n\nFrom a conversation with the operator, agreed there as "
f"rev-{loop.rev}.\n\n<!-- nisse-item: {new_id} -->"
)
intent = self._store.begin_intent(
FILE_ISSUE,
{"repo": project.repo, "item": new_id, "title": loop.proposal_title},
cause=f"filing {loop.proposal_title}",
work_item=item.id,
)
try:
number = self._gitea.create_issue(project.repo, loop.proposal_title, body)
self._gitea.assign_issue(project.repo, number, self._account)
except GiteaError as exc:
self._store.abort_intent(intent, cause=f"Gitea refused: {exc}")
note = f"I couldn't open the issue: {exc}. Say go to try again."
self._wait(item, replace(loop, wait=GO_AHEAD, note=note), note)
return
self._start_filed(item, project, new_id, number, loop.proposal_title, summary)
self._store.commit_intent(intent, cause=f"opened {project.repo}#{number}")
def _start_filed(
self,
conversation: WorkItem,
project: Project,
new_id: str,
number: int,
title: str,
summary: str,
) -> None:
"""The filed issue's item, agreed already, and the conversation told
where the work went."""
root = self._transport.post(
f"Queued {project.repo}#{number}: {title} (from a conversation)"
)
new = Loop(
thread=root,
issue=number,
labels=project.labels,
started=True, # agreed in the conversation
follows=conversation.id,
)
self._threads[(project.name, number)] = root
self._store.open_work_item(
new_id,
title=title,
scope=project.name,
position=READY,
data={"loop": new.to_data(), "roles": self._roles},
cause=f"filed {project.repo}#{number} from {conversation.id}",
)
self._store.transcript(new_id, "orchestrator").append(
Fact(
text=f"work item opened from {project.repo}#{number} ({title}), "
f"filed from the conversation {conversation.id}, where the operator "
f"agreed to this brief:\n{summary}"
)
)
loop = loop_of(self._store.work_item(conversation.id) or conversation)
loop = replace(
loop,
became=(*loop.became, f"{project.repo}#{number}"),
wait=CHAT,
note="",
proposal_project="",
proposal_title="",
idle_since=self._clock(),
)
self._fact(conversation, f"filed {project.repo}#{number} as {new_id}")
self._wait(
conversation,
loop,
f"Opened {project.repo}#{number} and started on it in its own thread. "
"We can keep talking here.",
)
def _latest_proposal(self, item: WorkItem) -> str:
proposals = [
e.event.plan
for e in self._store.transcript(item.id, "driver").read()
if isinstance(e.event, PlanSubmitted)
]
return proposals[-1] if proposals else ""
def _plain_go(self, item: WorkItem, text: str) -> bool:
"""Whether a reply that isn't a command agrees to the restatement up.
Questions never do (L5)."""
@@ -718,7 +971,7 @@ class LoopAgent:
def _which(self, command: str) -> str:
"""L5: an unnamed command at top level is ambiguous: ask."""
items = self._store.work_items("open")
items = [i for i in self._store.work_items("open") if i.scope != GENERAL]
if not items:
return f"{command} what? I have no open work items."
listed = ", ".join(f"{self._label(i)} ({i.title})" for i in items)
@@ -779,7 +1032,9 @@ class LoopAgent:
for item in items:
loop = loop_of(item)
what = item.position
if item.position == WAITING:
if item.position == WAITING and loop.wait == CHAT:
what = "talking; your turn"
elif item.position == WAITING:
what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
if loop.grace_until:
left = max(0, int(loop.grace_until - self._clock()) // 60)
@@ -788,7 +1043,12 @@ class LoopAgent:
what = f"blocked by {', '.join(loop.blocked_by)}"
elif item.position == READY:
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
lines.append(f"{self._label(item)} {item.title}: {what}")
name = (
f'conversation "{item.title}"'
if item.scope == GENERAL
else f"{self._label(item)} {item.title}"
)
lines.append(f"{name}: {what}")
return "\n".join(lines)
# -- running (L1, L2) ------------------------------------------------------
@@ -828,7 +1088,45 @@ class LoopAgent:
)
self._last_project = here
def _converse_run(self, item: WorkItem) -> None:
"""A conversation's turn (L1, L5): it talks, and may propose work on
a project. No workspace, nothing to run."""
loop = loop_of(item)
self._store.set_active(item.id, cause="ready")
save(self._store, item.id, RUNNING, loop, cause="driver run")
projects = "\n".join(f"- {p.name}: {p.repo}" for p in self._projects.values())
system = CONVERSATION.format(projects=f"The projects:\n{projects}")
self._active_thread = loop.thread
self._running = True
try:
outcome = self._driver.run(
self._store.transcript(item.id, "driver"),
system=system,
tools=[REPLY, ASK_HUMAN, PROPOSE_WORK],
workspace=None,
checks={"propose_work": self._proposal_problem},
)
finally:
self._running = False
self._cancel.clear()
self._settle(item.id, outcome)
def _proposal_problem(self, args: dict[str, object]) -> str | None:
project = str(args.get("project", "")).strip()
if project not in self._projects:
names = ", ".join(self._projects)
return f"no project {project!r}: the projects are {names}"
if not str(args.get("title", "")).strip():
return "the issue needs a title"
summary = str(args.get("summary", "")).strip()
if not summary:
return "the issue needs a summary: what was decided"
return _restatement_problem({"text": summary})
def _run(self, item: WorkItem) -> None:
if item.scope == GENERAL:
self._converse_run(item)
return
loop = loop_of(item)
project = self._at(item)
milestones = project.milestones
@@ -926,6 +1224,43 @@ class LoopAgent:
replace(loop, rev=rev, wait=GO_AHEAD, note="", grace_until=grace),
None,
)
elif outcome.kind == "wait" and outcome.tool == "reply":
text = str(args.get("text", "")).strip()
self._transport.post(text, thread=loop.thread)
self._wait(
item,
replace(loop, wait=CHAT, note="", idle_since=self._clock()),
None,
)
elif outcome.kind == "wait" and outcome.tool == "propose_work":
project = self._projects[str(args.get("project", "")).strip()]
title = str(args.get("title", "")).strip()
summary = str(args.get("summary", "")).strip()
rev = loop.rev + 1
self._store.transcript(item.id, "driver").append(
PlanSubmitted(plan=summary, model=self._roles.get("planner", ""))
)
self._fact(item, f"work proposed as rev-{rev}: {project.repo}, {title}")
self._shown[loop.thread] = rev
self._transport.post(
f"rev-{rev}: I'd open an issue in {project.repo}, "
f'"{title}":\n\n{summary}\n\nSay go to open it and start, or '
"reply with changes.",
thread=loop.thread,
)
self._wait(
item,
replace(
loop,
rev=rev,
wait=GO_AHEAD,
note="",
proposal_project=project.name,
proposal_title=title,
idle_since=self._clock(),
),
None,
)
elif outcome.kind == "wait" and outcome.tool == "ask_human":
question = str(args.get("question", "")).strip()
self._answer_where_asked(item, loop, question)
+1
View File
@@ -134,6 +134,7 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
# classifier role arrives (M2.6).
reader=GoAheadReader(normalized, config.roles.resolve("planner")),
bug_grace_s=config.tuning.loop.bug_grace_s,
general_idle_s=config.tuning.loop.general_idle_s,
)
return RunningLoop(
config=config,
+17 -5
View File
@@ -20,6 +20,7 @@ CLOSED = "closed"
GO_AHEAD = "go-ahead" # a go on the restatement up (L3, D1)
ANSWER = "answer" # an answer to a question
MERGE = "merge" # the PR's review and merge (D4, D5)
CHAT = "conversation" # a conversation waits for the operator to say more (L5)
@dataclass(frozen=True)
@@ -43,6 +44,14 @@ class Loop:
labels: tuple[str, ...] = () # the issue's labels at intake: they steer (D1)
grace_until: float = 0.0 # a `bug` restatement proceeds then, unanswered
talked: bool = False # the operator has spoken in the item's thread
# A conversation (`general`, L1): when it last heard or said anything,
# the conversation it continues, the work it turned into, and the work
# proposed for a go-ahead.
idle_since: float = 0.0
follows: str = ""
became: tuple[str, ...] = ()
proposal_project: str = ""
proposal_title: str = ""
# Where the operator last spoke (D5: answer where asked): "" for the
# Matrix thread, or the PR it came from, with the inline location and a
# little of what they said, for quoting.
@@ -59,6 +68,7 @@ def loop_of(item: WorkItem) -> Loop:
data["notes"] = tuple(data.get("notes", ()))
data["blocked_by"] = tuple(data.get("blocked_by", ()))
data["labels"] = tuple(data.get("labels", ()))
data["became"] = tuple(data.get("became", ()))
return Loop(**data)
@@ -75,11 +85,13 @@ def save(
)
def item_for_thread(store: StateStore, thread: str) -> WorkItem | None:
for item in store.work_items("open"):
if loop_of(item).thread == thread:
return item
return None
def item_for_thread(
store: StateStore, thread: str, status: str | None = "open"
) -> WorkItem | None:
"""The item whose conversation is ``thread``; the latest, when a closed
conversation's thread was picked up again."""
found = [i for i in store.work_items(status) if loop_of(i).thread == thread]
return found[-1] if found else None
def item_for_issue(
+28 -1
View File
@@ -66,8 +66,35 @@ WAIT_FOR_REVIEW = _tool(
note="What you're waiting on, for the operator.",
)
# A conversation's tools (L1, L5): it talks, and may propose work.
REPLY = _tool(
"reply",
"Answer the operator in the conversation's thread. Ends your turn; you "
"continue when they say more.",
text="Your answer, in plain language.",
)
PROPOSE_WORK = _tool(
"propose_work",
"The conversation has turned into work on one of the projects: propose "
"an issue for it. The operator agrees to it as to a restatement; then the "
"issue is opened, and work on it starts as its own item. Ends your turn.",
project="The project's name, from the list in your instructions.",
title="A short title for the issue.",
summary="What was decided, distilled: the request, what it means, and the "
"first step. Not the conversation; it is the issue's body and the work's "
"whole brief.",
)
WAITING = frozenset(
{"restate", "ask_human", "close_item", "finish_milestone", "wait_for_review"}
{
"restate",
"ask_human",
"close_item",
"finish_milestone",
"wait_for_review",
"reply",
"propose_work",
}
)
+8
View File
@@ -50,6 +50,14 @@ class FakeGitea:
self.closed: list[int] = []
self.deps: dict[int, list[int]] = {}
def create_issue(self, repo: str, title: str, body: str) -> int:
n = max([i.number for i in self.issues] + [0]) + 1
self.issues.append(Issue(n, title, body, "nisse-dev", ()))
return n
def assign_issue(self, repo: str, index: int, assignee: str) -> None:
pass
def open_dependencies(self, repo: str, index: int) -> list[str]:
return [f"{repo}#{n}" for n in self.deps.get(index, []) if n not in self.closed]
+239
View File
@@ -0,0 +1,239 @@
"""Conversations on the new loop (M2.5, PR A): the `general` scope, a
thread per conversation, idle close and continuation (L1, L5), and agreed
work filed as a record issue whose item starts as agreed (D1, L8). Real git
on the throwaway build host; a fake Gitea."""
import re
from typing import Any
from test_loop_milestones import DON, Forge, ScriptedModel, call, loop_agent
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
from nisse_dev.canonical import Fact, ToolResult
from nisse_dev.gitea import Issue
from nisse_dev.loop.agent import FILE_ISSUE
from nisse_dev.loop.items import CHAT, GO_AHEAD, MERGE, WAITING, loop_of
PROPOSAL = call(
"propose_work",
project="sandbox",
title="Add count_lines",
summary="Add count_lines(text) to text.py, counting newline-separated lines, "
"with tests. First step: the function and its tests.",
)
def conversations(world: dict[str, Any]) -> list[Any]:
return [i for i in world["store"].work_items() if i.scope == "general"]
def facts(world: dict[str, Any], item: Any) -> list[str]:
return [
e.event.text
for e in world["store"].transcript(item.id, "orchestrator").read()
if isinstance(e.event, Fact)
]
def quiet(world: dict[str, Any]) -> None:
"""No assigned issue: only conversations in these tests."""
world["forge"].issues.clear()
def test_a_top_level_message_is_a_conversation_in_its_own_thread(
world: dict[str, Any],
) -> None:
quiet(world)
model = ScriptedModel(
[call("reply", text="A 4090 runs a 27B model at 4-bit comfortably.")],
[call("reply", text="Yes, with a smaller context.")],
)
agent, transport = loop_agent(world, model)
asked = transport.receive(DON, "Could a 4090 run a 27B model?")
[chat] = conversations(world)
loop = loop_of(chat)
assert loop.thread == asked # the thread is under the operator's message
assert (chat.position, loop.wait) == (WAITING, CHAT)
reply = transport.outbound[-1]
assert reply.thread == asked and "4-bit" in reply.text
assert 'conversation "Could a 4090 run a 27B model?": talking' in agent.status()
transport.receive(DON, "And a 32B?", thread=asked)
assert len(conversations(world)) == 1 # the same conversation
assert transport.outbound[-1].text == "Yes, with a smaller context."
def test_agreed_work_is_filed_and_starts_as_agreed(world: dict[str, Any]) -> None:
quiet(world)
forge: Forge = world["forge"]
model = ScriptedModel(
[PROPOSAL],
[ # the filed item's first run: straight to work, no restatement
call("start_milestone", name="count_lines"),
call("write_file", path="lines.py", content="L = 1\n"),
call("finish_milestone", summary="count_lines", final="yes"),
],
)
_, transport = loop_agent(world, model)
root = transport.receive(DON, "I'd like a line counter in the sandbox.")
assert transport.outbound[-1].text.startswith(
'rev-1: I\'d open an issue in don/sandbox, "Add count_lines"'
)
transport.receive(DON, "go", thread=root)
[filed] = forge.issues
marker = re.search(r"<!-- nisse-item: ([\w-]+) -->", filed.body)
assert (
marker and "count newline-separated lines" not in filed.body
) # it's the summary
assert "counting newline-separated lines" in filed.body
assert forge.assigned == [filed.number]
work = world["store"].work_item(marker.group(1))
assert work is not None and work.scope == "sandbox"
assert loop_of(work).issue == filed.number and loop_of(work).started
assert (work.position, loop_of(work).wait) == (WAITING, MERGE) # it worked
[pr] = forge.prs
assert f"Closes #{filed.number}" in forge.prs[pr]["body"]
# The conversation says where the work went and goes on.
[chat] = conversations(world)
assert loop_of(chat).became == (f"don/sandbox#{filed.number}",)
assert loop_of(chat).wait == CHAT
assert any("filed from the conversation" in f for f in facts(world, work))
def test_intake_never_takes_a_record_issue(world: dict[str, Any]) -> None:
quiet(world)
forge: Forge = world["forge"]
body = "brief\n\n<!-- nisse-item: sandbox-cabc123 -->"
forge.issues.append(Issue(30, "a record", body, "nisse-dev", ()))
agent, _ = loop_agent(world, ScriptedModel())
agent.intake()
assert world["store"].work_items() == []
def test_a_proposal_for_an_unknown_project_is_sent_back(world: dict[str, Any]) -> None:
quiet(world)
model = ScriptedModel(
[call("propose_work", project="nope", title="t", summary="s.")],
[call("reply", text="Which project should it go in?")],
)
_, transport = loop_agent(world, model)
transport.receive(DON, "Let's build a thing")
results = [
e.event.result
for e in world["store"].transcript(conversations(world)[0].id, "driver").read()
if isinstance(e.event, ToolResult)
]
assert any("no project 'nope': the projects are sandbox" in r for r in results)
assert loop_of(conversations(world)[0]).wait == CHAT
def test_changes_to_a_proposal_go_back_to_the_driver(world: dict[str, Any]) -> None:
quiet(world)
model = ScriptedModel([PROPOSAL], [PROPOSAL])
_, transport = loop_agent(world, model)
root = transport.receive(DON, "a line counter please")
transport.receive(DON, "call it line_count instead", thread=root)
loop = loop_of(conversations(world)[0])
assert (loop.wait, loop.rev) == (GO_AHEAD, 2)
assert world["forge"].issues == []
def test_an_idle_conversation_closes_and_a_reply_continues_it(
world: dict[str, Any],
) -> None:
quiet(world)
model = ScriptedModel(
[call("reply", text="Sure.")], [call("reply", text="Picking up again.")]
)
agent, transport = loop_agent(world, model)
clock = [1000.0]
agent._clock = lambda: clock[0]
agent._idle = 3600
root = transport.receive(DON, "thinking about a backup tool")
clock[0] += 3599
agent.intake()
assert conversations(world)[0].status == "open"
clock[0] += 1
agent.intake()
[old] = conversations(world)
assert (old.status, old.outcome) == ("closed", "idle")
transport.receive(DON, "back to that backup tool", thread=root)
new = [c for c in conversations(world) if c.status == "open"]
assert len(new) == 1 and loop_of(new[0]).follows == old.id
assert loop_of(new[0]).thread == root
assert any("this continues the conversation" in f for f in facts(world, new[0]))
def test_a_plain_yes_files_the_work(world: dict[str, Any]) -> None:
quiet(world)
class Reader:
def agrees(self, proposal: str, reply: str) -> bool:
return reply == "sounds right"
model = ScriptedModel([PROPOSAL], [call("ask_human", question="anything else?")])
agent, transport = loop_agent(world, model)
agent._reader = Reader()
root = transport.receive(DON, "a line counter please")
transport.receive(DON, "sounds right", thread=root)
assert len(world["forge"].issues) == 1
def test_a_filing_cut_off_by_a_crash_is_settled_at_boot(world: dict[str, Any]) -> None:
quiet(world)
forge: Forge = world["forge"]
model = ScriptedModel([PROPOSAL])
_, transport = loop_agent(world, model)
root = transport.receive(DON, "a line counter please")
chat = conversations(world)[0]
store = world["store"]
# The issue was opened, then the agent died before its item existed.
intent = store.begin_intent(
FILE_ISSUE,
{"repo": "don/sandbox", "item": "sandbox-cdead01", "title": "Add count_lines"},
cause="filing",
work_item=chat.id,
)
forge.issues.append(
Issue(
40, "Add count_lines", "b\n\n<!-- nisse-item: sandbox-cdead01 -->", "x", ()
)
)
agent2, _ = loop_agent(world, ScriptedModel([call("ask_human", question="ok?")]))
agent2.boot()
work = store.work_item("sandbox-cdead01")
assert work is not None and loop_of(work).issue == 40 and loop_of(work).started
assert not [i for i in store.pending_intents() if i.id == intent]
assert root # the conversation itself is untouched
def test_a_filing_that_never_reached_gitea_is_put_up_again(
world: dict[str, Any],
) -> None:
quiet(world)
_, transport = loop_agent(world, ScriptedModel([PROPOSAL]))
transport.receive(DON, "a line counter please")
chat = conversations(world)[0]
world["store"].begin_intent(
FILE_ISSUE,
{"repo": "don/sandbox", "item": "sandbox-cnever1", "title": "Add count_lines"},
cause="filing",
work_item=chat.id,
)
agent2, _ = loop_agent(world, ScriptedModel())
agent2.boot()
loop = loop_of(world["store"].work_item(chat.id))
assert loop.wait == GO_AHEAD and "cut off by a restart" in loop.note
assert world["store"].work_item("sandbox-cnever1") is None
def test_commands_alone_at_top_level_are_not_conversations(
world: dict[str, Any],
) -> None:
quiet(world)
_, transport = loop_agent(world, ScriptedModel())
transport.receive(DON, "status")
transport.receive(DON, "go")
assert conversations(world) == []
+9
View File
@@ -60,11 +60,20 @@ class Forge:
self.closed_issues: list[int] = []
self.issues = [Issue(14, "Add count_words", "count words", "don", ())]
self.deps: dict[int, list[int]] = {} # issue -> the issues it depends on
self.assigned: list[int] = [] # issues the agent assigned to itself
# intake
def assigned_issues(self, repo: str, assignee: str) -> list[Issue]:
return [i for i in self.issues if i.number not in self.closed_issues]
def create_issue(self, repo: str, title: str, body: str) -> int:
n = max([i.number for i in self.issues] + [0]) + 1
self.issues.append(Issue(n, title, body, "nisse-dev", ()))
return n
def assign_issue(self, repo: str, index: int, assignee: str) -> None:
self.assigned.append(index)
def open_dependencies(self, repo: str, index: int) -> list[str]:
return [
f"{repo}#{n}"
+72 -13
View File
@@ -9,6 +9,7 @@ the seed and the steps, so it replays exactly.
"""
import random
import re
import subprocess
from pathlib import Path
from typing import Any
@@ -28,6 +29,7 @@ from nisse_dev.loop.driver import Driver
from nisse_dev.loop.items import (
ANSWER,
BLOCKED,
CHAT,
GO_AHEAD,
MERGE,
RUNNING,
@@ -49,8 +51,9 @@ class PolicyModel:
after comments, close after a merge; sometimes asks a question or
replies in prose. Seeded, so a walk replays exactly."""
def __init__(self, rng: random.Random) -> None:
def __init__(self, rng: random.Random, projects: list[str]) -> None:
self.rng = rng
self.projects = projects
self.n = 0
def complete(self, events, tools, model, system=None) -> list[Any]:
@@ -63,6 +66,17 @@ 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: # a conversation: talk, or propose work
if self.rng.random() < 0.4:
return [
call(
"propose_work",
project=self.rng.choice(self.projects),
title=f"filed {self.n}",
summary=f"do thing {self.n}.",
)
]
return [call("reply", text=f"an answer {self.n}")]
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:
@@ -101,7 +115,6 @@ class Walk:
self.seed = seed
self.steps: list[str] = []
self.shown_at_go: list[str] = [] # item:revision on screen at each go
self.next_issue = dict.fromkeys(worlds, 15) # numbers repeat across projects
self.now = 1000.0
self.agent, self.transport = self._agent()
@@ -132,7 +145,11 @@ class Walk:
account="nisse-dev",
operators={DON},
gitea_operators={"don"},
driver=Driver(PolicyModel(self.rng), "m", cancel=cancel), # type: ignore[arg-type]
driver=Driver(
PolicyModel(self.rng, list(self.worlds)), # type: ignore[arg-type]
"m",
cancel=cancel,
),
worker=InlineWorker(),
cancel=cancel,
oplog=OpLog("nisse-dev", _Null()),
@@ -158,15 +175,26 @@ class Walk:
open_items = [i for i in self.items() if i.status == "open"]
# Weighted towards what an item can actually do, so review paths
# are walked often, not drowned out by intake and crashes.
weights = {"intake": 2 if not open_items else 1, "crash": 1, "issue": 1}
weights = {
"intake": 2 if not open_items else 1,
"crash": 1,
"issue": 1,
"chat": 1,
}
item = self.rng.choice(open_items) if open_items else None
loop = loop_of(item) if item else None
# The forge an item's project lives on; a new issue picks one.
project = item.scope if item else self.rng.choice(list(self.worlds))
project = (
item.scope
if item and item.scope != "general"
else self.rng.choice(list(self.worlds))
)
forge: Forge = self.worlds[project]["forge"]
if item and loop:
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
weights.update({"first": 1, "plain-yes": 2, "named-go": 1, "wait": 2})
weights.update({"plain-yes": 2, "wait": 2})
if item.scope != "general": # conversations aren't named by number
weights.update({"first": 1, "named-go": 1})
if loop.pr:
weights.update(
{"comment": 4, "merge": 2, "close-pr": 1, "comment-merge": 2}
@@ -175,12 +203,15 @@ class Walk:
self.steps.append(f"{action}{self.name(item)}" if item else action)
if action == "intake":
self.agent.intake()
elif action == "chat":
# A top-level message: a conversation of its own (L1, L5).
self.transport.receive(DON, f"thinking aloud {len(self.steps)}")
elif action == "issue":
# A new issue, sometimes depending on one that is still open.
project = self.rng.choice(list(self.worlds))
forge = self.worlds[project]["forge"]
n = self.next_issue[project]
self.next_issue[project] += 1
# Numbered by the forge, as filed records are: never reused.
n = max([i.number for i in forge.issues] + [14]) + 1
labels = self.rng.choice([(), (), ("bug",), ("discuss",), ("design",)])
forge.issues.append(Issue(n, f"task {n}", f"do task {n}", "don", labels))
live = [i.number for i in forge.assigned_issues("", "") if i.number != n]
@@ -197,8 +228,8 @@ class Walk:
self.shown_at_go.append(f"{item.id}:{loop.rev}")
self.transport.receive(DON, f"go {self.name(item)}")
elif action == "wait":
# Time passes: bug countdowns may run out.
self.now += self.rng.choice([60, 700])
# Time passes: bug countdowns may run out, conversations idle.
self.now += self.rng.choice([60, 700, 90000])
for other in open_items:
o = loop_of(other)
if (
@@ -261,11 +292,10 @@ class Walk:
assert len(issues) == len(set(issues)), f"one_item_per_issue: {where}"
for item in items:
loop = loop_of(item)
forge = self.worlds[item.scope]["forge"]
repo = self.worlds[item.scope]["repo_name"]
if item.status == "open" and item.position == WAITING:
explained = (
(loop.wait == GO_AHEAD and loop.rev > 0)
(loop.wait == CHAT and item.scope == "general")
or (loop.wait == GO_AHEAD and loop.rev > 0)
or (loop.wait == ANSWER and bool(loop.note))
or (loop.wait == MERGE and loop.pr > 0)
)
@@ -281,6 +311,10 @@ class Walk:
assert all(f"{item.id}:{rev}" in self.shown_at_go for rev in gos), (
f"go_binds: {item.id} {gos} not in {self.shown_at_go}: {where}"
)
if item.scope == "general":
continue # no forge: nothing below applies
forge = self.worlds[item.scope]["forge"]
repo = self.worlds[item.scope]["repo_name"]
# Blocked comes from external truth (L1): an item whose issue
# had an open dependency from the start never ran before it closed.
if forge.open_dependencies(repo, loop.issue):
@@ -337,12 +371,37 @@ class Walk:
assert not [i for i in items if i.position == RUNNING], (
f"one_active (nothing left running): {where}"
)
for item in items:
if item.scope == "general":
loop = loop_of(item)
# L1: a conversation has no workspace, milestone or PR.
assert not (loop.prepared or loop.milestone or loop.pr), (
f"conversations_do_no_work: {item.id}: {where}"
)
for name, w in self.worlds.items():
self._records_have_one_item(name, w, where)
self._own_repository_only(name, w, where)
self._parked_work_safe(w, name, where)
self._milestones_start_fresh(w, where)
self._review_fixes_land(w, where)
def _records_have_one_item(self, name: str, world: Any, where: str) -> None:
"""D1: a record issue filed from a conversation is its item's, from
the moment it exists, and intake never takes it for another."""
store = self.world["store"]
for issue in world["forge"].issues:
found = re.search(r"<!-- nisse-item: ([\w-]+) -->", issue.body or "")
if not found:
continue
item = store.work_item(found.group(1))
assert item is not None and item.scope == name, (
f"records_have_one_item: {name}#{issue.number} has no item: {where}"
)
assert loop_of(item).issue == issue.number, (
f"records_have_one_item: {item.id} isn't #{issue.number}: {where}"
)
assert loop_of(item).started, f"filed work starts agreed: {where}"
def _own_repository_only(self, name: str, world: Any, where: str) -> None:
"""D8: an item's branches and PRs reach only its own project's
repository."""