Retire the old build spec, and point every reference at the architecture (M0.1) #85
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.
|
library (`nisse-core`), one package per directory.
|
||||||
|
|
||||||
**Read the architecture before making design decisions.** The system-level
|
**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
|
free choices — gating structure, state durability, LLM access paths, permission
|
||||||
policy — are usually settled there.
|
policy — are usually settled there.
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ return.
|
|||||||
|
|
||||||
| Package | Role |
|
| Package | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`nisse-core/`](nisse-core/) | Shared library both agents sit on (Part A) |
|
| [`nisse-core/`](nisse-core/) | Shared library both agents sit on (Part L) |
|
||||||
| [`nisse-dev/`](nisse-dev/) | Dev Agent — plans and implements software projects (Part B) |
|
| [`nisse-dev/`](nisse-dev/) | Dev Agent — plans and implements software projects (Part D) |
|
||||||
| [`nisse-ops/`](nisse-ops/) | Sysadmin Agent — operates infrastructure (Part C) |
|
| [`nisse-ops/`](nisse-ops/) | Sysadmin Agent — operates infrastructure (Part O) |
|
||||||
|
|
||||||
One repository, three packages. The library does not exist as code yet: it is
|
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
|
extracted from `nisse-dev` once that agent works (build-order step 10), as a
|
||||||
@@ -17,6 +17,6 @@ package split within this tree.
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The system-level specification lives at
|
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
|
shared library, both agents, the supporting services, the build order, and the
|
||||||
remaining open decisions.
|
remaining open decisions.
|
||||||
File diff suppressed because it is too large.
Load diff
+10
-5
@@ -1,10 +1,8 @@
|
|||||||
# Nisse — Architecture
|
# Nisse — Architecture
|
||||||
|
|
||||||
**Status: agreed (PR #83).** How and in what order it gets built is in
|
**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/Development-Plan.md`. It replaced `docs/Architecture-Build-Spec.md`, which
|
||||||
`docs/Architecture-Build-Spec.md`, code comments still cite the old spec's section
|
remains in git history; its section letters (A–H) are not reused here.
|
||||||
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.
|
|
||||||
|
|
||||||
This document says what Nisse is, how its parts fit together, and why. It records
|
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.
|
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
|
Comments from allowlisted operators are instructions or approvals; comments from
|
||||||
anyone else are data (L11).
|
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
|
### L6. Context and memory
|
||||||
|
|
||||||
What each model sees, what persists, and how memory is written and used.
|
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
|
## 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,
|
- **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
|
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
|
### 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
|
- 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).
|
`README.md` files from the old spec's A–H to the new letters.
|
||||||
- Delete `docs/Architecture-Build-Spec.md` (git keeps it); drop §6 from this plan.
|
- 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.
|
- 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.*
|
**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 |
|
| #79 ending projects and milestones | **Superseded** | M2.5 |
|
||||||
| #80 build artifacts in PRs | Milestone | M1.4 |
|
| #80 build artifacts in PRs | Milestone | M1.4 |
|
||||||
| #81 no-change milestones | **Superseded** | M2.5 |
|
| #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:
|
Two consequences:
|
||||||
|
|
||||||
- **Context length is a memory decision, not a free setting.** The spec's
|
- **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.
|
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
|
- **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
|
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
|
### `tuning.executor.failure_limit` — default 2
|
||||||
|
|
||||||
How many consecutive failed `run_command` calls trigger escalation: the
|
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
|
successful command resets the count. Failed file reads don't count, since
|
||||||
reading is looking around, not doing the work.
|
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
|
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
|
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
|
- **Too low:** the executor can't get unstuck on a knowledge question it
|
||||||
can't answer from the code.
|
can't answer from the code.
|
||||||
- **Too high:** a confused executor asks variations of the same question
|
- **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
|
- **Deciding:** count consults per milestone, and check whether the answers
|
||||||
changed what the executor did next. If it regularly exhausts the budget
|
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
|
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
|
### `tuning.delegation` — hard caps on one delegation
|
||||||
|
|
||||||
A delegation is a frontier model working a sub-task with full tool access
|
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.
|
work are discarded, because resuming on a half-edited tree isn't safe.
|
||||||
|
|
||||||
| Knob | Default | Caps |
|
| 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 |
|
| `wall_clock_s` | 900 | elapsed time |
|
||||||
|
|
||||||
- **`max_tokens` is a scope limit, not the money limit.** Spending limits live
|
- **`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
|
without bound. An unbounded one could still exhaust a daily budget and take
|
||||||
every frontier model away until it resets.
|
every frontier model away until it resets.
|
||||||
- **Too low:** delegations fail just short of finishing and their work is
|
- **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.
|
- **Too high:** a stuck delegation burns tokens and time before failing.
|
||||||
- **Deciding:** every delegation records the tokens it used and how long it
|
- **Deciding:** every delegation records the tokens it used and how long it
|
||||||
ran (the `delegation_completed` event, captured for evaluation per spec
|
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.
|
room to spare.
|
||||||
|
|
||||||
### `tuning.tools.command_timeout_s` — default 120
|
### `tuning.tools.command_timeout_s` — default 120
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# nisse-core
|
# 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
|
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.
|
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
|
workspace: ~/nisse-demo-workspace/project # the clone
|
||||||
project: demo
|
project: demo
|
||||||
|
|
||||||
llm: # A5: LiteLLM is the only LLM destination
|
llm: # L9: LiteLLM is the only LLM destination
|
||||||
base_url: http://litellm.example:4000
|
base_url: http://litellm.example:4000
|
||||||
token: ${NISSE_LLM_TOKEN}
|
token: ${NISSE_LLM_TOKEN}
|
||||||
# Ceiling on each response. The model's own max_output_tokens in LiteLLM
|
# 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.
|
# hung connection; slow local models can take minutes over a long answer.
|
||||||
request_timeout_s: 600
|
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)
|
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
|
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
|
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
|
consultant: claude-haiku-4-5 # one question at a time, no tools: cheap is enough
|
||||||
|
|
||||||
matrix: # A1
|
matrix: # L5
|
||||||
homeserver: https://matrix.example
|
homeserver: https://matrix.example
|
||||||
user_id: "@nisse-dev:example"
|
user_id: "@nisse-dev:example"
|
||||||
token: ${NISSE_MATRIX_TOKEN}
|
token: ${NISSE_MATRIX_TOKEN}
|
||||||
room_id: "!roomid:example"
|
room_id: "!roomid:example"
|
||||||
operators: ["@you:example"]
|
operators: ["@you:example"]
|
||||||
|
|
||||||
gitea: # B2
|
gitea: # D4, D5
|
||||||
url: https://gitea.example
|
url: https://gitea.example
|
||||||
token: ${NISSE_GITEA_TOKEN}
|
token: ${NISSE_GITEA_TOKEN}
|
||||||
repo: owner/demo-repo
|
repo: owner/demo-repo
|
||||||
@@ -48,10 +48,10 @@ gitea: # B2
|
|||||||
tuning:
|
tuning:
|
||||||
executor:
|
executor:
|
||||||
max_turns: 40 # model calls per milestone before it stops as capped
|
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:
|
expert:
|
||||||
consult_budget: 3 # ask_expert calls per milestone (A14)
|
consult_budget: 3 # ask_expert calls per milestone (L9)
|
||||||
delegation: # hard caps on one delegation (A13)
|
delegation: # hard caps on one delegation (L9)
|
||||||
max_turns: 30
|
max_turns: 30
|
||||||
max_tokens: 200000
|
max_tokens: 200000
|
||||||
wall_clock_s: 900
|
wall_clock_s: 900
|
||||||
|
|||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
# nisse-dev
|
# 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.
|
`nisse-core` package.
|
||||||
|
|
||||||
**Read the architecture before making design decisions here:**
|
**Read the architecture before making design decisions here:**
|
||||||
`docs/Architecture-Build-Spec.md` at the repo root. Part B specifies this agent;
|
`docs/Architecture.md` at the repo root. Part D specifies this agent;
|
||||||
Part A specifies the library contracts it depends on. Requirements that look like
|
Part L specifies the library contracts it depends on. Requirements that look like
|
||||||
free choices in this package — gating structure, state durability, LLM access
|
free choices in this package — gating structure, state durability, LLM access
|
||||||
paths — are usually settled there.
|
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
|
review, and reacts to review comments and commits. A thin application over the
|
||||||
shared `nisse-core` library.
|
shared `nisse-core` library.
|
||||||
|
|
||||||
**Part B** of the specification at [`../docs/Architecture-Build-Spec.md`](../docs/Architecture-Build-Spec.md)
|
**Part D** of the architecture at [`../docs/Architecture.md`](../docs/Architecture.md)
|
||||||
covers this agent; **Part A** covers the library it sits on.
|
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
|
from nisse_dev.worker import SerialWorker
|
||||||
|
|
||||||
# How long shutdown waits for running work before leaving the store to the
|
# 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.
|
# manager allows for a stop, and a hard exit mid-work is already safe.
|
||||||
SHUTDOWN_WAIT_S = 10.0
|
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 = ModelCatalog.discover(config.llm_base_url, config.llm_token)
|
||||||
catalog.validate(config.roles.pinned())
|
catalog.validate(config.roles.pinned())
|
||||||
# Every role's context window must be known before a request is built
|
# 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.
|
# silently cut the prompt.
|
||||||
catalog.require_limits(config.roles.pinned())
|
catalog.require_limits(config.roles.pinned())
|
||||||
for role in TOOL_DRIVING_ROLES:
|
for role in TOOL_DRIVING_ROLES:
|
||||||
facts = catalog.facts(config.roles.resolve(role))
|
facts = catalog.facts(config.roles.resolve(role))
|
||||||
if facts.supports_tools is False:
|
if facts.supports_tools is False:
|
||||||
# A warning, not a refusal: models LiteLLM declares tool-less are
|
# 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.
|
# models to be accommodated rather than excluded.
|
||||||
oplog.emit(
|
oplog.emit(
|
||||||
"model_tools_unsupported",
|
"model_tools_unsupported",
|
||||||
@@ -83,7 +83,7 @@ class Agent:
|
|||||||
_stopped: threading.Event
|
_stopped: threading.Event
|
||||||
|
|
||||||
def start(self) -> None:
|
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."""
|
before accepting a single operator command."""
|
||||||
self.worker.call(self.orchestrator.start)
|
self.worker.call(self.orchestrator.start)
|
||||||
self.transport.start()
|
self.transport.start()
|
||||||
@@ -108,7 +108,7 @@ class Agent:
|
|||||||
try:
|
try:
|
||||||
self.worker.call(self.store.close, timeout=SHUTDOWN_WAIT_S)
|
self.worker.call(self.store.close, timeout=SHUTDOWN_WAIT_S)
|
||||||
except TimeoutError:
|
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.worker.stop(timeout=SHUTDOWN_WAIT_S)
|
||||||
self._stopped.set()
|
self._stopped.set()
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ def make_llm_clients(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Delegations prefer the vendor-native route; fidelity is cheap
|
# 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.
|
# select_client, not by where the caller happens to sit.
|
||||||
passthrough = budgeted(
|
passthrough = budgeted(
|
||||||
AnthropicPassthroughClient(
|
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
|
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
|
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.
|
even after the state volume is lost: external truth wins.
|
||||||
|
|
||||||
Approvals arriving here bind to a revision exactly as they do over transport
|
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.
|
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
|
`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
|
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``
|
The durable record is this event log, never a provider-shaped ``messages``
|
||||||
array; provider request bodies are rendered from it at call time. Canonical
|
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.
|
never used as a join key.
|
||||||
|
|
||||||
Transcript appends are the conversation record itself, not state-machine
|
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
|
import uuid
|
||||||
@@ -59,7 +59,7 @@ class DelegationStarted:
|
|||||||
model: str # the alias asked for, e.g. "remote-architecture"
|
model: str # the alias asked for, e.g. "remote-architecture"
|
||||||
caps: dict[str, Any] # turns / tokens / wall-clock limits
|
caps: dict[str, Any] # turns / tokens / wall-clock limits
|
||||||
# What the alias resolved to at task start. An alias is free to change
|
# 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.
|
# would average results across whatever it happened to point at.
|
||||||
backing_model: str = ""
|
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
|
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
|
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]]:
|
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
|
Recording only the alias would let an alias that silently changes
|
||||||
models muddle the eval history behind it: "remote-architecture: 80%
|
models muddle the eval history behind it: "remote-architecture: 80%
|
||||||
@@ -217,9 +217,9 @@ def select_client(
|
|||||||
) -> LLMClient:
|
) -> LLMClient:
|
||||||
"""Choose the route from what the model *is*, not from what it is called.
|
"""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
|
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
|
delegations. There is deliberately no name-based fallback here: a second
|
||||||
heuristic could disagree with the proxy, and an unknown model raises
|
heuristic could disagree with the proxy, and an unknown model raises
|
||||||
rather than quietly taking the normalized route.
|
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
|
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
|
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
|
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
|
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``
|
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``
|
(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.
|
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
|
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
|
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.
|
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, MILESTONE_PLANNING),
|
||||||
(AWAIT_MILESTONE_SIGNOFF, EXECUTING),
|
(AWAIT_MILESTONE_SIGNOFF, EXECUTING),
|
||||||
(EXECUTING, PR_OPEN),
|
(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
|
# durable record, so completion returns to planning or to idle rather
|
||||||
# than passing through a separate FINISHED state.
|
# than passing through a separate FINISHED state.
|
||||||
(PR_OPEN, MILESTONE_PLANNING),
|
(PR_OPEN, MILESTONE_PLANNING),
|
||||||
(PR_OPEN, IDLE),
|
(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_INPUT may go anywhere already.)
|
||||||
(AWAIT_ARCH_SIGNOFF, IDLE),
|
(AWAIT_ARCH_SIGNOFF, IDLE),
|
||||||
(AWAIT_MILESTONE_SIGNOFF, IDLE),
|
(AWAIT_MILESTONE_SIGNOFF, IDLE),
|
||||||
@@ -91,7 +91,7 @@ class PlanningError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class PlanningStopped(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)
|
@dataclass(frozen=True)
|
||||||
@@ -193,7 +193,7 @@ def _project_name(text: str) -> str:
|
|||||||
def _fresh_data() -> dict[str, Any]:
|
def _fresh_data() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
# Latest revision per gate; revision numbers themselves run across
|
# 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": {},
|
"revs": {},
|
||||||
"last_rev": 0,
|
"last_rev": 0,
|
||||||
# The revision last put up for sign-off: a reply that names none
|
# 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,
|
"pending": None,
|
||||||
"milestone": 1,
|
"milestone": 1,
|
||||||
"task_id": None,
|
"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
|
# What each pinned alias resolved to, discovered from LiteLLM. An
|
||||||
# alias is free to point somewhere else tomorrow, so the transcript
|
# 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,
|
"models": None,
|
||||||
"pr": None, # the open PR for the current milestone, once published
|
"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
|
# only this, so gate state can be re-derived from Gitea after a
|
||||||
# state-volume loss.
|
# state-volume loss.
|
||||||
"board": None,
|
"board": None,
|
||||||
@@ -244,7 +244,7 @@ class Orchestrator:
|
|||||||
self._executor = executor
|
self._executor = executor
|
||||||
self._publisher = publisher
|
self._publisher = publisher
|
||||||
self._operators = set(operators)
|
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
|
# are different names for the same person, and neither implies
|
||||||
# the other.
|
# the other.
|
||||||
self._gitea_operators = set(gitea_operators)
|
self._gitea_operators = set(gitea_operators)
|
||||||
@@ -276,14 +276,14 @@ class Orchestrator:
|
|||||||
transport.on_message(self._handle)
|
transport.on_message(self._handle)
|
||||||
|
|
||||||
def start(self, reconcilers: Mapping[str, Reconciler] | None = None) -> None:
|
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
|
# 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.
|
# genuinely unclean cut is left for the sweep below to gate on.
|
||||||
if self._delegate is not None:
|
if self._delegate is not None:
|
||||||
with self._long_work():
|
with self._long_work():
|
||||||
self._delegate.recover(self._transport.send_message)
|
self._delegate.recover(self._transport.send_message)
|
||||||
# The publisher owns the external-truth query for its own intents, so
|
# 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 {})
|
registered: dict[str, Reconciler] = dict(reconcilers or {})
|
||||||
if self._publisher is not None:
|
if self._publisher is not None:
|
||||||
registered = {**self._publisher.reconcilers(), **registered}
|
registered = {**self._publisher.reconcilers(), **registered}
|
||||||
@@ -317,7 +317,7 @@ class Orchestrator:
|
|||||||
pending={"kind": "boot", "interrupted": state.state},
|
pending={"kind": "boot", "interrupted": state.state},
|
||||||
)
|
)
|
||||||
elif state.state == AWAIT_INPUT and self._data.get("pending"):
|
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
|
# nothing was in flight, and the question and what answering it
|
||||||
# resumes are durable. Ask it again rather than replacing it.
|
# resumes are durable. Ask it again rather than replacing it.
|
||||||
pending = self._data["pending"]
|
pending = self._data["pending"]
|
||||||
@@ -326,7 +326,7 @@ class Orchestrator:
|
|||||||
+ pending.get("prompt", "my last question still stands")
|
+ pending.get("prompt", "my last question still stands")
|
||||||
)
|
)
|
||||||
elif state.state == EXECUTING and (published := self._published_pr()):
|
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
|
# published and only PR_OPEN went unrecorded. Adopt the PR rather
|
||||||
# than ask, and never publish it again. Nothing watched it yet,
|
# than ask, and never publish it again. Nothing watched it yet,
|
||||||
# so every comment on it is new.
|
# so every comment on it is new.
|
||||||
@@ -347,7 +347,7 @@ class Orchestrator:
|
|||||||
"review comments"
|
"review comments"
|
||||||
)
|
)
|
||||||
elif state.state == PR_OPEN:
|
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.
|
# 8): resume, and watch the PR again from its cursor.
|
||||||
pr = self._data.get("pr") or {}
|
pr = self._data.get("pr") or {}
|
||||||
self._watch_pr(pr)
|
self._watch_pr(pr)
|
||||||
@@ -385,7 +385,7 @@ class Orchestrator:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Interrupted mid-turn (planning, executing, unanswered question):
|
# Interrupted mid-turn (planning, executing, unanswered question):
|
||||||
# never blind-resume (A3.6).
|
# never blind-resume (L8 boot step 5).
|
||||||
self._await_input(
|
self._await_input(
|
||||||
cause=f"boot: restarted in {state.state}",
|
cause=f"boot: restarted in {state.state}",
|
||||||
prompt=f"restarted mid-{state.state}; {_BOOT_CHOICES}",
|
prompt=f"restarted mid-{state.state}; {_BOOT_CHOICES}",
|
||||||
@@ -400,7 +400,7 @@ class Orchestrator:
|
|||||||
text = msg.text.strip()
|
text = msg.text.strip()
|
||||||
if command_form(text) == "stop":
|
if command_form(text) == "stop":
|
||||||
# Straight to the flag, not behind the work in the queue: the
|
# 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:
|
if self._worker.busy:
|
||||||
self._cancel.set()
|
self._cancel.set()
|
||||||
self._worker.submit(lambda: self._finish_stop(msg))
|
self._worker.submit(lambda: self._finish_stop(msg))
|
||||||
@@ -478,7 +478,7 @@ class Orchestrator:
|
|||||||
def _halt_for_stop(
|
def _halt_for_stop(
|
||||||
self, *, what: str, note: str, next_step: str, pending: dict[str, Any]
|
self, *, what: str, note: str, next_step: str, pending: dict[str, Any]
|
||||||
) -> None:
|
) -> 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
|
operator's call. The prompt asks why, because the model should hear
|
||||||
the reason before it tries again."""
|
the reason before it tries again."""
|
||||||
self._halted_by_stop = True
|
self._halted_by_stop = True
|
||||||
@@ -494,7 +494,7 @@ class Orchestrator:
|
|||||||
def _handle_now(self, msg: InboundMessage, about: int | None = None) -> None:
|
def _handle_now(self, msg: InboundMessage, about: int | None = None) -> None:
|
||||||
# Every operator message enters the canonical transcript, so planning
|
# Every operator message enters the canonical transcript, so planning
|
||||||
# turns see feedback and answers as conversation rather than as
|
# turns see feedback and answers as conversation rather than as
|
||||||
# out-of-band parameters (A10).
|
# out-of-band parameters (L7).
|
||||||
self._store.append_event(
|
self._store.append_event(
|
||||||
UserMessage(
|
UserMessage(
|
||||||
text=(msg.original or msg.text).strip(),
|
text=(msg.original or msg.text).strip(),
|
||||||
@@ -555,7 +555,7 @@ class Orchestrator:
|
|||||||
|
|
||||||
def _begin_project(self, project: str) -> None:
|
def _begin_project(self, project: str) -> None:
|
||||||
task_id = f"{project}-{uuid.uuid4().hex[:8]}"
|
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.
|
# split one task across two planners.
|
||||||
pinned = self._roles.pinned() if self._roles is not None else None
|
pinned = self._roles.pinned() if self._roles is not None else None
|
||||||
resolved = (
|
resolved = (
|
||||||
@@ -666,7 +666,7 @@ class Orchestrator:
|
|||||||
def _apply_gate_reply(
|
def _apply_gate_reply(
|
||||||
self, *, sender: str, text: str, channel: str, about: int | None = None
|
self, *, sender: str, text: str, channel: str, about: int | None = 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
|
a Matrix MXID and a Gitea login are different names, and holding one
|
||||||
says nothing about holding the other."""
|
says nothing about holding the other."""
|
||||||
gate_data = self._data["gate"]
|
gate_data = self._data["gate"]
|
||||||
@@ -771,7 +771,7 @@ class Orchestrator:
|
|||||||
|
|
||||||
def _echo_outcome(self, deciding_channel: str, text: str) -> None:
|
def _echo_outcome(self, deciding_channel: str, text: str) -> None:
|
||||||
"""A gate satisfied on one channel is echoed to the other, so neither
|
"""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")
|
board_state = self._data.get("board")
|
||||||
if deciding_channel != "gitea" and self._board is not None and board_state:
|
if deciding_channel != "gitea" and self._board is not None and board_state:
|
||||||
self._board.echo(board_state["issue"], text)
|
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
|
# Which comment put which revision up: a Gitea reply binds to the
|
||||||
# latest plan posted before it, since comment IDs only grow.
|
# latest plan posted before it, since comment IDs only grow.
|
||||||
board_state.setdefault("posts", []).append([post.comment_id, rev])
|
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
|
# 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.
|
# what came before it: an early "approve" must not approve it.
|
||||||
board_state["last_comment"] = self._watch(
|
board_state["last_comment"] = self._watch(
|
||||||
@@ -801,7 +801,7 @@ class Orchestrator:
|
|||||||
self._save(f"{gate_name} rev-{rev} posted to Gitea")
|
self._save(f"{gate_name} rev-{rev} posted to Gitea")
|
||||||
|
|
||||||
def handle_plan_comment(self, comment: Any) -> None:
|
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
|
Approvals bind to a revision here exactly as over transport, and the
|
||||||
first valid approval on either channel wins. Called from the poller
|
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:
|
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:
|
Comment authors are checked against the Gitea operator allowlist:
|
||||||
a PR comment is an instruction channel into an agent that writes
|
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
|
code, so it is operator-scoped like any other (L11, and the same
|
||||||
reasoning C4 applies to tickets).
|
reasoning O5 applies to tickets).
|
||||||
"""
|
"""
|
||||||
self._worker.call(
|
self._worker.call(
|
||||||
lambda: self._settled(
|
lambda: self._settled(
|
||||||
@@ -991,7 +991,7 @@ class Orchestrator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _abandon(self, sender: str) -> None:
|
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
|
abandon), closing its Gitea records. Only reached from a waiting
|
||||||
state: while work runs the reply is "busy; say 'stop'" instead.
|
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:
|
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
|
resume: 'continue' re-runs the interrupted planning turn, or returns
|
||||||
an interrupted milestone to its approved plan to be started again."""
|
an interrupted milestone to its approved plan to be started again."""
|
||||||
choice = command_form(text)
|
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
|
Escalation is delegation, not consultation: a frontier model receives a scoped
|
||||||
sub-task, works autonomously with full tool access, and hands back. The local
|
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
|
**Checkpointing as it happens.** Every tool call, result, and assistant turn
|
||||||
is written to the canonical log when it occurs, never batched at handback.
|
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
|
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.
|
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 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
|
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.
|
would throw away work because the budget ran out.
|
||||||
"""
|
"""
|
||||||
return self.status == "interrupted"
|
return self.status == "interrupted"
|
||||||
@@ -152,7 +152,7 @@ class DelegationResult:
|
|||||||
@property
|
@property
|
||||||
def usable(self) -> bool:
|
def usable(self) -> bool:
|
||||||
"""Only a successful handback may be built upon. A cap breach, a
|
"""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."""
|
says must gate rather than be resumed."""
|
||||||
return self.status == "handback" and self.handback is not None
|
return self.status == "handback" and self.handback is not None
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ class Delegation:
|
|||||||
started_at = self._now()
|
started_at = self._now()
|
||||||
base_branch = self._git.current_branch()
|
base_branch = self._git.current_branch()
|
||||||
# Recorded before the branch exists, so a crash anywhere after this
|
# 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(
|
intent_id = self._store.begin_intent(
|
||||||
DELEGATION_INTENT,
|
DELEGATION_INTENT,
|
||||||
{
|
{
|
||||||
@@ -234,12 +234,12 @@ class Delegation:
|
|||||||
)
|
)
|
||||||
if result.interrupted:
|
if result.interrupted:
|
||||||
# Leave the branch and the intent alone: this delegation is not
|
# 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.
|
# machinery for picking it back up.
|
||||||
return result
|
return result
|
||||||
if not result.usable:
|
if not result.usable:
|
||||||
# Abort discards the branch: the caller must never resume on top
|
# 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)
|
self._abandon(branch, base_branch)
|
||||||
# Settled either way: an aborted delegation is a *finished* one, and
|
# Settled either way: an aborted delegation is a *finished* one, and
|
||||||
# leaving the intent pending would make the next boot think work was
|
# leaving the intent pending would make the next boot think work was
|
||||||
@@ -248,12 +248,12 @@ class Delegation:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def resume(self, intent: Intent) -> DelegationResult:
|
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
|
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``:
|
it and carries on. No new branch and no second ``delegation_started``:
|
||||||
this is the same delegation, which is why its vendor-opaque reasoning
|
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).
|
process lifetime).
|
||||||
"""
|
"""
|
||||||
payload = intent.payload
|
payload = intent.payload
|
||||||
@@ -284,7 +284,7 @@ class Delegation:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def recover(self, notify: Callable[[str], None]) -> DelegationResult | None:
|
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
|
Settles what is already finished, discards what never started, resumes
|
||||||
a clean cut — announcing it with a *notice*, not a question, because a
|
a clean cut — announcing it with a *notice*, not a question, because a
|
||||||
@@ -363,7 +363,7 @@ class Delegation:
|
|||||||
tokens=spent,
|
tokens=spent,
|
||||||
)
|
)
|
||||||
# Metered from what the provider reported, not estimated. The cap
|
# 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
|
# the LiteLLM key — but an unbounded delegation can still exhaust
|
||||||
# a daily budget and take external models away until it resets.
|
# a daily budget and take external models away until it resets.
|
||||||
usage = getattr(self._client, "last_usage", {}) or {}
|
usage = getattr(self._client, "last_usage", {}) or {}
|
||||||
@@ -380,7 +380,7 @@ class Delegation:
|
|||||||
|
|
||||||
calls: list[ToolCall] = []
|
calls: list[ToolCall] = []
|
||||||
for event in produced:
|
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.
|
# this is what makes the delegation resumable after a crash.
|
||||||
self._store.append_event(event)
|
self._store.append_event(event)
|
||||||
if isinstance(event, ToolCall):
|
if isinstance(event, ToolCall):
|
||||||
@@ -475,7 +475,7 @@ class Delegation:
|
|||||||
|
|
||||||
# Commit each turn's work onto the delegation branch, so that
|
# Commit each turn's work onto the delegation branch, so that
|
||||||
# discarding the branch really does discard the work, and so a
|
# 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)
|
self._commit_turn(turn)
|
||||||
|
|
||||||
return DelegationResult(
|
return DelegationResult(
|
||||||
@@ -504,7 +504,7 @@ class Delegation:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_handback(arguments: dict[str, Any]) -> Handback | None:
|
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."""
|
structured output applies to the handback too."""
|
||||||
required = ("changed", "now_true", "next")
|
required = ("changed", "now_true", "next")
|
||||||
if any(not str(arguments.get(key, "")).strip() for key in required):
|
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:
|
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
|
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
|
- ``True`` the delegation is settled or cleanly resumable
|
||||||
- ``False`` it never really started, so there is nothing to resume
|
- ``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
|
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
|
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
|
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:
|
def check(intent: Intent) -> bool | None:
|
||||||
@@ -549,7 +549,7 @@ def delegation_reconciler(store: StateStore, git_repo: GitRepo) -> Reconciler:
|
|||||||
def delegation_verdict(
|
def delegation_verdict(
|
||||||
store: StateStore, git_repo: GitRepo, intent: Intent
|
store: StateStore, git_repo: GitRepo, intent: Intent
|
||||||
) -> Literal["resume", "settled", "absent", "gate"]:
|
) -> 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."""
|
boot must *act* on it — resuming is not the same as judging settled."""
|
||||||
branch = intent.payload.get("branch")
|
branch = intent.payload.get("branch")
|
||||||
if not branch:
|
if not branch:
|
||||||
@@ -564,7 +564,7 @@ def delegation_verdict(
|
|||||||
last = events[-1].event
|
last = events[-1].event
|
||||||
if isinstance(last, ToolCall):
|
if isinstance(last, ToolCall):
|
||||||
# A call with no recorded result: its effect is unknown and cannot be
|
# 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"
|
return "gate"
|
||||||
if not isinstance(last, AssistantText | ToolResult):
|
if not isinstance(last, AssistantText | ToolResult):
|
||||||
return "gate"
|
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
|
Deliberately tiny: KEY=VALUE lines, comments and blanks ignored, existing
|
||||||
environment variables never overridden (real env wins over the file).
|
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
|
Each ``delegation_started`` / ``delegation_completed`` pair is already a clean
|
||||||
input-output record; this pairs them up and attaches a verdict, so "best model
|
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.
|
"""Pair each start with the completion that follows it.
|
||||||
|
|
||||||
A start with no completion is skipped rather than reported half-formed:
|
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.
|
unfinished record would pollute the eval set.
|
||||||
"""
|
"""
|
||||||
verdicts = store.outcomes()
|
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
|
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
|
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
|
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
|
or finished-enough. Until delegation exists (step 7), an escalation gates to
|
||||||
the human with the failures attached rather than engaging a frontier model.
|
the human with the failures attached rather than engaging a frontier model.
|
||||||
@@ -115,7 +115,7 @@ class LLMExecutor:
|
|||||||
def _escalate(
|
def _escalate(
|
||||||
self, milestone: int, failures: int, last_failure: str
|
self, milestone: int, failures: int, last_failure: str
|
||||||
) -> ExecutorOutcome | None:
|
) -> 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
|
Returns None when the delegation handed back usable work and the local
|
||||||
loop should carry on; an ExecutorOutcome when the caller must stop.
|
loop should carry on; an ExecutorOutcome when the caller must stop.
|
||||||
@@ -124,7 +124,7 @@ class LLMExecutor:
|
|||||||
if self._delegate is None:
|
if self._delegate is None:
|
||||||
return ExecutorOutcome("escalate", note)
|
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.
|
# rediscover what the local model already learned the hard way.
|
||||||
brief = (
|
brief = (
|
||||||
f"Milestone {milestone} is stuck. The local executor tried and its "
|
f"Milestone {milestone} is stuck. The local executor tried and its "
|
||||||
@@ -140,14 +140,14 @@ class LLMExecutor:
|
|||||||
return None
|
return None
|
||||||
if result.status == "stopped":
|
if result.status == "stopped":
|
||||||
return ExecutorOutcome("stopped", f"during a delegation: {result.note}")
|
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.
|
# loop must not resume on top of it.
|
||||||
return ExecutorOutcome(
|
return ExecutorOutcome(
|
||||||
"escalate", f"{note}; delegation {result.status}: {result.note}"
|
"escalate", f"{note}; delegation {result.status}: {result.note}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _tools(self) -> list[dict[str, Any]]:
|
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.
|
# triggers, never as a replacement for them.
|
||||||
if self._expert is None:
|
if self._expert is None:
|
||||||
return [*EXECUTOR_TOOLS, FINISH_TOOL]
|
return [*EXECUTOR_TOOLS, FINISH_TOOL]
|
||||||
@@ -203,7 +203,7 @@ class LLMExecutor:
|
|||||||
prose_nudged = False
|
prose_nudged = False
|
||||||
|
|
||||||
for turn in range(self._max_turns):
|
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.
|
# a turn is one, and so is the gap between two tool calls below.
|
||||||
if self._cancel.requested:
|
if self._cancel.requested:
|
||||||
return ExecutorOutcome("stopped", f"before turn {turn + 1}")
|
return ExecutorOutcome("stopped", f"before turn {turn + 1}")
|
||||||
@@ -267,7 +267,7 @@ class LLMExecutor:
|
|||||||
elif call.tool not in offered:
|
elif call.tool not in offered:
|
||||||
# A model inventing tools is not doing the work, and with
|
# A model inventing tools is not doing the work, and with
|
||||||
# no server enforcement nothing else would stop it, so it
|
# 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(
|
outcome = ToolOutcome(
|
||||||
f"no such tool: {call.tool}; the tools are "
|
f"no such tool: {call.tool}; the tools are "
|
||||||
+ ", ".join(sorted(offered)),
|
+ ", ".join(sorted(offered)),
|
||||||
@@ -294,7 +294,7 @@ class LLMExecutor:
|
|||||||
0 if outcome.success else consecutive_failures + 1
|
0 if outcome.success else consecutive_failures + 1
|
||||||
)
|
)
|
||||||
if consecutive_failures >= self._failure_limit:
|
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
|
# Escalating to the human is the fallback when there
|
||||||
# is no delegate, not the intended path.
|
# is no delegate, not the intended path.
|
||||||
escalation = self._escalate(
|
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 —
|
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
|
"which of these two approaches is better", "is this API deprecated". A single
|
||||||
stateless call, a text answer, no session state, no diff application.
|
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.**
|
**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
|
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
|
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
|
**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
|
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,
|
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
|
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
|
set", and reusing the composite events gets consult evaluation for free rather
|
||||||
than inventing a parallel record.
|
than inventing a parallel record.
|
||||||
"""
|
"""
|
||||||
@@ -150,7 +150,7 @@ class ExpertDesk:
|
|||||||
last = "no reply"
|
last = "no reply"
|
||||||
for attempt in (1, 2):
|
for attempt in (1, 2):
|
||||||
try:
|
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
|
# synthesised message that is never appended to the log, so a
|
||||||
# consult neither depends on nor leaks repo context. It cannot
|
# consult neither depends on nor leaks repo context. It cannot
|
||||||
# ride in the system prompt alone — the Messages API requires
|
# 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
|
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
|
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
|
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
|
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:
|
def discard_branch(self, branch: str, *, back_to: str) -> None:
|
||||||
"""Throw away a branch and everything on it.
|
"""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
|
atomicity that reconciliation gives an interrupted transition, and
|
||||||
half-finished work must not survive to be built upon.
|
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
|
Outbound: open PRs and post comments through a dedicated service account with
|
||||||
a scoped token. Inbound is deliberately *polled* rather than webhook-driven,
|
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.
|
never routed through a chat bridge.
|
||||||
|
|
||||||
``find_pull_request`` is the external-truth query that makes opening a PR
|
``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.
|
effect already happened rather than repeating it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ class GiteaClient:
|
|||||||
return _pull_request(data)
|
return _pull_request(data)
|
||||||
|
|
||||||
def find_pull_request(self, repo: str, head: str) -> PullRequest | None:
|
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
|
opened? Includes closed PRs — a crash after opening must not be
|
||||||
answered with "no PR is open, so open another".
|
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:
|
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
|
An unreachable Gitea yields None — unverifiable, so the agent gates to
|
||||||
the human rather than risking a duplicate PR. That is the conservative
|
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
|
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
|
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
|
LiteLLM token and no vendor credentials. The base URL and token come from the
|
||||||
agent's config (nisse_dev.config), nothing hardcoded.
|
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
|
from config; the resolved mapping is pinned at task start so a mid-task
|
||||||
config change cannot split a task across two planners.
|
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
|
# 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
|
# 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).
|
# total).
|
||||||
RATE_LIMIT_STATUS = 429
|
RATE_LIMIT_STATUS = 429
|
||||||
RATE_LIMIT_RETRY_S = 20.0
|
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
|
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
|
own within the hour: an exhausted daily budget takes external models away
|
||||||
until it resets, so retrying wastes time and the operator needs telling.
|
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))
|
return str(getattr(self, role))
|
||||||
|
|
||||||
def pinned(self) -> dict[str, str]:
|
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)
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ class LiteLLMClient:
|
|||||||
self._renderer = OpenAIChatRenderer()
|
self._renderer = OpenAIChatRenderer()
|
||||||
self._sleep = sleep
|
self._sleep = sleep
|
||||||
self._max_tokens = max_tokens or DEFAULT_MAX_OUTPUT_TOKENS
|
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
|
# a side channel rather than a return value, so the LLMClient
|
||||||
# protocol stays the same for every implementation.
|
# protocol stays the same for every implementation.
|
||||||
self.last_usage: dict[str, int] = {}
|
self.last_usage: dict[str, int] = {}
|
||||||
@@ -211,13 +211,13 @@ class LiteLLMClient:
|
|||||||
out: list[CanonicalEvent] = []
|
out: list[CanonicalEvent] = []
|
||||||
if message.get("content"):
|
if message.get("content"):
|
||||||
out.append(AssistantText(text=message["content"], model=produced_by))
|
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
|
# gets a locally-minted canonical ID; the provider's is dropped, since
|
||||||
# every future request is rendered wholly from the canonical log.
|
# every future request is rendered wholly from the canonical log.
|
||||||
for call in message.get("tool_calls") or []:
|
for call in message.get("tool_calls") or []:
|
||||||
raw = call["function"]["arguments"] or "{}"
|
raw = call["function"]["arguments"] or "{}"
|
||||||
# Weak models emit truncated or non-JSON argument strings; that is
|
# 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:
|
try:
|
||||||
arguments = json.loads(raw)
|
arguments = json.loads(raw)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
@@ -244,10 +244,10 @@ class LiteLLMClient:
|
|||||||
|
|
||||||
|
|
||||||
class AnthropicPassthroughClient:
|
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.
|
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.
|
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))
|
out.append(AssistantText(text=block["text"], model=produced_by))
|
||||||
elif kind == "tool_use":
|
elif kind == "tool_use":
|
||||||
# Provider ids are mapped at the boundary and never used as a
|
# 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(
|
out.append(
|
||||||
ToolCall(
|
ToolCall(
|
||||||
call_id=mint_call_id(),
|
call_id=mint_call_id(),
|
||||||
@@ -349,7 +349,7 @@ class AnthropicPassthroughClient:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Vendor-opaque reasoning blocks (thinking, redacted_thinking) are
|
# 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
|
# with the producing model and never replaying them outside the
|
||||||
# session that made them, and the canonical schema has no event
|
# session that made them, and the canonical schema has no event
|
||||||
# for that yet. Nothing requests extended thinking, so none arrive.
|
# for that yet. Nothing requests extended thinking, so none arrive.
|
||||||
@@ -378,7 +378,7 @@ def _post_with_rate_limit_retry(
|
|||||||
raise AssertionError("unreachable")
|
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
|
# the vendor's passthrough route because a normalising middlebox is lossy
|
||||||
# exactly where a long-running delegation lives.
|
# exactly where a long-running delegation lives.
|
||||||
def _response_cost(response: httpx.Response) -> float:
|
def _response_cost(response: httpx.Response) -> float:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Entry point for the dev agent.
|
"""Entry point for the dev agent.
|
||||||
|
|
||||||
Loads configuration, builds the agent, and stays up until told to stop.
|
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
|
the process may die at any moment, but a clean stop still beats relying on
|
||||||
them.
|
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
|
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
|
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
|
earns its keep when E2E arrives, and bot rooms are created unencrypted until
|
||||||
then, so encrypted rooms are not supported here.
|
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
|
loop backs off (capped) and retries indefinitely — an outage delays reactions
|
||||||
but never fails anything.
|
but never fails anything.
|
||||||
"""
|
"""
|
||||||
@@ -101,7 +101,7 @@ class MatrixTransport:
|
|||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
"""Begin delivering new room messages to the registered callback.
|
"""Begin delivering new room messages to the registered callback.
|
||||||
History from before start() is never delivered (Matrix room history
|
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:
|
if self._callback is None:
|
||||||
raise TransportError("no message handler registered; call on_message first")
|
raise TransportError("no message handler registered; call on_message first")
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
@@ -184,7 +184,7 @@ class MatrixTransport:
|
|||||||
"""Who a leading mention addresses, and the body without a leading
|
"""Who a leading mention addresses, and the body without a leading
|
||||||
mention of this agent. Only the start of the message counts: a
|
mention of this agent. Only the start of the message counts: a
|
||||||
mention later on ("approve, cc @alice") is part of the text. The
|
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"]
|
body: str = content["body"]
|
||||||
own = self._config.user_id
|
own = self._config.user_id
|
||||||
if content.get("format") == "org.matrix.custom.html":
|
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
|
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,
|
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:
|
def __init__(self, agent: str, stream: TextIO | None = None) -> None:
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
# Correlation ID for the enclosing unit of work; the orchestrator sets
|
# 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.task_id: str | None = None
|
||||||
self._stream = stream if stream is not None else sys.stderr
|
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
|
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,
|
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
|
and ``ask_human``, which pauses for an answer. No "proceed" tool exists, so
|
||||||
the model has no path to advance itself past a gate.
|
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.
|
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
|
from nisse_dev.state import StateStore
|
||||||
|
|
||||||
# Plans may be executed by a materially weaker model, which cannot
|
# 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.
|
SYSTEM_PROMPT = """You are nisse-dev, a software engineering agent.
|
||||||
|
|
||||||
You are planning, not implementing. Produce plans that are explicit and
|
You are planning, not implementing. Produce plans that are explicit and
|
||||||
@@ -132,7 +134,7 @@ class LLMPlanningAgent:
|
|||||||
self, model: str, system: str
|
self, model: str, system: str
|
||||||
) -> tuple[SubmitPlan | AskHuman | None, str | None]:
|
) -> tuple[SubmitPlan | AskHuman | None, str | None]:
|
||||||
"""A malformed or failed call is a bad turn, not a crash: the caller
|
"""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)."""
|
output is observable, never the model's own claim)."""
|
||||||
try:
|
try:
|
||||||
return self._turn(model, system), None
|
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
|
needs to reach in. Intake is structured JSON straight from the API and never
|
||||||
routed through a chat bridge.
|
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
|
indefinitely, and log. Polling is pure intake — an outage delays a reaction
|
||||||
but must never fail a task or force a state transition.
|
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
|
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
|
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,
|
plan,
|
||||||
]
|
]
|
||||||
if service_url:
|
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
|
# 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]
|
parts += ["", "## Running test service", "", service_url]
|
||||||
return "\n".join(parts)
|
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.
|
known state.
|
||||||
|
|
||||||
Every pending intent found at startup is checked against external truth by a
|
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.
|
call time, never stored provider-shaped.
|
||||||
|
|
||||||
First adapter: the OpenAI-compatible chat format LiteLLM's normalized route
|
First adapter: the OpenAI-compatible chat format LiteLLM's normalized route
|
||||||
speaks — the executor role's path (A5). The Anthropic passthrough adapter
|
speaks — the executor role's path (L9). The Anthropic passthrough adapter
|
||||||
arrives with delegations (A13). Token budgeting and prompt-cache breakpoints
|
arrives with delegations (L9). Token budgeting and prompt-cache breakpoints
|
||||||
are render-time concerns and are never stored (A10); neither exists yet in
|
are render-time concerns and are never stored (L7); neither exists yet in
|
||||||
this skeleton.
|
this skeleton.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ class OpenAIChatRenderer:
|
|||||||
_append_merged(messages, self._message_for(stored.event))
|
_append_merged(messages, self._message_for(stored.event))
|
||||||
# Providers reject a tool call with no result following it. One
|
# Providers reject a tool call with no result following it. One
|
||||||
# can legitimately be missing: a crash between the call and its
|
# 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.
|
# the transcript stays replayable rather than poisoned.
|
||||||
if isinstance(stored.event, ToolCall) and (
|
if isinstance(stored.event, ToolCall) and (
|
||||||
stored.event.call_id not in answered
|
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
|
rather than on the wire: the two vendors disagree *structurally*, not
|
||||||
cosmetically.
|
cosmetically.
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class AnthropicMessagesRenderer:
|
|||||||
blocks.extend(self._blocks_for(stored.event))
|
blocks.extend(self._blocks_for(stored.event))
|
||||||
# A dangling tool_use is rejected outright by this API — more
|
# A dangling tool_use is rejected outright by this API — more
|
||||||
# strictly than the OpenAI shape — so a crash between a call and
|
# 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 (
|
if isinstance(stored.event, ToolCall) and (
|
||||||
stored.event.call_id not in answered
|
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
|
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
|
power cut mid-write can never produce torn state. Every state transition and
|
||||||
@@ -127,7 +127,7 @@ class StateStore:
|
|||||||
migrations: Mapping[int, Migration] = MIGRATIONS,
|
migrations: Mapping[int, Migration] = MIGRATIONS,
|
||||||
oplog: OpLog | None = None,
|
oplog: OpLog | None = 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.
|
# performs a durable write emits its log line, right after commit.
|
||||||
self.oplog = oplog if oplog is not None else OpLog("nisse-dev")
|
self.oplog = oplog if oplog is not None else OpLog("nisse-dev")
|
||||||
self._conn = sqlite3.connect(path, isolation_level=None)
|
self._conn = sqlite3.connect(path, isolation_level=None)
|
||||||
@@ -291,7 +291,7 @@ class StateStore:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def append_event(self, event: CanonicalEvent) -> int:
|
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."""
|
before return, like every other durable write."""
|
||||||
cursor = self._conn.execute(
|
cursor = self._conn.execute(
|
||||||
"INSERT INTO canonical_events (ts, type, payload) VALUES (?, ?, ?)",
|
"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:
|
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
|
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
|
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
|
Deliberately unsandboxed. The VM is the trust boundary (D7) and tool use is
|
||||||
auto-approved inside an approved unit of work (A4), so per-command
|
auto-approved inside an approved unit of work (L3), so per-command
|
||||||
confinement would add failure modes without adding safety. The workspace is
|
confinement would add failure modes without adding safety. The workspace is
|
||||||
where work happens — the default for relative paths and the command cwd — not
|
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
|
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
|
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
|
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:
|
def write_file(self, path: str, content: str) -> ToolOutcome:
|
||||||
target = self._resolve(path)
|
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
|
# 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
|
# to git, so the milestone would publish an empty PR while the model
|
||||||
# truthfully reports success. Models do guess plausible absolute paths
|
# 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
|
The orchestrator depends only on this interface; no platform SDK is imported
|
||||||
anywhere outside a concrete implementation. MatrixTransport arrives as its own
|
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.
|
Planning turns, milestones and review responses can each run for minutes.
|
||||||
Running them on the thread that delivered the message blocked that thread for
|
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
|
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.
|
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
|
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
|
import io
|
||||||
from pathlib import Path
|
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._board is not None
|
||||||
assert orchestrator._delegate is not None
|
assert orchestrator._delegate is not None
|
||||||
assert orchestrator._poller_factory is not None
|
assert orchestrator._poller_factory is not None
|
||||||
assert orchestrator._executor._delegate is not None # A12 escalation path
|
assert orchestrator._executor._delegate is not None # L9 escalation path
|
||||||
assert orchestrator._executor._expert is not None # A14 consult path
|
assert orchestrator._executor._expert is not None # L9 consult path
|
||||||
agent.stop()
|
agent.stop()
|
||||||
|
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ def test_poller_factory_ignores_the_agents_own_comments(
|
|||||||
def test_delegation_uses_the_passthrough_route(
|
def test_delegation_uses_the_passthrough_route(
|
||||||
tmp_path: Path, origin: Path, monkeypatch
|
tmp_path: Path, origin: Path, monkeypatch
|
||||||
) -> None:
|
) -> 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.
|
# places where fidelity is cheap.
|
||||||
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
|
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ def test_httpx_clients_are_not_shared_between_routes(
|
|||||||
agent.stop()
|
agent.stop()
|
||||||
|
|
||||||
|
|
||||||
# --- startup model discovery (A5/A11) ------------------------------------
|
# --- startup model discovery (L9) ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_a_typod_role_refuses_to_boot(tmp_path: Path, monkeypatch) -> None:
|
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:
|
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.
|
# declares tool-less are observed driving tool loops anyway.
|
||||||
import io
|
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(
|
def test_delegation_records_what_the_alias_resolved_to(
|
||||||
tmp_path: Path, origin: Path, monkeypatch
|
tmp_path: Path, origin: Path, monkeypatch
|
||||||
) -> None:
|
) -> 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.
|
# that alias pointed at over time.
|
||||||
agent = build_offline(tmp_path, origin, monkeypatch)
|
agent = build_offline(tmp_path, origin, monkeypatch)
|
||||||
assert agent.orchestrator._delegate._backing_model == "anthropic/claude-opus-5"
|
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
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Plan externalisation to Gitea and approvals arriving on either channel
|
"""Plan externalisation to Gitea and approvals arriving on either channel
|
||||||
(spec A4, Part H)."""
|
(architecture L3, D2)."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from pathlib import Path
|
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]
|
issue, body = gitea.comments[0]
|
||||||
assert issue == 1
|
assert issue == 1
|
||||||
assert "plan 1" in body
|
assert "plan 1" in body
|
||||||
assert "approve rev-1" in body # the tag is printed for copy-paste (A4)
|
assert "approve rev-1" in body # the tag is printed for copy-paste (L3)
|
||||||
# The store keeps a pointer, not the plan record itself (Part H).
|
# The store keeps a pointer, not the plan record itself (D2).
|
||||||
assert store.get_state().data["board"]["issue"] == 1
|
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:
|
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
|
# 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()
|
gitea = FakeGitea()
|
||||||
orch, store, transport, _ = build(tmp_path, gitea)
|
orch, store, transport, _ = build(tmp_path, gitea)
|
||||||
transport.receive("don", "use demo")
|
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
|
# 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 = [
|
V1_SCHEMA = [
|
||||||
"CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
"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
|
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
|
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:
|
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.
|
# native adapter, rather than sending it Anthropic-shaped bodies.
|
||||||
cat = catalog(entry("remote-architecture", "openai/gpt-6-astra", "openai"))
|
cat = catalog(entry("remote-architecture", "openai/gpt-6-astra", "openai"))
|
||||||
normalized, passthrough = object(), object()
|
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:
|
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.
|
# the alias alone would average across whatever it pointed at.
|
||||||
cat = catalog(entry("remote-architecture", "anthropic/claude-opus-5", "anthropic"))
|
cat = catalog(entry("remote-architecture", "anthropic/claude-opus-5", "anthropic"))
|
||||||
pinned = cat.pinned({"planner": "remote-architecture"})
|
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:
|
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()
|
config = build()
|
||||||
assert config.state_db.parent != config.workspace
|
assert config.state_db.parent != config.workspace
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""The context budget (spec A10)."""
|
"""The context budget (architecture L6)."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from pathlib import Path
|
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
|
assert "monorepo" in h.transport.outbound[-1].text
|
||||||
|
|
||||||
|
|
||||||
# --- restarts (A3 steps 7 and 8) --------------------------------------------
|
# --- restarts (L8 boot steps 4 and 5) --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class FailsOnce:
|
class FailsOnce:
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ def test_delegation_works_on_its_own_branch(
|
|||||||
def test_abort_discards_the_branch_and_its_work(
|
def test_abort_discards_the_branch_and_its_work(
|
||||||
store: StateStore, ws: Workspace, repo: GitRepo
|
store: StateStore, ws: Workspace, repo: GitRepo
|
||||||
) -> None:
|
) -> None:
|
||||||
# A13: abort must leave nothing to resume on top of.
|
# L9: abort must leave nothing to resume on top of.
|
||||||
client = ScriptedClient(
|
client = ScriptedClient(
|
||||||
[
|
[
|
||||||
[call("write_file", path="half-done.py", content="oops")],
|
[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(
|
def test_stop_discards_the_branch_and_settles_the_intent(
|
||||||
store: StateStore, ws: Workspace, repo: GitRepo
|
store: StateStore, ws: Workspace, repo: GitRepo
|
||||||
) -> None:
|
) -> 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.
|
# stopped it for a reason, so nothing is kept for resuming.
|
||||||
cancel = Cancel()
|
cancel = Cancel()
|
||||||
client = ScriptedClient(
|
client = ScriptedClient(
|
||||||
@@ -213,7 +213,7 @@ def test_cap_breach_is_not_usable_and_discards_work(
|
|||||||
brief="b", branch="d"
|
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()
|
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.brief == "the brief"
|
||||||
assert started.model == "claude-haiku-4-5"
|
assert started.model == "claude-haiku-4-5"
|
||||||
assert started.caps == {"turns": 7, "tokens": 1234, "wall_clock_s": 42.0}
|
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 completed.exit_status == "handback"
|
||||||
assert "greet()" in completed.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:
|
def test_hand_back_tool_is_offered_alongside_the_executor_tools() -> None:
|
||||||
names = [t["function"]["name"] for t in DELEGATE_TOOLS]
|
names = [t["function"]["name"] for t in DELEGATE_TOOLS]
|
||||||
assert names[-1] == "hand_back"
|
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(
|
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
|
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:
|
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
|
store: StateStore, ws: Workspace, repo: GitRepo
|
||||||
) -> None:
|
) -> None:
|
||||||
# Interrupt after one turn, then resume: no second delegation_started,
|
# 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")
|
repo.checkout_new_branch("d")
|
||||||
store.append_event(
|
store.append_event(
|
||||||
DelegationStarted(brief="b", role="delegate", model="m", caps={})
|
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)
|
result = build(client, store, ws, repo).recover(notices.append)
|
||||||
|
|
||||||
assert result is not None and result.usable
|
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]
|
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]
|
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):
|
class MeteredClient(ScriptedClient):
|
||||||
@@ -599,7 +599,7 @@ def test_tokens_spent_are_reported_not_left_at_zero(
|
|||||||
completed = next(
|
completed = next(
|
||||||
e.event for e in store.read_events() if isinstance(e.event, DelegationCompleted)
|
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
|
assert completed.tokens == 2000
|
||||||
|
|
||||||
|
|
||||||
@@ -639,7 +639,7 @@ def test_budget_exhaustion_preserves_the_work_for_later(
|
|||||||
# The work survives, on its branch.
|
# The work survives, on its branch.
|
||||||
assert repo.current_branch() == "d"
|
assert repo.current_branch() == "d"
|
||||||
assert (repo.root / "progress.py").exists()
|
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]
|
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
|
store: StateStore, ws: Workspace, repo: GitRepo
|
||||||
) -> None:
|
) -> None:
|
||||||
# Only external interruptions are preserved; a delegate that made a mess
|
# 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")]])
|
client = ScriptedClient([[hand_back(succeeded=False, changed="cannot")]])
|
||||||
result = build(client, store, ws, repo).run(brief="b", branch="d")
|
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:
|
def test_unpaired_start_is_skipped(store: StateStore) -> None:
|
||||||
# A crash mid-delegation leaves a start with no completion; a half-formed
|
# 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(
|
store.append_event(
|
||||||
DelegationStarted(brief="died", role="delegate", model="m", caps={})
|
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"]]
|
names = [t["function"]["name"] for t in client2.calls[0]["tools"]]
|
||||||
assert "ask_expert" in names
|
assert "ask_expert" in names
|
||||||
assert names[-1] == "finish"
|
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
|
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?")
|
desk(client, store).ask("Is this API deprecated?")
|
||||||
|
|
||||||
call = client.calls[0]
|
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.
|
# of the log the caller was working from.
|
||||||
assert [e.event.text for e in call["events"]] == ["Is this API deprecated?"]
|
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"])
|
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:
|
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.
|
# evaluation for free.
|
||||||
desk(ScriptedClient([GOOD]), store).ask("q")
|
desk(ScriptedClient([GOOD]), store).ask("q")
|
||||||
events = [e.event for e in store.read_events()]
|
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:
|
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:
|
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).
|
# typed word approve, nothing else).
|
||||||
result = gate().evaluate("don", "approve, but rename the module", "transport")
|
result = gate().evaluate("don", "approve, but rename the module", "transport")
|
||||||
assert isinstance(result, Rejected)
|
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"
|
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.
|
# 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"
|
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:
|
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)
|
assert issubclass(BudgetExhausted, UpstreamUnavailable)
|
||||||
|
|
||||||
|
|
||||||
# --- tool_choice (A15: constrained decoding) --------------------------------
|
# --- tool_choice (L9: constrained decoding) --------------------------------
|
||||||
|
|
||||||
TOOLS = [
|
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."""
|
JSON-lines event with the fixed envelope, from the store's own write path."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def test_outage_does_not_raise_out_of_the_loop() -> None:
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
poller.stop()
|
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).
|
# outage delays a reaction, never fails a task).
|
||||||
assert [c.id for c in seen] == [1]
|
assert [c.id for c in seen] == [1]
|
||||||
assert "gitea.poll_failed" in stream.getvalue()
|
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()]
|
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 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"
|
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."""
|
project completion."""
|
||||||
|
|
||||||
import io
|
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:
|
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.
|
# or with turn-ending gate tools like submit_plan.
|
||||||
events = stored(
|
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
|
import io
|
||||||
from pathlib import Path
|
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 io
|
||||||
import threading
|
import threading
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ def ws(tmp_path: Path) -> Workspace:
|
|||||||
|
|
||||||
|
|
||||||
def test_manifest_is_small_and_flat() -> None:
|
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]
|
names = [t["function"]["name"] for t in EXECUTOR_TOOLS]
|
||||||
assert names == ["read_file", "write_file", "list_dir", "run_command"]
|
assert names == ["read_file", "write_file", "list_dir", "run_command"]
|
||||||
for tool in EXECUTOR_TOOLS:
|
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:
|
def test_nonzero_exit_is_a_result_not_an_error(ws: Workspace) -> None:
|
||||||
outcome = ws.run_command("exit 3")
|
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
|
assert "exit code: 3" in outcome.result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
# nisse-ops
|
# 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.
|
`nisse-core` package.
|
||||||
|
|
||||||
**Read the architecture before making design decisions here:**
|
**Read the architecture before making design decisions here:**
|
||||||
`docs/Architecture-Build-Spec.md` at the repo root. Part C specifies this agent;
|
`docs/Architecture.md` at the repo root. Part O specifies this agent;
|
||||||
Part A specifies the library contracts it depends on. Requirements that look like
|
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,
|
free choices in this package — the permission policy engine, confirm-first gating,
|
||||||
secrets and SSO handling — are usually settled there.
|
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
|
tickets, gated on high-consequence actions. A thin application over the shared
|
||||||
`nisse-core` library. Built after the Dev Agent works.
|
`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)
|
**Part O** of the architecture at [`../docs/Architecture.md`](../docs/Architecture.md)
|
||||||
covers this agent; **Part A** covers the library it sits on.
|
covers this agent; **Part L** covers the library it sits on.
|
||||||
@@ -56,7 +56,7 @@ module orchestrator {
|
|||||||
task: bool, // a project is in use
|
task: bool, // a project is in use
|
||||||
gate: Gate,
|
gate: Gate,
|
||||||
revs: str -> int, // latest revision per 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,
|
ms: int,
|
||||||
pending: Pending,
|
pending: Pending,
|
||||||
prOn: bool, prKey: int, prLast: int, // _data["pr"]
|
prOn: bool, prKey: int, prLast: int, // _data["pr"]
|
||||||
@@ -270,7 +270,7 @@ module orchestrator {
|
|||||||
else if (s.st == "AWAIT_INPUT" and s.pending.kind != "")
|
else if (s.st == "AWAIT_INPUT" and s.pending.kind != "")
|
||||||
s0
|
s0
|
||||||
else if (s.st == "EXECUTING" and giteaUp and prState(s, prKeyOf(s)) == "open")
|
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.
|
// PR_OPEN went unrecorded. Adopt it; nothing watched it, so from 0.
|
||||||
{ ...s0, st: "PR_OPEN", prOn: true, prKey: prKeyOf(s), prLast: 0,
|
{ ...s0, st: "PR_OPEN", prOn: true, prKey: prKeyOf(s), prLast: 0,
|
||||||
prWatch: { on: true, floor: 0 }, planWatch: OFF }
|
prWatch: { on: true, floor: 0 }, planWatch: OFF }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Integration: state written before an abrupt process death must be intact
|
"""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 subprocess
|
||||||
import sys
|
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
|
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.
|
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 answer.confidence in {"high", "medium", "low"}
|
||||||
assert len(answer.answer) > 20
|
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)
|
[record] = collect(store)
|
||||||
assert record.role == "consultant"
|
assert record.role == "consultant"
|
||||||
assert record.exit_status == "answered"
|
assert record.exit_status == "answered"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Integration: a restart mid-gate resumes the open gate with its revision
|
"""Integration: a restart mid-gate resumes the open gate with its revision
|
||||||
history intact; unresolved intents gate the whole boot to AWAIT_INPUT
|
history intact; unresolved intents gate the whole boot to AWAIT_INPUT
|
||||||
(spec A2/A3/A4 together)."""
|
(architecture L8 and L3 together)."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Live Gitea round trip against the sandbox repo: push a branch, open a PR,
|
"""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
|
Cleans up after itself (closes the PR, deletes the branch) so repeated runs
|
||||||
do not silt up the sandbox.
|
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.
|
# Now the same reconciler must see the effect and refuse to repeat it.
|
||||||
assert reconcile(intent_for(branch)) is True
|
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
|
# 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.
|
# able to push to the protected default branch at all.
|
||||||
merged = client._http.post(
|
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
|
The thresholds that depend on local capability (L9: the failure count, the
|
||||||
count, A14's consult budget, A15's manifest size — must be tuned "at
|
consult budget, the manifest size) are tuned on the real hardware. This is the
|
||||||
build-order step 5 on the real hardware". This is the measurement that makes
|
measurement that makes that possible, rather than reasoning about model
|
||||||
that possible, rather than reasoning about model strength from reputation.
|
strength from reputation.
|
||||||
|
|
||||||
What it measures is the thing that actually breaks: whether a model can hold
|
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
|
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(
|
def test_model_holds_the_planner_contract(
|
||||||
model: str, catalog: ModelCatalog, routes
|
model: str, catalog: ModelCatalog, routes
|
||||||
) -> None:
|
) -> 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."""
|
prose cannot be gated, because there is no plan to approve."""
|
||||||
normalized, passthrough = routes
|
normalized, passthrough = routes
|
||||||
client = select_client(model, normalized, passthrough, catalog)
|
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(
|
def test_model_holds_the_prompt_level_json_contract(
|
||||||
model: str, catalog: ModelCatalog, routes
|
model: str, catalog: ModelCatalog, routes
|
||||||
) -> None:
|
) -> 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."""
|
differ on JSON enforcement. This measures whether that holds per model."""
|
||||||
normalized, passthrough = routes
|
normalized, passthrough = routes
|
||||||
client = select_client(model, normalized, passthrough, catalog)
|
client = select_client(model, normalized, passthrough, catalog)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Live Anthropic passthrough: the vendor-native route delegations will use.
|
"""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
|
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.
|
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(
|
def test_prompt_caching_survives_the_passthrough(
|
||||||
live_config: AgentConfig, live_model: str
|
live_config: AgentConfig, live_model: str
|
||||||
) -> None:
|
) -> 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
|
# flattened the request would silently drop cache control and turn a
|
||||||
# cost optimisation into a per-call expense.
|
# cost optimisation into a per-call expense.
|
||||||
base = live_config.llm_base_url.rstrip("/")
|
base = live_config.llm_base_url.rstrip("/")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Live planning turn: a real model must answer with a submit_plan or
|
"""Live planning turn: a real model must answer with a submit_plan or
|
||||||
ask_human tool call, not prose.
|
ask_human tool call, not prose.
|
||||||
|
|
||||||
Plans with the test config's planner role. Planning escalates to a frontier
|
Plans with the test config's planner role. The planner role is a frontier
|
||||||
model unconditionally (A12), and small local models do not hold the tool-call
|
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.
|
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)
|
assert isinstance(action, SubmitPlan | AskHuman)
|
||||||
text = action.content if isinstance(action, SubmitPlan) else action.question
|
text = action.content if isinstance(action, SubmitPlan) else action.question
|
||||||
assert text.strip(), "tool call carried empty content"
|
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
|
assert len(store.read_events()) > 1
|
||||||
Reference in new issue
Block a user