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

+2 -2
View File
@@ -1,5 +1,5 @@
"""The conversational loop (architecture L1, L2, L5; Development Plan M2.1).
Built alongside control.py, which keeps running until the switch-over
(M2.8); the `loop: new` setting chooses this one.
The agent's only loop since the switch-over (M2.8), which retired the gated
state machine it was built alongside.
"""
+11 -14
View File
@@ -37,7 +37,6 @@ from nisse_dev.canonical import (
from nisse_dev.config import GENERAL
from nisse_dev.delegation import DELEGATION_INTENT, Delegation
from nisse_dev.expert import BudgetExhausted, ExpertDesk
from nisse_dev.gates import command_form
from nisse_dev.git import GitError
from nisse_dev.gitea import GiteaClient, GiteaError, Issue
from nisse_dev.llm import LLMError, RoleMap
@@ -61,7 +60,7 @@ from nisse_dev.loop.items import (
save,
)
from nisse_dev.loop.milestones import OPEN_PR, Milestones
from nisse_dev.loop.reader import GoAheadReader, is_question
from nisse_dev.loop.reader import GoAheadReader, command_form, is_question
from nisse_dev.loop.tools import (
ASK_HUMAN,
CLOSE_ITEM,
@@ -75,7 +74,7 @@ from nisse_dev.loop.tools import (
)
from nisse_dev.oplog import OpLog
from nisse_dev.state import StateStore, WorkItem
from nisse_dev.tools import EXECUTOR_TOOLS, Workspace
from nisse_dev.tools import EXECUTION_TOOLS, Workspace
from nisse_dev.transport import InboundMessage, Transport
from nisse_dev.worker import Worker
@@ -113,7 +112,7 @@ _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.
_LOOK = [t for t in EXECUTOR_TOOLS if t["function"]["name"] != "write_file"]
_LOOK = [t for t in EXECUTION_TOOLS if t["function"]["name"] != "write_file"]
SYSTEM = """You are nisse-dev, a software agent working with the operator on
the repository {repo}. You are on one work item: issue #{issue}, "{title}".
@@ -1411,12 +1410,7 @@ class LoopAgent:
default = (
self._helpers.roles
if self._helpers
else RoleMap(
executor="",
planner=self._roles.get("planner", ""),
helper="",
expert="",
)
else RoleMap(driver=self._roles.get("driver", ""), helper="", expert="")
)
pinned = item.data.get("roles") or self._roles
return RoleMap.from_pinned(dict(pinned), default)
@@ -1454,6 +1448,9 @@ class LoopAgent:
workspace,
milestones.git,
model=helper,
backing_model=(
self._helpers.backing(helper) if self._helpers.backing else ""
),
caps=self._helpers.caps,
cancel=self._cancel,
reader=self._helpers.reader(roles),
@@ -1737,7 +1734,7 @@ class LoopAgent:
*([WAIT_FOR_REVIEW] if loop.pr else []),
*([DELEGATE, ASK_EXPERT] if self._helpers else []),
*([REPLY] if loop.back_to else []),
*EXECUTOR_TOOLS,
*EXECUTION_TOOLS,
]
),
]
@@ -1826,7 +1823,7 @@ class LoopAgent:
text = str(args.get("text", "")).strip()
rev = loop.rev + 1
self._store.transcript(item.id, "driver").append(
PlanSubmitted(plan=text, model=self._roles.get("planner", ""))
PlanSubmitted(plan=text, model=self._roles.get("driver", ""))
)
self._fact(item, f"restatement rev-{rev} put up for a go-ahead")
self._shown[loop.thread] = rev
@@ -1869,7 +1866,7 @@ class LoopAgent:
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", ""))
PlanSubmitted(plan=summary, model=self._roles.get("driver", ""))
)
self._fact(item, f"work proposed as rev-{rev}: {project.repo}, {title}")
self._shown[loop.thread] = rev
@@ -1899,7 +1896,7 @@ class LoopAgent:
brief = f"{purpose}\n\nDecisions:\n{decisions}" if decisions else purpose
rev = loop.rev + 1
self._store.transcript(item.id, "driver").append(
PlanSubmitted(plan=brief, model=self._roles.get("planner", ""))
PlanSubmitted(plan=brief, model=self._roles.get("driver", ""))
)
self._fact(item, f"new project proposed as rev-{rev}: {name}")
self._shown[loop.thread] = rev
+159 -16
View File
@@ -1,25 +1,22 @@
"""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."""
"""Composition root: the one place that knows how the whole agent fits
together. Every component is unit-tested in isolation; this is where they meet,
which is what keeps a built piece from being reachable only from its own tests
(the delegation harness and the comment poller once were)."""
import threading
from collections.abc import Callable
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.catalog import select_client
from nisse_dev.catalog import ModelCatalog, select_client
from nisse_dev.config import AgentConfig, ConfigError
from nisse_dev.config import Project as ProjectEntry
from nisse_dev.context import BudgetedClient
from nisse_dev.delegation import Caps
from nisse_dev.execution import SSHHost
from nisse_dev.git import GitRepo, HTTPSToken, PushCredential, SSHKey
from nisse_dev.gitea import GiteaClient
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
from nisse_dev.loop.agent import LoopAgent, Project
from nisse_dev.loop.driver import Driver
from nisse_dev.loop.help import Helpers
@@ -31,6 +28,44 @@ from nisse_dev.state import StateStore
from nisse_dev.tools import Workspace
from nisse_dev.worker import SerialWorker
# How long shutdown waits for running work before leaving the store to the
# durability layer (L8). A milestone can run far longer than a service
# manager allows for a stop, and a hard exit mid-work is already safe.
SHUTDOWN_WAIT_S = 10.0
TOOL_DRIVING_ROLES = ("driver", "helper")
def discover_models(config: AgentConfig, oplog: OpLog) -> ModelCatalog:
"""Resolve every configured role against LiteLLM before anything runs.
Refusing to boot is the whole point: a role pointed at a model this key
cannot see is a misconfiguration, and the cheapest place to learn that is
here rather than ten minutes into a milestone. An unreachable proxy is
reported separately -- it is an outage, not a mistake, and telling them
apart is what lets an operator know whether to edit config or wait.
"""
catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token)
catalog.validate(config.roles.pinned())
# Every role's context window must be known before a request is built
# against it (L6): a guess that is too large reaches a server that may
# silently cut the prompt.
catalog.require_limits(config.roles.pinned())
for role in TOOL_DRIVING_ROLES:
facts = catalog.facts(config.roles.resolve(role))
if facts.supports_tools is False:
# A warning, not a refusal: models LiteLLM declares tool-less are
# observed driving tool loops anyway, and L9 expects weak local
# models to be accommodated rather than excluded.
oplog.emit(
"model_tools_unsupported",
cause="startup",
role=role,
model=facts.alias,
backing=facts.backing,
)
return catalog
@dataclass
class RunningLoop:
@@ -46,7 +81,7 @@ class RunningLoop:
def start(self) -> None:
self.loop.start()
self.transport.post(
f"nisse-dev up as {self.account} (new loop) on "
f"nisse-dev up as {self.account} on "
f"{', '.join(p.repo for p in self.config.projects)}; "
f"{self.loop_status()}"
)
@@ -69,6 +104,113 @@ class RunningLoop:
self._stopped.wait()
def make_llm_clients(
config: AgentConfig, catalog: ModelCatalog
) -> tuple[BudgetedClient, BudgetedClient]:
"""The two LLM routes, as every model call in the agent uses them.
Shared with the benchmark, so that what it measures is exactly what the
agent would do: the same context budget, the same declared limits, and
tool_choice asked only of models declared to enforce it.
"""
tuning = config.tuning
def limits_for(model: str) -> tuple[int, int | None]:
facts = catalog.facts(model)
assert facts.max_input_tokens is not None # require_limits checked
return facts.max_input_tokens, facts.max_output_tokens
def budgeted(client: object) -> BudgetedClient:
# Every request, on either route, is fitted to its model's window.
return BudgetedClient(
client,
limits_for,
max_output_tokens=config.max_output_tokens,
keep_recent_turns=tuning.context.keep_recent_turns,
safety_margin=tuning.context.safety_margin,
enforces_tool_choice=lambda m: (
catalog.facts(m).supports_tool_choice is True
),
)
normalized = budgeted(
LiteLLMClient(
config.llm_base_url,
config.llm_token,
max_tokens=config.max_output_tokens,
timeout_s=config.request_timeout_s,
)
)
# Delegations prefer the vendor-native route; fidelity is cheap
# elsewhere (L9). Which route a given model gets is decided by
# select_client, not by where the caller happens to sit.
passthrough = budgeted(
AnthropicPassthroughClient(
config.llm_base_url,
config.llm_token,
max_tokens=config.max_output_tokens,
timeout_s=config.request_timeout_s,
)
)
return normalized, passthrough
def build_host(config: AgentConfig, project: ProjectEntry) -> SSHHost:
"""A project's build VM (L13). Its known-hosts file and connection socket
live beside the state store."""
host = project.build_host
return SSHHost(
host.address,
key=host.key,
user=host.user,
port=host.port,
state_dir=config.state_db.parent / "ssh",
)
def push_credential(project: ProjectEntry) -> PushCredential | None:
"""What a project's pushes go by: a push-only account's token over
HTTPS, or a deploy key over SSH (D4)."""
if project.push_user and project.push_token:
return HTTPSToken(project.push_user, project.push_token)
if project.push_key is None:
return None
try:
return SSHKey(project.push_key.read_text())
except OSError as exc:
raise RuntimeError(
f"cannot read the push key {project.push_key}: {exc}"
) from exc
def remote_url(gitea: GiteaClient, repo: str, credential: PushCredential | None) -> str:
"""The credential-free URL the workspace clones from and pushes to."""
if isinstance(credential, SSHKey):
return gitea.ssh_url(repo)
return gitea.clone_url(repo)
def prepare_workspace(
host: SSHHost,
config: AgentConfig,
account: str,
remote: str,
credential: PushCredential | None,
project: ProjectEntry,
) -> GitRepo:
"""Ensure the workspace on the build VM is a clone of the project repo.
The clone uses the repository's SSH URL, so no credential is ever stored
in it (#89); the push credential is handed to the clone and nothing else.
"""
repo = GitRepo(host, project.workspace)
if not repo.exists():
repo.clone(remote, credential=credential)
repo.configure_identity(account, f"{account}@users.noreply.local")
repo.install_excludes() # build artifacts are never the work (#80)
return repo
def project_runtime(
config: AgentConfig,
entry: ProjectEntry,
@@ -80,7 +222,7 @@ def project_runtime(
(with the secrets its commands may see, redacted on return), push
credential and milestones."""
host = build_host(config, entry)
credential = push_credential(config, entry)
credential = push_credential(entry)
remote = remote_url(gitea, entry.repo, credential)
git = prepare_workspace(host, config, account, remote, credential, entry)
return Project(
@@ -133,15 +275,16 @@ def build_loop_agent(
driver = Driver(
normalized,
config.roles.resolve("driver"),
max_turns=config.tuning.executor.max_turns,
max_turns=config.tuning.driver.max_turns,
cancel=cancel,
failure_limit=config.tuning.executor.failure_limit,
failure_limit=config.tuning.driver.failure_limit,
)
tuning = config.tuning
helpers = Helpers(
client=normalized,
roles=config.roles,
client_for=lambda model: select_client(model, normalized, passthrough, catalog),
backing=lambda model: catalog.facts(model).backing,
caps=Caps(
turns=tuning.delegation.max_turns,
tokens=tuning.delegation.max_tokens,
+3
View File
@@ -42,6 +42,9 @@ class Helpers:
# The helper's client by model (vendor passthrough where it fits, L9);
# unset, ``client``.
client_for: Callable[[str], LLMClient] | None = None
# What an alias resolves to, recorded with each delegation: eval history
# keyed on an alias alone averages over whatever it pointed at (L9).
backing: Callable[[str], str] | None = None
caps: Caps = field(default_factory=Caps)
expert_budget: int = DEFAULT_BUDGET
profiles: Mapping[str, Profile] = field(default_factory=dict)
+9
View File
@@ -13,6 +13,15 @@ from typing import Any
from nisse_dev.canonical import StoredEvent, ToolCall, UserMessage
from nisse_dev.llm import LLMClient, LLMError
def command_form(text: str) -> str:
"""How a message reads as a command: case, surrounding whitespace, runs
of inner whitespace and trailing '.' or '!' do not matter, so a phone's
"Go." is still a go-ahead (#64). Only for matching; what the operator
wrote is what gets recorded and passed on."""
return " ".join(text.strip().rstrip(".!").split()).lower()
VERDICT = {
"type": "function",
"function": {