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
149 lines
4.7 KiB
Python
149 lines
4.7 KiB
Python
"""Assembling the new loop (M2.1): the same services as the old agent, with
|
|
LoopAgent in place of the Orchestrator. Chosen with `loop: new` in config."""
|
|
|
|
import threading
|
|
from dataclasses import dataclass
|
|
|
|
from nisse_dev.agent import (
|
|
SHUTDOWN_WAIT_S,
|
|
build_host,
|
|
discover_models,
|
|
make_llm_clients,
|
|
prepare_workspace,
|
|
push_credential,
|
|
remote_url,
|
|
)
|
|
from nisse_dev.cancel import Cancel
|
|
from nisse_dev.config import AgentConfig
|
|
from nisse_dev.gitea import GiteaClient
|
|
from nisse_dev.loop.agent import LoopAgent, Project
|
|
from nisse_dev.loop.driver import Driver
|
|
from nisse_dev.loop.milestones import Milestones
|
|
from nisse_dev.loop.reader import GoAheadReader
|
|
from nisse_dev.matrix import MatrixTransport
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.state import StateStore
|
|
from nisse_dev.tools import Workspace
|
|
from nisse_dev.worker import SerialWorker
|
|
|
|
|
|
@dataclass
|
|
class RunningLoop:
|
|
config: AgentConfig
|
|
account: str
|
|
loop: LoopAgent
|
|
store: StateStore
|
|
transport: MatrixTransport
|
|
gitea: GiteaClient
|
|
worker: SerialWorker
|
|
_stopped: threading.Event
|
|
|
|
def start(self) -> None:
|
|
self.loop.start()
|
|
self.transport.post(
|
|
f"nisse-dev up as {self.account} (new loop) on "
|
|
f"{', '.join(p.repo for p in self.config.projects)}; "
|
|
f"{self.loop_status()}"
|
|
)
|
|
|
|
def loop_status(self) -> str:
|
|
return self.worker.call(self.loop.status)
|
|
|
|
def stop(self) -> None:
|
|
self.loop.stop()
|
|
self.transport.close()
|
|
self.gitea.close()
|
|
try:
|
|
self.worker.call(self.store.close, timeout=SHUTDOWN_WAIT_S)
|
|
except TimeoutError:
|
|
pass # still working; L8 covers exiting mid-work
|
|
self.worker.stop(timeout=SHUTDOWN_WAIT_S)
|
|
self._stopped.set()
|
|
|
|
def wait(self) -> None:
|
|
self._stopped.wait()
|
|
|
|
|
|
def build_loop_agent(config: AgentConfig) -> RunningLoop:
|
|
oplog = OpLog("nisse-dev")
|
|
worker = SerialWorker(
|
|
on_error=lambda exc: oplog.emit(
|
|
"worker.job_failed", cause="unhandled exception", error=repr(exc)
|
|
)
|
|
)
|
|
worker.start()
|
|
store = worker.call(lambda: StateStore(config.state_db, oplog=oplog))
|
|
catalog = discover_models(config, oplog)
|
|
gitea = GiteaClient(config.gitea_url, config.gitea_token)
|
|
account = gitea.whoami()
|
|
# D8: each project its own build VM, clone, push credential and milestones.
|
|
projects = []
|
|
for entry in config.projects:
|
|
host = build_host(config, entry)
|
|
credential = push_credential(config, entry)
|
|
remote = remote_url(gitea, entry.repo, credential)
|
|
git = prepare_workspace(host, config, account, remote, credential, entry)
|
|
projects.append(
|
|
Project(
|
|
name=entry.name,
|
|
repo=entry.repo,
|
|
labels=entry.labels,
|
|
workspace=Workspace(
|
|
host,
|
|
entry.workspace,
|
|
timeout_s=config.tuning.tools.command_timeout_s,
|
|
max_output_chars=config.tuning.tools.max_output_chars,
|
|
# D7: the secrets its commands may see, redacted on return.
|
|
secrets=entry.command_secrets(),
|
|
),
|
|
milestones=Milestones(
|
|
store,
|
|
git,
|
|
gitea,
|
|
repo=entry.repo,
|
|
remote=remote,
|
|
credential=credential,
|
|
base=entry.base_branch,
|
|
),
|
|
)
|
|
)
|
|
normalized, _passthrough = make_llm_clients(config, catalog)
|
|
cancel = Cancel()
|
|
# The driver is the planner's model for now (M2.6 brings the driver role).
|
|
driver = Driver(
|
|
normalized,
|
|
config.roles.resolve("planner"),
|
|
max_turns=config.tuning.executor.max_turns,
|
|
cancel=cancel,
|
|
)
|
|
transport = MatrixTransport(config.matrix)
|
|
loop = LoopAgent(
|
|
store,
|
|
transport,
|
|
gitea,
|
|
projects=projects,
|
|
account=account,
|
|
operators=config.operators,
|
|
gitea_operators=config.gitea_operators,
|
|
driver=driver,
|
|
worker=worker,
|
|
cancel=cancel,
|
|
oplog=oplog,
|
|
roles=config.roles.pinned(),
|
|
# The driver's model reads plain-language go-aheads until the
|
|
# 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,
|
|
account=account,
|
|
loop=loop,
|
|
store=store,
|
|
transport=transport,
|
|
gitea=gitea,
|
|
worker=worker,
|
|
_stopped=threading.Event(),
|
|
)
|