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

-342
View File
@@ -1,342 +0,0 @@
"""Composition root: the one place that knows how the whole agent fits together.
Every component below has been unit-tested in isolation for some time; nothing
had ever assembled them. That gap hid two live bugs — the delegation harness
and the Gitea comment poller were both fully built and reachable from nothing
but their own tests — which is the argument for having a single root rather
than constructing pieces ad hoc.
"""
import threading
from collections.abc import Callable
from dataclasses import dataclass
from nisse_dev.approvals import PlanBoard
from nisse_dev.cancel import Cancel
from nisse_dev.catalog import ModelCatalog, select_client
from nisse_dev.config import AgentConfig, Project
from nisse_dev.context import BudgetedClient
from nisse_dev.control import Orchestrator
from nisse_dev.delegation import Caps, Delegation
from nisse_dev.execution import SSHHost
from nisse_dev.executor import LLMExecutor
from nisse_dev.expert import ExpertDesk
from nisse_dev.git import GitRepo, HTTPSToken, PushCredential, SSHKey
from nisse_dev.gitea import Comment, GiteaClient
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
from nisse_dev.matrix import MatrixTransport
from nisse_dev.oplog import OpLog
from nisse_dev.planner import LLMPlanningAgent
from nisse_dev.poller import CommentPoller
from nisse_dev.publish import MilestonePublisher
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 = ("planner", "executor", "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 Agent:
config: AgentConfig
store: StateStore
transport: MatrixTransport
orchestrator: Orchestrator
gitea: GiteaClient
account: str
worker: SerialWorker
_stopped: threading.Event
def start(self) -> None:
"""Boot in the order L8 requires: reconcile against external truth
before accepting a single operator command."""
self.worker.call(self.orchestrator.start)
self.transport.start()
state = self.worker.call(self.store.get_state)
where = state.state if state else "IDLE"
self.transport.send_message(
f"nisse-dev up as {self.account} — project {self.config.project}, "
f"state {where}, planner {self.config.roles.planner}, "
f"executor {self.config.roles.executor}"
)
def wait(self) -> None:
self._stopped.wait()
def stop(self) -> None:
"""Shut down outermost-first: stop accepting work, then stop talking,
then close the state store last so nothing writes to a closed file.
The store belongs to the worker, so the worker closes it."""
self.orchestrator.stop_watching()
self.transport.close()
self.gitea.close()
try:
self.worker.call(self.store.close, timeout=SHUTDOWN_WAIT_S)
except TimeoutError:
pass # still working; exiting mid-work is what L8 are for
self.worker.stop(timeout=SHUTDOWN_WAIT_S)
self._stopped.set()
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: Project | None = None) -> SSHHost:
"""A project's build VM (L13; the only project's, if none is named). Its
known-hosts file and connection socket live beside the state store."""
host = (project or config.only_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(
config: AgentConfig, project: Project | None = None
) -> PushCredential | None:
"""What a project's pushes go by: a push-only account's token over
HTTPS, or a deploy key over SSH (D4)."""
project = project or config.only_project()
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: Project | None = None,
) -> 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.
Cloning here rather than when a work item starts is the single-project
shortcut that M1.4 and D3's fresh workspace replace.
"""
repo = GitRepo(host, (project or config.only_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 build_agent(
config: AgentConfig, *, notify: Callable[[str], None] | None = None
) -> Agent:
oplog = OpLog("nisse-dev")
worker = SerialWorker(
on_error=lambda exc: oplog.emit(
"worker.job_failed", cause="unhandled exception", error=repr(exc)
)
)
worker.start()
# Opened on the worker, which is the only thread allowed to use it.
store = worker.call(lambda: StateStore(config.state_db, oplog=oplog))
cancel = Cancel()
# Before the clone, before the token checks: a typo'd role name should
# cost a second, not a repository.
catalog = discover_models(config, oplog)
gitea = GiteaClient(config.gitea_url, config.gitea_token)
# Whoami rather than another config variable: it names the identity every
# Gitea permission is evaluated against, and proves the token works before
# anything depends on it.
account = gitea.whoami()
host = build_host(config)
credential = push_credential(config)
remote = remote_url(gitea, config.gitea_repo, credential)
git_repo = prepare_workspace(host, config, account, remote, credential)
tuning = config.tuning
workspace = Workspace(
host,
config.workspace,
timeout_s=tuning.tools.command_timeout_s,
max_output_chars=tuning.tools.max_output_chars,
)
normalized, passthrough = make_llm_clients(config, catalog)
transport = MatrixTransport(config.matrix)
announce = notify or transport.send_message
expert = ExpertDesk(normalized, config.roles, budget=tuning.expert.consult_budget)
delegate_model = config.roles.resolve("helper")
delegation = Delegation(
select_client(delegate_model, normalized, passthrough, catalog),
store,
workspace,
git_repo,
model=delegate_model,
backing_model=catalog.facts(delegate_model).backing,
caps=Caps(
turns=tuning.delegation.max_turns,
tokens=tuning.delegation.max_tokens,
wall_clock_s=tuning.delegation.wall_clock_s,
),
cancel=cancel,
)
executor = LLMExecutor(
normalized,
config.roles,
workspace,
expert=expert,
delegate=delegation,
notify=announce,
cancel=cancel,
max_turns=tuning.executor.max_turns,
failure_limit=tuning.executor.failure_limit,
)
publisher = MilestonePublisher(
store,
gitea,
config.gitea_repo,
git_repo,
remote_url=remote,
push_credential=credential,
base_branch=config.base_branch,
)
def poller_factory(on_comment: Callable[[Comment], None]) -> CommentPoller:
return CommentPoller(
gitea,
config.gitea_repo,
on_comment,
oplog=oplog,
# Its own comments are not new input; without this the agent
# answers itself.
ignore_authors={account},
)
orchestrator = Orchestrator(
store,
transport,
LLMPlanningAgent(normalized, config.roles, cancel=cancel),
operators=config.operators,
roles=config.roles,
executor=executor,
publisher=publisher,
gitea_operators=config.gitea_operators,
board=PlanBoard(gitea, config.gitea_repo),
delegate=delegation,
poller_factory=poller_factory,
catalog=catalog,
worker=worker,
cancel=cancel,
)
return Agent(
config=config,
store=store,
transport=transport,
orchestrator=orchestrator,
gitea=gitea,
account=account,
worker=worker,
_stopped=threading.Event(),
)
-86
View File
@@ -1,86 +0,0 @@
"""Plan externalisation and Gitea-backed approvals (architecture L3, D2).
Plans are posted as comments on a per-project Gitea issue. The approval
comment — its author, timestamp, and revision tag — is the durable off-box
record, authenticated by Gitea's own auth. The state store keeps only a
pointer to it, so boot reconciliation can re-derive gate state from Gitea
even after the state volume is lost: external truth wins.
Approvals arriving here bind to a revision exactly as they do over transport
(L3). The first valid approval on any channel wins; the orchestrator echoes
the outcome to the other channel so the records converge.
"""
from dataclasses import dataclass
from nisse_dev.gitea import Comment, GiteaClient, GiteaError
APPROVAL_HINT = (
"Reply `approve {tag}` to approve this revision, or comment with feedback "
"to reject it."
)
@dataclass(frozen=True)
class PlanPost:
issue: int
comment_id: int
class PlanBoard:
"""The Gitea side of plan gating: one issue per project, one comment per
plan revision."""
def __init__(self, client: GiteaClient, repo: str) -> None:
self._client = client
self._repo = repo
@property
def repo(self) -> str:
return self._repo
def open_project_issue(self, project: str, task_id: str) -> int:
return self._client.create_issue(
self._repo,
title=f"nisse-dev: {project}",
body=(
f"Plan gate for project `{project}` (task `{task_id}`).\n\n"
"Each plan revision is posted here as a comment. Approving a "
"revision by comment is equivalent to approving it in Matrix; "
"the first valid approval on either channel wins."
),
)
def post_plan(self, issue: int, gate: str, rev: int, plan: str) -> PlanPost:
tag = f"rev-{rev}"
body = "\n".join(
[
f"## Plan for `{gate}` ({tag})",
"",
plan,
"",
"---",
APPROVAL_HINT.format(tag=tag),
]
)
comment = self._client.post_comment(self._repo, issue, body)
return PlanPost(issue=issue, comment_id=comment.id)
def echo(self, issue: int, text: str) -> None:
"""Mirror an outcome decided on another channel, so the Gitea record
does not silently disagree with Matrix."""
try:
self._client.post_comment(self._repo, issue, text)
except GiteaError:
pass
def close(self, issue: int, note: str) -> None:
"""Close the plan issue with a note saying why (e.g. abandoned).
Unlike echo, failures raise: the caller reports them."""
self._client.post_comment(self._repo, issue, note)
self._client.close_issue(self._repo, issue)
def comments_since(self, issue: int, after_id: int) -> list[Comment]:
return [
c for c in self._client.list_comments(self._repo, issue) if c.id > after_id
]
+2 -2
View File
@@ -1,6 +1,6 @@
"""The executor benchmark: canned milestones, run repeatedly per model.
"""The driver benchmark: canned milestones, run repeatedly per model.
Choosing an executor model, and later tuning its knobs, should rest on
Choosing a driver model, and later tuning its knobs, should rest on
measured success rates, not on one run or on reputation. Each task is a fixed
approved plan over a seed project, with a hidden check the model never sees;
success means the check passes, not that the model said it finished. Runs use
+8 -3
View File
@@ -74,12 +74,17 @@ class Row:
def tuning_label(tuning: dict[str, Any]) -> str:
"""The settings in a run's tuning that differ from today's defaults, as
"executor.max_turns=24"; empty when there are none. Relative to today's
defaults so that rows at the current settings stay unlabelled."""
"driver.max_turns=24"; empty when there are none. Relative to today's
defaults so that rows at the current settings stay unlabelled. Runs from
before M2.8 called the driver's section ``executor``."""
defaults = asdict(Tuning())
renamed = {
("driver" if section == "executor" else section): values
for section, values in tuning.items()
}
return ", ".join(
f"{section}.{key}={value}"
for section, values in sorted(tuning.items())
for section, values in sorted(renamed.items())
for key, value in sorted((values or {}).items())
if defaults.get(section, {}).get(key) != value
)
+63 -23
View File
@@ -1,4 +1,4 @@
"""Running one task once with one executor model, and recording it."""
"""Running one task once with one driver model, and recording it."""
import io
import shlex
@@ -14,7 +14,7 @@ from typing import Any
from nisse_dev.bench.tasks import Task
from nisse_dev.canonical import (
CanonicalEvent,
PlanSubmitted,
Fact,
StoredEvent,
ToolCall,
ToolResult,
@@ -23,11 +23,37 @@ from nisse_dev.canonical import (
from nisse_dev.catalog import ModelFacts
from nisse_dev.config import Tuning
from nisse_dev.execution import SSHHost
from nisse_dev.executor import LLMExecutor
from nisse_dev.llm import RoleMap
from nisse_dev.loop.agent import SYSTEM
from nisse_dev.loop.driver import Driver, RunOutcome
from nisse_dev.loop.tools import (
ASK_HUMAN,
CLOSE_ITEM,
FINISH_MILESTONE,
START_MILESTONE,
)
from nisse_dev.oplog import OpLog
from nisse_dev.state import StateStore
from nisse_dev.tools import Workspace
from nisse_dev.tools import EXECUTION_TOOLS, Workspace
# What the driver is offered once an item is agreed, less what the benchmark
# turns off: delegation and ask_expert (an escalation is the model's failure,
# not a frontier model's success) and review, which has no PR here.
BENCH_TOOLS = [
ASK_HUMAN,
START_MILESTONE,
FINISH_MILESTONE,
CLOSE_ITEM,
*EXECUTION_TOOLS,
]
def outcome_of(run: RunOutcome) -> str:
"""A driver run as the benchmark records it: ``completed`` when the
driver says the work is done, ``escalated`` when it asks the operator,
otherwise how the run ended (capped, stuck, failed)."""
if run.kind == "wait":
return "escalated" if run.tool == "ask_human" else "completed"
return run.kind
class MeteringClient:
@@ -158,11 +184,13 @@ def run_task(
commit: str = "",
workdir: Path | None = None,
) -> RunRecord:
"""Run one task once: seed a workspace, hand the executor the approved
plan, then judge the result with the hidden check.
"""Run one task once: seed a workspace, hand the driver an agreed item
whose restatement is the task's plan, then judge the result with the
hidden check.
Delegation and ask_expert are off: an escalation is a failure of the
model under test, not a frontier model's success."""
It is the loop's own driver, system prompt and tools, so what is measured
is what the agent would do, without the harness around it: the milestone
is started but no branch is cut, and finishing it ends the run."""
with tempfile.TemporaryDirectory(dir=workdir) as tmp:
root = Path(tmp)
# The workspace is on the build VM, like the agent's own (L13).
@@ -174,39 +202,51 @@ def run_task(
)
task.seed_workspace(workspace)
store = StateStore(root / "state.db", oplog=OpLog("nisse-bench", io.StringIO()))
# One work item per run, as the agent's own tasks are (L1).
# One work item per run, as the agent's own are (L1).
store.open_work_item(
"bench", title=task.name, position="EXECUTING", cause="benchmark run"
"bench", title=task.name, position="RUNNING", cause="benchmark run"
)
store.transcript("bench", "planner").append(
PlanSubmitted(plan=task.plan, model="benchmark")
store.transcript("bench", "orchestrator").append(
Fact(text=f"The operator agreed to this restatement:\n\n{task.plan}")
)
store.transcript("bench", "operator").append(
UserMessage(text="start milestone 1", source="benchmark")
UserMessage(text="go", source="benchmark")
)
meter = MeteringClient(client, facts)
trims_before = getattr(client, "trimmed_requests", 0)
model = facts.alias
roles = RoleMap(executor=model, planner=model, helper=model, expert=model)
executor = LLMExecutor(
driver = Driver(
meter,
roles,
workspace,
max_turns=tuning.executor.max_turns,
failure_limit=tuning.executor.failure_limit,
model,
max_turns=tuning.driver.max_turns,
failure_limit=tuning.driver.failure_limit,
)
system = SYSTEM.format(
repo=f"bench/{task.name}",
issue=1,
title=task.title,
labels="",
helpers="",
workspace=workspace.describe(),
)
started = time.monotonic()
try:
outcome = executor.run(store.transcript("bench", "executor"), 1)
status, detail = outcome.status, outcome.note
run = driver.run(
store.transcript("bench", "driver"),
system=system,
tools=BENCH_TOOLS,
workspace=workspace,
handlers={"start_milestone": lambda args: ("started", True)},
)
status, detail = outcome_of(run), run.note
except Exception as exc: # noqa: BLE001 -- recorded, the benchmark goes on
status, detail = "error", repr(exc)
wall = time.monotonic() - started
success, check_output = task.check_workspace(workspace)
host.execute(f"rm -rf -- {shlex.quote(str(workspace.root))}", timeout_s=60)
failed, invented = _count(store.transcript("bench", "executor").read())
failed, invented = _count(store.transcript("bench", "driver").read())
store.close()
return RunRecord(
+3 -3
View File
@@ -3,11 +3,11 @@
Each task is a directory under ``tasks/``:
- ``task.yaml``: ``title``, ``plan`` (the approved milestone plan, written the
way the planner is asked to write them), and ``check`` (a shell command run
way the driver restates them), and ``check`` (a shell command run
in the workspace afterwards).
- ``seed/``: the project the executor starts from.
- ``seed/``: the project the driver starts from.
- ``hidden/``: files the check needs, copied in only after the run, into
``.bench_check/``. The executor never sees them.
``.bench_check/``. The driver never sees them.
- ``solution/``: a reference solution, overlaid on the seed by the tests to
prove each task can be solved and that its check tells the difference.
"""
@@ -9,4 +9,4 @@ plan: |
3. Fix `total_value` so that each item contributes its unit price times its
quantity.
4. Run `python -m pytest -q` until every test passes.
5. Call finish.
5. Call finish_milestone, with final yes.
@@ -37,4 +37,4 @@ plan: |
then the same with `list --overdue`, which must print
`[!] 1 Pay rent (due 2026-10-01)`. Delete /tmp/tracker-check.json
before and after.
9. Call finish.
9. Call finish_milestone, with final yes.
@@ -9,4 +9,4 @@ plan: |
(NNN mod 25) / 100. Correct only the failing region's entry; leave every
other line as it is.
3. Run `python -m pytest -q` until every test passes.
4. Call finish.
4. Call finish_milestone, with final yes.
@@ -13,4 +13,4 @@ plan: |
using pytest's capsys fixture.
4. Run `python -m pytest -q` until every test passes, and check that
`python -m greeter --shout Bob` prints `HELLO, BOB!`.
5. Call finish.
5. Call finish_milestone, with final yes.
@@ -15,4 +15,4 @@ plan: |
are different functions and keep their names. Change no behaviour.
4. Update the tests under `tests/` to use the new name, and run
`python -m pytest -q` until every test passes.
5. Call finish.
5. Call finish_milestone, with final yes.
@@ -12,4 +12,4 @@ plan: |
invalid input for each of the eight rules.
4. Run `python -m pytest -q test_durations.py`, and fix `durations.py`
until every test passes.
5. Call finish.
5. Call finish_milestone, with final yes.
@@ -18,4 +18,4 @@ plan: |
`invoicing/currency.py` have up to six decimal places (JPY is 0.006712),
and no step may round a rate or fraction to fewer places than that.
4. Run `python -m pytest -q` until every test passes.
5. Call finish.
5. Call finish_milestone, with final yes.
@@ -10,4 +10,4 @@ plan: |
"a--b" -> "a-b"; "" -> "".
3. Create `test_slugify.py` with pytest tests covering the four examples.
4. Run `python -m pytest -q test_slugify.py`, and fix anything until it passes.
5. Call finish.
5. Call finish_milestone, with final yes.
+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,
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -43,7 +43,7 @@ from nisse_dev.git import GitError, GitRepo
from nisse_dev.llm import LLMClient, LLMError, UpstreamUnavailable
from nisse_dev.reconcile import Reconciler
from nisse_dev.state import Intent, StateStore, Transcript
from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, Workspace
from nisse_dev.tools import EXECUTION_TOOLS, MUTATING_TOOLS, Workspace
DELEGATION_INTENT = "delegation"
@@ -97,7 +97,7 @@ HAND_BACK_TOOL: dict[str, Any] = {
},
}
DELEGATE_TOOLS = [*EXECUTOR_TOOLS, HAND_BACK_TOOL]
DELEGATE_TOOLS = [*EXECUTION_TOOLS, HAND_BACK_TOOL]
@dataclass(frozen=True)
+5 -4
View File
@@ -62,13 +62,14 @@ def render(path: Path, *, full: bool = False) -> str:
active = agent["active_item"] if agent else None
lines.append(f"active work item: {active or '(none: idle)'}")
for item in conn.execute("SELECT * FROM work_items ORDER BY opened_at"):
data = json.loads(item["data"])
gate = data.get("gate") or {}
loop = json.loads(item["data"]).get("loop") or {}
status = item["status"] + (f" ({item['outcome']})" if item["outcome"] else "")
waiting = (
f" waits for: {loop['wait']}" if item["position"] == "WAITING" else ""
)
lines.append(
f" {item['id']} {status} position: {item['position']} "
f"scope: {item['scope']} gate: "
+ (f"{gate.get('name')} rev-{gate.get('rev')}" if gate else "none")
f"scope: {item['scope']} rev-{loop.get('rev', 0)}{waiting}"
)
pending = conn.execute(
"SELECT id, kind, payload, work_item FROM intent_log WHERE status = 'pending'"
-318
View File
@@ -1,318 +0,0 @@
"""Local executor loop (architecture L9 escalation triggers, L9 accommodations).
Runs the approved plan with the executor role — the cheap local model doing
mechanical work. Every tool call and result is checkpointed to the canonical
log as it happens (L9), so a crash mid-milestone leaves a resumable record.
Escalation is decided from outside the model (L9): consecutive command
failures and hard caps on turns, never the model's own claim that it is stuck
or finished-enough. Until delegation exists (step 7), an escalation gates to
the human with the failures attached rather than engaging a frontier model.
"""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Literal
from nisse_dev.cancel import Cancel, skip_calls
from nisse_dev.canonical import PlanSubmitted, StoredEvent, ToolCall, ToolResult
from nisse_dev.delegation import Delegation
from nisse_dev.expert import ASK_EXPERT_TOOL, BudgetExhausted, ExpertDesk
from nisse_dev.llm import LLMClient, LLMError, RoleMap
from nisse_dev.state import Transcript
from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, ToolOutcome, Workspace
EXECUTOR_SYSTEM = """You are nisse-dev's executor, doing mechanical work.
Follow the approved plan below exactly. It was written to be executed
verbatim; do not redesign it, expand its scope, or skip steps.
Every reply must be a tool call. Verify your work by running the project's
tests. When every step of the plan is done and the tests pass, call finish
with a short summary — that, and nothing else, ends the milestone."""
DEFAULT_MAX_TURNS = 24
DEFAULT_FAILURE_LIMIT = 2
FINISH_TOOL: dict[str, Any] = {
"type": "function",
"function": {
"name": "finish",
"description": (
"End the milestone: every step of the plan is done and the tests pass."
),
"parameters": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "What was done."}
},
"required": ["summary"],
},
},
}
# Finishing before any real work has happened would record an empty
# milestone as a success, so the claim is refused once first.
_NOT_YET = (
"You have not made any change yet, so the plan cannot be complete. Carry "
"out the next step of the plan, or run the tests to show the work is "
"already done, before calling finish."
)
# A reply in prose breaks the tool contract. A server that enforces
# tool_choice never allows one; on one that doesn't, the model is told once.
_PROSE_NUDGE = (
"\n\nYour last reply was prose. Every reply must be a tool call: carry "
"out the next step, or call finish if the plan is complete."
)
@dataclass(frozen=True)
class ExecutorOutcome:
status: Literal["completed", "capped", "escalate", "stopped"]
note: str
class LLMExecutor:
def __init__(
self,
client: LLMClient,
roles: RoleMap,
workspace: Workspace,
*,
max_turns: int = DEFAULT_MAX_TURNS,
failure_limit: int = DEFAULT_FAILURE_LIMIT,
expert: ExpertDesk | None = None,
delegate: Delegation | None = None,
notify: Callable[[str], None] | None = None,
cancel: Cancel | None = None,
) -> None:
self._client = client
self._roles = roles
self._workspace = workspace
self._max_turns = max_turns
self._failure_limit = failure_limit
self._expert = expert
self._delegate = delegate
# The loop runs synchronously inside the transport callback, so
# without this the agent is silent for the whole milestone and a
# working run looks exactly like a hung one.
self._notify = notify
self._cancel = cancel or Cancel()
def _report(self, turn: int, calls: list[ToolCall]) -> None:
if self._notify is None:
return
summary = ", ".join(
f"{c.tool}({c.arguments.get('path') or c.arguments.get('command', '')[:40]})"
if c.arguments
else c.tool
for c in calls
)
self._notify(f"turn {turn + 1}: {summary}")
def _escalate(
self, transcript: Transcript, milestone: int, failures: int, last_failure: str
) -> ExecutorOutcome | None:
"""Hand the sub-task to a stronger model, per L9.
Returns None when the delegation handed back usable work and the local
loop should carry on; an ExecutorOutcome when the caller must stop.
"""
note = f"{failures} consecutive command failures; last: {last_failure[:500]}"
if self._delegate is None:
return ExecutorOutcome("escalate", note)
# L9 says the failures go in the brief: the delegate must not have to
# rediscover what the local model already learned the hard way.
brief = (
f"Milestone {milestone} is stuck. The local executor tried and its "
f"commands failed {failures} times in a row.\n\n"
f"Most recent failure:\n{last_failure[:2000]}\n\n"
"The approved plan and the milestone so far are in the conversation "
"above. Finish this sub-task, staying inside the approved plan."
)
result = self._delegate.run(
transcript.as_actor("helper"),
brief=brief,
branch=f"delegation-milestone-{milestone}-{failures}",
)
if result.usable:
return None
if result.status == "stopped":
return ExecutorOutcome("stopped", f"during a delegation: {result.note}")
# L9: a cap breach or failure leaves a half-edited tree, so the local
# loop must not resume on top of it.
return ExecutorOutcome(
"escalate", f"{note}; delegation {result.status}: {result.note}"
)
def _tools(self) -> list[dict[str, Any]]:
# L9: ask_expert exists in addition to the observable escalation
# triggers, never as a replacement for them.
if self._expert is None:
return [*EXECUTOR_TOOLS, FINISH_TOOL]
return [*EXECUTOR_TOOLS, ASK_EXPERT_TOOL, FINISH_TOOL]
def _consult(
self, transcript: Transcript, arguments: dict[str, Any]
) -> ToolOutcome:
assert self._expert is not None
question = str(arguments.get("question", "")).strip()
if not question:
return ToolOutcome("ask_expert needs a question", success=False)
try:
answer = self._expert.ask(transcript.as_actor("expert"), question)
return ToolOutcome(answer.render(), success=True)
except BudgetExhausted as exc:
# Not an error the model should route around: it is the signal
# that it is circling and should get on with the work or fail.
return ToolOutcome(str(exc), success=False)
except LLMError as exc:
return ToolOutcome(f"the expert did not answer: {exc}", success=False)
def _scope(self, transcript: Transcript) -> tuple[str | None, int]:
"""The approved plan, and where this milestone's events begin.
The most recent plan in the log is the approved milestone plan: no
plan is submitted between approval and execution, or during review
responses. The executor sees that plan and what came after it, and
not the planning conversation or earlier milestones, which a small
context window cannot afford and the plan already distils.
"""
latest: StoredEvent | None = None
for stored in transcript.read():
if isinstance(stored.event, PlanSubmitted):
latest = stored
if latest is None:
return None, 0
assert isinstance(latest.event, PlanSubmitted)
return latest.event.plan, latest.id
def run(self, transcript: Transcript, milestone: int) -> ExecutorOutcome:
"""Run a milestone of the work item whose transcript this is, writing
as the executor (L6, L7)."""
model = self._roles.resolve("executor")
plan, after = self._scope(transcript)
system = (
f"{EXECUTOR_SYSTEM}\n\n{self._workspace.describe()}"
f"\n\nYou are executing milestone {milestone}."
)
if plan is not None:
# In the system prompt, so the context budget never drops it.
system += f"\n\nThe approved plan:\n{plan}"
tools = self._tools()
offered = {tool["function"]["name"] for tool in tools}
consecutive_failures = 0
did_work = False
finish_refused = False
prose_nudged = False
for turn in range(self._max_turns):
# L2: `stop` takes effect at the next tool boundary. The start of
# a turn is one, and so is the gap between two tool calls below.
if self._cancel.requested:
return ExecutorOutcome("stopped", f"before turn {turn + 1}")
try:
produced = self._client.complete(
transcript.view(after=after),
tools=tools,
model=model,
system=system,
)
except LLMError as exc:
return ExecutorOutcome("escalate", f"executor call failed: {exc}")
calls: list[ToolCall] = []
for event in produced:
transcript.append(event)
if isinstance(event, ToolCall):
calls.append(event)
if not calls:
if not prose_nudged:
prose_nudged = True
system += _PROSE_NUDGE
continue
return ExecutorOutcome(
"escalate", "executor kept replying in prose instead of tools"
)
self._report(turn, calls)
for index, call in enumerate(calls):
if self._cancel.requested:
skip_calls(transcript, calls[index:])
return ExecutorOutcome("stopped", f"during turn {turn + 1}")
if call.tool == "finish":
if not did_work and not finish_refused:
finish_refused = True
outcome = ToolOutcome(_NOT_YET, success=False)
elif not did_work:
return ExecutorOutcome(
"escalate",
"executor claimed the milestone was done without "
"making any change",
)
else:
transcript.append(
ToolResult(
call_id=call.call_id,
result="milestone finished",
success=True,
)
)
skip_calls(
transcript,
calls[index + 1 :],
"not run: finish had already ended the milestone",
)
summary = str(call.arguments.get("summary", "")).strip()
return ExecutorOutcome(
"completed", summary or "executor reported the plan done"
)
elif call.tool not in offered:
# A model inventing tools is not doing the work, and with
# no server enforcement nothing else would stop it, so it
# counts toward escalation like a failed command (L9).
outcome = ToolOutcome(
f"no such tool: {call.tool}; the tools are "
+ ", ".join(sorted(offered)),
success=False,
)
elif call.tool == "ask_expert":
outcome = self._consult(transcript, call.arguments)
else:
if call.tool in MUTATING_TOOLS:
did_work = True
outcome = self._workspace.dispatch(call.tool, call.arguments)
transcript.append(
ToolResult(
call_id=call.call_id,
result=outcome.result,
success=outcome.success,
)
)
# Commands and invented tools count toward escalation: a
# failed read is the model looking around, a failed command
# is work not working.
if call.tool == "run_command" or call.tool not in offered:
consecutive_failures = (
0 if outcome.success else consecutive_failures + 1
)
if consecutive_failures >= self._failure_limit:
# L9: this is the trigger delegation exists for.
# Escalating to the human is the fallback when there
# is no delegate, not the intended path.
escalation = self._escalate(
transcript,
milestone,
consecutive_failures,
outcome.result,
)
if escalation is not None:
return escalation
consecutive_failures = 0
did_work = True
return ExecutorOutcome(
"capped", f"hit the {self._max_turns}-turn cap for milestone {milestone}"
)
-109
View File
@@ -1,109 +0,0 @@
"""Approval gating primitives (architecture L3).
Approvals bind to a plan revision, never to "the current gate": every revision
carries a monotonic ``rev-N`` tag, an approval naming a stale revision is
answered with a notice and never applied to a newer one, and the first valid
approval wins. Approval is always the typed word ``approve`` (optionally
``approve rev-N``); any other reply is a rejection with feedback. The approver
is authenticated against a per-channel operator allowlist.
Revisions are numbered across a whole work item, not per gate, so ``rev-N``
names one plan. A reply that does not name a revision binds to the one that
was up when it arrived (its ``about``): if that is not the current revision,
it is ambiguous and not applied, neither as an approval nor as feedback. That
is how a second "approve", or an afterthought, sent while one plan was up can
never land on the next.
The channel seam: any approval source (Matrix transport, Gitea comment poller,
Vikunja comments) feeds operator replies to ``Gate.evaluate`` with its channel
name.
"""
import re
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
@dataclass(frozen=True)
class PlanRevision:
gate: str
rev: int
content: str
@property
def tag(self) -> str:
return f"rev-{self.rev}"
@dataclass(frozen=True)
class Approved:
revision: PlanRevision
approver: str
channel: str
@dataclass(frozen=True)
class Rejected:
feedback: str
approver: str
channel: str
@dataclass(frozen=True)
class Ignored:
notice: str | None # posted back to the channel when not None
_APPROVE_RE = re.compile(r"^approve(?:\s+(rev-\d+))?$")
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
"Approve." is still an approval. Only for matching; what the operator
wrote is what gets recorded and passed on."""
return " ".join(text.strip().rstrip(".!").split()).lower()
class Gate:
def __init__(self, revision: PlanRevision, operators: AbstractSet[str]) -> None:
self._revision = revision
self._operators = operators
def evaluate(
self, sender: str, text: str, channel: str, about: int | None = None
) -> Approved | Rejected | Ignored:
"""`about` is the revision that was up when the reply arrived; None
only for records kept before replies were stamped."""
if sender not in self._operators:
return Ignored(notice=None)
text = text.strip()
match = _APPROVE_RE.match(command_form(text))
named = match.group(1) if match else None
current = self._revision
if named is None and about is not None and about != current.rev:
was = f"rev-{about}" if about else "no plan"
if match is None:
return Ignored(
notice=(
f"noted, but it arrived while {was} was up, so it is not "
f"taken as feedback on {current.gate} {current.tag}"
)
)
return Ignored(
notice=(
f"that approval arrived while {was} was up; the current plan is "
f"{current.gate} {current.tag}, not applied. Reply again to "
"approve it"
)
)
if match is None:
return Rejected(feedback=text, approver=sender, channel=channel)
if named is not None and named != self._revision.tag:
return Ignored(
notice=(
f"approval names {named}, but the current plan is "
f"{self._revision.tag}; not applied"
)
)
return Approved(revision=self._revision, approver=sender, channel=channel)
+3 -12
View File
@@ -114,22 +114,13 @@ class LLMClient(Protocol):
@dataclass(frozen=True)
class RoleMap:
executor: str # the old loop's (control.py), until M2.8
planner: str # the old loop's, until M2.8
driver: str # drives the loop (L2)
helper: str # delegations (L9)
expert: str # ask_expert (L9)
driver: str = "" # drives the new loop; unset, the planner's model
classifier: str = "" # reads go-aheads now, actions later (L4); unset, expert's
ROLES: ClassVar[tuple[str, ...]] = (
"executor",
"planner",
"helper",
"expert",
"driver",
"classifier",
)
_FALLBACK: ClassVar[dict[str, str]] = {"driver": "planner", "classifier": "expert"}
ROLES: ClassVar[tuple[str, ...]] = ("driver", "helper", "expert", "classifier")
_FALLBACK: ClassVar[dict[str, str]] = {"classifier": "expert"}
def resolve(self, role: str) -> str:
if role not in self.ROLES:
+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": {
+4 -8
View File
@@ -12,9 +12,9 @@ import sys
from pathlib import Path
from types import FrameType
from nisse_dev.agent import build_agent
from nisse_dev.config import AgentConfig, ConfigError
from nisse_dev.envfile import load_env_file
from nisse_dev.loop.build import build_loop_agent
def main() -> int:
@@ -27,12 +27,7 @@ def main() -> int:
print(f"nisse-dev: {exc}", file=sys.stderr)
return 2
if config.loop == "new":
from nisse_dev.loop.build import build_loop_agent
agent = build_loop_agent(config)
else:
agent = build_agent(config)
agent = build_loop_agent(config)
def shutdown(_signum: int, _frame: FrameType | None) -> None:
print("nisse-dev: stopping", file=sys.stderr)
@@ -42,7 +37,8 @@ def main() -> int:
signal.signal(signal.SIGTERM, shutdown)
agent.start()
print(f"nisse-dev: ready as {agent.account} on {config.project}", file=sys.stderr)
names = ", ".join(p.name for p in config.projects)
print(f"nisse-dev: ready as {agent.account} on {names}", file=sys.stderr)
agent.wait()
return 0
-193
View File
@@ -1,193 +0,0 @@
"""LLM-backed planning agent (architecture L3 approvals, L9 roles, D2 plans).
Gating is structural at the tool layer: the planner is offered exactly two
tools — ``submit_plan``, which posts a plan for approval and ends the turn,
and ``ask_human``, which pauses for an answer. No "proceed" tool exists, so
the model has no path to advance itself past a gate.
Model output is appended to the canonical log as it arrives (L8/L7), so a
crash mid-planning leaves a truthful transcript.
"""
from typing import Any
from nisse_dev.cancel import Cancel
from nisse_dev.canonical import AssistantText, ToolCall, ToolResult
from nisse_dev.control import AskHuman, PlanningError, PlanningStopped, SubmitPlan
from nisse_dev.llm import BudgetExhausted, LLMClient, LLMError, RoleMap
from nisse_dev.state import Transcript
# Plans may be executed by a materially weaker model, which cannot
# reconstruct intent. This predates the architecture's capability profiles
# and plans that specify rather than implement (L9, D2); Development Plan
# M2.3 replaces it.
SYSTEM_PROMPT = """You are nisse-dev, a software engineering agent.
You are planning, not implementing. Produce plans that are explicit and
low-inference: a materially weaker model will execute them and cannot
reconstruct your intent. Name files, commands, and acceptance criteria
concretely; never write "handle the edge cases" or "wire it up".
You must answer by calling exactly one tool:
- submit_plan: your plan is posted for human approval and your turn ends.
- ask_human: ask one question when a decision is genuinely the operator's.
You cannot approve your own plan or start work; a human gate follows every
plan you submit."""
PLANNER_TOOLS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "submit_plan",
"description": "Post a plan for human approval. Ends your turn.",
"parameters": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Which plan this is, e.g. 'arch' or 'milestone-1'.",
},
"content": {
"type": "string",
"description": "The plan itself, explicit enough to execute verbatim.",
},
},
"required": ["type", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "ask_human",
"description": "Ask the operator one question, then wait.",
"parameters": {
"type": "object",
"properties": {
"question": {"type": "string"},
},
"required": ["question"],
},
},
},
]
_NUDGE = (
"You did not call a tool. Call submit_plan or ask_human now; "
"plain prose is not accepted."
)
class LLMPlanningAgent:
def __init__(
self,
client: LLMClient,
roles: RoleMap,
*,
system_prompt: str = SYSTEM_PROMPT,
cancel: Cancel | None = None,
) -> None:
self._client = client
self._roles = roles
self._system_prompt = system_prompt
# Checked before each turn only. A turn is one model call, and a turn
# that completes ends at a gate or a question, which is a halt anyway.
self._cancel = cancel or Cancel()
def plan(
self,
transcript: Transcript,
gate: str,
project: str,
feedback: str | None = None,
answer: str | None = None,
) -> SubmitPlan | AskHuman:
"""Operator feedback and answers are already durable in the canonical
log (the orchestrator appends every inbound message), so they are not
re-injected here; the log is the single source of context. The
transcript is the work item's, written as the planner (L6, L7)."""
model = self._roles.resolve("planner")
instruction = (
f"{self._system_prompt}\n\n"
f"Current task: draft the {gate} plan for project {project!r}."
)
self._check_stop()
action, first_error = self._try_turn(transcript, model, instruction)
if action is not None:
return action
self._check_stop()
action, second_error = self._try_turn(
transcript, model, f"{instruction}\n\n{_NUDGE}"
)
if action is None:
detail = second_error or first_error
raise PlanningError(
f"planner ({model}) produced no submit_plan or ask_human call "
f"after a retry{f': {detail}' if detail else ''}"
)
return action
def _check_stop(self) -> None:
if self._cancel.requested:
raise PlanningStopped("stopped by the operator")
def _try_turn(
self, transcript: Transcript, model: str, system: str
) -> tuple[SubmitPlan | AskHuman | None, str | None]:
"""A malformed or failed call is a bad turn, not a crash: the caller
retries once and then gates to the human (L9 — judgment about model
output is observable, never the model's own claim)."""
try:
return self._turn(transcript, model, system), None
except BudgetExhausted:
# Retrying cannot help: the budget will not reappear within a
# turn. Surface it now so the operator is told rather than the
# loop spending a second call to fail identically.
raise
except LLMError as exc:
return None, str(exc)
def _turn(
self, transcript: Transcript, model: str, system: str
) -> SubmitPlan | AskHuman | None:
events = transcript.view()
produced = self._client.complete(
events, tools=PLANNER_TOOLS, model=model, system=system
)
action: SubmitPlan | AskHuman | None = None
for event in produced:
# Append before interpreting: the transcript must be truthful even
# if the call below rejects the arguments.
transcript.append(event)
if isinstance(event, AssistantText) or action is not None:
continue
if isinstance(event, ToolCall):
action = self._interpret(event)
if action is not None:
# The gate is this call's outcome. Recording it keeps the
# transcript renderable: providers reject a tool call with
# no result, and the next planning turn replays this log.
transcript.append(
ToolResult(
call_id=event.call_id,
result=(
"plan posted for approval; turn ended"
if isinstance(action, SubmitPlan)
else "question posted; awaiting the operator"
),
success=True,
)
)
return action
def _interpret(self, call: ToolCall) -> SubmitPlan | AskHuman | None:
if call.tool == "submit_plan":
content = call.arguments.get("content")
if isinstance(content, str) and content.strip():
return SubmitPlan(content.strip())
elif call.tool == "ask_human":
question = call.arguments.get("question")
if isinstance(question, str) and question.strip():
return AskHuman(question.strip())
return None
-118
View File
@@ -1,118 +0,0 @@
"""Gitea comment intake (architecture D5).
Polling, not webhooks: the agent VM stays outbound-only (D7), so nothing
needs to reach in. Intake is structured JSON straight from the API and never
routed through a chat bridge.
Outage posture, per L5: back off exponentially, cap at a few minutes, retry
indefinitely, and log. Polling is pure intake — an outage delays a reaction
but must never fail a task or force a state transition.
"""
import threading
from collections.abc import Callable, Iterable
from nisse_dev.gitea import Comment, GiteaClient, GiteaError
from nisse_dev.oplog import OpLog
DEFAULT_INTERVAL_S = 20.0
INITIAL_BACKOFF_S = 5.0
MAX_BACKOFF_S = 300.0
class CommentPoller:
def __init__(
self,
client: GiteaClient,
repo: str,
on_comment: Callable[[Comment], None],
*,
oplog: OpLog | None = None,
ignore_authors: Iterable[str] = (),
interval_s: float = DEFAULT_INTERVAL_S,
initial_backoff_s: float = INITIAL_BACKOFF_S,
max_backoff_s: float = MAX_BACKOFF_S,
) -> None:
self._client = client
self._repo = repo
self._on_comment = on_comment
self._oplog = oplog
self._ignore = set(ignore_authors)
self._interval_s = interval_s
self._initial_backoff_s = initial_backoff_s
self._max_backoff_s = max_backoff_s
self._seen: set[int] = set()
# Comments at or below this ID were handled before this poller
# existed (a restart, or a watch attached again); Gitea's comment IDs
# only grow, so one number says it all.
self._after = 0
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def prime(self, index: int) -> int:
"""Mark existing comments as seen without delivering them, and
return the highest ID marked (0 if none).
A poller that starts by replaying the whole thread would re-act to
feedback already addressed — including its own earlier replies.
"""
highest = 0
try:
for comment in self._client.list_comments(self._repo, index):
self._seen.add(comment.id)
highest = max(highest, comment.id)
except GiteaError:
# Nothing seen means the first successful poll delivers the
# backlog once. Better than refusing to start.
pass
return max(highest, self._after)
def resume_after(self, comment_id: int) -> None:
"""Deliver only comments newer than comment_id: the ones posted
while nothing was watching are caught up, the handled ones are not
replayed. Needs no request, so an outage cannot cause a replay."""
self._after = comment_id
def poll_once(self, index: int) -> list[Comment]:
"""One pass; returns what was newly delivered. Separated from the
loop so behaviour is testable without threads or sleeps."""
delivered: list[Comment] = []
for comment in self._client.list_comments(self._repo, index):
if comment.id in self._seen or comment.id <= self._after:
continue
self._seen.add(comment.id)
if comment.author in self._ignore:
continue # the agent's own replies are not new input
delivered.append(comment)
self._on_comment(comment)
return delivered
def start(self, index: int) -> None:
self._stop.clear()
self._thread = threading.Thread(target=self._loop, args=(index,), daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=self._interval_s + 5)
self._thread = None
def _loop(self, index: int) -> None:
backoff = self._initial_backoff_s
while not self._stop.is_set():
try:
self.poll_once(index)
except GiteaError as exc:
if self._oplog is not None:
self._oplog.emit(
"gitea.poll_failed",
cause="gitea unreachable",
error=str(exc)[:300],
retry_in_s=backoff,
)
self._stop.wait(backoff)
backoff = min(backoff * 2, self._max_backoff_s)
continue
backoff = self._initial_backoff_s
self._stop.wait(self._interval_s)
-205
View File
@@ -1,205 +0,0 @@
"""Publishing a finished milestone as a Gitea PR (architecture D4, L8).
The ordering here is the whole point. Intent is recorded *before* the first
externally visible act, so a crash anywhere in the sequence leaves a durable
record of what was in flight. Boot reconciliation then asks Gitea whether the
PR exists rather than repeating the push-and-open — the spec's own example of
an idempotent transition reconciled against external truth.
"""
from collections.abc import Sequence
from dataclasses import dataclass
from nisse_dev.git import GitRepo, PushCredential
from nisse_dev.gitea import (
GiteaClient,
GiteaError,
PullRequest,
pull_request_reconciler,
)
from nisse_dev.reconcile import Reconciler
from nisse_dev.state import StateStore
OPEN_PR_INTENT = "open_pr"
@dataclass(frozen=True)
class PublishResult:
pull_request: PullRequest | None
note: str
class MilestonePublisher:
def __init__(
self,
store: StateStore,
client: GiteaClient,
repo: str,
git_repo: GitRepo,
*,
remote_url: str,
push_credential: PushCredential | None = None,
base_branch: str = "main",
) -> None:
self._store = store
self._client = client
self._repo = repo
self._git = git_repo
self._base_branch = base_branch
# A URL with no credential in it; the push credential is handed to
# each push on the build VM and nothing else (D4, L12).
self._remote = remote_url
self._credential = push_credential
def reconcilers(self) -> dict[str, Reconciler]:
return {OPEN_PR_INTENT: pull_request_reconciler(self._client, self._repo)}
def branch_for(self, task_id: str, milestone: int) -> str:
# Namespaced by task so re-running a project against the same repo
# cannot collide with an earlier run's branch. Flat, with no "/":
# git stores branch names as paths, so a branch named after the task
# (which a model did make, live) would block "<task>/milestone-1".
return f"{task_id}-milestone-{milestone}"
def publish(
self,
*,
task_id: str,
milestone: int,
project: str,
plan: str,
service_url: str | None = None,
) -> PublishResult:
branch = self.branch_for(task_id, milestone)
if self._git.current_branch() != branch:
self._git.checkout_new_branch(branch)
sha = self._git.commit_all(f"Milestone {milestone}: {project}")
if sha is None:
if self._git.commits_since(f"origin/{self._base_branch}") == 0:
# Not an error: the executor may have concluded there was
# nothing to change. Report it and let the operator decide.
return PublishResult(None, "no changes to publish")
# The executor committed its work itself; publish that.
sha = self._git.head_sha()
# D4: what was left out (build artifacts) is reported, not dropped.
left_out = self._git.left_out()
intent_id = self._store.begin_intent(
OPEN_PR_INTENT,
{"branch": branch, "milestone": milestone, "sha": sha},
cause=f"publishing milestone {milestone}",
work_item=task_id, # owned by the work item it publishes (L1)
)
self._git.push(self._remote, branch, credential=self._credential)
# Idempotent: if a PR for this branch is already open (a crash after
# opening it, or a response lost on the way back), the push above
# has updated it; adopt it rather than fail on a second.
existing = self.published_pull_request(task_id, milestone)
if existing is not None:
self._store.commit_intent(
intent_id, cause=f"adopted open PR #{existing.number}"
)
return PublishResult(
existing,
f"PR #{existing.number} was already open for this milestone; "
f"pushed to it: {existing.url}" + _left_out_note(left_out),
)
pr = self._client.open_pull_request(
self._repo,
head=branch,
base=self._base_branch,
title=f"Milestone {milestone}: {project}",
body=_pr_body(milestone, plan, service_url, left_out),
)
self._store.commit_intent(intent_id, cause=f"opened PR #{pr.number}")
return PublishResult(
pr, f"opened PR #{pr.number}: {pr.url}" + _left_out_note(left_out)
)
def published_pull_request(
self, task_id: str, milestone: int
) -> PullRequest | None:
"""The open PR for a milestone's branch, if there is one. A closed one
does not count: the operator closed it, so a new one may be opened.
Raises GiteaError when Gitea cannot say."""
pr = self._client.find_pull_request(
self._repo, self.branch_for(task_id, milestone)
)
return pr if pr is not None and pr.state == "open" else None
def comment(self, index: int, body: str) -> None:
"""Reply on the PR. Courtesy only — the pushed commit is the real
answer — so a failure here must not derail the review loop."""
try:
self._client.post_comment(self._repo, index, body)
except GiteaError:
pass
def close_pull_request(self, index: int, note: str) -> None:
"""Close a milestone PR with a note saying why. The branch is kept,
so the work can be reopened in Gitea."""
self._client.post_comment(self._repo, index, note)
self._client.close_pull_request(self._repo, index)
def push_followup(
self, *, task_id: str, milestone: int, message: str
) -> str | None:
"""Push a fix commit onto an open milestone's branch.
No intent is recorded: the PR already exists, so a crash mid-push
costs a repeated push of the same commit, which is harmless. That is
the difference from publish(), where the side effect is a new PR.
"""
branch = self.branch_for(task_id, milestone)
if self._git.current_branch() != branch:
self._git.checkout_new_branch(branch)
sha = self._git.commit_all(message)
if sha is None:
# The executor may have committed the fix itself.
sha = self._git.head_sha()
pushed = self._git.remote_sha(
self._remote, branch, credential=self._credential
)
if sha == pushed:
return None
self._git.push(self._remote, branch, credential=self._credential)
return sha
def _left_out_note(paths: list[str]) -> str:
if not paths:
return ""
shown = ", ".join(paths[:5]) + (
f" and {len(paths) - 5} more" if len(paths) > 5 else ""
)
return f" (left out {len(paths)} build artifact(s): {shown})"
def _pr_body(
milestone: int,
plan: str,
service_url: str | None,
left_out: Sequence[str] = (),
) -> str:
parts = [
f"Milestone {milestone}, implemented by nisse-dev against the approved plan.",
"",
"## Approved plan",
"",
plan,
]
if service_url:
# D4: a runnable test service's address belongs in the PR description
# so a reviewer can exercise it. Nothing reports one yet; the browser
# tool (D7) is what will.
parts += ["", "## Running test service", "", service_url]
if left_out:
parts += [
"",
"## Left out",
"",
"Build artifacts in the workspace, never part of the work:",
"",
*(f"- `{path}`" for path in left_out),
]
return "\n".join(parts)
+2 -70
View File
@@ -12,7 +12,6 @@ resolved against external truth on boot).
import json
import sqlite3
import uuid
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
@@ -42,10 +41,9 @@ Migration = Callable[[sqlite3.Connection], None]
# Maps version N to the migration that takes the schema from N to N+1.
MIGRATIONS: Mapping[int, Migration] = {}
IDLE = "IDLE"
# Who produced an event (L7): the operator, the orchestrator's own code, or a
# model role (L9). planner and executor become driver in the new loop (M2.3).
# model role (L9). planner and executor are the old loop's (until M2.8), kept
# so its records still read.
ACTORS = frozenset(
{
"operator",
@@ -64,17 +62,6 @@ class StateSchemaError(RuntimeError):
pass
@dataclass(frozen=True)
class AgentState:
"""The state machine's view of the active work item (control.py, until
Development Plan M2.6 retires it)."""
state: str
project: str | None
data: dict[str, Any]
updated_at: str
@dataclass(frozen=True)
class WorkItem:
id: str
@@ -406,61 +393,6 @@ class StateStore:
)
return len(rows)
# --- the state machine's view (control.py, until M2.6) -----------------
def get_state(self) -> AgentState | None:
"""The active work item as the state machine sees it: its position
is the state, IDLE when there is none. None only for a store the
agent has never started on."""
agent = self._conn.execute("SELECT * FROM agent WHERE id = 1").fetchone()
if agent is None:
return None
item = self.active_item()
if item is None:
return AgentState(IDLE, None, {}, agent["updated_at"])
return AgentState(item.position, item.scope, item.data, item.updated_at)
def transition(
self,
state: str,
*,
cause: str,
project: str | None = None,
data: dict[str, Any] | None = None,
) -> None:
"""The state machine's transition, over work items: IDLE closes and
deactivates the active item (its outcome should already be recorded
with close_work_item); any other state updates the active item,
opening one if there is none."""
self._ensure_agent_row()
active = self.active_item()
if state == IDLE:
if active is not None:
if active.status == "open":
self.close_work_item(active.id, outcome="closed", cause=cause)
self.set_active(None, cause=cause)
self.oplog.emit(
"state.transition",
cause=cause,
**{
"from": active.position if active else None,
"to": IDLE,
"project": project,
},
)
return
if active is None:
item_id = str((data or {}).get("task_id") or f"task-{uuid.uuid4().hex[:8]}")
active = self.open_work_item(
item_id,
title=project or item_id,
scope=project,
position=IDLE,
cause=cause,
)
self.set_active(item_id, cause=cause)
self.update_work_item(active.id, position=state, data=data or {}, cause=cause)
# --- intents (L8) ------------------------------------------------------
def begin_intent(
+1 -1
View File
@@ -31,7 +31,7 @@ MUTATING_TOOLS = frozenset({"write_file", "run_command"})
DEFAULT_TIMEOUT_S = 120
MAX_OUTPUT_CHARS = 4000
EXECUTOR_TOOLS: list[dict[str, Any]] = [
EXECUTION_TOOLS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
+3 -5
View File
@@ -38,10 +38,8 @@ NARRATION_RESULT_CHARS = 300
# The roles whose work the viewer is shown, and how: "report" keeps only the
# handback, "narrate" keeps a line per step. Anything not listed is not shown.
_SHOWN: dict[str, dict[str, str]] = {
"planner": {},
"driver": {"helper": "report"},
"executor": {"helper": "report"},
"helper": {"executor": "narrate", "driver": "narrate"},
"helper": {"driver": "narrate"},
}
@@ -101,8 +99,8 @@ def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]:
for stored in events:
event, actor = stored.event, stored.actor
if isinstance(event, PlanSubmitted):
# A record for the executor's scope and the dump, not a turn:
# the planner's own submit_plan call already carries the plan.
# A record for a helper's scope and the dump, not a turn: the
# driver's own call already carries what was agreed.
if viewer == "helper":
out.append(_said(stored, "approved plan", event.plan))
continue