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
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user