Retire the old build spec, and point every reference at the architecture (M0.1) #85

Merged
dongar merged 1 commits from retire-old-spec into main 2026-10-01 00:52:53 +00:00
73 changed files with 259 additions and 1786 deletions
Showing only changes of commit 1ad0b2147d - Show all commits

No files matched your search

+2 -1
View File
@@ -4,7 +4,8 @@ Monorepo for two self-hosted AI agents (`nisse-dev`, `nisse-ops`) over a shared
library (`nisse-core`), one package per directory.
**Read the architecture before making design decisions.** The system-level
specification is `docs/Architecture-Build-Spec.md`. Requirements that look like
specification is `docs/Architecture.md`, and how it gets built is
`docs/Development-Plan.md`. Requirements that look like
free choices — gating structure, state durability, LLM access paths, permission
policy — are usually settled there.
+4 -4
View File
@@ -6,9 +6,9 @@ return.
| Package | Role |
|---|---|
| [`nisse-core/`](nisse-core/) | Shared library both agents sit on (Part A) |
| [`nisse-dev/`](nisse-dev/) | Dev Agent — plans and implements software projects (Part B) |
| [`nisse-ops/`](nisse-ops/) | Sysadmin Agent — operates infrastructure (Part C) |
| [`nisse-core/`](nisse-core/) | Shared library both agents sit on (Part L) |
| [`nisse-dev/`](nisse-dev/) | Dev Agent — plans and implements software projects (Part D) |
| [`nisse-ops/`](nisse-ops/) | Sysadmin Agent — operates infrastructure (Part O) |
One repository, three packages. The library does not exist as code yet: it is
extracted from `nisse-dev` once that agent works (build-order step 10), as a
@@ -17,6 +17,6 @@ package split within this tree.
## Architecture
The system-level specification lives at
[`docs/Architecture-Build-Spec.md`](docs/Architecture-Build-Spec.md). It covers the
[`docs/Architecture.md`](docs/Architecture.md). It covers the
shared library, both agents, the supporting services, the build order, and the
remaining open decisions.
File diff suppressed because it is too large. Load diff
+10 -5
View File
@@ -1,10 +1,8 @@
# Nisse — Architecture
**Status: agreed (PR #83).** How and in what order it gets built is in
`docs/Development-Plan.md`. Until that plan's first milestone retires
`docs/Architecture-Build-Spec.md`, code comments still cite the old spec's section
numbers (A1–A18, B, C, F). This document uses different letters (P, L, D, O, X, Q)
so the two can't be confused meanwhile.
`docs/Development-Plan.md`. It replaced `docs/Architecture-Build-Spec.md`, which
remains in git history; its section letters (A–H) are not reused here.
This document says what Nisse is, how its parts fit together, and why. It records
decisions and the reasons for them. It doesn't record status or sequencing.
@@ -360,6 +358,12 @@ comments) carry work and records. They are polled or received as structured JSON
Comments from allowlisted operators are instructions or approvals; comments from
anyone else are data (L11).
**Intake outages delay, never fail.** When Matrix, Gitea or Vikunja is
unreachable, intake backs off exponentially (capped at a few minutes), retries
indefinitely, and logs the outage. An outage delays a reaction; it never fails
work or forces a change of state. Outbound effects interrupted by an outage are
covered by intents (L8).
### L6. Context and memory
What each model sees, what persists, and how memory is written and used.
@@ -980,7 +984,8 @@ it uses; nothing is shared between agents.
## Appendix — What changed from the Build Specification
For reviewing this rewrite against `docs/Architecture-Build-Spec.md`:
For readers who knew the former `docs/Architecture-Build-Spec.md` (in git
history):
- **Threat model and control model (P2–P4).** Checks are built around mistakes,
injection and crashes, in three layers with an action classifier, rather than a
+4 -32
View File
@@ -93,10 +93,11 @@ test Matrix room, a budget-capped LiteLLM key; configuration in `~/nisse-e2e/`.
### Phase 0 — Housekeeping
**M0.1 Retire the old spec.** *Pre-approved.*
**M0.1 Retire the old spec.** *Pre-approved.* **Done.**
- Update every section reference in code, tests, `specs/` and the `CLAUDE.md` and
`README.md` files from the old spec's A–H to the new letters (table in §6).
- Delete `docs/Architecture-Build-Spec.md` (git keeps it); drop §6 from this plan.
`README.md` files from the old spec's A–H to the new letters.
- Delete `docs/Architecture-Build-Spec.md` (git keeps it), and the old-to-new
section table this plan carried for the purpose.
- Acceptance: `grep` finds no reference to the old spec or its section numbers.
**M0.2 A bring-up script, and an unprivileged agent user on this box.** *Pre-approved.*
@@ -302,32 +303,3 @@ All 19 open issues, as of 2026-09-30:
| #79 ending projects and milestones | **Superseded** | M2.5 |
| #80 build artifacts in PRs | Milestone | M1.4 |
| #81 no-change milestones | **Superseded** | M2.5 |
---
## 6. Old section numbers → new (for M0.1, then removed)
| Old (`Architecture-Build-Spec.md`) | New (`Architecture.md`) |
|---|---|
| 0 Naming | Naming |
| 0.1 What we're building, guiding principles | P1, P3–P8 |
| 0.2 Work model | L1 |
| A1 Transport | L5 |
| A2 State store, A3 Boot reconciliation | L8 |
| A4 Control loop and gating | L2 (loop, `stop`, `abandon`), L3 (approvals) |
| A5 LLM access | L9 |
| A6 Browser tool | D7, O4 |
| A7 Secrets, A8 SSO identity, A9 Logging | L12 |
| A10 Canonical transcript | L7 (schema), L6 (context) |
| A11 Roles, A12 Escalation, A13 Delegation, A14 `ask_expert`, A15 Local accommodations, A16 Eval capture | L9 |
| A17 Knowledge store | L10 (store), L6 (memory) |
| A18 Prompt injection | L11 |
| B1 Workflow | L2, D2 |
| B2 Requirements | D2, D4, D5 |
| B3 Deployment | D7 |
| C1–C5 Sysadmin Agent | O1–O6 (C2 → O2, C3 → O4, C4 → O5, C5 → O6) |
| D Knowledge (Outline) | L10 |
| E Supporting services | X5, and §2 here |
| F1–F4 Deployment model | X1–X4 |
| G Build order | This plan |
| H Open decisions | Q |
+7 -7
View File
@@ -67,7 +67,7 @@ relying on them.)
Two consequences:
- **Context length is a memory decision, not a free setting.** The spec's
estimate (Part H) was about 5 GB left for KV cache beside a 4-bit 32B model.
estimate (the original build spec) was about 5 GB left for KV cache beside a 4-bit 32B model.
At 16-bit, that's only about 20k tokens of context for Qwen3-32B.
- **KV-cache quantization** stores keys and values in 8 bits (or 4). At 8
bits it halves the cache and is close to lossless. At 4 bits it is
@@ -170,7 +170,7 @@ How many model calls one milestone may make before the executor stops with
### `tuning.executor.failure_limit` — default 2
How many consecutive failed `run_command` calls trigger escalation: the
sub-task is handed to a frontier model as a delegation (spec A12). A
sub-task is handed to a frontier model as a delegation (architecture L9). A
successful command resets the count. Failed file reads don't count, since
reading is looking around, not doing the work.
@@ -192,12 +192,12 @@ reading is looking around, not doing the work.
How many `ask_expert` calls one milestone may make. `ask_expert` is a single
stateless question to a stronger model, with no access to the repository
(spec A14), for things like "is this API deprecated?".
(architecture L9), for things like "is this API deprecated?".
- **Too low:** the executor can't get unstuck on a knowledge question it
can't answer from the code.
- **Too high:** a confused executor asks variations of the same question
instead of doing the work or failing. That's the circling A12 warns about.
instead of doing the work or failing. That's the circling L9 warns about.
- **Deciding:** count consults per milestone, and check whether the answers
changed what the executor did next. If it regularly exhausts the budget
without progress, the budget is fine and the model or the plan is the
@@ -206,7 +206,7 @@ stateless question to a stronger model, with no access to the repository
### `tuning.delegation` — hard caps on one delegation
A delegation is a frontier model working a sub-task with full tool access
(spec A13). When a cap is hit, the delegation **fails**, and its branch and
(architecture L9). When a cap is hit, the delegation **fails**, and its branch and
work are discarded, because resuming on a half-edited tree isn't safe.
| Knob | Default | Caps |
@@ -216,7 +216,7 @@ work are discarded, because resuming on a half-edited tree isn't safe.
| `wall_clock_s` | 900 | elapsed time |
- **`max_tokens` is a scope limit, not the money limit.** Spending limits live
on the LiteLLM key (spec A5). This cap stops one delegation from growing
on the LiteLLM key (architecture L9). This cap stops one delegation from growing
without bound. An unbounded one could still exhaust a daily budget and take
every frontier model away until it resets.
- **Too low:** delegations fail just short of finishing and their work is
@@ -225,7 +225,7 @@ work are discarded, because resuming on a half-edited tree isn't safe.
- **Too high:** a stuck delegation burns tokens and time before failing.
- **Deciding:** every delegation records the tokens it used and how long it
ran (the `delegation_completed` event, captured for evaluation per spec
A16). Set each cap above what successful delegations actually use, with
L9). Set each cap above what successful delegations actually use, with
room to spare.
### `tuning.tools.command_timeout_s` — default 120
+1 -1
View File
@@ -1,6 +1,6 @@
# nisse-core
The shared library both Nisse agents sit on (Part A of the specification).
The shared library both Nisse agents sit on (Part L of the architecture).
It does not exist as code yet: it is extracted from `nisse-dev` once that agent
works, at step 10 of the build order — a package split within this repository.
+7 -7
View File
@@ -12,7 +12,7 @@ state_db: ~/nisse-demo-workspace/state.db
workspace: ~/nisse-demo-workspace/project # the clone
project: demo
llm: # A5: LiteLLM is the only LLM destination
llm: # L9: LiteLLM is the only LLM destination
base_url: http://litellm.example:4000
token: ${NISSE_LLM_TOKEN}
# Ceiling on each response. The model's own max_output_tokens in LiteLLM
@@ -22,20 +22,20 @@ llm: # A5: LiteLLM is the only LLM destination
# hung connection; slow local models can take minutes over a long answer.
request_timeout_s: 600
roles: # A11: role -> LiteLLM model alias
roles: # L9: role -> LiteLLM model alias
executor: qwen3.8-27b # the local tier: best executor in the benchmark (PR #55)
planner: claude-opus-5-5 # high-end judgement: plans a weaker executor follows
delegate: claude-opus-5-5 # takes over what the executor got stuck on
consultant: claude-haiku-4-5 # one question at a time, no tools: cheap is enough
matrix: # A1
matrix: # L5
homeserver: https://matrix.example
user_id: "@nisse-dev:example"
token: ${NISSE_MATRIX_TOKEN}
room_id: "!roomid:example"
operators: ["@you:example"]
gitea: # B2
gitea: # D4, D5
url: https://gitea.example
token: ${NISSE_GITEA_TOKEN}
repo: owner/demo-repo
@@ -48,10 +48,10 @@ gitea: # B2
tuning:
executor:
max_turns: 40 # model calls per milestone before it stops as capped
failure_limit: 2 # consecutive failed commands before escalating (A12)
failure_limit: 2 # consecutive failed commands before escalating (L9)
expert:
consult_budget: 3 # ask_expert calls per milestone (A14)
delegation: # hard caps on one delegation (A13)
consult_budget: 3 # ask_expert calls per milestone (L9)
delegation: # hard caps on one delegation (L9)
max_turns: 30
max_tokens: 200000
wall_clock_s: 900
+3 -3
View File
@@ -1,10 +1,10 @@
# nisse-dev
The Dev Agent (Part B of the Nisse system). A thin application over the shared
The Dev Agent (Part D of the Nisse architecture). A thin application over the shared
`nisse-core` package.
**Read the architecture before making design decisions here:**
`docs/Architecture-Build-Spec.md` at the repo root. Part B specifies this agent;
Part A specifies the library contracts it depends on. Requirements that look like
`docs/Architecture.md` at the repo root. Part D specifies this agent;
Part L specifies the library contracts it depends on. Requirements that look like
free choices in this package — gating structure, state durability, LLM access
paths — are usually settled there.
+2 -2
View File
@@ -4,5 +4,5 @@ The Dev Agent: plans and implements software projects, produces Gitea PRs for hu
review, and reacts to review comments and commits. A thin application over the
shared `nisse-core` library.
**Part B** of the specification at [`../docs/Architecture-Build-Spec.md`](../docs/Architecture-Build-Spec.md)
covers this agent; **Part A** covers the library it sits on.
**Part D** of the architecture at [`../docs/Architecture.md`](../docs/Architecture.md)
covers this agent; **Part L** covers the library it sits on.
+6 -6
View File
@@ -33,7 +33,7 @@ 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 (A2/A3). A milestone can run far longer than a service
# 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
@@ -52,14 +52,14 @@ def discover_models(config: AgentConfig, oplog: OpLog) -> ModelCatalog:
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 (A10): a guess that is too large reaches a server that may
# 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 A15 expects weak local
# observed driving tool loops anyway, and L9 expects weak local
# models to be accommodated rather than excluded.
oplog.emit(
"model_tools_unsupported",
@@ -83,7 +83,7 @@ class Agent:
_stopped: threading.Event
def start(self) -> None:
"""Boot in the order A3 requires: reconcile against external truth
"""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()
@@ -108,7 +108,7 @@ class Agent:
try:
self.worker.call(self.store.close, timeout=SHUTDOWN_WAIT_S)
except TimeoutError:
pass # still working; exiting mid-work is what A2/A3 are for
pass # still working; exiting mid-work is what L8 are for
self.worker.stop(timeout=SHUTDOWN_WAIT_S)
self._stopped.set()
@@ -151,7 +151,7 @@ def make_llm_clients(
)
)
# Delegations prefer the vendor-native route; fidelity is cheap
# elsewhere (A5). Which route a given model gets is decided by
# elsewhere (L9). Which route a given model gets is decided by
# select_client, not by where the caller happens to sit.
passthrough = budgeted(
AnthropicPassthroughClient(
+2 -2
View File
@@ -1,4 +1,4 @@
"""Plan externalisation and Gitea-backed approvals (spec A4, Part H).
"""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
@@ -7,7 +7,7 @@ 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
(A4). The first valid approval on any channel wins; the orchestrator echoes
(L3). The first valid approval on any channel wins; the orchestrator echoes
the outcome to the other channel so the records converge.
"""
+1 -1
View File
@@ -1,4 +1,4 @@
"""The operator's kill switch, as the working loops see it (spec A4).
"""The operator's kill switch, as the working loops see it (architecture L2).
`stop` has to reach work that is already running, from a thread that is not
running it. So it is a flag, set by whoever hears the command and checked by
+3 -3
View File
@@ -1,4 +1,4 @@
"""Canonical transcript schema (spec A10).
"""Canonical transcript schema (architecture L7).
The durable record is this event log, never a provider-shaped ``messages``
array; provider request bodies are rendered from it at call time. Canonical
@@ -6,7 +6,7 @@ tool-call IDs are minted locally — provider IDs are mapped at the boundary and
never used as a join key.
Transcript appends are the conversation record itself, not state-machine
mutations, so they are deliberately outside the A9 state-change taxonomy.
mutations, so they are deliberately outside the L12 state-change taxonomy.
"""
import uuid
@@ -59,7 +59,7 @@ class DelegationStarted:
model: str # the alias asked for, e.g. "remote-architecture"
caps: dict[str, Any] # turns / tokens / wall-clock limits
# What the alias resolved to at task start. An alias is free to change
# models between tasks, so eval history (A16) keyed on the alias alone
# models between tasks, so eval history (L9) keyed on the alias alone
# would average results across whatever it happened to point at.
backing_model: str = ""
+4 -4
View File
@@ -1,4 +1,4 @@
"""Runtime model discovery (spec A5, A11, A13).
"""Runtime model discovery (architecture L9).
LiteLLM is the VM's single LLM destination, which makes it the authority on
what any given model *is*. Asking it at startup keeps vendor knowledge in one
@@ -186,7 +186,7 @@ class ModelCatalog:
)
def pinned(self, roles: Mapping[str, str]) -> dict[str, dict[str, Any]]:
"""The resolved mapping to record at task start (A11, A16).
"""The resolved mapping to record at task start (L9).
Recording only the alias would let an alias that silently changes
models muddle the eval history behind it: "remote-architecture: 80%
@@ -217,9 +217,9 @@ def select_client(
) -> LLMClient:
"""Choose the route from what the model *is*, not from what it is called.
A13 spells out the rule: use the vendor-native adapter where one exists,
L9 spells out the rule: use the vendor-native adapter where one exists,
and otherwise provisionally accept LiteLLM's normalization for that
vendor -- validating it against the A16 eval set before trusting it with
vendor -- validating it against the L9 eval set before trusting it with
delegations. There is deliberately no name-based fallback here: a second
heuristic could disagree with the proxy, and an unknown model raises
rather than quietly taking the normalized route.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Agent configuration (spec A5: nothing hardcoded).
"""Agent configuration (architecture L9: nothing hardcoded).
Configuration is a YAML file: roles, connections, and the tuning knobs (see
docs/Tuning-Guide.md). It holds no secrets. A credential is written as a
+1 -1
View File
@@ -1,4 +1,4 @@
"""The context budget (spec A10: token budgeting is a render-time concern).
"""The context budget (architecture L6: token budgeting is a render-time concern).
Every model call re-sends a transcript that only grows, and a model server
does not always refuse one that no longer fits: Ollama silently drops the
+29 -29
View File
@@ -1,4 +1,4 @@
"""Control loop and state machine (spec A4, B1 subset).
"""Control loop and state machine (architecture L2, L3; retired by M2.6).
Gating is structural, not advisory: the agent's only actions are ``SubmitPlan``
(posts the plan for approval and ends the agent's turn) and ``AskHuman``
@@ -8,7 +8,7 @@ arrives over transport. Every position change is checkpointed through the state
store before the loop continues, so a restart lands mid-gate, not mid-air.
All of it runs on one worker (nisse_dev.worker), never on the thread that
delivered a message. The one exception is `stop` (A4), which has to reach
delivered a message. The one exception is `stop` (L2), which has to reach
work that is already running: the transport's thread sets the cancel flag
directly, and the worker records the stop once the work has returned.
"""
@@ -57,12 +57,12 @@ TRANSITIONS = {
(AWAIT_MILESTONE_SIGNOFF, MILESTONE_PLANNING),
(AWAIT_MILESTONE_SIGNOFF, EXECUTING),
(EXECUTING, PR_OPEN),
# PR acceptance is the milestone's final gate (B1): a merged PR is the
# PR acceptance is the milestone's final gate (D5): a merged PR is the
# durable record, so completion returns to planning or to idle rather
# than passing through a separate FINISHED state.
(PR_OPEN, MILESTONE_PLANNING),
(PR_OPEN, IDLE),
# abandon (A4, B2): the operator may end the task from any waiting state.
# abandon (L2): the operator may end the task from any waiting state.
# (AWAIT_INPUT may go anywhere already.)
(AWAIT_ARCH_SIGNOFF, IDLE),
(AWAIT_MILESTONE_SIGNOFF, IDLE),
@@ -91,7 +91,7 @@ class PlanningError(RuntimeError):
class PlanningStopped(RuntimeError):
"""The operator said `stop` (A4) before a planning turn could run."""
"""The operator said `stop` (L2) before a planning turn could run."""
@dataclass(frozen=True)
@@ -193,7 +193,7 @@ def _project_name(text: str) -> str:
def _fresh_data() -> dict[str, Any]:
return {
# Latest revision per gate; revision numbers themselves run across
# the whole work item (last_rev), so rev-N names one plan (A4).
# the whole work item (last_rev), so rev-N names one plan (L3).
"revs": {},
"last_rev": 0,
# The revision last put up for sign-off: a reply that names none
@@ -205,13 +205,13 @@ def _fresh_data() -> dict[str, Any]:
"pending": None,
"milestone": 1,
"task_id": None,
"roles": None, # A11 mapping, pinned at task start
"roles": None, # L9 mapping, pinned at task start
# What each pinned alias resolved to, discovered from LiteLLM. An
# alias is free to point somewhere else tomorrow, so the transcript
# has to say what actually answered (A11, A16).
# has to say what actually answered (L9).
"models": None,
"pr": None, # the open PR for the current milestone, once published
# Pointer to the Gitea plan record (Part H): the state store holds
# Pointer to the Gitea plan record (D2): the state store holds
# only this, so gate state can be re-derived from Gitea after a
# state-volume loss.
"board": None,
@@ -244,7 +244,7 @@ class Orchestrator:
self._executor = executor
self._publisher = publisher
self._operators = set(operators)
# Per-channel allowlists (A4): a Matrix MXID and a Gitea login
# Per-channel allowlists (L3): a Matrix MXID and a Gitea login
# are different names for the same person, and neither implies
# the other.
self._gitea_operators = set(gitea_operators)
@@ -276,14 +276,14 @@ class Orchestrator:
transport.on_message(self._handle)
def start(self, reconcilers: Mapping[str, Reconciler] | None = None) -> None:
# A3 step 5 runs before the generic sweep: an interrupted delegation is
# L8 boot step 5 runs before the generic sweep: an interrupted delegation is
# resumed or discarded by inspecting the branch and the log, and only a
# genuinely unclean cut is left for the sweep below to gate on.
if self._delegate is not None:
with self._long_work():
self._delegate.recover(self._transport.send_message)
# The publisher owns the external-truth query for its own intents, so
# a configured publisher always contributes its reconciler (A3).
# a configured publisher always contributes its reconciler (L8).
registered: dict[str, Reconciler] = dict(reconcilers or {})
if self._publisher is not None:
registered = {**self._publisher.reconcilers(), **registered}
@@ -317,7 +317,7 @@ class Orchestrator:
pending={"kind": "boot", "interrupted": state.state},
)
elif state.state == AWAIT_INPUT and self._data.get("pending"):
# Waiting on the operator is a committed state (A3 step 8):
# Waiting on the operator is a committed state (L8 boot step 4):
# nothing was in flight, and the question and what answering it
# resumes are durable. Ask it again rather than replacing it.
pending = self._data["pending"]
@@ -326,7 +326,7 @@ class Orchestrator:
+ pending.get("prompt", "my last question still stands")
)
elif state.state == EXECUTING and (published := self._published_pr()):
# A3 step 4: Gitea shows this milestone's PR open, so it was
# L8 boot step 3: Gitea shows this milestone's PR open, so it was
# published and only PR_OPEN went unrecorded. Adopt the PR rather
# than ask, and never publish it again. Nothing watched it yet,
# so every comment on it is new.
@@ -347,7 +347,7 @@ class Orchestrator:
"review comments"
)
elif state.state == PR_OPEN:
# Waiting on review is a committed state like a gate (A3 step
# Waiting on review is a committed state like a gate (L8 boot step
# 8): resume, and watch the PR again from its cursor.
pr = self._data.get("pr") or {}
self._watch_pr(pr)
@@ -385,7 +385,7 @@ class Orchestrator:
)
else:
# Interrupted mid-turn (planning, executing, unanswered question):
# never blind-resume (A3.6).
# never blind-resume (L8 boot step 5).
self._await_input(
cause=f"boot: restarted in {state.state}",
prompt=f"restarted mid-{state.state}; {_BOOT_CHOICES}",
@@ -400,7 +400,7 @@ class Orchestrator:
text = msg.text.strip()
if command_form(text) == "stop":
# Straight to the flag, not behind the work in the queue: the
# work is what it has to reach (A4).
# work is what it has to reach (L2).
if self._worker.busy:
self._cancel.set()
self._worker.submit(lambda: self._finish_stop(msg))
@@ -478,7 +478,7 @@ class Orchestrator:
def _halt_for_stop(
self, *, what: str, note: str, next_step: str, pending: dict[str, Any]
) -> None:
"""A4: stopped work moves to AWAIT_INPUT, and what happens next is the
"""L2: stopped work moves to AWAIT_INPUT, and what happens next is the
operator's call. The prompt asks why, because the model should hear
the reason before it tries again."""
self._halted_by_stop = True
@@ -494,7 +494,7 @@ class Orchestrator:
def _handle_now(self, msg: InboundMessage, about: int | None = None) -> None:
# Every operator message enters the canonical transcript, so planning
# turns see feedback and answers as conversation rather than as
# out-of-band parameters (A10).
# out-of-band parameters (L7).
self._store.append_event(
UserMessage(
text=(msg.original or msg.text).strip(),
@@ -555,7 +555,7 @@ class Orchestrator:
def _begin_project(self, project: str) -> None:
task_id = f"{project}-{uuid.uuid4().hex[:8]}"
# Pin the role mapping now (A11): a mid-task config change must not
# Pin the role mapping now (L9): a mid-task config change must not
# split one task across two planners.
pinned = self._roles.pinned() if self._roles is not None else None
resolved = (
@@ -666,7 +666,7 @@ class Orchestrator:
def _apply_gate_reply(
self, *, sender: str, text: str, channel: str, about: int | None = None
) -> None:
"""Shared by every approval source (A4). The allowlist is per channel:
"""Shared by every approval source (L3). The allowlist is per channel:
a Matrix MXID and a Gitea login are different names, and holding one
says nothing about holding the other."""
gate_data = self._data["gate"]
@@ -771,7 +771,7 @@ class Orchestrator:
def _echo_outcome(self, deciding_channel: str, text: str) -> None:
"""A gate satisfied on one channel is echoed to the other, so neither
record silently disagrees about what was approved (A4)."""
record silently disagrees about what was approved (L3)."""
board_state = self._data.get("board")
if deciding_channel != "gitea" and self._board is not None and board_state:
self._board.echo(board_state["issue"], text)
@@ -792,7 +792,7 @@ class Orchestrator:
# Which comment put which revision up: a Gitea reply binds to the
# latest plan posted before it, since comment IDs only grow.
board_state.setdefault("posts", []).append([post.comment_id, rev])
# Approvals can arrive on the issue as well as over transport (A4),
# Approvals can arrive on the issue as well as over transport (L3),
# so watch it for as long as the gate stands. A new revision skips
# what came before it: an early "approve" must not approve it.
board_state["last_comment"] = self._watch(
@@ -801,7 +801,7 @@ class Orchestrator:
self._save(f"{gate_name} rev-{rev} posted to Gitea")
def handle_plan_comment(self, comment: Any) -> None:
"""A comment on the project issue may satisfy an open plan gate (A4).
"""A comment on the project issue may satisfy an open plan gate (L3).
Approvals bind to a revision here exactly as over transport, and the
first valid approval on either channel wins. Called from the poller
@@ -837,12 +837,12 @@ class Orchestrator:
)
def handle_review_comment(self, comment: Any) -> None:
"""React to a PR review comment (B2). Called from the poller thread.
"""React to a PR review comment (D5). Called from the poller thread.
Comment authors are checked against the Gitea operator allowlist:
a PR comment is an instruction channel into an agent that writes
code, so it is operator-scoped like any other (A4, and the same
reasoning C4 applies to tickets).
code, so it is operator-scoped like any other (L11, and the same
reasoning O5 applies to tickets).
"""
self._worker.call(
lambda: self._settled(
@@ -991,7 +991,7 @@ class Orchestrator:
)
def _abandon(self, sender: str) -> None:
"""End the task on the operator's word (A4: continue, re-plan, or
"""End the task on the operator's word (L2: continue, re-plan, or
abandon), closing its Gitea records. Only reached from a waiting
state: while work runs the reply is "busy; say 'stop'" instead.
@@ -1196,7 +1196,7 @@ class Orchestrator:
)
def _resume_after_restart(self, pending: dict[str, Any], text: str) -> None:
"""Answer to the question a restart asks (A3 step 7). Never a blind
"""Answer to the question a restart asks (L8 boot step 5). Never a blind
resume: 'continue' re-runs the interrupted planning turn, or returns
an interrupted milestone to its approved plan to be started again."""
choice = command_form(text)
+19 -19
View File
@@ -1,4 +1,4 @@
"""Delegation harness (spec A13).
"""Delegation harness (architecture L9).
Escalation is delegation, not consultation: a frontier model receives a scoped
sub-task, works autonomously with full tool access, and hands back. The local
@@ -17,7 +17,7 @@ resuming on top of a half-edited tree.
**Checkpointing as it happens.** Every tool call, result, and assistant turn
is written to the canonical log when it occurs, never batched at handback.
That is what makes a delegation resumable after a crash (A3).
That is what makes a delegation resumable after a crash (L8).
Scope is bounded by construction: a delegation is handed a sub-task the
approved plan already contains. It is smart, not authorised to widen the work.
@@ -144,7 +144,7 @@ class DelegationResult:
The work on the branch may be perfectly good, so it is preserved and
the intent left pending: the next boot treats it exactly like a crash
mid-delegation (A3) and resumes if the cut was clean. Discarding here
mid-delegation (L8) and resumes if the cut was clean. Discarding here
would throw away work because the budget ran out.
"""
return self.status == "interrupted"
@@ -152,7 +152,7 @@ class DelegationResult:
@property
def usable(self) -> bool:
"""Only a successful handback may be built upon. A cap breach, a
failure or an operator's stop leaves a half-edited tree, which A13
failure or an operator's stop leaves a half-edited tree, which L9
says must gate rather than be resumed."""
return self.status == "handback" and self.handback is not None
@@ -190,7 +190,7 @@ class Delegation:
started_at = self._now()
base_branch = self._git.current_branch()
# Recorded before the branch exists, so a crash anywhere after this
# leaves a durable statement of what was in flight (A2/A3).
# leaves a durable statement of what was in flight (L8).
intent_id = self._store.begin_intent(
DELEGATION_INTENT,
{
@@ -234,12 +234,12 @@ class Delegation:
)
if result.interrupted:
# Leave the branch and the intent alone: this delegation is not
# finished, it was cut off, and A3's resume rule is exactly the
# finished, it was cut off, and L8's resume rule is exactly the
# machinery for picking it back up.
return result
if not result.usable:
# Abort discards the branch: the caller must never resume on top
# of a partially-edited tree (A13).
# of a partially-edited tree (L9).
self._abandon(branch, base_branch)
# Settled either way: an aborted delegation is a *finished* one, and
# leaving the intent pending would make the next boot think work was
@@ -248,12 +248,12 @@ class Delegation:
return result
def resume(self, intent: Intent) -> DelegationResult:
"""Continue a delegation the process died in the middle of (A3 step 5).
"""Continue a delegation the process died in the middle of (L8 boot step 5).
The transcript is already in the canonical log, so the loop re-renders
it and carries on. No new branch and no second ``delegation_started``:
this is the same delegation, which is why its vendor-opaque reasoning
may still be replayed (A10 defines a session as the delegation, not the
may still be replayed (L7 defines a session as the delegation, not the
process lifetime).
"""
payload = intent.payload
@@ -284,7 +284,7 @@ class Delegation:
return result
def recover(self, notify: Callable[[str], None]) -> DelegationResult | None:
"""Sweep pending delegation intents at boot (A3 step 5).
"""Sweep pending delegation intents at boot (L8 boot step 5).
Settles what is already finished, discards what never started, resumes
a clean cut — announcing it with a *notice*, not a question, because a
@@ -363,7 +363,7 @@ class Delegation:
tokens=spent,
)
# Metered from what the provider reported, not estimated. The cap
# is a scope limit (A13), distinct from the money cap A5 keeps on
# is a scope limit (L9), distinct from the money cap L9 keeps on
# the LiteLLM key — but an unbounded delegation can still exhaust
# a daily budget and take external models away until it resets.
usage = getattr(self._client, "last_usage", {}) or {}
@@ -380,7 +380,7 @@ class Delegation:
calls: list[ToolCall] = []
for event in produced:
# Checkpoint as it arrives, never batched at handback (A13):
# Checkpoint as it arrives, never batched at handback (L9):
# this is what makes the delegation resumable after a crash.
self._store.append_event(event)
if isinstance(event, ToolCall):
@@ -475,7 +475,7 @@ class Delegation:
# Commit each turn's work onto the delegation branch, so that
# discarding the branch really does discard the work, and so a
# crash leaves the branch at a known commit (A3).
# crash leaves the branch at a known commit (L8).
self._commit_turn(turn)
return DelegationResult(
@@ -504,7 +504,7 @@ class Delegation:
def _parse_handback(arguments: dict[str, Any]) -> Handback | None:
"""Validated locally rather than trusted: A14's rule for prompt-level
"""Validated locally rather than trusted: L9's rule for prompt-level
structured output applies to the handback too."""
required = ("changed", "now_true", "next")
if any(not str(arguments.get(key, "")).strip() for key in required):
@@ -521,10 +521,10 @@ def _parse_handback(arguments: dict[str, Any]) -> Handback | None:
def delegation_reconciler(store: StateStore, git_repo: GitRepo) -> Reconciler:
"""A3 step 5: decide whether an interrupted delegation may be resumed.
"""L8 boot step 5: decide whether an interrupted delegation may be resumed.
The judgement is an observable check, never model self-assessment — the
same reasoning as A12. Returns:
same reasoning as L9. Returns:
- ``True`` the delegation is settled or cleanly resumable
- ``False`` it never really started, so there is nothing to resume
@@ -534,7 +534,7 @@ def delegation_reconciler(store: StateStore, git_repo: GitRepo) -> Reconciler:
existing and carrying the delegation's commits: per-turn shas are not
recorded in the log today, so a branch rewritten out from under us would
not be detected. Stated rather than glossed, because the check is weaker
than A3's wording implies.
than L8's wording implies.
"""
def check(intent: Intent) -> bool | None:
@@ -549,7 +549,7 @@ def delegation_reconciler(store: StateStore, git_repo: GitRepo) -> Reconciler:
def delegation_verdict(
store: StateStore, git_repo: GitRepo, intent: Intent
) -> Literal["resume", "settled", "absent", "gate"]:
"""The A3 step 5 decision, separated from the Reconciler contract because
"""The L8 boot step 5 decision, separated from the Reconciler contract because
boot must *act* on it — resuming is not the same as judging settled."""
branch = intent.payload.get("branch")
if not branch:
@@ -564,7 +564,7 @@ def delegation_verdict(
last = events[-1].event
if isinstance(last, ToolCall):
# A call with no recorded result: its effect is unknown and cannot be
# verified by inspection, so gate (A3).
# verified by inspection, so gate (L8).
return "gate"
if not isinstance(last, AssistantText | ToolResult):
return "gate"
+1 -1
View File
@@ -1,4 +1,4 @@
"""Minimal .env loader (spec A5: config from env/.env, nothing hardcoded).
"""Minimal .env loader (architecture L12: secrets from env/.env, nothing hardcoded).
Deliberately tiny: KEY=VALUE lines, comments and blanks ignored, existing
environment variables never overridden (real env wins over the file).
+2 -2
View File
@@ -1,4 +1,4 @@
"""Delegation and consult evaluation capture (spec A16).
"""Delegation and consult evaluation capture (architecture L9).
Each ``delegation_started`` / ``delegation_completed`` pair is already a clean
input-output record; this pairs them up and attaches a verdict, so "best model
@@ -40,7 +40,7 @@ def collect(store: StateStore, after: int = 0) -> list[EvalRecord]:
"""Pair each start with the completion that follows it.
A start with no completion is skipped rather than reported half-formed:
it means the process died mid-delegation, which A3 handles, and an
it means the process died mid-delegation, which L8 handles, and an
unfinished record would pollute the eval set.
"""
verdicts = store.outcomes()
+10 -10
View File
@@ -1,10 +1,10 @@
"""Local executor loop (spec A12 escalation triggers, A15 accommodations).
"""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 (A13), so a crash mid-milestone leaves a resumable record.
log as it happens (L9), so a crash mid-milestone leaves a resumable record.
Escalation is decided from outside the model (A12): consecutive command
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.
@@ -115,7 +115,7 @@ class LLMExecutor:
def _escalate(
self, milestone: int, failures: int, last_failure: str
) -> ExecutorOutcome | None:
"""Hand the sub-task to a stronger model, per A12.
"""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.
@@ -124,7 +124,7 @@ class LLMExecutor:
if self._delegate is None:
return ExecutorOutcome("escalate", note)
# A12 says the failures go in the brief: the delegate must not have to
# 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 "
@@ -140,14 +140,14 @@ class LLMExecutor:
return None
if result.status == "stopped":
return ExecutorOutcome("stopped", f"during a delegation: {result.note}")
# A13: a cap breach or failure leaves a half-edited tree, so the local
# 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]]:
# A12: ask_expert exists in addition to the observable escalation
# 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]
@@ -203,7 +203,7 @@ class LLMExecutor:
prose_nudged = False
for turn in range(self._max_turns):
# A4: `stop` takes effect at the next tool boundary. The start of
# 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}")
@@ -267,7 +267,7 @@ class LLMExecutor:
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 (A12).
# counts toward escalation like a failed command (L9).
outcome = ToolOutcome(
f"no such tool: {call.tool}; the tools are "
+ ", ".join(sorted(offered)),
@@ -294,7 +294,7 @@ class LLMExecutor:
0 if outcome.success else consecutive_failures + 1
)
if consecutive_failures >= self._failure_limit:
# A12: this is the trigger delegation exists for.
# 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(
+6 -6
View File
@@ -1,23 +1,23 @@
"""`ask_expert` (spec A14).
"""`ask_expert` (architecture L9).
The one case delegation does not cover: a question needing no repo access —
"which of these two approaches is better", "is this API deprecated". A single
stateless call, a text answer, no session state, no diff application.
Two constraints from A14 shape the implementation:
Two constraints from L9 shape the implementation:
**Structured output is contracted at the prompt level, not the API level.**
Vendors differ on JSON mode and schema enforcement, so the answer is parsed and
validated against a local schema, with one retry. This runs over LiteLLM's
*normalized* route, which A13 reserves for calls where fidelity is cheap.
*normalized* route, which L9 reserves for calls where fidelity is cheap.
**A per-task budget**, so a confused executor cannot spend twenty consults
circling. The count comes from the canonical log rather than a counter in
memory: it survives a restart, and it is observable from outside the model,
which is the same reasoning A12 applies to escalation triggers.
which is the same reasoning L9 applies to escalation triggers.
Consults are recorded as ``delegation_started`` / ``delegation_completed`` with
role ``consultant``. That is deliberate — A16 wants a "consult/delegation eval
role ``consultant``. That is deliberate — L9 wants a "consult/delegation eval
set", and reusing the composite events gets consult evaluation for free rather
than inventing a parallel record.
"""
@@ -150,7 +150,7 @@ class ExpertDesk:
last = "no reply"
for attempt in (1, 2):
try:
# Stateless by design (A14): the question travels as a
# Stateless by design (L9): the question travels as a
# synthesised message that is never appended to the log, so a
# consult neither depends on nor leaks repo context. It cannot
# ride in the system prompt alone — the Messages API requires
+1 -1
View File
@@ -1,4 +1,4 @@
"""Approval gating primitives (spec A4).
"""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
+2 -2
View File
@@ -1,4 +1,4 @@
"""Git operations for the agent's workspace (spec B2 outbound).
"""Git operations for the agent's workspace (architecture D4).
Push authentication uses the Gitea service-account token embedded in the
remote URL. The token is never written to disk in the repo config and is
@@ -110,7 +110,7 @@ class GitRepo:
def discard_branch(self, branch: str, *, back_to: str) -> None:
"""Throw away a branch and everything on it.
Used when a delegation aborts: A13 gives a delegation the same
Used when a delegation aborts: L9 gives a delegation the same
atomicity that reconciliation gives an interrupted transition, and
half-finished work must not survive to be built upon.
"""
+5 -5
View File
@@ -1,12 +1,12 @@
"""Gitea client (spec B2).
"""Gitea client (architecture D4, D5).
Outbound: open PRs and post comments through a dedicated service account with
a scoped token. Inbound is deliberately *polled* rather than webhook-driven,
so the agent VM stays outbound-only (B3), and intake is structured JSON —
so the agent VM stays outbound-only (D7), and intake is structured JSON —
never routed through a chat bridge.
``find_pull_request`` is the external-truth query that makes opening a PR
idempotent across a crash: boot reconciliation (A3) asks Gitea whether the
idempotent across a crash: boot reconciliation (L8) asks Gitea whether the
effect already happened rather than repeating it.
"""
@@ -84,7 +84,7 @@ class GiteaClient:
return _pull_request(data)
def find_pull_request(self, repo: str, head: str) -> PullRequest | None:
"""External truth for A3: did the PR for this branch already get
"""External truth for L8: did the PR for this branch already get
opened? Includes closed PRs — a crash after opening must not be
answered with "no PR is open, so open another".
@@ -169,7 +169,7 @@ def _pull_request(data: dict[str, Any]) -> PullRequest:
def pull_request_reconciler(client: GiteaClient, repo: str) -> Reconciler:
"""Reconciler for an ``open_pr`` intent (A3 step 4).
"""Reconciler for an ``open_pr`` intent (L8 boot step 2).
An unreachable Gitea yields None — unverifiable, so the agent gates to
the human rather than risking a duplicate PR. That is the conservative
+13 -13
View File
@@ -1,11 +1,11 @@
"""LLM access (spec A5, A11).
"""LLM access (architecture L9).
One client interface, present from the first real LLM call. All traffic goes
to the LiteLLM proxy — the VM's single LLM destination; the agent holds one
LiteLLM token and no vendor credentials. The base URL and token come from the
agent's config (nisse_dev.config), nothing hardcoded.
Model identity is expressed as roles (A11), resolved to concrete model strings
Model identity is expressed as roles (L9), resolved to concrete model strings
from config; the resolved mapping is pinned at task start so a mid-task
config change cannot split a task across two planners.
"""
@@ -34,7 +34,7 @@ class LLMError(RuntimeError):
# A rate limit is a transient condition, not a bad request. Losing a
# ten-turn delegation to one wastes far more tokens than waiting out the
# window, so retry once before surfacing it (A13's caps still bound the
# window, so retry once before surfacing it (L9's caps still bound the
# total).
RATE_LIMIT_STATUS = 429
RATE_LIMIT_RETRY_S = 20.0
@@ -71,7 +71,7 @@ class BudgetExhausted(UpstreamUnavailable):
Distinct from the rest of its family because it will not clear on its
own within the hour: an exhausted daily budget takes external models away
until it resets, so retrying wastes time and the operator needs telling.
A5 keeps the budget itself at the proxy; recognising its refusal is ours.
L9 keeps the budget itself at the proxy; recognising its refusal is ours.
"""
@@ -120,7 +120,7 @@ class RoleMap:
return str(getattr(self, role))
def pinned(self) -> dict[str, str]:
"""The full resolved mapping, to record at task start (A11)."""
"""The full resolved mapping, to record at task start (L9)."""
return asdict(self)
@@ -144,7 +144,7 @@ class LiteLLMClient:
self._renderer = OpenAIChatRenderer()
self._sleep = sleep
self._max_tokens = max_tokens or DEFAULT_MAX_OUTPUT_TOKENS
# Set after each call so a caller can meter itself (A13 caps);
# Set after each call so a caller can meter itself (L9 caps);
# a side channel rather than a return value, so the LLMClient
# protocol stays the same for every implementation.
self.last_usage: dict[str, int] = {}
@@ -211,13 +211,13 @@ class LiteLLMClient:
out: list[CanonicalEvent] = []
if message.get("content"):
out.append(AssistantText(text=message["content"], model=produced_by))
# Provider tool-call IDs are mapped at the boundary (A10): each call
# Provider tool-call IDs are mapped at the boundary (L7): each call
# gets a locally-minted canonical ID; the provider's is dropped, since
# every future request is rendered wholly from the canonical log.
for call in message.get("tool_calls") or []:
raw = call["function"]["arguments"] or "{}"
# Weak models emit truncated or non-JSON argument strings; that is
# a bad model turn, not a client crash (A15).
# a bad model turn, not a client crash (L9).
try:
arguments = json.loads(raw)
except json.JSONDecodeError as exc:
@@ -244,10 +244,10 @@ class LiteLLMClient:
class AnthropicPassthroughClient:
"""LiteLLM's Anthropic passthrough (A5). Same interface as LiteLLMClient,
"""LiteLLM's Anthropic passthrough (L9). Same interface as LiteLLMClient,
so a caller chooses a route rather than a different kind of client.
Delegations take this path because fidelity matters there (A13);
Delegations take this path because fidelity matters there (L9);
ask_expert and fallbacks use the normalized route, where it does not.
"""
@@ -340,7 +340,7 @@ class AnthropicPassthroughClient:
out.append(AssistantText(text=block["text"], model=produced_by))
elif kind == "tool_use":
# Provider ids are mapped at the boundary and never used as a
# join key (A10); the canonical log owns identity.
# join key (L7); the canonical log owns identity.
out.append(
ToolCall(
call_id=mint_call_id(),
@@ -349,7 +349,7 @@ class AnthropicPassthroughClient:
)
)
# Vendor-opaque reasoning blocks (thinking, redacted_thinking) are
# deliberately dropped for now: A10 requires storing them tagged
# deliberately dropped for now: L7 requires storing them tagged
# with the producing model and never replaying them outside the
# session that made them, and the canonical schema has no event
# for that yet. Nothing requests extended thinking, so none arrive.
@@ -378,7 +378,7 @@ def _post_with_rate_limit_retry(
raise AssertionError("unreachable")
# Model families we have a native renderer adapter for. A13: delegations take
# Model families we have a native renderer adapter for. L9: delegations take
# the vendor's passthrough route because a normalising middlebox is lossy
# exactly where a long-running delegation lives.
def _response_cost(response: httpx.Response) -> float:
+1 -1
View File
@@ -1,7 +1,7 @@
"""Entry point for the dev agent.
Loads configuration, builds the agent, and stays up until told to stop.
Shutdown is deliberate rather than abrupt: A2's durability guarantees assume
Shutdown is deliberate rather than abrupt: L8's durability guarantees assume
the process may die at any moment, but a clean stop still beats relying on
them.
"""
+4 -4
View File
@@ -1,4 +1,4 @@
"""MatrixTransport (spec A1): Matrix as strictly a UI channel.
"""MatrixTransport (architecture L5): Matrix as strictly a UI channel.
Implements the Transport interface directly over the Matrix client-server HTTP
API (token auth, /sync long-poll, /send) with the httpx client already in the
@@ -6,7 +6,7 @@ dependency tree — no Matrix SDK. The surface used is four endpoints; an SDK
earns its keep when E2E arrives, and bot rooms are created unencrypted until
then, so encrypted rooms are not supported here.
Inbound handling follows B2's polling posture: on homeserver errors the sync
Inbound handling follows L5's intake posture: on homeserver errors the sync
loop backs off (capped) and retries indefinitely — an outage delays reactions
but never fails anything.
"""
@@ -101,7 +101,7 @@ class MatrixTransport:
def start(self) -> None:
"""Begin delivering new room messages to the registered callback.
History from before start() is never delivered (Matrix room history
must not become load-bearing context, A1)."""
must not become load-bearing context, L5)."""
if self._callback is None:
raise TransportError("no message handler registered; call on_message first")
self._stop.clear()
@@ -184,7 +184,7 @@ class MatrixTransport:
"""Who a leading mention addresses, and the body without a leading
mention of this agent. Only the start of the message counts: a
mention later on ("approve, cc @alice") is part of the text. The
sender is never read from here; it comes from the event (A4)."""
sender is never read from here; it comes from the event (L3)."""
body: str = content["body"]
own = self._config.user_id
if content.get("format") == "org.matrix.custom.html":
+2 -2
View File
@@ -1,4 +1,4 @@
"""Structured operational logging (spec A9).
"""Structured operational logging (architecture L12).
One JSON object per line to stderr (journald captures it when running as a
service). Every event carries the fixed envelope: ts, agent, event, task_id,
@@ -16,7 +16,7 @@ class OpLog:
def __init__(self, agent: str, stream: TextIO | None = None) -> None:
self.agent = agent
# Correlation ID for the enclosing unit of work; the orchestrator sets
# it when a unit starts and clears it when the unit ends (A9).
# it when a unit starts and clears it when the unit ends (L12).
self.task_id: str | None = None
self._stream = stream if stream is not None else sys.stderr
+6 -4
View File
@@ -1,11 +1,11 @@
"""LLM-backed planning agent (spec A4 gating, A11 roles, A13 plan quality).
"""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 (A2/A10), so a
Model output is appended to the canonical log as it arrives (L8/L7), so a
crash mid-planning leaves a truthful transcript.
"""
@@ -18,7 +18,9 @@ from nisse_dev.llm import BudgetExhausted, LLMClient, LLMError, RoleMap
from nisse_dev.state import StateStore
# Plans may be executed by a materially weaker model, which cannot
# reconstruct intent (A13).
# 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
@@ -132,7 +134,7 @@ class LLMPlanningAgent:
self, 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 (A12 — judgment about model
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(model, system), None
+3 -3
View File
@@ -1,10 +1,10 @@
"""Gitea comment intake (spec B2).
"""Gitea comment intake (architecture D5).
Polling, not webhooks: the agent VM stays outbound-only (B3), so nothing
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 B2: back off exponentially, cap at a few minutes, retry
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.
"""
+3 -3
View File
@@ -1,4 +1,4 @@
"""Publishing a finished milestone as a Gitea PR (spec B2, A2/A3).
"""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
@@ -164,8 +164,8 @@ def _pr_body(milestone: int, plan: str, service_url: str | None) -> str:
plan,
]
if service_url:
# B2: a runnable test service's address belongs in the PR description
# 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 (A6) is what will.
# tool (D7) is what will.
parts += ["", "## Running test service", "", service_url]
return "\n".join(parts)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Boot-time reconciliation (spec A3): turn an ambiguous crash into a safe,
"""Boot-time reconciliation (architecture L8): turn an ambiguous crash into a safe,
known state.
Every pending intent found at startup is checked against external truth by a
+5 -5
View File
@@ -1,10 +1,10 @@
"""A10 renderer: provider request bodies are built from the canonical log at
"""L7 renderer: provider request bodies are built from the canonical log at
call time, never stored provider-shaped.
First adapter: the OpenAI-compatible chat format LiteLLM's normalized route
speaks — the executor role's path (A5). The Anthropic passthrough adapter
arrives with delegations (A13). Token budgeting and prompt-cache breakpoints
are render-time concerns and are never stored (A10); neither exists yet in
speaks — the executor role's path (L9). The Anthropic passthrough adapter
arrives with delegations (L9). Token budgeting and prompt-cache breakpoints
are render-time concerns and are never stored (L7); neither exists yet in
this skeleton.
"""
@@ -63,7 +63,7 @@ class OpenAIChatRenderer:
_append_merged(messages, self._message_for(stored.event))
# Providers reject a tool call with no result following it. One
# can legitimately be missing: a crash between the call and its
# result (A3), or a turn-ending gate tool. Render a placeholder so
# result (L8), or a turn-ending gate tool. Render a placeholder so
# the transcript stays replayable rather than poisoned.
if isinstance(stored.event, ToolCall) and (
stored.event.call_id not in answered
+3 -3
View File
@@ -1,6 +1,6 @@
"""Anthropic Messages renderer (spec A10, A13).
"""Anthropic Messages renderer (architecture L7, L9).
The second adapter, and the reason A13 puts normalisation in the renderer
The second adapter, and the reason L9 puts normalisation in the renderer
rather than on the wire: the two vendors disagree *structurally*, not
cosmetically.
@@ -71,7 +71,7 @@ class AnthropicMessagesRenderer:
blocks.extend(self._blocks_for(stored.event))
# A dangling tool_use is rejected outright by this API — more
# strictly than the OpenAI shape — so a crash between a call and
# its result must not poison the transcript (A3).
# its result must not poison the transcript (L8).
if isinstance(stored.event, ToolCall) and (
stored.event.call_id not in answered
):
+4 -4
View File
@@ -1,4 +1,4 @@
"""Durable state store (spec A2).
"""Durable state store (architecture L8).
SQLite in WAL mode with ``synchronous=FULL``: every commit is fsynced, so a
power cut mid-write can never produce torn state. Every state transition and
@@ -127,7 +127,7 @@ class StateStore:
migrations: Mapping[int, Migration] = MIGRATIONS,
oplog: OpLog | None = None,
) -> None:
# Log emission is owned by the store (A9): the same code path that
# Log emission is owned by the store (L12): the same code path that
# performs a durable write emits its log line, right after commit.
self.oplog = oplog if oplog is not None else OpLog("nisse-dev")
self._conn = sqlite3.connect(path, isolation_level=None)
@@ -291,7 +291,7 @@ class StateStore:
]
def append_event(self, event: CanonicalEvent) -> int:
"""Append to the canonical transcript (A10); committed and fsynced
"""Append to the canonical transcript (L7); committed and fsynced
before return, like every other durable write."""
cursor = self._conn.execute(
"INSERT INTO canonical_events (ts, type, payload) VALUES (?, ?, ?)",
@@ -314,7 +314,7 @@ class StateStore:
]
def mark_outcome(self, event_id: int, verdict: str, note: str = "") -> None:
"""Record whether a delegation or consult was good (A16).
"""Record whether a delegation or consult was good (L9).
Kept out of the canonical log on purpose: the log is an append-only
record of what happened, and a verdict is a later judgement about it
+5 -5
View File
@@ -1,11 +1,11 @@
"""Executor tools (spec A15: reduced schema, flat arguments).
"""Executor tools (architecture L9: reduced schema, flat arguments).
Deliberately unsandboxed. The VM is the trust boundary (B3) and tool use is
auto-approved inside an approved unit of work (A4), so per-command
Deliberately unsandboxed. The VM is the trust boundary (D7) and tool use is
auto-approved inside an approved unit of work (L3), so per-command
confinement would add failure modes without adding safety. The workspace is
where work happens — the default for relative paths and the command cwd — not
a wall. Protecting agent state from the agent is a mount-layout concern
(A2/B3), not this module's job.
(L8/D7), not this module's job.
What *is* enforced here is operational: commands cannot hang the loop, cannot
consume the context window with megabytes of build log, and do not inherit
@@ -153,7 +153,7 @@ class Workspace:
def write_file(self, path: str, content: str) -> ToolOutcome:
target = self._resolve(path)
# Not a sandbox — the VM is the trust boundary (B3). This is about the
# Not a sandbox — the VM is the trust boundary (D7). This is about the
# work existing at all: a file written outside the repo is invisible
# to git, so the milestone would publish an empty PR while the model
# truthfully reports success. Models do guess plausible absolute paths
+1 -1
View File
@@ -1,4 +1,4 @@
"""Transport interface (spec A1): all human-to-agent conversation.
"""Transport interface (architecture L5): all human-to-agent conversation.
The orchestrator depends only on this interface; no platform SDK is imported
anywhere outside a concrete implementation. MatrixTransport arrives as its own
+1 -1
View File
@@ -3,7 +3,7 @@
Planning turns, milestones and review responses can each run for minutes.
Running them on the thread that delivered the message blocked that thread for
as long: the Matrix sync loop read nothing new until the work finished, so an
operator's `stop` (A4) would only have arrived after the work it was meant to
operator's `stop` (L2) would only have arrived after the work it was meant to
stop. Every handler now runs here instead, and transports only hand work over.
Owning the store is the other half. SQLite connections are bound to the thread
+1 -1
View File
@@ -1,4 +1,4 @@
"""The `abandon` command: ending a task from any waiting state (A4, B2)."""
"""The `abandon` command: ending a task from any waiting state (L2)."""
import io
from pathlib import Path
+6 -6
View File
@@ -137,8 +137,8 @@ def test_the_whole_graph_assembles(tmp_path: Path, origin: Path, monkeypatch) ->
assert orchestrator._board is not None
assert orchestrator._delegate is not None
assert orchestrator._poller_factory is not None
assert orchestrator._executor._delegate is not None # A12 escalation path
assert orchestrator._executor._expert is not None # A14 consult path
assert orchestrator._executor._delegate is not None # L9 escalation path
assert orchestrator._executor._expert is not None # L9 consult path
agent.stop()
@@ -181,7 +181,7 @@ def test_poller_factory_ignores_the_agents_own_comments(
def test_delegation_uses_the_passthrough_route(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
# A13: delegations run vendor-native; the normalized route is for the
# L9: delegations run vendor-native; the normalized route is for the
# places where fidelity is cheap.
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
@@ -203,7 +203,7 @@ def test_httpx_clients_are_not_shared_between_routes(
agent.stop()
# --- startup model discovery (A5/A11) ------------------------------------
# --- startup model discovery (L9) ------------------------------------
def test_a_typod_role_refuses_to_boot(tmp_path: Path, monkeypatch) -> None:
@@ -233,7 +233,7 @@ def test_a_role_this_key_cannot_reach_refuses_to_boot(
def test_a_tool_less_model_warns_but_still_boots(tmp_path: Path, monkeypatch) -> None:
# A15 expects weak local models to be accommodated, and models LiteLLM
# L9 expects weak local models to be accommodated, and models LiteLLM
# declares tool-less are observed driving tool loops anyway.
import io
@@ -285,7 +285,7 @@ def test_the_resolved_model_is_pinned_at_task_start(
def test_delegation_records_what_the_alias_resolved_to(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
# A16 eval history keyed on an alias alone would average across whatever
# L9 eval history keyed on an alias alone would average across whatever
# that alias pointed at over time.
agent = build_offline(tmp_path, origin, monkeypatch)
assert agent.orchestrator._delegate._backing_model == "anthropic/claude-opus-5"
+1 -1
View File
@@ -1,4 +1,4 @@
"""Approvals and feedback bind to the plan revision they were about (A4)."""
"""Approvals and feedback bind to the plan revision they were about (L3)."""
import io
from pathlib import Path
+4 -4
View File
@@ -1,5 +1,5 @@
"""Plan externalisation to Gitea and approvals arriving on either channel
(spec A4, Part H)."""
(architecture L3, D2)."""
import io
from pathlib import Path
@@ -96,8 +96,8 @@ def test_project_start_opens_an_issue_and_posts_the_plan(tmp_path: Path) -> None
issue, body = gitea.comments[0]
assert issue == 1
assert "plan 1" in body
assert "approve rev-1" in body # the tag is printed for copy-paste (A4)
# The store keeps a pointer, not the plan record itself (Part H).
assert "approve rev-1" in body # the tag is printed for copy-paste (L3)
# The store keeps a pointer, not the plan record itself (D2).
assert store.get_state().data["board"]["issue"] == 1
@@ -136,7 +136,7 @@ def test_non_operator_gitea_comment_cannot_approve(tmp_path: Path) -> None:
def test_agents_own_comment_cannot_approve_its_plan(tmp_path: Path) -> None:
# The agent posts plans as nisse-dev-testing; that login is not on the
# operator allowlist, so its own comments can never satisfy its gate (A4).
# operator allowlist, so its own comments can never satisfy its gate (L3).
gitea = FakeGitea()
orch, store, transport, _ = build(tmp_path, gitea)
transport.receive("don", "use demo")
+1 -1
View File
@@ -77,7 +77,7 @@ def test_unknown_fields_rejected() -> None:
# The v1 schema as it shipped (PR #2), frozen here to prove a newer image
# opens and upgrades a database written by the older one (A2).
# opens and upgrades a database written by the older one (L8).
V1_SCHEMA = [
"CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
"""
+3 -3
View File
@@ -1,4 +1,4 @@
"""Runtime model discovery (spec A5/A11/A13).
"""Runtime model discovery (architecture L9).
The point of these tests is that nothing here reads the model's *name*. A
semantic alias like "remote-architecture" is free to point anywhere, so the
@@ -78,7 +78,7 @@ def test_a_semantic_alias_routes_by_its_provider() -> None:
def test_the_same_alias_reroutes_when_it_is_repointed() -> None:
# A13: provisionally accept LiteLLM's normalization for a vendor with no
# L9: provisionally accept LiteLLM's normalization for a vendor with no
# native adapter, rather than sending it Anthropic-shaped bodies.
cat = catalog(entry("remote-architecture", "openai/gpt-6-astra", "openai"))
normalized, passthrough = object(), object()
@@ -189,7 +189,7 @@ def test_discovery_asks_the_proxy_with_the_token() -> None:
def test_pinning_records_what_the_alias_resolved_to() -> None:
# An alias may point somewhere else tomorrow; eval history (A16) keyed on
# An alias may point somewhere else tomorrow; eval history (L9) keyed on
# the alias alone would average across whatever it pointed at.
cat = catalog(entry("remote-architecture", "anthropic/claude-opus-5", "anthropic"))
pinned = cat.pinned({"planner": "remote-architecture"})
+1 -1
View File
@@ -281,7 +281,7 @@ def test_the_example_file_is_valid() -> None:
def test_state_db_and_workspace_are_separate() -> None:
# A2: build artefacts must not share a volume with agent state.
# L8: build artefacts must not share a volume with agent state.
config = build()
assert config.state_db.parent != config.workspace
+1 -1
View File
@@ -1,4 +1,4 @@
"""The context budget (spec A10)."""
"""The context budget (architecture L6)."""
import io
from pathlib import Path
+1 -1
View File
@@ -180,7 +180,7 @@ def test_ask_human_pauses_and_answer_resumes(tmp_path: Path) -> None:
assert "monorepo" in h.transport.outbound[-1].text
# --- restarts (A3 steps 7 and 8) --------------------------------------------
# --- restarts (L8 boot steps 4 and 5) --------------------------------------------
class FailsOnce:
+12 -12
View File
@@ -121,7 +121,7 @@ def test_delegation_works_on_its_own_branch(
def test_abort_discards_the_branch_and_its_work(
store: StateStore, ws: Workspace, repo: GitRepo
) -> None:
# A13: abort must leave nothing to resume on top of.
# L9: abort must leave nothing to resume on top of.
client = ScriptedClient(
[
[call("write_file", path="half-done.py", content="oops")],
@@ -138,7 +138,7 @@ def test_abort_discards_the_branch_and_its_work(
def test_stop_discards_the_branch_and_settles_the_intent(
store: StateStore, ws: Workspace, repo: GitRepo
) -> None:
# A4 stop is treated like a failure, not like an outage: the operator
# L2's stop is treated like a failure, not like an outage: the operator
# stopped it for a reason, so nothing is kept for resuming.
cancel = Cancel()
client = ScriptedClient(
@@ -213,7 +213,7 @@ def test_cap_breach_is_not_usable_and_discards_work(
brief="b", branch="d"
)
assert not result.usable # the caller must gate, not resume (A13)
assert not result.usable # the caller must gate, not resume (L9)
assert not (repo.root / "partial.py").exists()
@@ -251,7 +251,7 @@ def test_composite_events_record_the_brief_caps_and_outcome(
assert started.brief == "the brief"
assert started.model == "claude-haiku-4-5"
assert started.caps == {"turns": 7, "tokens": 1234, "wall_clock_s": 42.0}
# A16: this pair is the eval record, so it must carry the outcome.
# L9: this pair is the eval record, so it must carry the outcome.
assert completed.exit_status == "handback"
assert "greet()" in completed.handback
@@ -259,7 +259,7 @@ def test_composite_events_record_the_brief_caps_and_outcome(
def test_hand_back_tool_is_offered_alongside_the_executor_tools() -> None:
names = [t["function"]["name"] for t in DELEGATE_TOOLS]
assert names[-1] == "hand_back"
assert "run_command" in names # full tool access, not a consult (A13)
assert "run_command" in names # full tool access, not a consult (L9)
def test_malformed_handback_is_rejected_and_the_delegation_continues(
@@ -348,7 +348,7 @@ def test_failure_may_be_reported_without_doing_work(
assert len(client.calls) == 1 # believed immediately, not challenged
# --- A3 step 5: the resume rule -----------------------------------------
# --- L8 boot step 5: the resume rule -----------------------------------------
def pending_delegation_intent(store: StateStore, branch: str = "d") -> Any:
@@ -446,7 +446,7 @@ def test_resume_continues_the_same_delegation(
store: StateStore, ws: Workspace, repo: GitRepo
) -> None:
# Interrupt after one turn, then resume: no second delegation_started,
# because A10 defines a session as the delegation, not the process.
# because L7 defines a session as the delegation, not the process.
repo.checkout_new_branch("d")
store.append_event(
DelegationStarted(brief="b", role="delegate", model="m", caps={})
@@ -497,7 +497,7 @@ def test_recover_resumes_a_clean_cut_with_a_notice(
result = build(client, store, ws, repo).recover(notices.append)
assert result is not None and result.usable
# A3: a notice, not a question — a clean cut needs no operator decision.
# L8: a notice, not a question — a clean cut needs no operator decision.
assert notices and "resuming the delegation" in notices[0]
@@ -550,7 +550,7 @@ def test_recover_leaves_an_unclean_cut_for_the_boot_gate(
assert [i.kind for i in store.pending_intents()] == [DELEGATION_INTENT]
# --- the token cap, which A13 calls hard and was decoration -------------
# --- the token cap, which L9 calls hard and was decoration -------------
class MeteredClient(ScriptedClient):
@@ -599,7 +599,7 @@ def test_tokens_spent_are_reported_not_left_at_zero(
completed = next(
e.event for e in store.read_events() if isinstance(e.event, DelegationCompleted)
)
# A16 evaluates cost against outcome, which needs a real number.
# L9 evaluates cost against outcome, which needs a real number.
assert completed.tokens == 2000
@@ -639,7 +639,7 @@ def test_budget_exhaustion_preserves_the_work_for_later(
# The work survives, on its branch.
assert repo.current_branch() == "d"
assert (repo.root / "progress.py").exists()
# And the intent stays pending, which is what A3's resume rule keys on.
# And the intent stays pending, which is what L8's resume rule keys on.
assert [i.kind for i in store.pending_intents()] == [DELEGATION_INTENT]
@@ -647,7 +647,7 @@ def test_an_ordinary_failure_still_discards(
store: StateStore, ws: Workspace, repo: GitRepo
) -> None:
# Only external interruptions are preserved; a delegate that made a mess
# is still cleaned up (A13).
# is still cleaned up (L9).
client = ScriptedClient([[hand_back(succeeded=False, changed="cannot")]])
result = build(client, store, ws, repo).run(brief="b", branch="d")
+1 -1
View File
@@ -54,7 +54,7 @@ def test_pairs_a_start_with_its_completion(store: StateStore) -> None:
def test_unpaired_start_is_skipped(store: StateStore) -> None:
# A crash mid-delegation leaves a start with no completion; a half-formed
# record would pollute the set A16 exists to make trustworthy.
# record would pollute the set L9 exists to make trustworthy.
store.append_event(
DelegationStarted(brief="died", role="delegate", model="m", caps={})
)
+1 -1
View File
@@ -317,7 +317,7 @@ def test_ask_expert_is_offered_only_when_a_desk_exists(
names = [t["function"]["name"] for t in client2.calls[0]["tools"]]
assert "ask_expert" in names
assert names[-1] == "finish"
# A12: in addition to the observable triggers, not instead of them.
# L9: in addition to the observable triggers, not instead of them.
assert "run_command" in names
+3 -3
View File
@@ -109,7 +109,7 @@ def test_consult_is_stateless_and_uses_the_consultant_role(
desk(client, store).ask("Is this API deprecated?")
call = client.calls[0]
# A14: no repo access, no session state — exactly the question, and none
# L9: no repo access, no session state — exactly the question, and none
# of the log the caller was working from.
assert [e.event.text for e in call["events"]] == ["Is this API deprecated?"]
assert all("secret repo context" not in e.event.text for e in call["events"])
@@ -131,7 +131,7 @@ def test_retry_succeeds_after_a_bad_first_reply(store: StateStore) -> None:
def test_consults_are_recorded_as_composite_events(store: StateStore) -> None:
# A16 wants a consult/delegation eval set; reusing the pair gets consult
# L9 wants a consult/delegation eval set; reusing the pair gets consult
# evaluation for free.
desk(ScriptedClient([GOOD]), store).ask("q")
events = [e.event for e in store.read_events()]
@@ -183,4 +183,4 @@ def test_budget_counts_only_after_the_task_marker(store: StateStore) -> None:
def test_default_budget_is_small() -> None:
assert DEFAULT_BUDGET <= 5 # A14: do not let this become a second subsystem
assert DEFAULT_BUDGET <= 5 # L9: do not let this become a second subsystem
+2 -2
View File
@@ -44,7 +44,7 @@ def test_unknown_sender_is_silently_ignored() -> None:
def test_approve_with_trailing_words_is_rejection() -> None:
# "approve, but rename the module" is feedback, not approval (A4: the
# "approve, but rename the module" is feedback, not approval (L3: the
# typed word approve, nothing else).
result = gate().evaluate("don", "approve, but rename the module", "transport")
assert isinstance(result, Rejected)
@@ -69,7 +69,7 @@ def test_a_sentence_containing_approve_is_still_feedback() -> None:
assert result.feedback == "I approve of most of it"
# --- binding by arrival (A4): a reply that names no revision is about the one
# --- binding by arrival (L3): a reply that names no revision is about the one
# that was up when it arrived.
+1 -1
View File
@@ -163,7 +163,7 @@ def test_list_comments_passes_since() -> None:
assert seen["since"] == "2026-09-07T00:00:00Z"
# --- reconciler (A3 external truth) --------------------------------------
# --- reconciler (L8 external truth) --------------------------------------
def test_reconciler_reports_existing_pr_as_done() -> None:
+1 -1
View File
@@ -360,7 +360,7 @@ def test_budget_refusal_is_a_kind_of_upstream_trouble() -> None:
assert issubclass(BudgetExhausted, UpstreamUnavailable)
# --- tool_choice (A15: constrained decoding) --------------------------------
# --- tool_choice (L9: constrained decoding) --------------------------------
TOOLS = [
{
+1 -1
View File
@@ -1,4 +1,4 @@
"""State-change log conventions (spec A9): every durable mutation emits one
"""State-change log conventions (architecture L12): every durable mutation emits one
JSON-lines event with the fixed envelope, from the store's own write path."""
import io
+2 -2
View File
@@ -88,7 +88,7 @@ def test_outage_does_not_raise_out_of_the_loop() -> None:
time.sleep(0.05)
poller.stop()
# It recovered and delivered rather than dying on the outage (B2: an
# It recovered and delivered rather than dying on the outage (L5: an
# outage delays a reaction, never fails a task).
assert [c.id for c in seen] == [1]
assert "gitea.poll_failed" in stream.getvalue()
@@ -118,7 +118,7 @@ def test_backoff_grows_then_caps() -> None:
delays = [json.loads(line)["retry_in_s"] for line in stream.getvalue().splitlines()]
assert delays[:4] == [0.001, 0.002, 0.004, 0.008]
assert max(delays) == 0.008 # capped, and retried indefinitely (B2)
assert max(delays) == 0.008 # capped, and retried indefinitely (L5)
assert MAX_BACKOFF_S <= 300.0 # the shipped cap is "a few minutes"
+1 -1
View File
@@ -1,4 +1,4 @@
"""The B1 loop past EXECUTING: publish, review gate, then next milestone or
"""The loop past EXECUTING (D4, D5): publish, review gate, then next milestone or
project completion."""
import io
+1 -1
View File
@@ -74,7 +74,7 @@ def test_compaction_replaces_its_range_only() -> None:
def test_tool_call_without_a_result_gets_a_placeholder() -> None:
# Providers reject a dangling tool_use; this happens after a crash (A3)
# Providers reject a dangling tool_use; this happens after a crash (L8)
# or with turn-ending gate tools like submit_plan.
events = stored(
[
+1 -1
View File
@@ -1,4 +1,4 @@
"""Reacting to PR review comments while in PR_OPEN (spec B2)."""
"""Reacting to PR review comments while in PR_OPEN (architecture D5)."""
import io
from pathlib import Path
+1 -1
View File
@@ -1,4 +1,4 @@
"""The operator's kill switch, `stop` (spec A4)."""
"""The operator's kill switch, `stop` (architecture L2)."""
import io
import threading
+2 -2
View File
@@ -13,7 +13,7 @@ def ws(tmp_path: Path) -> Workspace:
def test_manifest_is_small_and_flat() -> None:
# A15: fewer tools, flatter arguments for the local executor.
# L9: fewer tools, flatter arguments for the local executor.
names = [t["function"]["name"] for t in EXECUTOR_TOOLS]
assert names == ["read_file", "write_file", "list_dir", "run_command"]
for tool in EXECUTOR_TOOLS:
@@ -62,7 +62,7 @@ def test_run_command_success_reports_exit_code_and_stdout(ws: Workspace) -> None
def test_nonzero_exit_is_a_result_not_an_error(ws: Workspace) -> None:
outcome = ws.run_command("exit 3")
assert not outcome.success # feeds the A12 failure count
assert not outcome.success # feeds the L9 failure count
assert "exit code: 3" in outcome.result
+3 -3
View File
@@ -1,10 +1,10 @@
# nisse-ops
The Sysadmin Agent (Part C of the Nisse system). A thin application over the shared
The Sysadmin Agent (Part O of the Nisse architecture). A thin application over the shared
`nisse-core` package.
**Read the architecture before making design decisions here:**
`docs/Architecture-Build-Spec.md` at the repo root. Part C specifies this agent;
Part A specifies the library contracts it depends on. Requirements that look like
`docs/Architecture.md` at the repo root. Part O specifies this agent;
Part L specifies the library contracts it depends on. Requirements that look like
free choices in this package — the permission policy engine, confirm-first gating,
secrets and SSO handling — are usually settled there.
+2 -2
View File
@@ -4,5 +4,5 @@ The Sysadmin Agent: operates and maintains infrastructure, driven by Vikunja
tickets, gated on high-consequence actions. A thin application over the shared
`nisse-core` library. Built after the Dev Agent works.
**Part C** of the specification at [`../docs/Architecture-Build-Spec.md`](../docs/Architecture-Build-Spec.md)
covers this agent; **Part A** covers the library it sits on.
**Part O** of the architecture at [`../docs/Architecture.md`](../docs/Architecture.md)
covers this agent; **Part L** covers the library it sits on.
+2 -2
View File
@@ -56,7 +56,7 @@ module orchestrator {
task: bool, // a project is in use
gate: Gate,
revs: str -> int, // latest revision per gate
lastRev: int, // revisions run across the task (A4)
lastRev: int, // revisions run across the task (L3)
ms: int,
pending: Pending,
prOn: bool, prKey: int, prLast: int, // _data["pr"]
@@ -270,7 +270,7 @@ module orchestrator {
else if (s.st == "AWAIT_INPUT" and s.pending.kind != "")
s0
else if (s.st == "EXECUTING" and giteaUp and prState(s, prKeyOf(s)) == "open")
// A3 step 4: the milestone's PR is open, so it was published and only
// L8 boot step 3: the milestone's PR is open, so it was published and only
// PR_OPEN went unrecorded. Adopt it; nothing watched it, so from 0.
{ ...s0, st: "PR_OPEN", prOn: true, prKey: prKeyOf(s), prLast: 0,
prWatch: { on: true, floor: 0 }, planWatch: OFF }
+1 -1
View File
@@ -1,5 +1,5 @@
"""Integration: state written before an abrupt process death must be intact
and the in-flight intent visible on the next boot (spec A2/A3)."""
and the in-flight intent visible on the next boot (architecture L8)."""
import subprocess
import sys
+2 -2
View File
@@ -1,4 +1,4 @@
"""A real consult over the normalized route (A14).
"""A real consult over the normalized route (L9).
The point is the prompt-level contract: a live model must produce something
that parses against our local schema, since no API-level enforcement exists.
@@ -41,7 +41,7 @@ def test_live_consult_parses_against_the_local_schema(desk) -> None:
assert answer.confidence in {"high", "medium", "low"}
assert len(answer.answer) > 20
# The consult is captured for A16 without any extra bookkeeping.
# The consult is captured for L9 without any extra bookkeeping.
[record] = collect(store)
assert record.role == "consultant"
assert record.exit_status == "answered"
+1 -1
View File
@@ -1,6 +1,6 @@
"""Integration: a restart mid-gate resumes the open gate with its revision
history intact; unresolved intents gate the whole boot to AWAIT_INPUT
(spec A2/A3/A4 together)."""
(architecture L8 and L3 together)."""
import io
from pathlib import Path
+2 -2
View File
@@ -1,5 +1,5 @@
"""Live Gitea round trip against the sandbox repo: push a branch, open a PR,
comment, and prove the A3 reconciler reads external truth correctly.
comment, and prove the L8 reconciler reads external truth correctly.
Cleans up after itself (closes the PR, deletes the branch) so repeated runs
do not silt up the sandbox.
@@ -74,7 +74,7 @@ def test_push_open_pr_and_reconcile(
# Now the same reconciler must see the effect and refuse to repeat it.
assert reconcile(intent_for(branch)) is True
# A4 is only real if the server enforces it. The agent's own token
# D4 is only real if the server enforces it. The agent's own token
# must not be able to merge the PR it just opened, and must not be
# able to push to the protected default branch at all.
merged = client._http.post(
+7 -7
View File
@@ -1,9 +1,9 @@
"""Capability matrix across models (spec Part H, A15).
"""Capability matrix across models (architecture L9, Q).
Part H says the thresholds that depend on local capability — A12's failure
count, A14's consult budget, A15's manifest size — must be tuned "at
build-order step 5 on the real hardware". This is the measurement that makes
that possible, rather than reasoning about model strength from reputation.
The thresholds that depend on local capability (L9: the failure count, the
consult budget, the manifest size) are tuned on the real hardware. This is the
measurement that makes that possible, rather than reasoning about model
strength from reputation.
What it measures is the thing that actually breaks: whether a model can hold
the tool-call contract our loops depend on. llama3.2 could not — it returned
@@ -102,7 +102,7 @@ def test_model_answers_at_all(model: str, catalog: ModelCatalog, routes) -> None
def test_model_holds_the_planner_contract(
model: str, catalog: ModelCatalog, routes
) -> None:
"""A4's gating is structural at the tool layer: a model that answers in
"""L3's approvals are structural at the tool layer: a model that answers in
prose cannot be gated, because there is no plan to approve."""
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
@@ -161,7 +161,7 @@ def test_model_round_trips_a_tool_result(
def test_model_holds_the_prompt_level_json_contract(
model: str, catalog: ModelCatalog, routes
) -> None:
"""A14 contracts structured output at the prompt level because vendors
"""L9 contracts structured output at the prompt level because vendors
differ on JSON enforcement. This measures whether that holds per model."""
normalized, passthrough = routes
client = select_client(model, normalized, passthrough, catalog)
+2 -2
View File
@@ -1,7 +1,7 @@
"""Live Anthropic passthrough: the vendor-native route delegations will use.
Proves the renderer's body is accepted as-is, that a tool call round-trips
through the canonical log, and — per A5 — that prompt caching survives the
through the canonical log, and — per L9 — that prompt caching survives the
passthrough rather than being flattened by the proxy.
"""
@@ -75,7 +75,7 @@ def test_passthrough_round_trips_a_tool_call(
def test_prompt_caching_survives_the_passthrough(
live_config: AgentConfig, live_model: str
) -> None:
# A5 asks for this to be verified rather than assumed: a proxy that
# L9 asks for this to be verified rather than assumed: a proxy that
# flattened the request would silently drop cache control and turn a
# cost optimisation into a per-call expense.
base = live_config.llm_base_url.rstrip("/")
+3 -3
View File
@@ -1,8 +1,8 @@
"""Live planning turn: a real model must answer with a submit_plan or
ask_human tool call, not prose.
Plans with the test config's planner role. Planning escalates to a frontier
model unconditionally (A12), and small local models do not hold the tool-call
Plans with the test config's planner role. The planner role is a frontier
model today (Development Plan §2), and small local models do not hold the tool-call
contract reliably, so that role must be one that does.
"""
@@ -43,5 +43,5 @@ def test_live_planning_turn_returns_a_tool_action(
assert isinstance(action, SubmitPlan | AskHuman)
text = action.content if isinstance(action, SubmitPlan) else action.question
assert text.strip(), "tool call carried empty content"
# The turn must be durable regardless of what the model said (A2/A10).
# The turn must be durable regardless of what the model said (L8/L7).
assert len(store.read_events()) > 1