M2.8: switch over to the new loop
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m16s
CI / e2e (pull_request) Successful in 10m51s

nisse-dev now runs only the conversational loop. The gated state machine
it was built alongside is gone:

- Removed control.py, planner.py, executor.py, approvals.py, gates.py
  (command_form moves to loop/reader.py), publish.py and poller.py, the
  old assembly in agent.py (its shared helpers move to loop/build.py),
  the store's state-machine view (get_state, transition), their tests,
  the old end-to-end and live planning/milestone/gate tests, and
  specs/orchestrator.qnt with its scenarios.
- Roles are driver, helper, expert and classifier; driver is required.
  loop:, roles.planner, roles.executor and tuning.executor are refused
  with what to use instead; tuning.driver takes the turn cap and failure
  limit.
- The benchmark and the capability matrix measure the driver: the
  loop's own system prompt and tools, the task's plan as the agreed
  restatement. Task plans end with finish_milestone, so earlier runs
  show as (old); stored runs' executor tuning reads as the driver's.
- A delegation on the loop records what its helper's alias resolved to
  (L9), as the old path's did.
- The dump shows a work item's revision and what it waits for.
- Behaviour the old tests covered and the loop shares stays covered:
  driver runs that fail on a model call, stop mid-turn, reset the
  failure count; go-aheads ignoring case and punctuation; a request too
  big to fit is never sent; the assembly tests, now on build_loop_agent.

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-08 03:12:58 +00:00
1 parent 09232dcbdd
commit 6ae2375194
89 files changed
+951 -8592

No files matched your search

+19 -69
View File
@@ -17,7 +17,7 @@ silently ignored would look exactly like a knob that has no effect.
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass, field, fields, replace
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import Any
@@ -32,8 +32,7 @@ REQUIRED_KEYS = (
"state_db",
"llm.base_url",
"llm.token",
"roles.executor",
"roles.planner",
"roles.driver",
"roles.helper",
"roles.expert",
"matrix.homeserver",
@@ -46,12 +45,16 @@ REQUIRED_KEYS = (
"gitea.operators",
)
OPTIONAL_KEYS = (
"roles.driver",
"roles.classifier",
"llm.max_output_tokens",
"llm.request_timeout_s",
"loop",
)
# Settings the old loop had (until M2.8), and what to do instead.
RETIRED_KEYS = {
"loop": "the loop is the only one now; remove it",
"roles.planner": "use roles.driver",
"roles.executor": "the driver does the work now; remove it",
}
# A project entry (D8), under `projects: {name: {...}}`.
PROJECT_REQUIRED = ("repo", "workspace", "build_host.address", "build_host.key")
PROJECT_OPTIONAL = (
@@ -85,7 +88,7 @@ class ConfigError(RuntimeError):
@dataclass(frozen=True)
class ExecutorTuning:
class DriverTuning:
max_turns: int = 40
failure_limit: int = 2
@@ -125,7 +128,7 @@ class LoopTuning:
@dataclass(frozen=True)
class Tuning:
executor: ExecutorTuning = field(default_factory=ExecutorTuning)
driver: DriverTuning = field(default_factory=DriverTuning)
expert: ExpertTuning = field(default_factory=ExpertTuning)
delegation: DelegationTuning = field(default_factory=DelegationTuning)
tools: ToolsTuning = field(default_factory=ToolsTuning)
@@ -148,6 +151,9 @@ def _parse_tuning(raw: Any, errors: list[str]) -> Tuning:
sections: dict[str, Any] = {}
for name, section in raw.items():
cls = _TUNING_SECTIONS.get(name)
if name == "executor": # until M2.8
errors.append("tuning.executor: retired — it is tuning.driver now")
continue
if cls is None:
errors.append(f"tuning.{name}: unknown section")
continue
@@ -474,60 +480,8 @@ class AgentConfig:
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
tuning: Tuning = field(default_factory=Tuning)
# Which loop runs: "old" (control.py) until the switch-over (M2.8), or
# "new" (nisse_dev.loop).
loop: str = "old"
config_file: Path | None = None
# -- one project, for the old loop ----------------------------------------
# control.py (until M2.8) knows one project; these read it, and refuse a
# configuration with more.
def only_project(self) -> Project:
if len(self.projects) != 1:
names = ", ".join(p.name for p in self.projects)
raise ConfigError(
f"cannot start: the old loop runs exactly one project, but "
f"{len(self.projects)} are configured ({names}); use `loop: new`"
)
return self.projects[0]
def with_project(self, **changes: Any) -> "AgentConfig":
"""This configuration with its one project changed (for tests)."""
return replace(self, projects=(replace(self.only_project(), **changes),))
@property
def project(self) -> str:
return self.only_project().name
@property
def gitea_repo(self) -> str:
return self.only_project().repo
@property
def workspace(self) -> Path:
return self.only_project().workspace
@property
def build_host(self) -> BuildHost:
return self.only_project().build_host
@property
def base_branch(self) -> str:
return self.only_project().base_branch
@property
def push_key(self) -> Path | None:
return self.only_project().push_key
@property
def push_user(self) -> str | None:
return self.only_project().push_user
@property
def push_token(self) -> str | None:
return self.only_project().push_token
@classmethod
def load(cls, env: Mapping[str, str] = os.environ) -> "AgentConfig":
"""The file named by NISSE_CONFIG, or ./nisse-dev.yaml."""
@@ -573,7 +527,10 @@ class AgentConfig:
known = set(REQUIRED_KEYS) | set(OPTIONAL_KEYS)
for key in sorted(set(flat) - known):
errors.append(f"{key}: unknown setting")
if key in RETIRED_KEYS:
errors.append(f"{key}: retired — {RETIRED_KEYS[key]}")
else:
errors.append(f"{key}: unknown setting")
values = {
key: _resolve(value, key, env, errors)
for key, value in flat.items()
@@ -607,9 +564,6 @@ class AgentConfig:
raise ValueError
except (TypeError, ValueError):
errors.append("llm.request_timeout_s: must be a positive number of seconds")
loop = str(values.get("loop") or "old")
if loop not in ("old", "new"):
errors.append("loop: must be old or new")
if errors:
raise ConfigError("cannot start: " + "; ".join(errors))
@@ -618,11 +572,9 @@ class AgentConfig:
llm_base_url=str(values["llm.base_url"]),
llm_token=str(values["llm.token"]),
roles=RoleMap(
executor=str(values["roles.executor"]),
planner=str(values["roles.planner"]),
driver=str(values["roles.driver"]),
helper=str(values["roles.helper"]),
expert=str(values["roles.expert"]),
driver=str(values.get("roles.driver") or ""),
classifier=str(values.get("roles.classifier") or ""),
),
matrix=MatrixConfig(
@@ -640,7 +592,6 @@ class AgentConfig:
max_output_tokens=max_output,
request_timeout_s=request_timeout,
tuning=tuning,
loop=loop,
config_file=config_file,
)
config._refuse_secrets_inside_the_clone(env)
@@ -685,7 +636,6 @@ class AgentConfig:
f"{p.name} ({p.repo}, {p.build_host.address}:{p.workspace})"
for p in self.projects
),
"planner": self.roles.planner,
"executor": self.roles.executor,
"driver": self.roles.driver,
"helper": self.roles.helper,
}