M2.8: switch over to the new loop
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:
1 parent
09232dcbdd
commit
6ae2375194
89 files changed
+951
-8592
No files matched your search
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user