Answering "is the workspace where the agent runs, or what it works
on?" — it is what it works on, and conflating the two is dangerous
rather than merely untidy.
The agent commits its workspace with git add -A and pushes it, so
anything inside the clone reaches the project repository. A .env
placed there would publish the LiteLLM, Matrix and Gitea tokens as a
side effect of the agent working correctly: silently, once, and
probably unnoticed. Startup now refuses both that and a state store
inside the clone, and .env.example documents the intended layout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing had ever assembled the agent. main.py was still the hello-world
placeholder from the scaffold, and no code outside the test suite
constructed an Orchestrator — which is how two fully built, fully
tested components came to be reachable from nothing: the delegation
harness, and the Gitea comment poller. Unit tests that construct a
component directly cannot catch that; only assembly can.
- config.py validates every variable before anything is built and
names all of them at once, rather than failing at first use twenty
minutes into a milestone on the call that mattered. Whitespace-only
counts as missing, since a token of spaces passes a truthiness check
and fails at the first request instead
- agent.py builds the whole graph and owns startup and shutdown order:
reconcile before accepting commands, and on stop, stop watching,
then stop talking, then close the store last
- The Gitea account is read via whoami rather than configured: it names
the identity every permission is evaluated against, and proves the
token works before anything depends on it
- Poller lifecycle is now a state-machine concern: watch the plan issue
while a gate stands, the PR while it is open, and stop on leaving.
The orchestrator owns when to watch; an injected factory owns how to
build one
- The executor reports each turn, because it runs synchronously inside
the transport callback: without it the agent is silent for a whole
milestone and a working run looks exactly like a hung one
- main.py loads .env, builds, and shuts down cleanly on SIGINT/SIGTERM
The entry-point tests changed shape: nisse-dev no longer greets, it
refuses to start unconfigured and says everything that is missing. Both
tests now run with a deliberately empty environment and a .env path
that does not exist, because the working directory's .env holds live
credentials and a test must never be one cwd away from starting a real
agent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- poller.py: CommentPoller polls PR comments rather than taking
webhooks, keeping the VM outbound-only (B3). On an outage it backs
off exponentially, caps, and retries indefinitely while logging:
polling is pure intake, so an outage delays a reaction but never
fails a task or forces a transition
- prime() marks the existing thread as seen without delivering it, so
starting a watch does not re-address feedback already handled
- Review loop: a comment in PR_OPEN enters the canonical transcript,
the executor responds, and the fix is pushed to the same branch with
a reply on the PR. No intent is recorded for a follow-up push — the
PR already exists, so a repeated push is harmless, unlike opening a
second PR
- Comment authors are checked against a separate Gitea operator
allowlist: a PR comment is an instruction channel into an agent that
writes code, so it is operator-scoped, and a Gitea login is not a
Matrix MXID (A4 per-channel allowlists)
- Orchestrator now holds a lock: transport and the poller deliver on
separate threads, and state transitions must not interleave
- Backoff timings are injectable, so the outage path is tested in
milliseconds instead of being untestable
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The standalone half of the Gitea layer; orchestrator wiring follows
once the executor loop lands, to avoid colliding with it in
control.py.
- gitea.py: GiteaClient over httpx — open PRs, list and post issue
comments, close PRs. find_pull_request is the A3 external-truth
query that makes opening a PR idempotent across a crash, and it
checks closed PRs too, since a crash after opening must not be
answered with "nothing is open, so open another"
- pull_request_reconciler: an unreachable Gitea returns None rather
than a guess, so the agent gates to the human instead of risking a
duplicate side effect
- git.py: branch, commit, push for the workspace. The token is passed
per-invocation rather than stored as a remote, so it never lands in
.git/config, and anything raised is scrubbed of it
- commit_all returns None when there is nothing to commit: an empty
milestone is a fact to report, not an error
- Live test does the real round trip against the sandbox repo and
cleans up after itself
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The virtual key now reaches a Haiku-class route, which holds the
tool-call contract llama3.2 could not: a real planning turn returns a
well-formed submit_plan call with an explicit, low-inference plan.
Wires NISSE_LIVE_PLANNER_MODEL through the CI workflow (add the
matching Actions secret to run it there) and records the finding in
.env.example.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- planner.py: LLMPlanningAgent offers exactly two tools, submit_plan
and ask_human, so gating stays structural at the tool layer; no
proceed tool exists. System prompt requires explicit, low-inference
plans because a weaker model executes them (A13)
- Model output is appended to the canonical log as it arrives, before
being interpreted, so a crash mid-planning leaves a truthful
transcript
- A bad turn (prose instead of a tool call, malformed arguments, or a
failed call) retries once, then raises PlanningError; the
orchestrator gates to AWAIT_INPUT rather than inventing a plan
- Orchestrator: appends every operator message, plan_submitted, and
gate_opened to the transcript, so re-planning sees rejection
feedback as conversation rather than out-of-band parameters; pins
the A11 role mapping at task start
- llm.py: non-JSON or non-object tool arguments now raise LLMError
instead of escaping as JSONDecodeError — observed from llama3.2,
which emits truncated argument strings
- Live planning test is opt-in on NISSE_LIVE_PLANNER_MODEL: planning
escalates unconditionally (A12) and the local model does not hold
the tool-call contract, so it awaits a frontier route
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- matrix.py: Transport implementation over the Matrix client-server
HTTP API directly (token auth, /sync long-poll, /send) using the
existing httpx dependency — no Matrix SDK; four endpoints total.
Deviation from the presented plan (matrix-nio) flagged in the PR
- First sync learns position and skips backlog, so room history is
never delivered as context (A1); own messages and non-message
events filtered; sender MXID passed through for the orchestrator's
operator allowlist
- Sync loop backs off (capped at 60s) and retries indefinitely on
homeserver errors, mirroring B2's polling posture
- Encrypted rooms unsupported: bot rooms are created unencrypted
until E2E lands (operator decision; room recreated accordingly)
- NISSE_MATRIX_TEST_ROOM_ID added: the live smoke test binds only
to the dedicated test room, never the production room
- 8 unit tests over a mocked HTTP layer; live integration test
verified against the real homeserver
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR 2 of the steps-3/4 plan.
- llm.py: LLMClient protocol — complete(events, tools, model) ->
canonical events — present from the first real LLM call.
LiteLLMClient renders requests via the A10 renderer, targets the
proxy's OpenAI-compatible route, and maps provider tool-call IDs
to locally-minted canonical IDs at the boundary
- RoleMap (A11): executor/planner/delegate/consultant resolved from
env; pinned() returns the mapping to record at task start
- Config strictly from env: NISSE_LLM_BASE_URL, NISSE_LLM_TOKEN;
envfile.py is a minimal .env loader where real env wins; .env now
gitignored with a committed .env.example documenting all variables
(Matrix ones included for the upcoming transport PR)
- Live smoke test against the proxy is integration-marked and skips
without credentials — CI stays credential-free per operator
decision; the test runs locally once .env exists
- httpx added as nisse-dev's HTTP client
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>