Conversations on the new loop (M2.5, PR A)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 5m14s
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:
1 parent
d70d43a8cc
commit
3f54896f65
14 files changed
+811
-35
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user