Merge pull request 'Retire the old build spec, and point every reference at the architecture (M0.1)' (#85) from retire-old-spec into main
CI / checks (push) Successful in 1m15s
CI / checks (push) Successful in 1m15s
Reviewed-on: dongar/nisse#85 Reviewed-by: Don Garrett <dgarrett@acm.org>
This commit was merged in pull request #85.
This commit is contained in:
commit
678466c176
73 files changed
+259
-1786
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
"""
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)",
|
||||
"""
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
"""The context budget (spec A10)."""
|
||||
"""The context budget (architecture L6)."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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={})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
"""The operator's kill switch, `stop` (spec A4)."""
|
||||
"""The operator's kill switch, `stop` (architecture L2)."""
|
||||
|
||||
import io
|
||||
import threading
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user