From 154a0c3fa8cffc8f57623268f0ad2b754663611a Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Tue, 13 Oct 2026 18:37:01 +0000 Subject: [PATCH] M7.1: extract nisse-core What isn't the dev agent's moves to nisse-core, unchanged in behaviour, with its tests: canonical, render, render_anthropic, llm, catalog, probe, context, views, state, reconcile, worker, cancel, dump, transport, matrix, execution, testing, vault, memory, oplog, envfile and gitea (whole). - nisse_core.state.StateStore holds the tables every agent has; an agent's subclass adds its tables (version, migrations), roles and views. nisse_dev.state.StateStore adds the build VM blocks (v5). - views.project takes what each role is shown, rather than a fixed table. - Vault takes the agent's name (agent=), for provenance. - A test that nisse_core never imports an agent. - The loop, config, git, delegation, expert, tools and evals stay in nisse-dev until nisse-ops has a loop to share. Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on the agent host and Terraform; Architecture Part L names the package. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk --- .gitea/workflows/ci.yml | 2 +- conftest.py | 4 +- docs/Architecture.md | 8 +- docs/Development-Plan.md | 26 +- nisse-core/README.md | 25 +- nisse-core/pyproject.toml | 5 +- .../src/nisse_core}/cancel.py | 4 +- .../src/nisse_core}/canonical.py | 0 .../src/nisse_core}/catalog.py | 2 +- .../src/nisse_core}/context.py | 4 +- .../src/nisse_core}/dump.py | 2 +- .../src/nisse_core}/envfile.py | 0 .../src/nisse_core}/execution.py | 0 .../src/nisse_core}/gitea.py | 4 +- .../src/nisse_core}/llm.py | 6 +- .../src/nisse_core}/matrix.py | 2 +- .../src/nisse_core}/memory.py | 2 +- .../src/nisse_core}/oplog.py | 0 .../src/nisse_core}/probe.py | 2 +- .../src/nisse_core}/reconcile.py | 2 +- .../src/nisse_core}/render.py | 2 +- .../src/nisse_core}/render_anthropic.py | 4 +- nisse-core/src/nisse_core/state.py | 576 +++++++++++++++++ .../src/nisse_core}/testing.py | 2 +- .../src/nisse_core}/transport.py | 0 .../src/nisse_core}/vault.py | 2 +- .../src/nisse_core}/views.py | 28 +- .../src/nisse_core}/worker.py | 0 .../tests/test_canonical.py | 10 +- .../tests/test_catalog.py | 2 +- .../tests/test_context.py | 10 +- {nisse-dev => nisse-core}/tests/test_dump.py | 8 +- .../tests/test_execution.py | 4 +- {nisse-dev => nisse-core}/tests/test_gitea.py | 4 +- nisse-core/tests/test_independence.py | 25 + {nisse-dev => nisse-core}/tests/test_llm.py | 6 +- .../tests/test_matrix.py | 6 +- .../tests/test_memory.py | 8 +- {nisse-dev => nisse-core}/tests/test_oplog.py | 8 +- {nisse-dev => nisse-core}/tests/test_probe.py | 2 +- .../tests/test_reconcile.py | 4 +- .../tests/test_render.py | 4 +- .../tests/test_render_anthropic.py | 6 +- {nisse-dev => nisse-core}/tests/test_state.py | 27 +- {nisse-dev => nisse-core}/tests/test_vault.py | 7 +- {nisse-dev => nisse-core}/tests/test_views.py | 23 +- .../tests/test_worker.py | 6 +- nisse-dev/src/nisse_dev/bench/runner.py | 10 +- nisse-dev/src/nisse_dev/config.py | 6 +- nisse-dev/src/nisse_dev/delegation.py | 11 +- nisse-dev/src/nisse_dev/evals.py | 2 +- nisse-dev/src/nisse_dev/expert.py | 6 +- nisse-dev/src/nisse_dev/git.py | 2 +- nisse-dev/src/nisse_dev/loop/agent.py | 23 +- nisse-dev/src/nisse_dev/loop/build.py | 29 +- nisse-dev/src/nisse_dev/loop/classifier.py | 4 +- nisse-dev/src/nisse_dev/loop/driver.py | 8 +- nisse-dev/src/nisse_dev/loop/help.py | 2 +- nisse-dev/src/nisse_dev/loop/items.py | 3 +- nisse-dev/src/nisse_dev/loop/milestones.py | 2 +- nisse-dev/src/nisse_dev/loop/preflight.py | 2 +- nisse-dev/src/nisse_dev/loop/reader.py | 4 +- nisse-dev/src/nisse_dev/main.py | 2 +- nisse-dev/src/nisse_dev/state.py | 601 +----------------- nisse-dev/src/nisse_dev/tools.py | 4 +- nisse-dev/tests/model_contract.py | 4 +- nisse-dev/tests/test_agent_build.py | 10 +- nisse-dev/tests/test_bench.py | 6 +- nisse-dev/tests/test_config.py | 4 +- nisse-dev/tests/test_delegation.py | 13 +- nisse-dev/tests/test_dev_state.py | 61 ++ nisse-dev/tests/test_evals.py | 4 +- nisse-dev/tests/test_expert.py | 9 +- nisse-dev/tests/test_git.py | 4 +- nisse-dev/tests/test_loop.py | 16 +- nisse-dev/tests/test_loop_actions.py | 2 +- nisse-dev/tests/test_loop_classifier.py | 6 +- nisse-dev/tests/test_loop_conversations.py | 4 +- nisse-dev/tests/test_loop_help.py | 4 +- nisse-dev/tests/test_loop_labels.py | 6 +- nisse-dev/tests/test_loop_live_fixes.py | 6 +- nisse-dev/tests/test_loop_memory.py | 10 +- nisse-dev/tests/test_loop_milestones.py | 16 +- nisse-dev/tests/test_loop_multi.py | 4 +- nisse-dev/tests/test_loop_new_projects.py | 10 +- nisse-dev/tests/test_loop_notes.py | 6 +- nisse-dev/tests/test_loop_projects.py | 10 +- nisse-dev/tests/test_loop_properties.py | 26 +- nisse-dev/tests/test_loop_queue.py | 4 +- nisse-dev/tests/test_loop_talk.py | 2 +- nisse-dev/tests/test_loop_toolchain.py | 16 +- nisse-dev/tests/test_loop_trust.py | 10 +- nisse-dev/tests/test_loop_vm.py | 6 +- nisse-dev/tests/test_preflight.py | 2 +- nisse-dev/tests/test_tools.py | 2 +- scripts/benchmark.py | 4 +- scripts/bringup-repo.sh | 4 +- scripts/bringup.sh | 2 +- scripts/ci-sshd.sh | 2 +- scripts/classifier-evals.py | 2 +- scripts/e2e-cleanup.sh | 2 +- scripts/e2e.sh | 2 +- scripts/probe_context.py | 8 +- tests/integration/conftest.py | 6 +- tests/integration/e2e_support.py | 6 +- tests/integration/test_benchmark_live.py | 4 +- tests/integration/test_classifier_cases.py | 2 +- tests/integration/test_crash_durability.py | 4 +- tests/integration/test_delegation_live.py | 6 +- tests/integration/test_end_to_end_loop.py | 10 +- tests/integration/test_expert_live.py | 4 +- tests/integration/test_fresh_repo_live.py | 2 +- tests/integration/test_gitea_live.py | 6 +- tests/integration/test_llm_live.py | 8 +- tests/integration/test_matrix_live.py | 2 +- tests/integration/test_model_capability.py | 6 +- tests/integration/test_passthrough_live.py | 6 +- tests/integration/test_trust_live.py | 2 +- uv.lock | 10 + 119 files changed, 1086 insertions(+), 912 deletions(-) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/cancel.py (92%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/canonical.py (100%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/catalog.py (99%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/context.py (99%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/dump.py (98%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/envfile.py (100%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/execution.py (100%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/gitea.py (99%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/llm.py (99%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/matrix.py (99%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/memory.py (98%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/oplog.py (100%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/probe.py (99%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/reconcile.py (97%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/render.py (99%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/render_anthropic.py (98%) create mode 100644 nisse-core/src/nisse_core/state.py rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/testing.py (98%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/transport.py (100%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/vault.py (99%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/views.py (89%) rename {nisse-dev/src/nisse_dev => nisse-core/src/nisse_core}/worker.py (100%) rename {nisse-dev => nisse-core}/tests/test_canonical.py (92%) rename {nisse-dev => nisse-core}/tests/test_catalog.py (99%) rename {nisse-dev => nisse-core}/tests/test_context.py (98%) rename {nisse-dev => nisse-core}/tests/test_dump.py (92%) rename {nisse-dev => nisse-core}/tests/test_execution.py (96%) rename {nisse-dev => nisse-core}/tests/test_gitea.py (99%) create mode 100644 nisse-core/tests/test_independence.py rename {nisse-dev => nisse-core}/tests/test_llm.py (99%) rename {nisse-dev => nisse-core}/tests/test_matrix.py (98%) rename {nisse-dev => nisse-core}/tests/test_memory.py (94%) rename {nisse-dev => nisse-core}/tests/test_oplog.py (97%) rename {nisse-dev => nisse-core}/tests/test_probe.py (98%) rename {nisse-dev => nisse-core}/tests/test_reconcile.py (95%) rename {nisse-dev => nisse-core}/tests/test_render.py (98%) rename {nisse-dev => nisse-core}/tests/test_render_anthropic.py (97%) rename {nisse-dev => nisse-core}/tests/test_state.py (78%) rename {nisse-dev => nisse-core}/tests/test_vault.py (97%) rename {nisse-dev => nisse-core}/tests/test_views.py (88%) rename {nisse-dev => nisse-core}/tests/test_worker.py (94%) create mode 100644 nisse-dev/tests/test_dev_state.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dc2e77e..096ded3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: - name: Install sshd # The tests reach "the build VM" over real SSH, through a throwaway - # sshd the test fixture starts (nisse_dev/testing.py). Skipped when the image has it; otherwise from + # sshd the test fixture starts (nisse_core/testing.py). Skipped when the image has it; otherwise from # a kernel.org mirror: archive.ubuntu.com has been slow enough to add # minutes to every job. run: scripts/ci-sshd.sh diff --git a/conftest.py b/conftest.py index 3514a20..2cc7e20 100644 --- a/conftest.py +++ b/conftest.py @@ -5,8 +5,8 @@ from pathlib import Path import pytest -from nisse_dev.execution import SSHHost -from nisse_dev.testing import local_sshd +from nisse_core.execution import SSHHost +from nisse_core.testing import local_sshd @pytest.fixture(scope="session") diff --git a/docs/Architecture.md b/docs/Architecture.md index a7fda31..d926d6e 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -131,7 +131,13 @@ naming a milestone (D4). ## Part L — The shared library (`nisse-core`) -Everything in this part is used by every agent. +Everything in this part is used by every agent. In code it is the `nisse_core` +package: the transcript (L7) and its renderers, the LLM client and model catalog +(L9), context fitting and per-role views (L6), the state store and reconciliation +(L8), transports (L5), SSH execution (L13), the vault and memory (L10, L6.4), +logging and secrets loading (L12), and the Gitea client (L1). An agent extends +the store with its own tables, roles and views; the loop is still `nisse-dev`'s +until a second agent shares it. ### L1. The work model diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index d9ee076..ef347c9 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -535,6 +535,11 @@ Authelia. ### Phase 6 — Production +*Waiting on infrastructure (2026-10-13):* the agent host doesn't exist yet, and +Terraform isn't running; the operator is planning both, Terraform with +`nisse-ops`. Until then M5.4 and M6.1–M6.4 wait, and the build VMs stay made by +hand (Ubuntu 24 now, 26 soon), with `vm ready` for a rebuilt one's host key. + **M6.1 The agent host.** *Needs discussion.* Containers, volumes, snapshots, per-agent networks (X1, X2, X4). @@ -550,7 +555,26 @@ LLDAP principal (L12). ### Phase 7 — `nisse-core` and `nisse-ops` -**M7.1 Extract `nisse-core`.** **M7.2 MCP servers** (#37). **M7.3 Approved step +**M7.1 Extract `nisse-core`.** *Done.* What isn't the dev agent's moved to +`nisse-core`, unchanged in behaviour, with its tests: the transcript and its +renderers, the LLM client and model catalog, context fitting and views, the state +store, reconciliation, the worker and `stop`, transports (Matrix), SSH execution, +the vault and memory, logging, `.env` loading, and the Gitea client (whole: L1 +makes Gitea every agent's tracker). `nisse-core` never imports an agent (a test +says so). + +- `nisse_core.state.StateStore` holds the tables every agent has; an agent's + subclass adds its own tables (with their schema version and migrations), its + model roles, and what each role is shown of the others (`views`). + `nisse_dev.state.StateStore` adds the build VM blocks (D3); stores already at + version 5 open unchanged. +- The vault names whose writes it records (`agent`), rather than assuming one. +- Deferred until `nisse-ops` has a loop to share: splitting the loop (`loop/*`) + and configuration into a common part and the dev agent's. The dev agent's + `git`, delegation, `ask_expert`, executor tools and evaluation capture stay + in `nisse-dev`. + +**M7.2 MCP servers** (#37). **M7.3 Approved step sequences** (O2). **M7.4 Ops intake and duties** from the infrastructure repository (O5). **M7.5 The ops tool VM and deployment** (O6). **M7.6 Requests between agents:** `nisse-dev` asking for build VMs (L1, O5). All *needs discussion* diff --git a/nisse-core/README.md b/nisse-core/README.md index 34ff013..8674ace 100644 --- a/nisse-core/README.md +++ b/nisse-core/README.md @@ -1,11 +1,22 @@ # nisse-core -The shared library both Nisse agents sit on (Part L of the architecture). +The shared library the Nisse agents sit on (Part L of the architecture). It +never imports an agent. -Most of it still lives in `nisse-dev`, to be extracted as `nisse-ops` arrives. -What is here so far: +- **Transcript and models:** `canonical` (the log's events, L7), `render` and + `render_anthropic`, `llm` (the one client, L9), `catalog` and `probe` (what + the models are), `context` (fitting a request to its window, L6), `views` + (what each role is shown of the others). +- **Durability:** `state` (the store, L8; an agent subclasses it for its own + tables, roles and views), `reconcile`, `worker`, `cancel`, `dump`. +- **Conversation:** `transport`, `matrix` (L5). +- **Execution:** `execution` (SSH to a tool VM, L13); `testing` (a throwaway + sshd for tests). +- **Knowledge:** `vault` (L10), `memory` (L6.4). +- **Operations:** `oplog`, `envfile` (L12); `gitea` (the tracker and forge, L1). +- **Authority:** `actions` (P3, P4, L4): `Action`, `Verdict`, and static + reading of shell commands, which each agent's fast-path rules build on + (`nisse-dev`'s are in `nisse_dev.loop.actions`). -- `nisse_core.actions`: actions by their effects (P3, P4, L4). The `Action` - and `Verdict` types, and static reading of shell commands, which each - agent's fast-path rules build on (`nisse-dev`'s are in - `nisse_dev.loop.actions`). +The loop is still `nisse-dev`'s, until `nisse-ops` has one to share it with +(Development Plan M7.1). diff --git a/nisse-core/pyproject.toml b/nisse-core/pyproject.toml index 3e1f3c6..4f280d4 100644 --- a/nisse-core/pyproject.toml +++ b/nisse-core/pyproject.toml @@ -3,7 +3,10 @@ name = "nisse-core" version = "0.1.0" description = "Shared agent library for nisse-dev and nisse-ops" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "httpx>=0.27", + "pyyaml>=6", +] [build-system] requires = ["uv_build>=0.12,<0.13"] diff --git a/nisse-dev/src/nisse_dev/cancel.py b/nisse-core/src/nisse_core/cancel.py similarity index 92% rename from nisse-dev/src/nisse_dev/cancel.py rename to nisse-core/src/nisse_core/cancel.py index 7d9ee5e..8b03de4 100644 --- a/nisse-dev/src/nisse_dev/cancel.py +++ b/nisse-core/src/nisse_core/cancel.py @@ -8,8 +8,8 @@ record of a stop is written by the worker when the stopped work returns. import threading -from nisse_dev.canonical import ToolCall, ToolResult -from nisse_dev.state import Transcript +from nisse_core.canonical import ToolCall, ToolResult +from nisse_core.state import Transcript STOPPED_RESULT = "not run: the operator stopped the agent" diff --git a/nisse-dev/src/nisse_dev/canonical.py b/nisse-core/src/nisse_core/canonical.py similarity index 100% rename from nisse-dev/src/nisse_dev/canonical.py rename to nisse-core/src/nisse_core/canonical.py diff --git a/nisse-dev/src/nisse_dev/catalog.py b/nisse-core/src/nisse_core/catalog.py similarity index 99% rename from nisse-dev/src/nisse_dev/catalog.py rename to nisse-core/src/nisse_core/catalog.py index 99b1912..3af21b3 100644 --- a/nisse-dev/src/nisse_dev/catalog.py +++ b/nisse-core/src/nisse_core/catalog.py @@ -19,7 +19,7 @@ from typing import Any import httpx -from nisse_dev.llm import LLMClient, LLMError +from nisse_core.llm import LLMClient, LLMError # LiteLLM's provider name for models we have a vendor-native adapter for. ANTHROPIC_PROVIDER = "anthropic" diff --git a/nisse-dev/src/nisse_dev/context.py b/nisse-core/src/nisse_core/context.py similarity index 99% rename from nisse-dev/src/nisse_dev/context.py rename to nisse-core/src/nisse_core/context.py index 0724b9a..c812f27 100644 --- a/nisse-dev/src/nisse_dev/context.py +++ b/nisse-core/src/nisse_core/context.py @@ -28,7 +28,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass, replace from typing import Any -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, CanonicalEvent, DelegationStarted, @@ -38,7 +38,7 @@ from nisse_dev.canonical import ( ToolResult, UserMessage, ) -from nisse_dev.llm import DEFAULT_MAX_OUTPUT_TOKENS, LLMError +from nisse_core.llm import DEFAULT_MAX_OUTPUT_TOKENS, LLMError # English and code run at about four characters a token. Three is deliberately # pessimistic, before any correction from what the server reports. diff --git a/nisse-dev/src/nisse_dev/dump.py b/nisse-core/src/nisse_core/dump.py similarity index 98% rename from nisse-dev/src/nisse_dev/dump.py rename to nisse-core/src/nisse_core/dump.py index f923f39..8575969 100644 --- a/nisse-dev/src/nisse_dev/dump.py +++ b/nisse-core/src/nisse_core/dump.py @@ -1,6 +1,6 @@ """A readable dump of a state store, for bring-up runs and debugging. - python -m nisse_dev.dump STATE_DB [--full] + python -m nisse_core.dump STATE_DB [--full] Opens the store read-only (SQLite `mode=ro`), so it is safe to run against a live agent's database: it never migrates, writes or locks it. Prints the work diff --git a/nisse-dev/src/nisse_dev/envfile.py b/nisse-core/src/nisse_core/envfile.py similarity index 100% rename from nisse-dev/src/nisse_dev/envfile.py rename to nisse-core/src/nisse_core/envfile.py diff --git a/nisse-dev/src/nisse_dev/execution.py b/nisse-core/src/nisse_core/execution.py similarity index 100% rename from nisse-dev/src/nisse_dev/execution.py rename to nisse-core/src/nisse_core/execution.py diff --git a/nisse-dev/src/nisse_dev/gitea.py b/nisse-core/src/nisse_core/gitea.py similarity index 99% rename from nisse-dev/src/nisse_dev/gitea.py rename to nisse-core/src/nisse_core/gitea.py index 4641f3e..49b4d0a 100644 --- a/nisse-dev/src/nisse_dev/gitea.py +++ b/nisse-core/src/nisse_core/gitea.py @@ -16,8 +16,8 @@ from typing import Any import httpx -from nisse_dev.reconcile import Reconciler -from nisse_dev.state import Intent +from nisse_core.reconcile import Reconciler +from nisse_core.state import Intent PR_PAGE_SIZE = 50 MAX_PR_PAGES = 40 # 2000 PRs; beyond that, say so rather than guess diff --git a/nisse-dev/src/nisse_dev/llm.py b/nisse-core/src/nisse_core/llm.py similarity index 99% rename from nisse-dev/src/nisse_dev/llm.py rename to nisse-core/src/nisse_core/llm.py index 2e2dcb8..8ec99dc 100644 --- a/nisse-dev/src/nisse_dev/llm.py +++ b/nisse-core/src/nisse_core/llm.py @@ -17,15 +17,15 @@ from typing import Any, ClassVar, Protocol import httpx -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, CanonicalEvent, StoredEvent, ToolCall, mint_call_id, ) -from nisse_dev.render import OpenAIChatRenderer -from nisse_dev.render_anthropic import AnthropicMessagesRenderer +from nisse_core.render import OpenAIChatRenderer +from nisse_core.render_anthropic import AnthropicMessagesRenderer class LLMError(RuntimeError): diff --git a/nisse-dev/src/nisse_dev/matrix.py b/nisse-core/src/nisse_core/matrix.py similarity index 99% rename from nisse-dev/src/nisse_dev/matrix.py rename to nisse-core/src/nisse_core/matrix.py index 43608c5..f4721b2 100644 --- a/nisse-dev/src/nisse_dev/matrix.py +++ b/nisse-core/src/nisse_core/matrix.py @@ -27,7 +27,7 @@ from urllib.parse import unquote import httpx -from nisse_dev.transport import InboundMessage +from nisse_core.transport import InboundMessage # A mention pill at the start of an HTML body: the link holds the user ID, # the link text is what the plain body starts with. diff --git a/nisse-dev/src/nisse_dev/memory.py b/nisse-core/src/nisse_core/memory.py similarity index 98% rename from nisse-dev/src/nisse_dev/memory.py rename to nisse-core/src/nisse_core/memory.py index 0fd96b1..2f99f97 100644 --- a/nisse-dev/src/nisse_dev/memory.py +++ b/nisse-core/src/nisse_core/memory.py @@ -14,7 +14,7 @@ from dataclasses import dataclass import yaml -from nisse_dev.vault import Vault, VaultError, split_frontmatter +from nisse_core.vault import Vault, VaultError, split_frontmatter INDEX = "index" # the note the harness keeps; never the model's _NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,63}") diff --git a/nisse-dev/src/nisse_dev/oplog.py b/nisse-core/src/nisse_core/oplog.py similarity index 100% rename from nisse-dev/src/nisse_dev/oplog.py rename to nisse-core/src/nisse_core/oplog.py diff --git a/nisse-dev/src/nisse_dev/probe.py b/nisse-core/src/nisse_core/probe.py similarity index 99% rename from nisse-dev/src/nisse_dev/probe.py rename to nisse-core/src/nisse_core/probe.py index 1fda6e9..acf515f 100644 --- a/nisse-dev/src/nisse_dev/probe.py +++ b/nisse-core/src/nisse_core/probe.py @@ -24,7 +24,7 @@ from dataclasses import dataclass import httpx -from nisse_dev.catalog import ModelFacts +from nisse_core.catalog import ModelFacts # Of a probe's size, how much must come back for it to count as uncut. A # chat template adds a little, and a tokenizer counts a little differently. diff --git a/nisse-dev/src/nisse_dev/reconcile.py b/nisse-core/src/nisse_core/reconcile.py similarity index 97% rename from nisse-dev/src/nisse_dev/reconcile.py rename to nisse-core/src/nisse_core/reconcile.py index 95c9900..f7dbe6e 100644 --- a/nisse-dev/src/nisse_dev/reconcile.py +++ b/nisse-core/src/nisse_core/reconcile.py @@ -11,7 +11,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Literal -from nisse_dev.state import Intent, StateStore +from nisse_core.state import Intent, StateStore # Returns True if the intent's external effect verifiably happened (do not # repeat it), False if it verifiably never started (safe to discard), or diff --git a/nisse-dev/src/nisse_dev/render.py b/nisse-core/src/nisse_core/render.py similarity index 99% rename from nisse-dev/src/nisse_dev/render.py rename to nisse-core/src/nisse_core/render.py index a899d92..16dd2dc 100644 --- a/nisse-dev/src/nisse_dev/render.py +++ b/nisse-core/src/nisse_core/render.py @@ -11,7 +11,7 @@ this skeleton. import json from typing import Any -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, Compaction, DelegationCompleted, diff --git a/nisse-dev/src/nisse_dev/render_anthropic.py b/nisse-core/src/nisse_core/render_anthropic.py similarity index 98% rename from nisse-dev/src/nisse_dev/render_anthropic.py rename to nisse-core/src/nisse_core/render_anthropic.py index 2effc97..763ba67 100644 --- a/nisse-dev/src/nisse_dev/render_anthropic.py +++ b/nisse-core/src/nisse_core/render_anthropic.py @@ -18,7 +18,7 @@ route and this renderer builds the vendor-native body. from typing import Any -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, Compaction, DelegationCompleted, @@ -31,7 +31,7 @@ from nisse_dev.canonical import ( ToolResult, UserMessage, ) -from nisse_dev.render import effective_events +from nisse_core.render import effective_events DEFAULT_MAX_TOKENS = 4096 NO_RESULT_PLACEHOLDER = "[no result recorded]" diff --git a/nisse-core/src/nisse_core/state.py b/nisse-core/src/nisse_core/state.py new file mode 100644 index 0000000..18c2f55 --- /dev/null +++ b/nisse-core/src/nisse_core/state.py @@ -0,0 +1,576 @@ +"""Durable state store (architecture L8). + +SQLite in WAL mode with ``synchronous=FULL``: every commit is fsynced, so a +power cut mid-write can never produce torn state. Every state transition and +intent-log write is a single immediately-committed statement — in-memory state +that hasn't hit disk is assumed lost. + +The intent log records a unit of work *before* it starts, so a restart knows +what was in flight (see ``nisse_core.reconcile`` for how pending intents are +resolved against external truth on boot). +""" + +import json +import sqlite3 +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Self + +from nisse_core.canonical import ( + CanonicalEvent, + StoredEvent, + UserMessage, + event_type, + from_payload, + to_payload, +) +from nisse_core.oplog import OpLog +from nisse_core.views import project + +# The tables every agent has. An agent's own tables come on top: a subclass +# names them, the version they bring, and the migrations to it. +CURRENT_SCHEMA_VERSION = 4 + +# Stores older than this predate work items (Development Plan M1.1). There was +# no production data to carry over, so there is no migration from them: start +# a fresh store (scripts/bringup.sh start sets the old one aside). +OLDEST_SUPPORTED_VERSION = 4 + +Migration = Callable[[sqlite3.Connection], None] + +# Who produced an event (L7): the operator, the orchestrator's own code, or a +# model role (L9). An agent adds its own roles. +ACTORS = frozenset( + {"operator", "orchestrator", "driver", "helper", "expert", "classifier"} +) + + +class StateSchemaError(RuntimeError): + pass + + +@dataclass(frozen=True) +class WorkItem: + id: str + scope: str | None + title: str + status: str # open | closed + outcome: str | None # completed | abandoned, once closed + position: str # where the loop is for this item + data: dict[str, Any] + opened_at: str + updated_at: str + closed_at: str | None + + +@dataclass(frozen=True) +class Intent: + id: int + kind: str + payload: dict[str, Any] + status: str + created_at: str + resolved_at: str | None + work_item: str | None = None + + +_SCHEMA = [ + "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)", + """ + CREATE TABLE work_items ( + id TEXT PRIMARY KEY, + scope TEXT, + title TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('open', 'closed')), + outcome TEXT, + position TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}', + opened_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + closed_at TEXT + ) + """, + """ + CREATE TABLE agent ( + id INTEGER PRIMARY KEY CHECK (id = 1), + active_item TEXT REFERENCES work_items(id), + idle_from_event INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + ) + """, + """ + CREATE TABLE intent_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + resolved_at TEXT, + work_item TEXT + ) + """, + """ + CREATE TABLE canonical_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + type TEXT NOT NULL, + payload TEXT NOT NULL, + work_item TEXT, + actor TEXT NOT NULL + ) + """, + "CREATE INDEX canonical_events_by_item ON canonical_events (work_item, id)", + """ + CREATE TABLE eval_outcomes ( + event_id INTEGER PRIMARY KEY, + verdict TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + marked_at TEXT NOT NULL + ) + """, +] + + +def utc_now() -> str: + return datetime.now(UTC).isoformat() + + +class StateStore: + """The store every agent shares. An agent's subclass adds its own tables + (``SCHEMA``, with the version they bring and ``MIGRATIONS`` to it), its + model roles (``ACTORS``) and what each role is shown of the others + (``SHOWN``, see ``views``).""" + + AGENT = "nisse" # what the operational log names + SCHEMA_VERSION = CURRENT_SCHEMA_VERSION + SCHEMA: tuple[str, ...] = () + # Maps version N to the migration that takes the schema from N to N+1. + MIGRATIONS: Mapping[int, Migration] = {} + ACTORS: frozenset[str] = ACTORS + SHOWN: Mapping[str, Mapping[str, str]] = {} + + def __init__( + self, + path: str | Path, + *, + schema_version: int | None = None, + migrations: Mapping[int, Migration] | None = None, + oplog: OpLog | None = None, + ) -> None: + # Log emission is owned by the store (L12): the same code path that + # performs a durable write emits its log line, right after commit. + self.oplog = oplog if oplog is not None else OpLog(self.AGENT) + self._conn = sqlite3.connect(path, isolation_level=None) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA synchronous=FULL") + self._conn.execute("PRAGMA busy_timeout=5000") + self._target_version = ( + self.SCHEMA_VERSION if schema_version is None else schema_version + ) + self.closed = False + self._init_or_migrate(self.MIGRATIONS if migrations is None else migrations) + + def _init_or_migrate(self, migrations: Mapping[int, Migration]) -> None: + fresh = ( + self._conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='meta'" + ).fetchone() + is None + ) + if fresh: + self._in_transaction(self._create_schema) + return + stored = self.schema_version + if stored < OLDEST_SUPPORTED_VERSION <= self._target_version: + raise StateSchemaError( + f"state store schema version {stored} predates work items " + f"(version {OLDEST_SUPPORTED_VERSION}); start a fresh store " + "(scripts/bringup.sh start sets the old one aside)" + ) + if stored > self._target_version: + raise StateSchemaError( + f"state schema version {stored} is newer than this code " + f"supports ({self._target_version}); refusing to open" + ) + for version in range(stored, self._target_version): + migrate = migrations.get(version) + if migrate is None: + raise StateSchemaError( + f"no migration registered from schema version {version}" + ) + self._in_transaction(lambda m=migrate, v=version: self._apply(m, v)) + self.oplog.emit( + "schema.migrate", + cause="startup", + from_version=version, + to_version=version + 1, + ) + + def _create_schema(self) -> None: + for statement in (*_SCHEMA, *self.SCHEMA): + self._conn.execute(statement) + self._conn.execute( + "INSERT INTO meta (key, value) VALUES ('schema_version', ?)", + (str(self._target_version),), + ) + + def _apply(self, migrate: Migration, from_version: int) -> None: + migrate(self._conn) + self._conn.execute( + "UPDATE meta SET value = ? WHERE key = 'schema_version'", + (str(from_version + 1),), + ) + + def _in_transaction(self, work: Callable[[], None]) -> None: + self._conn.execute("BEGIN IMMEDIATE") + try: + work() + except BaseException: + self._conn.execute("ROLLBACK") + raise + self._conn.execute("COMMIT") + + @property + def schema_version(self) -> int: + row = self._conn.execute( + "SELECT value FROM meta WHERE key = 'schema_version'" + ).fetchone() + return int(row["value"]) + + # --- work items (L1) --------------------------------------------------- + + def _ensure_agent_row(self) -> None: + self._conn.execute( + "INSERT INTO agent (id, active_item, updated_at) VALUES (1, NULL, ?) " + "ON CONFLICT(id) DO NOTHING", + (utc_now(),), + ) + + @staticmethod + def _item(row: sqlite3.Row) -> WorkItem: + return WorkItem( + id=row["id"], + scope=row["scope"], + title=row["title"], + status=row["status"], + outcome=row["outcome"], + position=row["position"], + data=json.loads(row["data"]), + opened_at=row["opened_at"], + updated_at=row["updated_at"], + closed_at=row["closed_at"], + ) + + def work_item(self, item_id: str) -> WorkItem | None: + row = self._conn.execute( + "SELECT * FROM work_items WHERE id = ?", (item_id,) + ).fetchone() + return None if row is None else self._item(row) + + def work_items(self, status: str | None = None) -> list[WorkItem]: + query = "SELECT * FROM work_items" + args: tuple[Any, ...] = () + if status is not None: + query += " WHERE status = ?" + args = (status,) + rows = self._conn.execute(query + " ORDER BY opened_at", args).fetchall() + return [self._item(row) for row in rows] + + def active_item(self) -> WorkItem | None: + row = self._conn.execute( + "SELECT w.* FROM agent a JOIN work_items w ON w.id = a.active_item " + "WHERE a.id = 1" + ).fetchone() + return None if row is None else self._item(row) + + def open_work_item( + self, + item_id: str, + *, + title: str, + scope: str | None = None, + position: str, + data: dict[str, Any] | None = None, + cause: str, + ) -> WorkItem: + now = utc_now() + self._conn.execute( + "INSERT INTO work_items (id, scope, title, status, position, data, " + "opened_at, updated_at) VALUES (?, ?, ?, 'open', ?, ?, ?, ?)", + (item_id, scope, title, position, json.dumps(data or {}), now, now), + ) + self.oplog.emit("work_item.open", cause=cause, work_item=item_id, scope=scope) + item = self.work_item(item_id) + assert item is not None + return item + + def set_active(self, item_id: str | None, *, cause: str) -> None: + """Point the agent at a work item, or at none (idle). Going idle + records where in the log idleness began, so messages sent while idle + can be carried into the next work item (carry_idle_messages).""" + self._ensure_agent_row() + previous = self._conn.execute( + "SELECT active_item FROM agent WHERE id = 1" + ).fetchone()["active_item"] + if item_id is None: + last = self._conn.execute( + "SELECT COALESCE(MAX(id), 0) AS last FROM canonical_events" + ).fetchone()["last"] + self._conn.execute( + "UPDATE agent SET active_item = NULL, idle_from_event = ?, " + "updated_at = ? WHERE id = 1", + (last, utc_now()), + ) + else: + self._conn.execute( + "UPDATE agent SET active_item = ?, updated_at = ? WHERE id = 1", + (item_id, utc_now()), + ) + if previous != item_id: + self.oplog.emit( + "work_item.switch", + cause=cause, + **{"from": previous, "to": item_id}, + ) + + def update_work_item( + self, + item_id: str, + *, + position: str, + data: dict[str, Any], + cause: str, + ) -> None: + """Record a work item's new position and data; the full data is + passed every time (no partial updates).""" + previous = self.work_item(item_id) + if previous is None: + raise KeyError(f"no work item {item_id!r}") + self._conn.execute( + "UPDATE work_items SET position = ?, data = ?, updated_at = ? WHERE id = ?", + (position, json.dumps(data), utc_now(), item_id), + ) + self.oplog.emit( + "state.transition", + cause=cause, + **{"from": previous.position, "to": position, "project": previous.scope}, + ) + + def close_work_item(self, item_id: str, *, outcome: str, cause: str) -> None: + now = utc_now() + row = self._conn.execute( + "UPDATE work_items SET status = 'closed', outcome = ?, closed_at = ?, " + "updated_at = ? WHERE id = ? AND status = 'open' RETURNING id", + (outcome, now, now, item_id), + ).fetchone() + if row is None: + raise KeyError(f"no open work item {item_id!r}") + self.oplog.emit( + "work_item.close", cause=cause, work_item=item_id, outcome=outcome + ) + + def carry_idle_messages(self, item_id: str) -> int: + """Copy what the operator said while the agent was idle into a work + item that has just opened (D1: messages sent before `use` are kept + for it). Copied as new events, with the original's ID in the source, + never by reassigning the originals: the log is never edited (L7).""" + self._ensure_agent_row() + since = self._conn.execute( + "SELECT idle_from_event FROM agent WHERE id = 1" + ).fetchone()["idle_from_event"] + rows = self._conn.execute( + "SELECT * FROM canonical_events WHERE work_item IS NULL " + "AND actor = 'operator' AND type = 'user_message' AND id > ? ORDER BY id", + (since,), + ).fetchall() + log = self.transcript(item_id, "operator") + for row in rows: + payload = json.loads(row["payload"]) + log.append( + UserMessage( + text=payload["text"], + source=f"{payload.get('source', '')} (sent while idle, #{row['id']})", + ) + ) + return len(rows) + + # --- intents (L8) ------------------------------------------------------ + + def begin_intent( + self, + kind: str, + payload: dict[str, Any], + *, + cause: str, + work_item: str | None = None, + ) -> int: + """Durably record that a unit of work is about to start, owned by + the work item it belongs to (L1).""" + cursor = self._conn.execute( + "INSERT INTO intent_log (kind, payload, created_at, work_item) " + "VALUES (?, ?, ?, ?)", + (kind, json.dumps(payload), utc_now(), work_item), + ) + assert cursor.lastrowid is not None + self.oplog.emit( + "intent.begin", cause=cause, intent_id=cursor.lastrowid, kind=kind + ) + return cursor.lastrowid + + def commit_intent(self, intent_id: int, *, cause: str) -> None: + self._resolve_intent(intent_id, "committed", "intent.commit", cause) + + def abort_intent(self, intent_id: int, *, cause: str) -> None: + self._resolve_intent(intent_id, "aborted", "intent.abort", cause) + + def _resolve_intent( + self, intent_id: int, status: str, event: str, cause: str + ) -> None: + row = self._conn.execute( + "UPDATE intent_log SET status = ?, resolved_at = ? " + "WHERE id = ? AND status = 'pending' RETURNING kind", + (status, utc_now(), intent_id), + ).fetchone() + if row is None: + raise KeyError(f"no pending intent with id {intent_id}") + self.oplog.emit(event, cause=cause, intent_id=intent_id, kind=row["kind"]) + + def pending_intents(self) -> list[Intent]: + rows = self._conn.execute( + "SELECT * FROM intent_log WHERE status = 'pending' ORDER BY id" + ).fetchall() + return [ + Intent( + id=row["id"], + kind=row["kind"], + payload=json.loads(row["payload"]), + status=row["status"], + created_at=row["created_at"], + resolved_at=row["resolved_at"], + work_item=row["work_item"], + ) + for row in rows + ] + + # --- the canonical log (L7) ------------------------------------------- + + def transcript(self, work_item: str | None, actor: str) -> "Transcript": + """The log as one role sees it for one work item: what it reads is + that item's events only, and what it writes carries the item and the + actor. None is the agent-level scope (L6).""" + if actor not in self.ACTORS: + raise ValueError(f"unknown actor {actor!r}") + return Transcript(self, work_item, actor) + + def all_events(self, after: int = 0) -> list[StoredEvent]: + """Every event across work items, for the few readers whose job + spans them (evaluation capture, dumps). Model calls never use this.""" + rows = self._conn.execute( + "SELECT * FROM canonical_events WHERE id > ? ORDER BY id", (after,) + ).fetchall() + return [_stored(row) for row in rows] + + def _append(self, event: CanonicalEvent, work_item: str | None, actor: str) -> int: + cursor = self._conn.execute( + "INSERT INTO canonical_events (ts, type, payload, work_item, actor) " + "VALUES (?, ?, ?, ?, ?)", + ( + utc_now(), + event_type(event), + json.dumps(to_payload(event)), + work_item, + actor, + ), + ) + assert cursor.lastrowid is not None + return cursor.lastrowid + + def _read(self, work_item: str | None, after: int) -> list[StoredEvent]: + rows = self._conn.execute( + "SELECT * FROM canonical_events WHERE work_item IS ? AND id > ? " + "ORDER BY id", + (work_item, after), + ).fetchall() + return [_stored(row) for row in rows] + + def mark_outcome(self, event_id: int, verdict: str, note: str = "") -> None: + """Record whether a delegation or consult was good (L9). + + Kept out of the canonical log on purpose: the log is an append-only + record of what happened, and a verdict is a later judgement about it + that may be revised. + """ + self._conn.execute( + "INSERT INTO eval_outcomes (event_id, verdict, note, marked_at) " + "VALUES (?, ?, ?, ?) ON CONFLICT(event_id) DO UPDATE SET " + "verdict = excluded.verdict, note = excluded.note, " + "marked_at = excluded.marked_at", + (event_id, verdict, note, utc_now()), + ) + + def outcomes(self) -> dict[int, tuple[str, str]]: + rows = self._conn.execute( + "SELECT event_id, verdict, note FROM eval_outcomes" + ).fetchall() + return {r["event_id"]: (r["verdict"], r["note"]) for r in rows} + + def close(self) -> None: + self._conn.close() + self.closed = True + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc_info: object) -> None: + self.close() + + +def _stored(row: sqlite3.Row) -> StoredEvent: + return StoredEvent( + id=row["id"], + ts=row["ts"], + event=from_payload(row["type"], json.loads(row["payload"])), + work_item=row["work_item"], + actor=row["actor"], + ) + + +class Transcript: + """One work item's log, as one actor reads and writes it (L6, L7). + + Handed to each component per call by the orchestrator, which knows the + active work item, so a component can neither read another item's events + nor write without attribution. + """ + + def __init__(self, store: StateStore, work_item: str | None, actor: str) -> None: + self._store = store + self.work_item = work_item + self.actor = actor + + def append(self, event: CanonicalEvent) -> int: + """Committed and fsynced before return, like every durable write.""" + return self._store._append(event, self.work_item, self.actor) + + def read(self, after: int = 0) -> list[StoredEvent]: + """The log as recorded: every role's events, for the record.""" + return self._store._read(self.work_item, after) + + def view(self, after: int = 0) -> list[StoredEvent]: + """The log as this actor is shown it: what a model's context is + built from (L6). See ``views``.""" + return project(self.read(after), self.actor, self._store.SHOWN) + + def as_actor(self, actor: str) -> "Transcript": + """The same work item, written by another role: e.g. the executor + handing a sub-task to the helper.""" + return self._store.transcript(self.work_item, actor) + + def begin_intent(self, kind: str, payload: dict[str, Any], *, cause: str) -> int: + """An intent owned by this transcript's work item.""" + return self._store.begin_intent( + kind, payload, cause=cause, work_item=self.work_item + ) diff --git a/nisse-dev/src/nisse_dev/testing.py b/nisse-core/src/nisse_core/testing.py similarity index 98% rename from nisse-dev/src/nisse_dev/testing.py rename to nisse-core/src/nisse_core/testing.py index 95f70d5..a0f2855 100644 --- a/nisse-dev/src/nisse_dev/testing.py +++ b/nisse-core/src/nisse_core/testing.py @@ -14,7 +14,7 @@ from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path -from nisse_dev.execution import SSHHost +from nisse_core.execution import SSHHost SSHD = "/usr/sbin/sshd" diff --git a/nisse-dev/src/nisse_dev/transport.py b/nisse-core/src/nisse_core/transport.py similarity index 100% rename from nisse-dev/src/nisse_dev/transport.py rename to nisse-core/src/nisse_core/transport.py diff --git a/nisse-dev/src/nisse_dev/vault.py b/nisse-core/src/nisse_core/vault.py similarity index 99% rename from nisse-dev/src/nisse_dev/vault.py rename to nisse-core/src/nisse_core/vault.py index fb54228..86f3d31 100644 --- a/nisse-dev/src/nisse_dev/vault.py +++ b/nisse-core/src/nisse_core/vault.py @@ -62,8 +62,8 @@ class Vault: root: Path, folders: Mapping[str, str], *, + agent: str, # whose writes these are, for provenance ignore: Sequence[str] = DEFAULT_IGNORE, - agent: str = "nisse-dev", clock: Any = None, ) -> None: self.root = Path(root) diff --git a/nisse-dev/src/nisse_dev/views.py b/nisse-core/src/nisse_core/views.py similarity index 89% rename from nisse-dev/src/nisse_dev/views.py rename to nisse-core/src/nisse_core/views.py index e87e0a0..c7cd109 100644 --- a/nisse-dev/src/nisse_dev/views.py +++ b/nisse-core/src/nisse_core/views.py @@ -17,9 +17,10 @@ ranges still line up with the log. Everything not the viewer's own becomes a ``UserMessage``: from where the viewer sits, it is all input. """ +from collections.abc import Mapping from dataclasses import replace -from nisse_dev.canonical import ( +from nisse_core.canonical import ( ActionVerdict, AssistantText, DelegationCompleted, @@ -36,18 +37,6 @@ from nisse_dev.canonical import ( # How much of another role's tool result a narration line keeps. NARRATION_RESULT_CHARS = 300 -# The roles whose work the viewer is shown, and how: "report" keeps only the -# handback, "narrate" keeps a line per step. Anything not listed is not shown. -_SHOWN: dict[str, dict[str, str]] = { - "driver": {"helper": "report"}, - "helper": {"driver": "narrate"}, - # The toolchain step works in its own transcript: the driver sees only - # its report (a tool result, or facts), the step the driver's work. - "toolchain": {"driver": "narrate"}, - # The memory pass at an item's close (L6.4): the item as it went. - "memory": {"driver": "narrate"}, -} - def operator_label(source: str) -> str: """Where an operator message came from, for its label.""" @@ -90,11 +79,18 @@ def _narration(call: ToolCall, result: ToolResult | None) -> str: return f"{step} → {outcome}: {text}" if text else f"{step} → {outcome}" -def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]: - """The events as ``viewer`` should be shown them, in log order.""" +def project( + events: list[StoredEvent], + viewer: str, + shown_by_viewer: Mapping[str, Mapping[str, str]], +) -> list[StoredEvent]: + """The events as ``viewer`` should be shown them, in log order. + ``shown_by_viewer``: for each viewer, the roles whose work it is shown, + and how: "report" keeps only the handback, "narrate" keeps a line per + step. Anything not listed is not shown.""" # Verdicts are records about calls, never part of any role's context. events = [e for e in events if not isinstance(e.event, ActionVerdict)] - shown = _SHOWN.get(viewer) + shown = shown_by_viewer.get(viewer) if shown is None: # A role with no view defined (the expert is stateless, the # classifier sees only an action) reads the log as it is, with diff --git a/nisse-dev/src/nisse_dev/worker.py b/nisse-core/src/nisse_core/worker.py similarity index 100% rename from nisse-dev/src/nisse_dev/worker.py rename to nisse-core/src/nisse_core/worker.py diff --git a/nisse-dev/tests/test_canonical.py b/nisse-core/tests/test_canonical.py similarity index 92% rename from nisse-dev/tests/test_canonical.py rename to nisse-core/tests/test_canonical.py index f740770..40c2af1 100644 --- a/nisse-dev/tests/test_canonical.py +++ b/nisse-core/tests/test_canonical.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, Compaction, DelegationCompleted, @@ -16,7 +16,7 @@ from nisse_dev.canonical import ( from_payload, mint_call_id, ) -from nisse_dev.state import StateSchemaError, StateStore +from nisse_core.state import StateSchemaError, StateStore ALL_EVENTS = [ UserMessage(text="use demo", source="matrix:$abc"), @@ -45,12 +45,12 @@ ALL_EVENTS = [ def test_all_event_types_round_trip(tmp_path: Path) -> None: with StateStore(tmp_path / "state.db") as store: for event in ALL_EVENTS: - store.transcript("t", "executor").append(event) + store.transcript("t", "driver").append(event) with StateStore(tmp_path / "state.db") as store: - stored = store.transcript("t", "executor").read() + stored = store.transcript("t", "driver").read() assert [s.event for s in stored] == ALL_EVENTS assert [s.id for s in stored] == sorted(s.id for s in stored) - assert {(s.work_item, s.actor) for s in stored} == {("t", "executor")} + assert {(s.work_item, s.actor) for s in stored} == {("t", "driver")} def test_read_events_after_id(tmp_path: Path) -> None: diff --git a/nisse-dev/tests/test_catalog.py b/nisse-core/tests/test_catalog.py similarity index 99% rename from nisse-dev/tests/test_catalog.py rename to nisse-core/tests/test_catalog.py index 1b8913a..e07f5e1 100644 --- a/nisse-dev/tests/test_catalog.py +++ b/nisse-core/tests/test_catalog.py @@ -8,7 +8,7 @@ route has to come from what LiteLLM says the model is. import httpx import pytest -from nisse_dev.catalog import ( +from nisse_core.catalog import ( CatalogUnavailable, ModelCatalog, UnknownModel, diff --git a/nisse-dev/tests/test_context.py b/nisse-core/tests/test_context.py similarity index 98% rename from nisse-dev/tests/test_context.py rename to nisse-core/tests/test_context.py index 51a3854..12d7192 100644 --- a/nisse-dev/tests/test_context.py +++ b/nisse-core/tests/test_context.py @@ -6,7 +6,7 @@ from typing import Any import pytest -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, CanonicalEvent, StoredEvent, @@ -14,8 +14,8 @@ from nisse_dev.canonical import ( ToolResult, UserMessage, ) -from nisse_dev.catalog import ModelCatalog, UnknownLimits -from nisse_dev.context import ( +from nisse_core.catalog import ModelCatalog, UnknownLimits +from nisse_core.context import ( Budget, BudgetedClient, ContextOverflow, @@ -23,9 +23,9 @@ from nisse_dev.context import ( fit, head_and_tail, ) +from nisse_core.oplog import OpLog +from nisse_core.state import StateStore from nisse_dev.loop.driver import Driver -from nisse_dev.oplog import OpLog -from nisse_dev.state import StateStore def stored(events: list[CanonicalEvent]) -> list[StoredEvent]: diff --git a/nisse-dev/tests/test_dump.py b/nisse-core/tests/test_dump.py similarity index 92% rename from nisse-dev/tests/test_dump.py rename to nisse-core/tests/test_dump.py index 4c31ac0..0e9eba1 100644 --- a/nisse-dev/tests/test_dump.py +++ b/nisse-core/tests/test_dump.py @@ -1,10 +1,10 @@ import io from pathlib import Path -from nisse_dev.canonical import Fact, PlanSubmitted, ToolCall, ToolResult, UserMessage -from nisse_dev.dump import main, render -from nisse_dev.oplog import OpLog -from nisse_dev.state import StateStore +from nisse_core.canonical import Fact, PlanSubmitted, ToolCall, ToolResult, UserMessage +from nisse_core.dump import main, render +from nisse_core.oplog import OpLog +from nisse_core.state import StateStore def store_with_a_run(path: Path) -> None: diff --git a/nisse-dev/tests/test_execution.py b/nisse-core/tests/test_execution.py similarity index 96% rename from nisse-dev/tests/test_execution.py rename to nisse-core/tests/test_execution.py index 9386def..60cec7a 100644 --- a/nisse-dev/tests/test_execution.py +++ b/nisse-core/tests/test_execution.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from nisse_dev.execution import REDACTED, HostUnavailable, SSHHost, redact +from nisse_core.execution import REDACTED, HostUnavailable, SSHHost, redact def test_exit_codes_and_streams_come_back(ssh_host: SSHHost) -> None: @@ -82,7 +82,7 @@ def test_a_changed_host_key_is_its_own_failure_and_only_forgetting_clears_it( ) -> None: import subprocess - from nisse_dev.execution import HostKeyChanged + from nisse_core.execution import HostKeyChanged host = SSHHost( ssh_host.address, diff --git a/nisse-dev/tests/test_gitea.py b/nisse-core/tests/test_gitea.py similarity index 99% rename from nisse-dev/tests/test_gitea.py rename to nisse-core/tests/test_gitea.py index 8340093..c88fbbb 100644 --- a/nisse-dev/tests/test_gitea.py +++ b/nisse-core/tests/test_gitea.py @@ -3,14 +3,14 @@ import json import httpx import pytest -from nisse_dev.gitea import ( +from nisse_core.gitea import ( PR_PAGE_SIZE, Comment, GiteaClient, GiteaError, pull_request_reconciler, ) -from nisse_dev.state import Intent +from nisse_core.state import Intent INTENT = Intent( id=1, diff --git a/nisse-core/tests/test_independence.py b/nisse-core/tests/test_independence.py new file mode 100644 index 0000000..9382fdc --- /dev/null +++ b/nisse-core/tests/test_independence.py @@ -0,0 +1,25 @@ +"""nisse-core is what every agent is built on (Part L): it never imports an +agent.""" + +import ast +from pathlib import Path + +import nisse_core + +AGENTS = ("nisse_dev", "nisse_ops") + + +def test_core_imports_no_agent() -> None: + root = Path(nisse_core.__file__).parent + found = [] + for path in sorted(root.rglob("*.py")): + for node in ast.walk(ast.parse(path.read_text())): + if isinstance(node, ast.Import): + names = [a.name for a in node.names] + elif isinstance(node, ast.ImportFrom): + names = [node.module or ""] + else: + continue + found += [f"{path.name}: {n}" for n in names if n.split(".")[0] in AGENTS] + assert len(list(root.rglob("*.py"))) > 10 # the modules really are here + assert found == [] diff --git a/nisse-dev/tests/test_llm.py b/nisse-core/tests/test_llm.py similarity index 99% rename from nisse-dev/tests/test_llm.py rename to nisse-core/tests/test_llm.py index d5bbe7b..65fe27e 100644 --- a/nisse-dev/tests/test_llm.py +++ b/nisse-core/tests/test_llm.py @@ -5,14 +5,14 @@ from typing import Any import httpx import pytest -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, StoredEvent, ToolCall, UserMessage, ) -from nisse_dev.envfile import load_env_file -from nisse_dev.llm import ( +from nisse_core.envfile import load_env_file +from nisse_core.llm import ( DEFAULT_MAX_OUTPUT_TOKENS, OUTAGE_BACKOFF_S, RATE_LIMIT_RETRY_S, diff --git a/nisse-dev/tests/test_matrix.py b/nisse-core/tests/test_matrix.py similarity index 98% rename from nisse-dev/tests/test_matrix.py rename to nisse-core/tests/test_matrix.py index 54b2e85..7d59c0c 100644 --- a/nisse-dev/tests/test_matrix.py +++ b/nisse-core/tests/test_matrix.py @@ -4,8 +4,8 @@ import threading import httpx import pytest -from nisse_dev.matrix import MatrixConfig, MatrixTransport, TransportError -from nisse_dev.transport import InboundMessage +from nisse_core.matrix import MatrixConfig, MatrixTransport, TransportError +from nisse_core.transport import InboundMessage CONFIG = MatrixConfig( homeserver="https://matrix.test", @@ -349,7 +349,7 @@ def test_a_reply_to_the_operators_own_threaded_message_joins_the_thread() -> Non def test_in_memory_transport_plays_threads() -> None: - from nisse_dev.transport import InMemoryTransport + from nisse_core.transport import InMemoryTransport transport = InMemoryTransport() seen: list[InboundMessage] = [] diff --git a/nisse-dev/tests/test_memory.py b/nisse-core/tests/test_memory.py similarity index 94% rename from nisse-dev/tests/test_memory.py rename to nisse-core/tests/test_memory.py index 214f31d..8659196 100644 --- a/nisse-dev/tests/test_memory.py +++ b/nisse-core/tests/test_memory.py @@ -5,15 +5,17 @@ from pathlib import Path import pytest -from nisse_dev.memory import Memory -from nisse_dev.vault import Vault, VaultError, split_frontmatter +from nisse_core.memory import Memory +from nisse_core.vault import Vault, VaultError, split_frontmatter FOLDER = "memory/nisse-dev" @pytest.fixture def memory(tmp_path: Path) -> Memory: - return Memory(Vault(tmp_path / "v", {FOLDER: "rw", "notes": "ro"}), FOLDER) + return Memory( + Vault(tmp_path / "v", {FOLDER: "rw", "notes": "ro"}, agent="nisse-dev"), FOLDER + ) def note(memory: Memory, name: str) -> tuple[dict, str]: diff --git a/nisse-dev/tests/test_oplog.py b/nisse-core/tests/test_oplog.py similarity index 97% rename from nisse-dev/tests/test_oplog.py rename to nisse-core/tests/test_oplog.py index 26dc1dc..4be77be 100644 --- a/nisse-dev/tests/test_oplog.py +++ b/nisse-core/tests/test_oplog.py @@ -8,9 +8,9 @@ from pathlib import Path import pytest -from nisse_dev.oplog import OpLog -from nisse_dev.reconcile import reconcile_on_boot -from nisse_dev.state import StateStore +from nisse_core.oplog import OpLog +from nisse_core.reconcile import reconcile_on_boot +from nisse_core.state import StateStore ENVELOPE_FIELDS = {"ts", "agent", "event", "task_id", "cause"} @@ -132,7 +132,7 @@ def test_literal_log_lines_for_human_readability( return datetime(2026, 9, 5, 12, 0, 0, tzinfo=UTC) - monkeypatch.setattr("nisse_dev.oplog.datetime", _FrozenDatetime) + monkeypatch.setattr("nisse_core.oplog.datetime", _FrozenDatetime) start_item(store, "demo-m2", scope="demo", cause="issue assigned: demo#2") store.oplog.task_id = "demo-m2" diff --git a/nisse-dev/tests/test_probe.py b/nisse-core/tests/test_probe.py similarity index 98% rename from nisse-dev/tests/test_probe.py rename to nisse-core/tests/test_probe.py index 3543f78..2991b7e 100644 --- a/nisse-dev/tests/test_probe.py +++ b/nisse-core/tests/test_probe.py @@ -2,7 +2,7 @@ import json import httpx -from nisse_dev.probe import interpret, probe +from nisse_core.probe import interpret, probe def test_a_cut_at_the_declared_window_matches() -> None: diff --git a/nisse-dev/tests/test_reconcile.py b/nisse-core/tests/test_reconcile.py similarity index 95% rename from nisse-dev/tests/test_reconcile.py rename to nisse-core/tests/test_reconcile.py index a99b447..4ad5f16 100644 --- a/nisse-dev/tests/test_reconcile.py +++ b/nisse-core/tests/test_reconcile.py @@ -2,8 +2,8 @@ from pathlib import Path import pytest -from nisse_dev.reconcile import reconcile_on_boot -from nisse_dev.state import Intent, StateStore +from nisse_core.reconcile import reconcile_on_boot +from nisse_core.state import Intent, StateStore @pytest.fixture diff --git a/nisse-dev/tests/test_render.py b/nisse-core/tests/test_render.py similarity index 98% rename from nisse-dev/tests/test_render.py rename to nisse-core/tests/test_render.py index bf8ec85..87de5e2 100644 --- a/nisse-dev/tests/test_render.py +++ b/nisse-core/tests/test_render.py @@ -1,7 +1,7 @@ import json from itertools import pairwise -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, Compaction, GateOpened, @@ -10,7 +10,7 @@ from nisse_dev.canonical import ( ToolResult, UserMessage, ) -from nisse_dev.render import OpenAIChatRenderer, effective_events +from nisse_core.render import OpenAIChatRenderer, effective_events def stored(events: list) -> list[StoredEvent]: diff --git a/nisse-dev/tests/test_render_anthropic.py b/nisse-core/tests/test_render_anthropic.py similarity index 97% rename from nisse-dev/tests/test_render_anthropic.py rename to nisse-core/tests/test_render_anthropic.py index f516d59..79ea1a0 100644 --- a/nisse-dev/tests/test_render_anthropic.py +++ b/nisse-core/tests/test_render_anthropic.py @@ -4,7 +4,7 @@ these tests pin the differences that a normalising middlebox would blur.""" import httpx import pytest -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, Compaction, StoredEvent, @@ -12,8 +12,8 @@ from nisse_dev.canonical import ( ToolResult, UserMessage, ) -from nisse_dev.llm import AnthropicPassthroughClient, LLMError -from nisse_dev.render_anthropic import ( +from nisse_core.llm import AnthropicPassthroughClient, LLMError +from nisse_core.render_anthropic import ( AnthropicMessagesRenderer, to_anthropic_tools, ) diff --git a/nisse-dev/tests/test_state.py b/nisse-core/tests/test_state.py similarity index 78% rename from nisse-dev/tests/test_state.py rename to nisse-core/tests/test_state.py index 25861b3..5a52ac3 100644 --- a/nisse-dev/tests/test_state.py +++ b/nisse-core/tests/test_state.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from nisse_dev.state import StateSchemaError, StateStore +from nisse_core.state import StateSchemaError, StateStore @pytest.fixture @@ -121,28 +121,3 @@ def test_newer_schema_is_refused(db: Path) -> None: pass with pytest.raises(StateSchemaError, match="newer"): StateStore(db, schema_version=1) - - -def test_a_vm_block_persists_across_reopen(db: Path) -> None: - with StateStore(db) as store: - store.block_vm("root@vm1", cause="broken", reason="pip crashes", work_item="x") - store.block_vm("root@vm1", cause="broken", reason="later", work_item="y") - with StateStore(db) as store: - [block] = store.vm_blocks().values() - assert (block.host, block.reason, block.work_item) == ( - "root@vm1", - "pip crashes", # the first block is kept - "x", - ) - assert store.unblock_vm("root@vm1", cause="test") == block - assert store.unblock_vm("root@vm1", cause="test") is None - assert store.vm_blocks() == {} - - -def test_a_version_4_store_gains_vm_blocks(db: Path) -> None: - with StateStore(db, schema_version=4) as store: - store._conn.execute("DROP TABLE vm_blocks") # as a real version 4 store - with StateStore(db) as store: - assert store.schema_version == 5 - store.block_vm("root@vm1", cause="broken", reason="r", work_item=None) - assert list(store.vm_blocks()) == ["root@vm1"] diff --git a/nisse-dev/tests/test_vault.py b/nisse-core/tests/test_vault.py similarity index 97% rename from nisse-dev/tests/test_vault.py rename to nisse-core/tests/test_vault.py index f35dd90..b1430b0 100644 --- a/nisse-dev/tests/test_vault.py +++ b/nisse-core/tests/test_vault.py @@ -9,8 +9,8 @@ from pathlib import Path import pytest import yaml -from nisse_dev import vault as vault_module -from nisse_dev.vault import Vault, VaultError, split_frontmatter +from nisse_core import vault as vault_module +from nisse_core.vault import Vault, VaultError, split_frontmatter @pytest.fixture @@ -32,6 +32,7 @@ def vault(tmp_path: Path) -> Vault: return Vault( root, {"memory/nisse-dev": "rw", "notes": "ro"}, + agent="nisse-dev", clock=lambda: datetime(2026, 10, 8, 12, 0, tzinfo=UTC), ) @@ -138,7 +139,7 @@ def test_bad_frontmatter_is_refused(vault: Vault) -> None: def test_read_write_folders_are_made(tmp_path: Path) -> None: - Vault(tmp_path / "v", {"memory/nisse-dev": "rw", "notes": "ro"}) + Vault(tmp_path / "v", {"memory/nisse-dev": "rw", "notes": "ro"}, agent="a") assert (tmp_path / "v" / "memory" / "nisse-dev").is_dir() assert not (tmp_path / "v" / "notes").exists() # not ours to make assert yaml # frontmatter is YAML diff --git a/nisse-dev/tests/test_views.py b/nisse-core/tests/test_views.py similarity index 88% rename from nisse-dev/tests/test_views.py rename to nisse-core/tests/test_views.py index b5ae05a..2470453 100644 --- a/nisse-dev/tests/test_views.py +++ b/nisse-core/tests/test_views.py @@ -1,9 +1,10 @@ """Per-role transcript views (architecture L6; Development Plan M1.2).""" import io +from collections.abc import Mapping from pathlib import Path -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, DelegationCompleted, DelegationStarted, @@ -14,14 +15,24 @@ from nisse_dev.canonical import ( ToolResult, UserMessage, ) -from nisse_dev.oplog import OpLog -from nisse_dev.render import OpenAIChatRenderer -from nisse_dev.render_anthropic import AnthropicMessagesRenderer -from nisse_dev.state import StateStore +from nisse_core.oplog import OpLog +from nisse_core.render import OpenAIChatRenderer +from nisse_core.render_anthropic import AnthropicMessagesRenderer +from nisse_core.state import StateStore + + +class Store(StateStore): + """An agent's store: the helper is shown the driver's work as narration, + the driver only the helper's report.""" + + SHOWN: Mapping[str, Mapping[str, str]] = { + "driver": {"helper": "report"}, + "helper": {"driver": "narrate"}, + } def new_store(tmp_path: Path) -> StateStore: - return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO())) + return Store(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO())) def texts(events: list[StoredEvent]) -> list[str]: diff --git a/nisse-dev/tests/test_worker.py b/nisse-core/tests/test_worker.py similarity index 94% rename from nisse-dev/tests/test_worker.py rename to nisse-core/tests/test_worker.py index 41f2ee4..7eba8af 100644 --- a/nisse-dev/tests/test_worker.py +++ b/nisse-core/tests/test_worker.py @@ -4,9 +4,9 @@ from pathlib import Path import pytest -from nisse_dev.oplog import OpLog -from nisse_dev.state import StateStore -from nisse_dev.worker import InlineWorker, SerialWorker +from nisse_core.oplog import OpLog +from nisse_core.state import StateStore +from nisse_core.worker import InlineWorker, SerialWorker @pytest.fixture diff --git a/nisse-dev/src/nisse_dev/bench/runner.py b/nisse-dev/src/nisse_dev/bench/runner.py index fc197a9..de80c7b 100644 --- a/nisse-dev/src/nisse_dev/bench/runner.py +++ b/nisse-dev/src/nisse_dev/bench/runner.py @@ -11,8 +11,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any -from nisse_dev.bench.tasks import Task -from nisse_dev.canonical import ( +from nisse_core.canonical import ( CanonicalEvent, Fact, StoredEvent, @@ -20,9 +19,11 @@ from nisse_dev.canonical import ( ToolResult, UserMessage, ) -from nisse_dev.catalog import ModelFacts +from nisse_core.catalog import ModelFacts +from nisse_core.execution import SSHHost +from nisse_core.oplog import OpLog +from nisse_dev.bench.tasks import Task from nisse_dev.config import Tuning -from nisse_dev.execution import SSHHost from nisse_dev.loop.agent import SYSTEM from nisse_dev.loop.driver import Driver, RunOutcome from nisse_dev.loop.tools import ( @@ -31,7 +32,6 @@ from nisse_dev.loop.tools import ( FINISH_MILESTONE, START_MILESTONE, ) -from nisse_dev.oplog import OpLog from nisse_dev.state import StateStore from nisse_dev.tools import EXECUTION_TOOLS, Workspace diff --git a/nisse-dev/src/nisse_dev/config.py b/nisse-dev/src/nisse_dev/config.py index 1469ea5..8c6360d 100644 --- a/nisse-dev/src/nisse_dev/config.py +++ b/nisse-dev/src/nisse_dev/config.py @@ -23,9 +23,9 @@ from typing import Any import yaml -from nisse_dev.llm import DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_REQUEST_TIMEOUT_S, RoleMap -from nisse_dev.matrix import MatrixConfig -from nisse_dev.vault import ACCESS, DEFAULT_IGNORE +from nisse_core.llm import DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_REQUEST_TIMEOUT_S, RoleMap +from nisse_core.matrix import MatrixConfig +from nisse_core.vault import ACCESS, DEFAULT_IGNORE DEFAULT_CONFIG_FILE = "nisse-dev.yaml" diff --git a/nisse-dev/src/nisse_dev/delegation.py b/nisse-dev/src/nisse_dev/delegation.py index 3535e18..f54cda9 100644 --- a/nisse-dev/src/nisse_dev/delegation.py +++ b/nisse-dev/src/nisse_dev/delegation.py @@ -28,8 +28,8 @@ from collections.abc import Callable from dataclasses import dataclass, field from typing import Any, Literal -from nisse_dev.cancel import Cancel, skip_calls -from nisse_dev.canonical import ( +from nisse_core.cancel import Cancel, skip_calls +from nisse_core.canonical import ( AssistantText, DelegationCompleted, DelegationStarted, @@ -39,10 +39,11 @@ from nisse_dev.canonical import ( ToolCall, ToolResult, ) +from nisse_core.llm import LLMClient, LLMError, UpstreamUnavailable +from nisse_core.reconcile import Reconciler +from nisse_core.state import Intent, Transcript from nisse_dev.git import GitError, GitRepo -from nisse_dev.llm import LLMClient, LLMError, UpstreamUnavailable -from nisse_dev.reconcile import Reconciler -from nisse_dev.state import Intent, StateStore, Transcript +from nisse_dev.state import StateStore from nisse_dev.tools import EXECUTION_TOOLS, MUTATING_TOOLS, Workspace DELEGATION_INTENT = "delegation" diff --git a/nisse-dev/src/nisse_dev/evals.py b/nisse-dev/src/nisse_dev/evals.py index 239067d..59e9f33 100644 --- a/nisse-dev/src/nisse_dev/evals.py +++ b/nisse-dev/src/nisse_dev/evals.py @@ -15,7 +15,7 @@ from collections.abc import Iterable from dataclasses import asdict, dataclass from typing import Any -from nisse_dev.canonical import ( +from nisse_core.canonical import ( ActionVerdict, DelegationCompleted, DelegationStarted, diff --git a/nisse-dev/src/nisse_dev/expert.py b/nisse-dev/src/nisse_dev/expert.py index 0735f4c..801596e 100644 --- a/nisse-dev/src/nisse_dev/expert.py +++ b/nisse-dev/src/nisse_dev/expert.py @@ -26,14 +26,14 @@ import json import re from dataclasses import dataclass -from nisse_dev.canonical import ( +from nisse_core.canonical import ( DelegationCompleted, DelegationStarted, StoredEvent, UserMessage, ) -from nisse_dev.llm import LLMClient, LLMError, RoleMap -from nisse_dev.state import Transcript +from nisse_core.llm import LLMClient, LLMError, RoleMap +from nisse_core.state import Transcript EXPERT_ROLE = "expert" DEFAULT_BUDGET = 3 diff --git a/nisse-dev/src/nisse_dev/git.py b/nisse-dev/src/nisse_dev/git.py index ee1225f..c576018 100644 --- a/nisse-dev/src/nisse_dev/git.py +++ b/nisse-dev/src/nisse_dev/git.py @@ -21,7 +21,7 @@ import shlex from dataclasses import dataclass, field from pathlib import Path -from nisse_dev.execution import HostUnavailable, SSHHost +from nisse_core.execution import HostUnavailable, SSHHost TIMEOUT_S = 120 diff --git a/nisse-dev/src/nisse_dev/loop/agent.py b/nisse-dev/src/nisse_dev/loop/agent.py index 9127ba6..2fb8d7d 100644 --- a/nisse-dev/src/nisse_dev/loop/agent.py +++ b/nisse-dev/src/nisse_dev/loop/agent.py @@ -34,8 +34,8 @@ from nisse_core.actions import ( Action, Verdict, ) -from nisse_dev.cancel import Cancel -from nisse_dev.canonical import ( +from nisse_core.cancel import Cancel +from nisse_core.canonical import ( ActionVerdict, DelegationCompleted, DelegationStarted, @@ -46,13 +46,19 @@ from nisse_dev.canonical import ( ToolResult, UserMessage, ) +from nisse_core.execution import HostKeyChanged, HostUnavailable +from nisse_core.gitea import GiteaClient, GiteaError, Issue, PullRequest +from nisse_core.llm import LLMError, RoleMap +from nisse_core.memory import Memory +from nisse_core.oplog import OpLog +from nisse_core.state import WorkItem +from nisse_core.transport import InboundMessage, Transport +from nisse_core.vault import Vault, VaultError +from nisse_core.worker import Worker from nisse_dev.config import GENERAL from nisse_dev.delegation import DELEGATION_INTENT, Delegation -from nisse_dev.execution import HostKeyChanged, HostUnavailable from nisse_dev.expert import BudgetExhausted, ExpertDesk from nisse_dev.git import GitError -from nisse_dev.gitea import GiteaClient, GiteaError, Issue, PullRequest -from nisse_dev.llm import LLMError, RoleMap from nisse_dev.loop import preflight from nisse_dev.loop.actions import classify, commits from nisse_dev.loop.classifier import IN_SCOPE, ActionClassifier, Scope @@ -99,13 +105,8 @@ from nisse_dev.loop.tools import ( TOOLCHAIN_STEP, WAIT_FOR_REVIEW, ) -from nisse_dev.memory import Memory -from nisse_dev.oplog import OpLog -from nisse_dev.state import StateStore, WorkItem +from nisse_dev.state import StateStore from nisse_dev.tools import EXECUTION_TOOLS, Workspace -from nisse_dev.transport import InboundMessage, Transport -from nisse_dev.vault import Vault, VaultError -from nisse_dev.worker import Worker INTAKE_INTERVAL_S = 30.0 _GO = frozenset({"go", "approve"}) diff --git a/nisse-dev/src/nisse_dev/loop/build.py b/nisse-dev/src/nisse_dev/loop/build.py index 70cca59..8027c21 100644 --- a/nisse-dev/src/nisse_dev/loop/build.py +++ b/nisse-dev/src/nisse_dev/loop/build.py @@ -7,29 +7,29 @@ import threading from collections.abc import Callable from dataclasses import dataclass -from nisse_dev.cancel import Cancel -from nisse_dev.catalog import ModelCatalog, select_client +from nisse_core.cancel import Cancel +from nisse_core.catalog import ModelCatalog, select_client +from nisse_core.context import BudgetedClient +from nisse_core.execution import SSHHost +from nisse_core.gitea import GiteaClient +from nisse_core.llm import AnthropicPassthroughClient, LiteLLMClient +from nisse_core.matrix import MatrixTransport +from nisse_core.memory import Memory +from nisse_core.oplog import OpLog +from nisse_core.vault import Vault +from nisse_core.worker import SerialWorker from nisse_dev.config import AgentConfig, ConfigError from nisse_dev.config import Project as ProjectEntry -from nisse_dev.context import BudgetedClient from nisse_dev.delegation import Caps -from nisse_dev.execution import SSHHost from nisse_dev.git import GitRepo, HTTPSToken, PushCredential, SSHKey -from nisse_dev.gitea import GiteaClient -from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient from nisse_dev.loop.agent import LoopAgent, Project from nisse_dev.loop.classifier import ActionClassifier from nisse_dev.loop.driver import Driver from nisse_dev.loop.help import Helpers from nisse_dev.loop.milestones import Milestones from nisse_dev.loop.reader import GoAheadReader -from nisse_dev.matrix import MatrixTransport -from nisse_dev.memory import Memory -from nisse_dev.oplog import OpLog from nisse_dev.state import StateStore from nisse_dev.tools import Workspace -from nisse_dev.vault import Vault -from nisse_dev.worker import SerialWorker # How long shutdown waits for running work before leaving the store to the # durability layer (L8). A milestone can run far longer than a service @@ -301,7 +301,12 @@ def build_loop_agent( ) transport = MatrixTransport(config.matrix) vault = ( - Vault(config.vault.root, config.vault.folders, ignore=config.vault.ignore) + Vault( + config.vault.root, + config.vault.folders, + agent="nisse-dev", + ignore=config.vault.ignore, + ) if config.vault else None ) diff --git a/nisse-dev/src/nisse_dev/loop/classifier.py b/nisse-dev/src/nisse_dev/loop/classifier.py index 9e8f24f..e09e2b9 100644 --- a/nisse-dev/src/nisse_dev/loop/classifier.py +++ b/nisse-dev/src/nisse_dev/loop/classifier.py @@ -17,8 +17,8 @@ from dataclasses import dataclass from typing import Any from nisse_core.actions import CONTAINED, DANGEROUS, Action, Verdict -from nisse_dev.canonical import StoredEvent, ToolCall, UserMessage -from nisse_dev.llm import LLMClient, LLMError +from nisse_core.canonical import StoredEvent, ToolCall, UserMessage +from nisse_core.llm import LLMClient, LLMError SAFE = "safe" IN_SCOPE = "in_scope" diff --git a/nisse-dev/src/nisse_dev/loop/driver.py b/nisse-dev/src/nisse_dev/loop/driver.py index b9ebc72..04cc93d 100644 --- a/nisse-dev/src/nisse_dev/loop/driver.py +++ b/nisse-dev/src/nisse_dev/loop/driver.py @@ -11,11 +11,11 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any, Literal -from nisse_dev.cancel import Cancel, skip_calls -from nisse_dev.canonical import AssistantText, Fact, ToolCall, ToolResult -from nisse_dev.llm import LLMClient, LLMError +from nisse_core.cancel import Cancel, skip_calls +from nisse_core.canonical import AssistantText, Fact, ToolCall, ToolResult +from nisse_core.llm import LLMClient, LLMError +from nisse_core.state import Transcript from nisse_dev.loop.tools import WAITING, names -from nisse_dev.state import Transcript from nisse_dev.tools import Workspace DEFAULT_MAX_TURNS = 30 diff --git a/nisse-dev/src/nisse_dev/loop/help.py b/nisse-dev/src/nisse_dev/loop/help.py index bc49b36..26e79d9 100644 --- a/nisse-dev/src/nisse_dev/loop/help.py +++ b/nisse-dev/src/nisse_dev/loop/help.py @@ -11,10 +11,10 @@ half-edited tree). from collections.abc import Callable, Mapping from dataclasses import dataclass, field +from nisse_core.llm import LLMClient, RoleMap from nisse_dev.config import Profile from nisse_dev.delegation import Caps from nisse_dev.expert import DEFAULT_BUDGET -from nisse_dev.llm import LLMClient, RoleMap from nisse_dev.loop.tools import _tool DELEGATE = _tool( diff --git a/nisse-dev/src/nisse_dev/loop/items.py b/nisse-dev/src/nisse_dev/loop/items.py index 49b0970..018fa62 100644 --- a/nisse-dev/src/nisse_dev/loop/items.py +++ b/nisse-dev/src/nisse_dev/loop/items.py @@ -8,7 +8,8 @@ agreement, what it is waiting for) is in ``data["loop"]``. from dataclasses import asdict, dataclass from typing import Any -from nisse_dev.state import StateStore, WorkItem +from nisse_core.state import WorkItem +from nisse_dev.state import StateStore READY = "ready" RUNNING = "running" # a driver run is under way; on boot it means interrupted diff --git a/nisse-dev/src/nisse_dev/loop/milestones.py b/nisse-dev/src/nisse_dev/loop/milestones.py index 736b08f..29a015b 100644 --- a/nisse-dev/src/nisse_dev/loop/milestones.py +++ b/nisse-dev/src/nisse_dev/loop/milestones.py @@ -13,8 +13,8 @@ import uuid from dataclasses import dataclass from typing import Literal +from nisse_core.gitea import GiteaClient, GiteaError, PullRequest from nisse_dev.git import GitError, GitRepo, PushCredential -from nisse_dev.gitea import GiteaClient, GiteaError, PullRequest from nisse_dev.state import StateStore OPEN_PR = "open_pr" diff --git a/nisse-dev/src/nisse_dev/loop/preflight.py b/nisse-dev/src/nisse_dev/loop/preflight.py index d4e6ca1..76803db 100644 --- a/nisse-dev/src/nisse_dev/loop/preflight.py +++ b/nisse-dev/src/nisse_dev/loop/preflight.py @@ -21,7 +21,7 @@ from dataclasses import dataclass, field from typing import Any from nisse_core.actions import split_shell, unwrap -from nisse_dev.execution import SSHHost +from nisse_core.execution import SSHHost # What the harness itself runs on a build VM (L13, D4): part of the # template, never of a project's toolchain. diff --git a/nisse-dev/src/nisse_dev/loop/reader.py b/nisse-dev/src/nisse_dev/loop/reader.py index 0aaea2b..585cc28 100644 --- a/nisse-dev/src/nisse_dev/loop/reader.py +++ b/nisse-dev/src/nisse_dev/loop/reader.py @@ -10,8 +10,8 @@ The driver's model reads for now; the classifier role takes over in M2.6. from typing import Any -from nisse_dev.canonical import StoredEvent, ToolCall, UserMessage -from nisse_dev.llm import LLMClient, LLMError +from nisse_core.canonical import StoredEvent, ToolCall, UserMessage +from nisse_core.llm import LLMClient, LLMError def command_form(text: str) -> str: diff --git a/nisse-dev/src/nisse_dev/main.py b/nisse-dev/src/nisse_dev/main.py index 397bec6..2138385 100644 --- a/nisse-dev/src/nisse_dev/main.py +++ b/nisse-dev/src/nisse_dev/main.py @@ -12,8 +12,8 @@ import sys from pathlib import Path from types import FrameType +from nisse_core.envfile import load_env_file from nisse_dev.config import AgentConfig, ConfigError -from nisse_dev.envfile import load_env_file from nisse_dev.loop.build import build_loop_agent diff --git a/nisse-dev/src/nisse_dev/state.py b/nisse-dev/src/nisse_dev/state.py index af3f1fc..5d761f4 100644 --- a/nisse-dev/src/nisse_dev/state.py +++ b/nisse-dev/src/nisse_dev/state.py @@ -1,42 +1,12 @@ -"""Durable state store (architecture L8). +"""nisse-dev's state store (architecture L8): the shared one, with the dev +agent's own roles, views and tables (build VM blocks, D3).""" -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 -intent-log write is a single immediately-committed statement — in-memory state -that hasn't hit disk is assumed lost. - -The intent log records a unit of work *before* it starts, so a restart knows -what was in flight (see ``nisse_dev.reconcile`` for how pending intents are -resolved against external truth on boot). -""" - -import json import sqlite3 -from collections.abc import Callable, Mapping +from collections.abc import Mapping from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from typing import Any, Self -from nisse_dev.canonical import ( - CanonicalEvent, - StoredEvent, - UserMessage, - event_type, - from_payload, - to_payload, -) -from nisse_dev.oplog import OpLog -from nisse_dev.views import project - -CURRENT_SCHEMA_VERSION = 5 - -# Stores older than this predate work items (Development Plan M1.1). There was -# no production data to carry over, so there is no migration from them: start -# a fresh store (scripts/bringup.sh start sets the old one aside). -OLDEST_SUPPORTED_VERSION = 4 - -Migration = Callable[[sqlite3.Connection], None] +from nisse_core import state +from nisse_core.state import Migration, utc_now _VM_BLOCKS = """ CREATE TABLE vm_blocks ( @@ -53,46 +23,6 @@ def _add_vm_blocks(conn: sqlite3.Connection) -> None: conn.execute(_VM_BLOCKS) -# Maps version N to the migration that takes the schema from N to N+1. -MIGRATIONS: Mapping[int, Migration] = {4: _add_vm_blocks} - -# Who produced an event (L7): the operator, the orchestrator's own code, or a -# model role (L9). planner and executor are the old loop's (until M2.8), kept -# so its records still read. -ACTORS = frozenset( - { - "operator", - "orchestrator", - "planner", - "executor", - "driver", - "helper", - "expert", - "classifier", - "toolchain", # the toolchain step (D3): the driver's model, apart - "memory", # the memory pass at an item's close (L6.4), apart too - } -) - - -class StateSchemaError(RuntimeError): - pass - - -@dataclass(frozen=True) -class WorkItem: - id: str - scope: str | None - title: str - status: str # open | closed - outcome: str | None # completed | abandoned, once closed - position: str # where the loop is for this item - data: dict[str, Any] - opened_at: str - updated_at: str - closed_at: str | None - - @dataclass(frozen=True) class VMBlock: """A build VM nothing may run on until it is recovered (D3): every @@ -105,298 +35,30 @@ class VMBlock: since: str -@dataclass(frozen=True) -class Intent: - id: int - kind: str - payload: dict[str, Any] - status: str - created_at: str - resolved_at: str | None - work_item: str | None = None - - -_SCHEMA = [ - "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)", - """ - CREATE TABLE work_items ( - id TEXT PRIMARY KEY, - scope TEXT, - title TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('open', 'closed')), - outcome TEXT, - position TEXT NOT NULL, - data TEXT NOT NULL DEFAULT '{}', - opened_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - closed_at TEXT - ) - """, - """ - CREATE TABLE agent ( - id INTEGER PRIMARY KEY CHECK (id = 1), - active_item TEXT REFERENCES work_items(id), - idle_from_event INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL - ) - """, - """ - CREATE TABLE intent_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - kind TEXT NOT NULL, - payload TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - created_at TEXT NOT NULL, - resolved_at TEXT, - work_item TEXT - ) - """, - """ - CREATE TABLE canonical_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL, - type TEXT NOT NULL, - payload TEXT NOT NULL, - work_item TEXT, - actor TEXT NOT NULL - ) - """, - "CREATE INDEX canonical_events_by_item ON canonical_events (work_item, id)", - """ - CREATE TABLE eval_outcomes ( - event_id INTEGER PRIMARY KEY, - verdict TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '', - marked_at TEXT NOT NULL - ) - """, - _VM_BLOCKS, -] - - -def _now() -> str: - return datetime.now(UTC).isoformat() - - -class StateStore: - def __init__( - self, - path: str | Path, - *, - schema_version: int = CURRENT_SCHEMA_VERSION, - migrations: Mapping[int, Migration] = MIGRATIONS, - oplog: OpLog | None = None, - ) -> None: - # Log emission is owned by the store (L12): the same code path that - # performs a durable write emits its log line, right after commit. - self.oplog = oplog if oplog is not None else OpLog("nisse-dev") - self._conn = sqlite3.connect(path, isolation_level=None) - self._conn.row_factory = sqlite3.Row - self._conn.execute("PRAGMA journal_mode=WAL") - self._conn.execute("PRAGMA synchronous=FULL") - self._conn.execute("PRAGMA busy_timeout=5000") - self._target_version = schema_version - self.closed = False - self._init_or_migrate(migrations) - - def _init_or_migrate(self, migrations: Mapping[int, Migration]) -> None: - fresh = ( - self._conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='meta'" - ).fetchone() - is None - ) - if fresh: - self._in_transaction(self._create_schema) - return - stored = self.schema_version - if stored < OLDEST_SUPPORTED_VERSION <= self._target_version: - raise StateSchemaError( - f"state store schema version {stored} predates work items " - f"(version {OLDEST_SUPPORTED_VERSION}); start a fresh store " - "(scripts/bringup.sh start sets the old one aside)" - ) - if stored > self._target_version: - raise StateSchemaError( - f"state schema version {stored} is newer than this code " - f"supports ({self._target_version}); refusing to open" - ) - for version in range(stored, self._target_version): - migrate = migrations.get(version) - if migrate is None: - raise StateSchemaError( - f"no migration registered from schema version {version}" - ) - self._in_transaction(lambda m=migrate, v=version: self._apply(m, v)) - self.oplog.emit( - "schema.migrate", - cause="startup", - from_version=version, - to_version=version + 1, - ) - - def _create_schema(self) -> None: - for statement in _SCHEMA: - self._conn.execute(statement) - self._conn.execute( - "INSERT INTO meta (key, value) VALUES ('schema_version', ?)", - (str(self._target_version),), - ) - - def _apply(self, migrate: Migration, from_version: int) -> None: - migrate(self._conn) - self._conn.execute( - "UPDATE meta SET value = ? WHERE key = 'schema_version'", - (str(from_version + 1),), - ) - - def _in_transaction(self, work: Callable[[], None]) -> None: - self._conn.execute("BEGIN IMMEDIATE") - try: - work() - except BaseException: - self._conn.execute("ROLLBACK") - raise - self._conn.execute("COMMIT") - - @property - def schema_version(self) -> int: - row = self._conn.execute( - "SELECT value FROM meta WHERE key = 'schema_version'" - ).fetchone() - return int(row["value"]) - - # --- work items (L1) --------------------------------------------------- - - def _ensure_agent_row(self) -> None: - self._conn.execute( - "INSERT INTO agent (id, active_item, updated_at) VALUES (1, NULL, ?) " - "ON CONFLICT(id) DO NOTHING", - (_now(),), - ) - - @staticmethod - def _item(row: sqlite3.Row) -> WorkItem: - return WorkItem( - id=row["id"], - scope=row["scope"], - title=row["title"], - status=row["status"], - outcome=row["outcome"], - position=row["position"], - data=json.loads(row["data"]), - opened_at=row["opened_at"], - updated_at=row["updated_at"], - closed_at=row["closed_at"], - ) - - def work_item(self, item_id: str) -> WorkItem | None: - row = self._conn.execute( - "SELECT * FROM work_items WHERE id = ?", (item_id,) - ).fetchone() - return None if row is None else self._item(row) - - def work_items(self, status: str | None = None) -> list[WorkItem]: - query = "SELECT * FROM work_items" - args: tuple[Any, ...] = () - if status is not None: - query += " WHERE status = ?" - args = (status,) - rows = self._conn.execute(query + " ORDER BY opened_at", args).fetchall() - return [self._item(row) for row in rows] - - def active_item(self) -> WorkItem | None: - row = self._conn.execute( - "SELECT w.* FROM agent a JOIN work_items w ON w.id = a.active_item " - "WHERE a.id = 1" - ).fetchone() - return None if row is None else self._item(row) - - def open_work_item( - self, - item_id: str, - *, - title: str, - scope: str | None = None, - position: str, - data: dict[str, Any] | None = None, - cause: str, - ) -> WorkItem: - now = _now() - self._conn.execute( - "INSERT INTO work_items (id, scope, title, status, position, data, " - "opened_at, updated_at) VALUES (?, ?, ?, 'open', ?, ?, ?, ?)", - (item_id, scope, title, position, json.dumps(data or {}), now, now), - ) - self.oplog.emit("work_item.open", cause=cause, work_item=item_id, scope=scope) - item = self.work_item(item_id) - assert item is not None - return item - - def set_active(self, item_id: str | None, *, cause: str) -> None: - """Point the agent at a work item, or at none (idle). Going idle - records where in the log idleness began, so messages sent while idle - can be carried into the next work item (carry_idle_messages).""" - self._ensure_agent_row() - previous = self._conn.execute( - "SELECT active_item FROM agent WHERE id = 1" - ).fetchone()["active_item"] - if item_id is None: - last = self._conn.execute( - "SELECT COALESCE(MAX(id), 0) AS last FROM canonical_events" - ).fetchone()["last"] - self._conn.execute( - "UPDATE agent SET active_item = NULL, idle_from_event = ?, " - "updated_at = ? WHERE id = 1", - (last, _now()), - ) - else: - self._conn.execute( - "UPDATE agent SET active_item = ?, updated_at = ? WHERE id = 1", - (item_id, _now()), - ) - if previous != item_id: - self.oplog.emit( - "work_item.switch", - cause=cause, - **{"from": previous, "to": item_id}, - ) - - def update_work_item( - self, - item_id: str, - *, - position: str, - data: dict[str, Any], - cause: str, - ) -> None: - """Record a work item's new position and data; the full data is - passed every time (no partial updates).""" - previous = self.work_item(item_id) - if previous is None: - raise KeyError(f"no work item {item_id!r}") - self._conn.execute( - "UPDATE work_items SET position = ?, data = ?, updated_at = ? WHERE id = ?", - (position, json.dumps(data), _now(), item_id), - ) - self.oplog.emit( - "state.transition", - cause=cause, - **{"from": previous.position, "to": position, "project": previous.scope}, - ) - - def close_work_item(self, item_id: str, *, outcome: str, cause: str) -> None: - now = _now() - row = self._conn.execute( - "UPDATE work_items SET status = 'closed', outcome = ?, closed_at = ?, " - "updated_at = ? WHERE id = ? AND status = 'open' RETURNING id", - (outcome, now, now, item_id), - ).fetchone() - if row is None: - raise KeyError(f"no open work item {item_id!r}") - self.oplog.emit( - "work_item.close", cause=cause, work_item=item_id, outcome=outcome - ) +class StateStore(state.StateStore): + AGENT = "nisse-dev" + SCHEMA_VERSION = 5 + SCHEMA = (_VM_BLOCKS,) + MIGRATIONS: Mapping[int, Migration] = {4: _add_vm_blocks} + # planner and executor are the old loop's (until M2.8), kept so its + # records still read. + ACTORS = state.ACTORS | { + "planner", + "executor", + "toolchain", # the toolchain step (D3): the driver's model, apart + "memory", # the memory pass at an item's close (L6.4), apart too + } + # What each role is shown of the others' work (``nisse_core.views``). + SHOWN: Mapping[str, Mapping[str, str]] = { + "driver": {"helper": "report"}, + "helper": {"driver": "narrate"}, + # The toolchain step works in its own transcript: the driver sees + # only its report (a tool result, or facts), the step the driver's + # work. + "toolchain": {"driver": "narrate"}, + # The memory pass at an item's close (L6.4): the item as it went. + "memory": {"driver": "narrate"}, + } # --- build VMs (D3) ---------------------------------------------------- @@ -407,7 +69,7 @@ class StateStore: row = self._conn.execute( "INSERT INTO vm_blocks (host, cause, reason, work_item, since) " "VALUES (?, ?, ?, ?, ?) ON CONFLICT(host) DO NOTHING RETURNING host", - (host, cause, reason, work_item, _now()), + (host, cause, reason, work_item, utc_now()), ).fetchone() if row is not None: self.oplog.emit("vm.block", cause=cause, host=host, work_item=work_item) @@ -434,206 +96,3 @@ class StateStore: def vm_blocks(self) -> dict[str, VMBlock]: rows = self._conn.execute("SELECT * FROM vm_blocks").fetchall() return {row["host"]: VMBlock(**dict(row)) for row in rows} - - def carry_idle_messages(self, item_id: str) -> int: - """Copy what the operator said while the agent was idle into a work - item that has just opened (D1: messages sent before `use` are kept - for it). Copied as new events, with the original's ID in the source, - never by reassigning the originals: the log is never edited (L7).""" - self._ensure_agent_row() - since = self._conn.execute( - "SELECT idle_from_event FROM agent WHERE id = 1" - ).fetchone()["idle_from_event"] - rows = self._conn.execute( - "SELECT * FROM canonical_events WHERE work_item IS NULL " - "AND actor = 'operator' AND type = 'user_message' AND id > ? ORDER BY id", - (since,), - ).fetchall() - log = self.transcript(item_id, "operator") - for row in rows: - payload = json.loads(row["payload"]) - log.append( - UserMessage( - text=payload["text"], - source=f"{payload.get('source', '')} (sent while idle, #{row['id']})", - ) - ) - return len(rows) - - # --- intents (L8) ------------------------------------------------------ - - def begin_intent( - self, - kind: str, - payload: dict[str, Any], - *, - cause: str, - work_item: str | None = None, - ) -> int: - """Durably record that a unit of work is about to start, owned by - the work item it belongs to (L1).""" - cursor = self._conn.execute( - "INSERT INTO intent_log (kind, payload, created_at, work_item) " - "VALUES (?, ?, ?, ?)", - (kind, json.dumps(payload), _now(), work_item), - ) - assert cursor.lastrowid is not None - self.oplog.emit( - "intent.begin", cause=cause, intent_id=cursor.lastrowid, kind=kind - ) - return cursor.lastrowid - - def commit_intent(self, intent_id: int, *, cause: str) -> None: - self._resolve_intent(intent_id, "committed", "intent.commit", cause) - - def abort_intent(self, intent_id: int, *, cause: str) -> None: - self._resolve_intent(intent_id, "aborted", "intent.abort", cause) - - def _resolve_intent( - self, intent_id: int, status: str, event: str, cause: str - ) -> None: - row = self._conn.execute( - "UPDATE intent_log SET status = ?, resolved_at = ? " - "WHERE id = ? AND status = 'pending' RETURNING kind", - (status, _now(), intent_id), - ).fetchone() - if row is None: - raise KeyError(f"no pending intent with id {intent_id}") - self.oplog.emit(event, cause=cause, intent_id=intent_id, kind=row["kind"]) - - def pending_intents(self) -> list[Intent]: - rows = self._conn.execute( - "SELECT * FROM intent_log WHERE status = 'pending' ORDER BY id" - ).fetchall() - return [ - Intent( - id=row["id"], - kind=row["kind"], - payload=json.loads(row["payload"]), - status=row["status"], - created_at=row["created_at"], - resolved_at=row["resolved_at"], - work_item=row["work_item"], - ) - for row in rows - ] - - # --- the canonical log (L7) ------------------------------------------- - - def transcript(self, work_item: str | None, actor: str) -> "Transcript": - """The log as one role sees it for one work item: what it reads is - that item's events only, and what it writes carries the item and the - actor. None is the agent-level scope (L6).""" - if actor not in ACTORS: - raise ValueError(f"unknown actor {actor!r}") - return Transcript(self, work_item, actor) - - def all_events(self, after: int = 0) -> list[StoredEvent]: - """Every event across work items, for the few readers whose job - spans them (evaluation capture, dumps). Model calls never use this.""" - rows = self._conn.execute( - "SELECT * FROM canonical_events WHERE id > ? ORDER BY id", (after,) - ).fetchall() - return [_stored(row) for row in rows] - - def _append(self, event: CanonicalEvent, work_item: str | None, actor: str) -> int: - cursor = self._conn.execute( - "INSERT INTO canonical_events (ts, type, payload, work_item, actor) " - "VALUES (?, ?, ?, ?, ?)", - ( - _now(), - event_type(event), - json.dumps(to_payload(event)), - work_item, - actor, - ), - ) - assert cursor.lastrowid is not None - return cursor.lastrowid - - def _read(self, work_item: str | None, after: int) -> list[StoredEvent]: - rows = self._conn.execute( - "SELECT * FROM canonical_events WHERE work_item IS ? AND id > ? " - "ORDER BY id", - (work_item, after), - ).fetchall() - return [_stored(row) for row in rows] - - def mark_outcome(self, event_id: int, verdict: str, note: str = "") -> None: - """Record whether a delegation or consult was good (L9). - - Kept out of the canonical log on purpose: the log is an append-only - record of what happened, and a verdict is a later judgement about it - that may be revised. - """ - self._conn.execute( - "INSERT INTO eval_outcomes (event_id, verdict, note, marked_at) " - "VALUES (?, ?, ?, ?) ON CONFLICT(event_id) DO UPDATE SET " - "verdict = excluded.verdict, note = excluded.note, " - "marked_at = excluded.marked_at", - (event_id, verdict, note, _now()), - ) - - def outcomes(self) -> dict[int, tuple[str, str]]: - rows = self._conn.execute( - "SELECT event_id, verdict, note FROM eval_outcomes" - ).fetchall() - return {r["event_id"]: (r["verdict"], r["note"]) for r in rows} - - def close(self) -> None: - self._conn.close() - self.closed = True - - def __enter__(self) -> Self: - return self - - def __exit__(self, *exc_info: object) -> None: - self.close() - - -def _stored(row: sqlite3.Row) -> StoredEvent: - return StoredEvent( - id=row["id"], - ts=row["ts"], - event=from_payload(row["type"], json.loads(row["payload"])), - work_item=row["work_item"], - actor=row["actor"], - ) - - -class Transcript: - """One work item's log, as one actor reads and writes it (L6, L7). - - Handed to each component per call by the orchestrator, which knows the - active work item, so a component can neither read another item's events - nor write without attribution. - """ - - def __init__(self, store: StateStore, work_item: str | None, actor: str) -> None: - self._store = store - self.work_item = work_item - self.actor = actor - - def append(self, event: CanonicalEvent) -> int: - """Committed and fsynced before return, like every durable write.""" - return self._store._append(event, self.work_item, self.actor) - - def read(self, after: int = 0) -> list[StoredEvent]: - """The log as recorded: every role's events, for the record.""" - return self._store._read(self.work_item, after) - - def view(self, after: int = 0) -> list[StoredEvent]: - """The log as this actor is shown it: what a model's context is - built from (L6). See ``views``.""" - return project(self.read(after), self.actor) - - def as_actor(self, actor: str) -> "Transcript": - """The same work item, written by another role: e.g. the executor - handing a sub-task to the helper.""" - return self._store.transcript(self.work_item, actor) - - def begin_intent(self, kind: str, payload: dict[str, Any], *, cause: str) -> int: - """An intent owned by this transcript's work item.""" - return self._store.begin_intent( - kind, payload, cause=cause, work_item=self.work_item - ) diff --git a/nisse-dev/src/nisse_dev/tools.py b/nisse-dev/src/nisse_dev/tools.py index acce705..d22dcf5 100644 --- a/nisse-dev/src/nisse_dev/tools.py +++ b/nisse-dev/src/nisse_dev/tools.py @@ -20,8 +20,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from nisse_dev.context import head_and_tail -from nisse_dev.execution import HostUnavailable, Result, SSHHost +from nisse_core.context import head_and_tail +from nisse_core.execution import HostUnavailable, Result, SSHHost # Tools that change or verify something. Reading a file or listing a # directory is a model looking around, not doing the work, so neither counts diff --git a/nisse-dev/tests/model_contract.py b/nisse-dev/tests/model_contract.py index 4c87a7d..5154534 100644 --- a/nisse-dev/tests/model_contract.py +++ b/nisse-dev/tests/model_contract.py @@ -10,8 +10,8 @@ unit tests and be rejected live; this makes it fail here. from typing import Any -from nisse_dev.render import OpenAIChatRenderer -from nisse_dev.render_anthropic import AnthropicMessagesRenderer +from nisse_core.render import OpenAIChatRenderer +from nisse_core.render_anthropic import AnthropicMessagesRenderer def assert_renderable(events: list[Any]) -> None: diff --git a/nisse-dev/tests/test_agent_build.py b/nisse-dev/tests/test_agent_build.py index bef461c..1e1a02c 100644 --- a/nisse-dev/tests/test_agent_build.py +++ b/nisse-dev/tests/test_agent_build.py @@ -11,13 +11,13 @@ import httpx import pytest from test_config import SECRETS, P, tree -from nisse_dev.catalog import ModelCatalog, UnknownModel +from nisse_core.catalog import ModelCatalog, UnknownModel +from nisse_core.execution import SSHHost +from nisse_core.llm import AnthropicPassthroughClient, LiteLLMClient +from nisse_core.oplog import OpLog from nisse_dev.config import AgentConfig -from nisse_dev.execution import SSHHost -from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient from nisse_dev.loop.build import RunningLoop, build_loop_agent, discover_models from nisse_dev.loop.build import prepare_workspace as prepare -from nisse_dev.oplog import OpLog # What the test roles resolve to, as LiteLLM would report them. TEST_CATALOG = ModelCatalog.from_payload( @@ -143,7 +143,7 @@ def offline(monkeypatch: Any, origins: dict[str, str] | str, catalog=None) -> No """Only the network boundaries faked: Gitea, LiteLLM's model list, Matrix.""" # The proxy here doesn't exist: a call to it fails at once rather than # waiting out an outage's backoff (L5). - monkeypatch.setattr("nisse_dev.llm.OUTAGE_BACKOFF_S", ()) + monkeypatch.setattr("nisse_core.llm.OUTAGE_BACKOFF_S", ()) monkeypatch.setattr( "nisse_dev.loop.build.GiteaClient.whoami", lambda self: "nisse-dev-testing" ) diff --git a/nisse-dev/tests/test_bench.py b/nisse-dev/tests/test_bench.py index cfe82bc..b81f1f9 100644 --- a/nisse-dev/tests/test_bench.py +++ b/nisse-dev/tests/test_bench.py @@ -7,6 +7,9 @@ from typing import Any import pytest +from nisse_core.canonical import AssistantText, ToolCall +from nisse_core.catalog import ModelFacts +from nisse_core.execution import SSHHost from nisse_dev.bench.results import ( append, load, @@ -17,10 +20,7 @@ from nisse_dev.bench.results import ( ) from nisse_dev.bench.runner import MeteringClient, run_task from nisse_dev.bench.tasks import CHECK_DIR, load_tasks -from nisse_dev.canonical import AssistantText, ToolCall -from nisse_dev.catalog import ModelFacts from nisse_dev.config import DriverTuning, Tuning -from nisse_dev.execution import SSHHost # The build VM for every test here: the session's throwaway sshd. HOST: SSHHost diff --git a/nisse-dev/tests/test_config.py b/nisse-dev/tests/test_config.py index a2b74b1..bb2adcb 100644 --- a/nisse-dev/tests/test_config.py +++ b/nisse-dev/tests/test_config.py @@ -5,8 +5,8 @@ from typing import Any import pytest +from nisse_core.llm import DEFAULT_REQUEST_TIMEOUT_S from nisse_dev.config import AgentConfig, ConfigError, Project -from nisse_dev.llm import DEFAULT_REQUEST_TIMEOUT_S # A complete config as the YAML file would give it, with secrets by reference. COMPLETE: dict[str, Any] = { @@ -142,7 +142,7 @@ def test_request_timeout_is_configurable_and_checked() -> None: def test_the_request_timeout_reaches_both_clients() -> None: - from nisse_dev.catalog import ModelCatalog + from nisse_core.catalog import ModelCatalog from nisse_dev.loop.build import make_llm_clients normalized, passthrough = make_llm_clients( diff --git a/nisse-dev/tests/test_delegation.py b/nisse-dev/tests/test_delegation.py index acac56a..fcc8beb 100644 --- a/nisse-dev/tests/test_delegation.py +++ b/nisse-dev/tests/test_delegation.py @@ -4,8 +4,8 @@ from typing import Any import pytest -from nisse_dev.cancel import STOPPED_RESULT, Cancel -from nisse_dev.canonical import ( +from nisse_core.cancel import STOPPED_RESULT, Cancel +from nisse_core.canonical import ( AssistantText, CanonicalEvent, DelegationCompleted, @@ -14,6 +14,10 @@ from nisse_dev.canonical import ( ToolCall, ToolResult, ) +from nisse_core.execution import SSHHost +from nisse_core.llm import BudgetExhausted, LLMError, UpstreamUnavailable +from nisse_core.oplog import OpLog +from nisse_core.state import Transcript from nisse_dev.delegation import ( DELEGATE_TOOLS, DELEGATION_INTENT, @@ -22,11 +26,8 @@ from nisse_dev.delegation import ( DelegationResult, delegation_reconciler, ) -from nisse_dev.execution import SSHHost from nisse_dev.git import GitRepo -from nisse_dev.llm import BudgetExhausted, LLMError, UpstreamUnavailable -from nisse_dev.oplog import OpLog -from nisse_dev.state import StateStore, Transcript +from nisse_dev.state import StateStore from nisse_dev.tools import Workspace diff --git a/nisse-dev/tests/test_dev_state.py b/nisse-dev/tests/test_dev_state.py new file mode 100644 index 0000000..45d9373 --- /dev/null +++ b/nisse-dev/tests/test_dev_state.py @@ -0,0 +1,61 @@ +"""nisse-dev's state store: the shared one, with its own roles, views and +build VM blocks (D3).""" + +from pathlib import Path + +import pytest + +from nisse_core.canonical import ToolCall +from nisse_core.state import StateStore as CoreStore +from nisse_dev.state import StateStore + + +@pytest.fixture +def db(tmp_path: Path) -> Path: + return tmp_path / "state.db" + + +def test_a_vm_block_persists_across_reopen(db: Path) -> None: + with StateStore(db) as store: + store.block_vm("root@vm1", cause="broken", reason="pip crashes", work_item="x") + store.block_vm("root@vm1", cause="broken", reason="later", work_item="y") + with StateStore(db) as store: + [block] = store.vm_blocks().values() + assert (block.host, block.reason, block.work_item) == ( + "root@vm1", + "pip crashes", # the first block is kept + "x", + ) + assert store.unblock_vm("root@vm1", cause="test") == block + assert store.unblock_vm("root@vm1", cause="test") is None + assert store.vm_blocks() == {} + + +def test_a_version_4_store_gains_vm_blocks(db: Path) -> None: + with StateStore(db, schema_version=4) as store: + store._conn.execute("DROP TABLE vm_blocks") # as a real version 4 store + with StateStore(db) as store: + assert store.schema_version == 5 + store.block_vm("root@vm1", cause="broken", reason="r", work_item=None) + assert list(store.vm_blocks()) == ["root@vm1"] + + +def test_the_dev_roles_are_the_dev_stores_own(db: Path) -> None: + with StateStore(db) as store: + store.transcript("x", "toolchain") + assert store.oplog.agent == "nisse-dev" + core = CoreStore(db.with_name("core.db")) + with core, pytest.raises(ValueError, match="unknown actor"): + core.transcript("x", "toolchain") + + +def test_each_role_is_shown_the_others_as_the_dev_store_says(db: Path) -> None: + with StateStore(db) as store: + for actor in ("driver", "toolchain"): + store.transcript("x", actor).append( + ToolCall(call_id=actor, tool="read_file", arguments={"path": "a"}) + ) + [seen] = store.transcript("x", "memory").view() # the driver's, told + assert seen.actor == "driver" and "read_file(a)" in seen.event.text + [own] = store.transcript("x", "driver").view() # not the toolchain's + assert own.event.call_id == "driver" diff --git a/nisse-dev/tests/test_evals.py b/nisse-dev/tests/test_evals.py index 282b42a..972fbdb 100644 --- a/nisse-dev/tests/test_evals.py +++ b/nisse-dev/tests/test_evals.py @@ -4,13 +4,13 @@ from pathlib import Path import pytest -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, DelegationCompleted, DelegationStarted, ) +from nisse_core.oplog import OpLog from nisse_dev.evals import UNMARKED, collect, summarise, to_jsonl -from nisse_dev.oplog import OpLog from nisse_dev.state import StateStore diff --git a/nisse-dev/tests/test_expert.py b/nisse-dev/tests/test_expert.py index 06d5e92..52e4fdb 100644 --- a/nisse-dev/tests/test_expert.py +++ b/nisse-dev/tests/test_expert.py @@ -4,13 +4,16 @@ from typing import Any import pytest -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, CanonicalEvent, DelegationCompleted, DelegationStarted, StoredEvent, ) +from nisse_core.llm import LLMError, RoleMap +from nisse_core.oplog import OpLog +from nisse_core.state import Transcript from nisse_dev.expert import ( DEFAULT_BUDGET, BudgetExhausted, @@ -18,9 +21,7 @@ from nisse_dev.expert import ( ExpertDesk, parse_answer, ) -from nisse_dev.llm import LLMError, RoleMap -from nisse_dev.oplog import OpLog -from nisse_dev.state import StateStore, Transcript +from nisse_dev.state import StateStore ROLES = RoleMap( driver="haiku", diff --git a/nisse-dev/tests/test_git.py b/nisse-dev/tests/test_git.py index 1a81524..080f2ac 100644 --- a/nisse-dev/tests/test_git.py +++ b/nisse-dev/tests/test_git.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest -from nisse_dev.execution import SSHHost +from nisse_core.execution import SSHHost from nisse_dev.git import GitError, GitRepo, HTTPSToken, SSHKey @@ -170,7 +170,7 @@ class RecordingHost: self.calls: list[dict] = [] def execute(self, command: str, **kwargs): - from nisse_dev.execution import Result + from nisse_core.execution import Result self.calls.append({"command": command, **kwargs}) return Result(0, "", "") diff --git a/nisse-dev/tests/test_loop.py b/nisse-dev/tests/test_loop.py index 5fa73f2..b99896e 100644 --- a/nisse-dev/tests/test_loop.py +++ b/nisse-dev/tests/test_loop.py @@ -8,25 +8,25 @@ from typing import Any import pytest from model_contract import assert_renderable -from nisse_dev.cancel import Cancel -from nisse_dev.canonical import ( +from nisse_core.cancel import Cancel +from nisse_core.canonical import ( AssistantText, CanonicalEvent, Fact, ToolCall, ToolResult, ) -from nisse_dev.gitea import Issue -from nisse_dev.llm import LLMError +from nisse_core.gitea import Issue +from nisse_core.llm import LLMError +from nisse_core.oplog import OpLog +from nisse_core.transport import InMemoryTransport +from nisse_core.worker import InlineWorker from nisse_dev.loop.agent import LoopAgent from nisse_dev.loop.driver import Driver from nisse_dev.loop.items import CLOSED, GO_AHEAD, READY, RUNNING, WAITING, loop_of from nisse_dev.loop.reader import command_form -from nisse_dev.oplog import OpLog from nisse_dev.state import StateStore from nisse_dev.tools import ToolOutcome -from nisse_dev.transport import InMemoryTransport -from nisse_dev.worker import InlineWorker DON = "@don:test" @@ -250,7 +250,7 @@ def test_a_go_for_an_earlier_revision_is_not_applied(h: Harness) -> None: h.say("put it in its own module") # changes, so the driver restates rev-2 assert loop_of(h.item()).rev == 2 # A go sent while rev-1 was on screen, arriving now. - from nisse_dev.transport import InboundMessage + from nisse_core.transport import InboundMessage h.agent.handle(InboundMessage(DON, "go", thread=h.thread()), about=1) assert not loop_of(h.item()).started diff --git a/nisse-dev/tests/test_loop_actions.py b/nisse-dev/tests/test_loop_actions.py index f77b4ad..cb69f86 100644 --- a/nisse-dev/tests/test_loop_actions.py +++ b/nisse-dev/tests/test_loop_actions.py @@ -12,7 +12,7 @@ from test_loop_milestones import item as the_item from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture from nisse_core.actions import Action -from nisse_dev.canonical import ActionVerdict, ToolCall, ToolResult +from nisse_core.canonical import ActionVerdict, ToolCall, ToolResult from nisse_dev.loop.actions import classify, commits from nisse_dev.loop.items import ANSWER, loop_of diff --git a/nisse-dev/tests/test_loop_classifier.py b/nisse-dev/tests/test_loop_classifier.py index 4f8d674..881b896 100644 --- a/nisse-dev/tests/test_loop_classifier.py +++ b/nisse-dev/tests/test_loop_classifier.py @@ -14,8 +14,8 @@ from test_loop_milestones import item as the_item from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture from nisse_core.actions import Action -from nisse_dev.canonical import ActionVerdict, ToolCall -from nisse_dev.llm import LLMError +from nisse_core.canonical import ActionVerdict, ToolCall +from nisse_core.llm import LLMError from nisse_dev.loop.classifier import ActionClassifier, Scope from nisse_dev.loop.items import ANSWER, loop_of @@ -312,7 +312,7 @@ def test_the_export_script_reads_a_store(tmp_path: Path) -> None: import subprocess import sys - from nisse_dev.oplog import OpLog + from nisse_core.oplog import OpLog from nisse_dev.state import StateStore store = StateStore(tmp_path / "state.db", oplog=OpLog("t", io.StringIO())) diff --git a/nisse-dev/tests/test_loop_conversations.py b/nisse-dev/tests/test_loop_conversations.py index cc84be3..bff96f1 100644 --- a/nisse-dev/tests/test_loop_conversations.py +++ b/nisse-dev/tests/test_loop_conversations.py @@ -9,8 +9,8 @@ from typing import Any from test_loop_milestones import DON, Forge, ScriptedModel, call, loop_agent from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture -from nisse_dev.canonical import Fact, ToolResult -from nisse_dev.gitea import Issue +from nisse_core.canonical import Fact, ToolResult +from nisse_core.gitea import Issue from nisse_dev.loop.agent import FILE_ISSUE from nisse_dev.loop.items import CHAT, GO_AHEAD, MERGE, WAITING, loop_of diff --git a/nisse-dev/tests/test_loop_help.py b/nisse-dev/tests/test_loop_help.py index 41da1c4..562f025 100644 --- a/nisse-dev/tests/test_loop_help.py +++ b/nisse-dev/tests/test_loop_help.py @@ -18,16 +18,16 @@ from test_loop_milestones import ( ) from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, DelegationStarted, Fact, ToolCall, ToolResult, ) +from nisse_core.llm import RoleMap from nisse_dev.config import Profile from nisse_dev.delegation import DELEGATION_INTENT, Caps -from nisse_dev.llm import RoleMap from nisse_dev.loop.help import Helpers from nisse_dev.loop.items import ANSWER, MERGE, loop_of diff --git a/nisse-dev/tests/test_loop_labels.py b/nisse-dev/tests/test_loop_labels.py index 9176a7c..b97bdfc 100644 --- a/nisse-dev/tests/test_loop_labels.py +++ b/nisse-dev/tests/test_loop_labels.py @@ -9,8 +9,8 @@ import pytest from test_loop import DON, RESTATED, FakeGitea, Harness, ScriptedModel, call, issue from test_loop import h as h # noqa: PLC0414 -- the fixture -from nisse_dev.canonical import Fact, ToolCall -from nisse_dev.llm import LLMError +from nisse_core.canonical import Fact, ToolCall +from nisse_core.llm import LLMError from nisse_dev.loop.items import ANSWER, GO_AHEAD, READY, WAITING, loop_of from nisse_dev.loop.reader import GoAheadReader @@ -284,7 +284,7 @@ def test_an_unlabelled_issue_waits_for_go_with_no_countdown(h: Harness) -> None: def _msg(text: str, thread: str) -> Any: - from nisse_dev.transport import InboundMessage + from nisse_core.transport import InboundMessage return InboundMessage(DON, text, thread=thread) diff --git a/nisse-dev/tests/test_loop_live_fixes.py b/nisse-dev/tests/test_loop_live_fixes.py index af63356..c5c678f 100644 --- a/nisse-dev/tests/test_loop_live_fixes.py +++ b/nisse-dev/tests/test_loop_live_fixes.py @@ -16,9 +16,9 @@ from test_loop_milestones import RESTATE, Forge, loop_agent, say from test_loop_milestones import item as the_item from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture -from nisse_dev.canonical import AssistantText, Fact +from nisse_core.canonical import AssistantText, Fact +from nisse_core.gitea import Comment from nisse_dev.delegation import Caps -from nisse_dev.gitea import Comment from nisse_dev.loop.help import Helpers from nisse_dev.loop.items import GO_AHEAD, MERGE, WAITING, loop_of @@ -231,7 +231,7 @@ def test_an_issue_assigned_again_after_its_item_closed_is_taken_again( def test_abandoning_a_blocked_issue_says_why_it_stays_open(tmp_path: Path) -> None: - from nisse_dev.gitea import GiteaError + from nisse_core.gitea import GiteaError class Strict(FakeGitea): def close_issue(self, repo: str, index: int) -> None: diff --git a/nisse-dev/tests/test_loop_memory.py b/nisse-dev/tests/test_loop_memory.py index f655706..6ecd5e0 100644 --- a/nisse-dev/tests/test_loop_memory.py +++ b/nisse-dev/tests/test_loop_memory.py @@ -12,17 +12,17 @@ from test_loop_milestones import item as the_item from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture from test_loop_notes import Spy -from nisse_dev.canonical import AssistantText, Fact -from nisse_dev.llm import RoleMap -from nisse_dev.memory import Memory -from nisse_dev.vault import Vault +from nisse_core.canonical import AssistantText, Fact +from nisse_core.llm import RoleMap +from nisse_core.memory import Memory +from nisse_core.vault import Vault FOLDER = "memory/nisse-dev" @pytest.fixture def memory(tmp_path: Path) -> Memory: - return Memory(Vault(tmp_path / "v", {FOLDER: "rw"}), FOLDER) + return Memory(Vault(tmp_path / "v", {FOLDER: "rw"}, agent="nisse-dev"), FOLDER) def facts(world: dict[str, Any]) -> list[str]: diff --git a/nisse-dev/tests/test_loop_milestones.py b/nisse-dev/tests/test_loop_milestones.py index c2f8d48..8029a5e 100644 --- a/nisse-dev/tests/test_loop_milestones.py +++ b/nisse-dev/tests/test_loop_milestones.py @@ -10,11 +10,10 @@ from typing import Any import pytest from model_contract import assert_renderable -from nisse_dev.cancel import Cancel -from nisse_dev.canonical import ToolCall -from nisse_dev.execution import SSHHost -from nisse_dev.git import GitRepo -from nisse_dev.gitea import ( +from nisse_core.cancel import Cancel +from nisse_core.canonical import ToolCall +from nisse_core.execution import SSHHost +from nisse_core.gitea import ( Approval, Comment, GiteaError, @@ -22,15 +21,16 @@ from nisse_dev.gitea import ( PullRequest, ReviewNote, ) +from nisse_core.oplog import OpLog +from nisse_core.transport import InMemoryTransport +from nisse_core.worker import InlineWorker +from nisse_dev.git import GitRepo from nisse_dev.loop.agent import LoopAgent from nisse_dev.loop.driver import Driver from nisse_dev.loop.items import ANSWER, MERGE, WAITING, loop_of from nisse_dev.loop.milestones import OPEN_PR, Milestones -from nisse_dev.oplog import OpLog from nisse_dev.state import StateStore from nisse_dev.tools import Workspace -from nisse_dev.transport import InMemoryTransport -from nisse_dev.worker import InlineWorker DON = "@don:test" diff --git a/nisse-dev/tests/test_loop_multi.py b/nisse-dev/tests/test_loop_multi.py index 98e7a0c..43f5309 100644 --- a/nisse-dev/tests/test_loop_multi.py +++ b/nisse-dev/tests/test_loop_multi.py @@ -18,8 +18,8 @@ from test_loop_milestones import ( ) from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture -from nisse_dev.canonical import ToolCall, ToolResult -from nisse_dev.gitea import Issue +from nisse_core.canonical import ToolCall, ToolResult +from nisse_core.gitea import Issue from nisse_dev.loop.items import MERGE, WAITING, loop_of diff --git a/nisse-dev/tests/test_loop_new_projects.py b/nisse-dev/tests/test_loop_new_projects.py index fbbde04..cfa42f3 100644 --- a/nisse-dev/tests/test_loop_new_projects.py +++ b/nisse-dev/tests/test_loop_new_projects.py @@ -14,16 +14,16 @@ import yaml from test_loop_milestones import DON, Forge, ScriptedModel, call, make_world from test_loop_projects import Router -from nisse_dev.cancel import Cancel +from nisse_core.cancel import Cancel +from nisse_core.execution import SSHHost +from nisse_core.oplog import OpLog +from nisse_core.transport import InMemoryTransport +from nisse_core.worker import InlineWorker from nisse_dev.config import _parse_projects -from nisse_dev.execution import SSHHost from nisse_dev.loop.agent import LoopAgent, Project from nisse_dev.loop.driver import Driver from nisse_dev.loop.items import CHAT, GO_AHEAD, MERGE, PROVISION, loop_of -from nisse_dev.oplog import OpLog from nisse_dev.tools import Workspace -from nisse_dev.transport import InMemoryTransport -from nisse_dev.worker import InlineWorker PROPOSAL = call( "propose_project", diff --git a/nisse-dev/tests/test_loop_notes.py b/nisse-dev/tests/test_loop_notes.py index 10b8be4..619b683 100644 --- a/nisse-dev/tests/test_loop_notes.py +++ b/nisse-dev/tests/test_loop_notes.py @@ -11,9 +11,9 @@ from test_loop_milestones import DON, RESTATE, ScriptedModel, call, loop_agent from test_loop_milestones import item as the_item from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture -from nisse_dev.canonical import ToolResult +from nisse_core.canonical import ToolResult +from nisse_core.vault import Vault from nisse_dev.loop.items import GO_AHEAD, loop_of -from nisse_dev.vault import Vault @pytest.fixture @@ -21,7 +21,7 @@ def vault(tmp_path: Path) -> Vault: root = tmp_path / "vault" (root / "notes").mkdir(parents=True) (root / "notes" / "Style.md").write_text("# Style\n\nCount words with split.\n") - return Vault(root, {"memory/nisse-dev": "rw", "notes": "ro"}) + return Vault(root, {"memory/nisse-dev": "rw", "notes": "ro"}, agent="nisse-dev") class Spy(ScriptedModel): diff --git a/nisse-dev/tests/test_loop_projects.py b/nisse-dev/tests/test_loop_projects.py index 883a33a..64f0041 100644 --- a/nisse-dev/tests/test_loop_projects.py +++ b/nisse-dev/tests/test_loop_projects.py @@ -10,15 +10,15 @@ from typing import Any import pytest from test_loop_milestones import DON, Forge, ScriptedModel, call, git, make_world -from nisse_dev.cancel import Cancel -from nisse_dev.execution import SSHHost +from nisse_core.cancel import Cancel +from nisse_core.execution import SSHHost +from nisse_core.oplog import OpLog +from nisse_core.transport import InMemoryTransport +from nisse_core.worker import InlineWorker from nisse_dev.loop.agent import LoopAgent, Project from nisse_dev.loop.driver import Driver from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, item_for_issue, loop_of -from nisse_dev.oplog import OpLog from nisse_dev.tools import Workspace -from nisse_dev.transport import InMemoryTransport -from nisse_dev.worker import InlineWorker REPOS = {"alpha": "don/alpha", "beta": "don/beta"} diff --git a/nisse-dev/tests/test_loop_properties.py b/nisse-dev/tests/test_loop_properties.py index 5cfc5c5..1c666a6 100644 --- a/nisse-dev/tests/test_loop_properties.py +++ b/nisse-dev/tests/test_loop_properties.py @@ -24,8 +24,8 @@ from test_loop_milestones import DON, Forge, call, make_world from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture from test_loop_projects import Router -from nisse_dev.cancel import Cancel -from nisse_dev.canonical import ( +from nisse_core.cancel import Cancel +from nisse_core.canonical import ( ActionVerdict, AssistantText, Fact, @@ -34,10 +34,15 @@ from nisse_dev.canonical import ( ToolResult, UserMessage, ) +from nisse_core.execution import HostUnavailable, SSHHost +from nisse_core.gitea import Comment, Issue +from nisse_core.llm import LLMError, RoleMap +from nisse_core.memory import Memory +from nisse_core.oplog import OpLog +from nisse_core.transport import InboundMessage, InMemoryTransport +from nisse_core.vault import Vault, split_frontmatter +from nisse_core.worker import InlineWorker from nisse_dev.delegation import DELEGATION_INTENT, Caps -from nisse_dev.execution import HostUnavailable, SSHHost -from nisse_dev.gitea import Comment, Issue -from nisse_dev.llm import LLMError, RoleMap from nisse_dev.loop.agent import LoopAgent, Project from nisse_dev.loop.classifier import ActionClassifier from nisse_dev.loop.driver import Driver @@ -53,12 +58,7 @@ from nisse_dev.loop.items import ( WAITING, loop_of, ) -from nisse_dev.memory import Memory -from nisse_dev.oplog import OpLog from nisse_dev.tools import Workspace -from nisse_dev.transport import InboundMessage, InMemoryTransport -from nisse_dev.vault import Vault, split_frontmatter -from nisse_dev.worker import InlineWorker # How many walks, and from which seed: all 48 by default; CI runs fewer on a # PR, from a seed that moves with the run number, so PRs still cover new @@ -360,7 +360,11 @@ class Walk: (vault_root / "notes").mkdir(parents=True, exist_ok=True) (vault_root / "notes" / "Ops.md").write_text("the operator's\n") self.memory = Memory( - Vault(vault_root, {"memory/nisse-dev": "rw", "notes": "ro"}), + Vault( + vault_root, + {"memory/nisse-dev": "rw", "notes": "ro"}, + agent="nisse-dev", + ), "memory/nisse-dev", ) real = SSHHost.execute diff --git a/nisse-dev/tests/test_loop_queue.py b/nisse-dev/tests/test_loop_queue.py index 69a723a..e41de95 100644 --- a/nisse-dev/tests/test_loop_queue.py +++ b/nisse-dev/tests/test_loop_queue.py @@ -7,7 +7,8 @@ from typing import Any from test_loop_milestones import DON, Forge, ScriptedModel, call, git, loop_agent from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture -from nisse_dev.gitea import Comment, Issue +from nisse_core.gitea import Comment, Issue +from nisse_core.transport import InboundMessage, InMemoryTransport from nisse_dev.loop.items import ( ANSWER, BLOCKED, @@ -18,7 +19,6 @@ from nisse_dev.loop.items import ( item_for_issue, loop_of, ) -from nisse_dev.transport import InboundMessage, InMemoryTransport def two_issues(forge: Forge) -> None: diff --git a/nisse-dev/tests/test_loop_talk.py b/nisse-dev/tests/test_loop_talk.py index f8b2252..92fa855 100644 --- a/nisse-dev/tests/test_loop_talk.py +++ b/nisse-dev/tests/test_loop_talk.py @@ -8,7 +8,7 @@ from typing import Any from test_loop import DON, RESTATED, FakeGitea, Harness, ScriptedModel, call, issue -from nisse_dev.canonical import AssistantText +from nisse_core.canonical import AssistantText from nisse_dev.loop.items import ANSWER, GO_AHEAD, WAITING, loop_of diff --git a/nisse-dev/tests/test_loop_toolchain.py b/nisse-dev/tests/test_loop_toolchain.py index 4f4bc66..a700146 100644 --- a/nisse-dev/tests/test_loop_toolchain.py +++ b/nisse-dev/tests/test_loop_toolchain.py @@ -26,18 +26,18 @@ from test_loop_milestones import ( from test_loop_milestones import item as the_item from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture -from nisse_dev.cancel import Cancel -from nisse_dev.canonical import Fact, ToolResult -from nisse_dev.execution import HostKeyChanged -from nisse_dev.gitea import Issue +from nisse_core.cancel import Cancel +from nisse_core.canonical import Fact, ToolResult +from nisse_core.execution import HostKeyChanged +from nisse_core.gitea import Issue +from nisse_core.oplog import OpLog +from nisse_core.transport import InMemoryTransport +from nisse_core.worker import InlineWorker from nisse_dev.loop import preflight from nisse_dev.loop.agent import LoopAgent, Project from nisse_dev.loop.driver import Driver from nisse_dev.loop.items import ANSWER, GO_AHEAD, WAITING, loop_of -from nisse_dev.oplog import OpLog from nisse_dev.tools import Workspace -from nisse_dev.transport import InMemoryTransport -from nisse_dev.worker import InlineWorker PYTHON = {"name": "python", "version_command": "python3 --version"} @@ -194,7 +194,7 @@ def test_what_was_installed_or_is_missing_is_said( def test_an_unfinished_step_is_said_and_work_goes_on( world: dict[str, Any], project: str ) -> None: - from nisse_dev.canonical import AssistantText + from nisse_core.canonical import AssistantText prose = [AssistantText(text="hmm", model="m")] model = ScriptedModel(prose, prose, RESTATE) diff --git a/nisse-dev/tests/test_loop_trust.py b/nisse-dev/tests/test_loop_trust.py index c899b30..ac99a39 100644 --- a/nisse-dev/tests/test_loop_trust.py +++ b/nisse-dev/tests/test_loop_trust.py @@ -19,14 +19,14 @@ from test_loop_milestones import ( from test_loop_milestones import item as the_item from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture -from nisse_dev.cancel import Cancel +from nisse_core.cancel import Cancel +from nisse_core.oplog import OpLog +from nisse_core.transport import InMemoryTransport +from nisse_core.worker import InlineWorker from nisse_dev.loop.agent import LoopAgent, Project from nisse_dev.loop.driver import Driver from nisse_dev.loop.items import MERGE, loop_of -from nisse_dev.oplog import OpLog from nisse_dev.tools import Workspace -from nisse_dev.transport import InMemoryTransport -from nisse_dev.worker import InlineWorker CLOSE = [call("close_item", summary="added and merged")] @@ -169,7 +169,7 @@ def test_unprotected_means_the_operator_merges(world: dict[str, Any]) -> None: def test_a_refused_merge_is_said_once_and_left_to_the_operator( world: dict[str, Any], ) -> None: - from nisse_dev.gitea import GiteaError + from nisse_core.gitea import GiteaError forge: Forge = world["forge"] agent, transport = to_review(world) diff --git a/nisse-dev/tests/test_loop_vm.py b/nisse-dev/tests/test_loop_vm.py index 0dc6665..327c58c 100644 --- a/nisse-dev/tests/test_loop_vm.py +++ b/nisse-dev/tests/test_loop_vm.py @@ -19,9 +19,9 @@ from test_loop_toolchain import ( ) from test_loop_toolchain import project as project # noqa: PLC0414 -- the fixture -from nisse_dev.canonical import ToolResult -from nisse_dev.execution import HostKeyChanged, HostUnavailable -from nisse_dev.gitea import Issue +from nisse_core.canonical import ToolResult +from nisse_core.execution import HostKeyChanged, HostUnavailable +from nisse_core.gitea import Issue from nisse_dev.loop.items import GO_AHEAD, READY, loop_of RETRY, SETTLE = 300.0, 180.0 diff --git a/nisse-dev/tests/test_preflight.py b/nisse-dev/tests/test_preflight.py index 0e22b3e..79532d6 100644 --- a/nisse-dev/tests/test_preflight.py +++ b/nisse-dev/tests/test_preflight.py @@ -5,7 +5,7 @@ import uuid import pytest -from nisse_dev.execution import SSHHost +from nisse_core.execution import SSHHost from nisse_dev.loop import preflight from nisse_dev.loop.preflight import Tool diff --git a/nisse-dev/tests/test_tools.py b/nisse-dev/tests/test_tools.py index 7e652e9..0a9d680 100644 --- a/nisse-dev/tests/test_tools.py +++ b/nisse-dev/tests/test_tools.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from nisse_dev.execution import REDACTED, SSHHost +from nisse_core.execution import REDACTED, SSHHost from nisse_dev.tools import EXECUTION_TOOLS, MAX_OUTPUT_CHARS, Workspace diff --git a/scripts/benchmark.py b/scripts/benchmark.py index 77e86d2..aa35531 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -14,12 +14,12 @@ import os import sys from pathlib import Path +from nisse_core.catalog import ModelCatalog +from nisse_core.envfile import load_env_file from nisse_dev.bench.results import append, load, render, results_file, summarise from nisse_dev.bench.runner import agent_commit, run_task from nisse_dev.bench.tasks import load_tasks -from nisse_dev.catalog import ModelCatalog from nisse_dev.config import AgentConfig, ConfigError -from nisse_dev.envfile import load_env_file from nisse_dev.loop.build import build_host, make_llm_clients REPO = Path(__file__).resolve().parents[1] diff --git a/scripts/bringup-repo.sh b/scripts/bringup-repo.sh index df58556..bedec54 100755 --- a/scripts/bringup-repo.sh +++ b/scripts/bringup-repo.sh @@ -23,8 +23,8 @@ from pathlib import Path sys.path.insert(0, "tests/integration") from e2e_support import OWNER, delete_repo, make_repo, operator_gitea # noqa: E402 -from nisse_dev.envfile import load_env_file # noqa: E402 -from nisse_dev.gitea import GiteaClient # noqa: E402 +from nisse_core.envfile import load_env_file # noqa: E402 +from nisse_core.gitea import GiteaClient # noqa: E402 action, e2e = sys.argv[1], Path(sys.argv[2]) url = "https://gitea.dongar.net" diff --git a/scripts/bringup.sh b/scripts/bringup.sh index 01b9233..d79dba8 100755 --- a/scripts/bringup.sh +++ b/scripts/bringup.sh @@ -133,7 +133,7 @@ cmd_logs() { journalctl -u "$UNIT" -f -o cat; } # The state store, read with the image's own code. dump() { docker run --rm --user 0 -v "$E2E:$E2E" --entrypoint python "$IMAGE" \ - -m nisse_dev.dump "$@" + -m nisse_core.dump "$@" } cmd_collect() { diff --git a/scripts/ci-sshd.sh b/scripts/ci-sshd.sh index c05b98b..8af4313 100755 --- a/scripts/ci-sshd.sh +++ b/scripts/ci-sshd.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# The throwaway sshd the tests need (nisse_dev/testing.py), in a CI job. +# The throwaway sshd the tests need (nisse_core/testing.py), in a CI job. # Skipped when the job's image already has it. Otherwise apt fetches from a # kernel.org mirror rather than archive.ubuntu.com, which has been slow # enough (a few KB/s) to add minutes to every job. diff --git a/scripts/classifier-evals.py b/scripts/classifier-evals.py index 765ccdb..f39b7e3 100644 --- a/scripts/classifier-evals.py +++ b/scripts/classifier-evals.py @@ -19,7 +19,7 @@ import sys from dataclasses import asdict from pathlib import Path -from nisse_dev.canonical import StoredEvent, from_payload +from nisse_core.canonical import StoredEvent, from_payload from nisse_dev.evals import classifier_cases diff --git a/scripts/e2e-cleanup.sh b/scripts/e2e-cleanup.sh index 5db44b3..a0c67db 100755 --- a/scripts/e2e-cleanup.sh +++ b/scripts/e2e-cleanup.sh @@ -11,7 +11,7 @@ if [ -f .env ]; then set -a; . ./.env; set +a; fi exec uv run python - "$@" <<'PY' import os, sys from datetime import datetime, timedelta, timezone -from nisse_dev.gitea import GiteaClient +from nisse_core.gitea import GiteaClient gitea = GiteaClient("https://gitea.dongar.net", os.environ["NISSE_OPERATOR_TOKEN"]) cutoff = datetime.now(timezone.utc) - (timedelta(0) if "--all" in sys.argv else timedelta(days=1)) diff --git a/scripts/e2e.sh b/scripts/e2e.sh index c2bdf10..8c18612 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -36,7 +36,7 @@ else export NISSE_E2E_BUILD_HOST=${NISSE_E2E_BUILD_HOST:-nisse-dev.garrett-hall.com} export NISSE_E2E_BUILD_KEY=${NISSE_E2E_BUILD_KEY:-/root/nisse-e2e/ssh/id_ed25519} fi -# Each run's state store is kept here, for nisse_dev.dump after a failure. +# Each run's state store is kept here, for nisse_core.dump after a failure. export NISSE_E2E_RUNS=${NISSE_E2E_RUNS:-/root/nisse-e2e/test-runs} # What the run spent on the test key, read from LiteLLM (spend only). The diff --git a/scripts/probe_context.py b/scripts/probe_context.py index 0364a44..3d249a0 100644 --- a/scripts/probe_context.py +++ b/scripts/probe_context.py @@ -5,7 +5,7 @@ ALIAS is a LiteLLM model alias. The LiteLLM URL and token come from the agent config (NISSE_CONFIG, ./nisse-dev.yaml, or --config), with its secrets from the environment or .env. See docs/Tuning-Guide.md, "Declaring a local model's -limits", and nisse_dev/probe.py for what the verdicts mean. +limits", and nisse_core/probe.py for what the verdicts mean. """ import argparse @@ -15,10 +15,10 @@ from pathlib import Path import httpx -from nisse_dev.catalog import ModelCatalog +from nisse_core.catalog import ModelCatalog +from nisse_core.envfile import load_env_file +from nisse_core.probe import probe from nisse_dev.config import AgentConfig, ConfigError -from nisse_dev.envfile import load_env_file -from nisse_dev.probe import probe def main() -> int: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1ab9f15..a263b7e 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -13,10 +13,10 @@ from pathlib import Path import pytest +from nisse_core.envfile import load_env_file +from nisse_core.gitea import GiteaClient +from nisse_core.llm import AnthropicPassthroughClient, LiteLLMClient from nisse_dev.config import AgentConfig -from nisse_dev.envfile import load_env_file -from nisse_dev.gitea import GiteaClient -from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient REPO = Path(__file__).resolve().parents[2] TEST_CONFIG = Path(__file__).parent / "nisse-test.yaml" diff --git a/tests/integration/e2e_support.py b/tests/integration/e2e_support.py index 9acd07c..479fee4 100644 --- a/tests/integration/e2e_support.py +++ b/tests/integration/e2e_support.py @@ -10,10 +10,10 @@ from collections.abc import Iterator from dataclasses import dataclass from pathlib import Path +from nisse_core.execution import SSHHost +from nisse_core.gitea import GiteaClient, GiteaError +from nisse_core.llm import RoleMap from nisse_dev.config import BuildHost -from nisse_dev.execution import SSHHost -from nisse_dev.gitea import GiteaClient, GiteaError -from nisse_dev.llm import RoleMap # The end-to-end tests run every role on a local model, which costs nothing # per call; NISSE_E2E_MODEL overrides it (e.g. claude-haiku-5-5 for a quick, diff --git a/tests/integration/test_benchmark_live.py b/tests/integration/test_benchmark_live.py index e74a77a..df87fd5 100644 --- a/tests/integration/test_benchmark_live.py +++ b/tests/integration/test_benchmark_live.py @@ -5,11 +5,11 @@ from pathlib import Path import pytest +from nisse_core.catalog import ModelCatalog +from nisse_core.execution import SSHHost from nisse_dev.bench.runner import run_task from nisse_dev.bench.tasks import load_tasks -from nisse_dev.catalog import ModelCatalog from nisse_dev.config import AgentConfig -from nisse_dev.execution import SSHHost from nisse_dev.loop.build import make_llm_clients pytestmark = pytest.mark.integration diff --git a/tests/integration/test_classifier_cases.py b/tests/integration/test_classifier_cases.py index eee47a7..94febf8 100644 --- a/tests/integration/test_classifier_cases.py +++ b/tests/integration/test_classifier_cases.py @@ -16,7 +16,7 @@ import pytest import yaml from nisse_core.actions import Action -from nisse_dev.llm import LiteLLMClient +from nisse_core.llm import LiteLLMClient from nisse_dev.loop.classifier import ActionClassifier, Scope pytestmark = pytest.mark.integration diff --git a/tests/integration/test_crash_durability.py b/tests/integration/test_crash_durability.py index e0eeb02..ed9c790 100644 --- a/tests/integration/test_crash_durability.py +++ b/tests/integration/test_crash_durability.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest -from nisse_dev.reconcile import reconcile_on_boot +from nisse_core.reconcile import reconcile_on_boot from nisse_dev.state import StateStore pytestmark = pytest.mark.integration @@ -15,7 +15,7 @@ pytestmark = pytest.mark.integration CRASH_SCRIPT = """ import os import sys -from nisse_dev.canonical import UserMessage +from nisse_core.canonical import UserMessage from nisse_dev.state import StateStore store = StateStore(sys.argv[1]) diff --git a/tests/integration/test_delegation_live.py b/tests/integration/test_delegation_live.py index 92b7a04..48e2495 100644 --- a/tests/integration/test_delegation_live.py +++ b/tests/integration/test_delegation_live.py @@ -6,11 +6,11 @@ from pathlib import Path import pytest +from nisse_core.execution import SSHHost +from nisse_core.llm import AnthropicPassthroughClient +from nisse_core.oplog import OpLog from nisse_dev.delegation import Caps, Delegation -from nisse_dev.execution import SSHHost from nisse_dev.git import GitRepo -from nisse_dev.llm import AnthropicPassthroughClient -from nisse_dev.oplog import OpLog from nisse_dev.state import StateStore from nisse_dev.tools import Workspace diff --git a/tests/integration/test_end_to_end_loop.py b/tests/integration/test_end_to_end_loop.py index bf40526..85de07e 100644 --- a/tests/integration/test_end_to_end_loop.py +++ b/tests/integration/test_end_to_end_loop.py @@ -31,10 +31,11 @@ from e2e_support import ( task, ) -from nisse_dev.canonical import ActionVerdict +from nisse_core.canonical import ActionVerdict +from nisse_core.execution import SSHHost +from nisse_core.gitea import GiteaClient, GiteaError +from nisse_core.transport import InMemoryTransport from nisse_dev.config import AgentConfig, LoopTuning -from nisse_dev.execution import SSHHost -from nisse_dev.gitea import GiteaClient, GiteaError from nisse_dev.loop import preflight from nisse_dev.loop.build import RunningLoop, build_loop_agent from nisse_dev.loop.items import ( @@ -46,7 +47,6 @@ from nisse_dev.loop.items import ( item_for_issue, loop_of, ) -from nisse_dev.transport import InMemoryTransport pytestmark = [pytest.mark.integration, pytest.mark.e2e] @@ -71,7 +71,7 @@ def running( run = uuid.uuid4().hex[:8] stack = contextlib.ExitStack() # Kept outside pytest's temp directories, which pytest prunes, so a - # failed run's transcript can be read afterwards (nisse_dev.dump). + # failed run's transcript can be read afterwards (nisse_core.dump). keep = Path(os.environ.get("NISSE_E2E_RUNS", "/tmp/nisse-e2e-runs")) / run keep.mkdir(parents=True, exist_ok=True) print(f"\ne2e run {run}: state store in {keep}") diff --git a/tests/integration/test_expert_live.py b/tests/integration/test_expert_live.py index 0df266a..9c86619 100644 --- a/tests/integration/test_expert_live.py +++ b/tests/integration/test_expert_live.py @@ -9,10 +9,10 @@ from pathlib import Path import pytest +from nisse_core.llm import LiteLLMClient, RoleMap +from nisse_core.oplog import OpLog from nisse_dev.evals import collect from nisse_dev.expert import BudgetExhausted, ExpertDesk -from nisse_dev.llm import LiteLLMClient, RoleMap -from nisse_dev.oplog import OpLog from nisse_dev.state import StateStore pytestmark = pytest.mark.integration diff --git a/tests/integration/test_fresh_repo_live.py b/tests/integration/test_fresh_repo_live.py index fbd9eba..45ac34c 100644 --- a/tests/integration/test_fresh_repo_live.py +++ b/tests/integration/test_fresh_repo_live.py @@ -7,8 +7,8 @@ from pathlib import Path import pytest from e2e_support import fresh_repo, operator_gitea +from nisse_core.gitea import GiteaClient, GiteaError from nisse_dev.config import AgentConfig -from nisse_dev.gitea import GiteaClient, GiteaError pytestmark = pytest.mark.integration diff --git a/tests/integration/test_gitea_live.py b/tests/integration/test_gitea_live.py index 2b9beef..a03920d 100644 --- a/tests/integration/test_gitea_live.py +++ b/tests/integration/test_gitea_live.py @@ -9,12 +9,12 @@ from pathlib import Path import pytest from e2e_support import fresh_repo, operator_gitea +from nisse_core.execution import SSHHost +from nisse_core.gitea import GiteaClient, pull_request_reconciler +from nisse_core.state import Intent from nisse_dev.config import AgentConfig -from nisse_dev.execution import SSHHost from nisse_dev.git import GitError, GitRepo, SSHKey -from nisse_dev.gitea import GiteaClient, pull_request_reconciler from nisse_dev.loop.build import remote_url -from nisse_dev.state import Intent pytestmark = pytest.mark.integration diff --git a/tests/integration/test_llm_live.py b/tests/integration/test_llm_live.py index b596ae9..c16e4d2 100644 --- a/tests/integration/test_llm_live.py +++ b/tests/integration/test_llm_live.py @@ -2,11 +2,11 @@ import pytest -from nisse_dev.canonical import AssistantText, StoredEvent, UserMessage -from nisse_dev.catalog import ModelCatalog +from nisse_core.canonical import AssistantText, StoredEvent, UserMessage +from nisse_core.catalog import ModelCatalog +from nisse_core.context import BudgetedClient +from nisse_core.llm import LiteLLMClient from nisse_dev.config import AgentConfig -from nisse_dev.context import BudgetedClient -from nisse_dev.llm import LiteLLMClient pytestmark = pytest.mark.integration diff --git a/tests/integration/test_matrix_live.py b/tests/integration/test_matrix_live.py index a1d3216..cd3d870 100644 --- a/tests/integration/test_matrix_live.py +++ b/tests/integration/test_matrix_live.py @@ -5,8 +5,8 @@ import uuid import pytest +from nisse_core.matrix import MatrixConfig, MatrixTransport from nisse_dev.config import AgentConfig -from nisse_dev.matrix import MatrixConfig, MatrixTransport pytestmark = pytest.mark.integration diff --git a/tests/integration/test_model_capability.py b/tests/integration/test_model_capability.py index dbb5a0a..abb43e4 100644 --- a/tests/integration/test_model_capability.py +++ b/tests/integration/test_model_capability.py @@ -21,17 +21,17 @@ argument, not configuration. import pytest -from nisse_dev.canonical import ( +from nisse_core.canonical import ( AssistantText, StoredEvent, ToolCall, ToolResult, UserMessage, ) -from nisse_dev.catalog import ModelCatalog, select_client +from nisse_core.catalog import ModelCatalog, select_client +from nisse_core.llm import AnthropicPassthroughClient, LiteLLMClient, LLMError from nisse_dev.config import AgentConfig from nisse_dev.expert import parse_answer -from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient, LLMError from nisse_dev.loop.tools import ASK_HUMAN, FINISH_MILESTONE, RESTATE from nisse_dev.tools import EXECUTION_TOOLS diff --git a/tests/integration/test_passthrough_live.py b/tests/integration/test_passthrough_live.py index 55f4f60..f98b0a2 100644 --- a/tests/integration/test_passthrough_live.py +++ b/tests/integration/test_passthrough_live.py @@ -11,10 +11,10 @@ from pathlib import Path import httpx import pytest -from nisse_dev.canonical import AssistantText, ToolCall, ToolResult, UserMessage +from nisse_core.canonical import AssistantText, ToolCall, ToolResult, UserMessage +from nisse_core.llm import AnthropicPassthroughClient +from nisse_core.oplog import OpLog from nisse_dev.config import AgentConfig -from nisse_dev.llm import AnthropicPassthroughClient -from nisse_dev.oplog import OpLog from nisse_dev.state import StateStore from nisse_dev.tools import EXECUTION_TOOLS diff --git a/tests/integration/test_trust_live.py b/tests/integration/test_trust_live.py index 364abc9..99b6bcd 100644 --- a/tests/integration/test_trust_live.py +++ b/tests/integration/test_trust_live.py @@ -10,8 +10,8 @@ from pathlib import Path import pytest from e2e_support import fresh_repo, operator_gitea +from nisse_core.gitea import GiteaClient, GiteaError from nisse_dev.config import AgentConfig -from nisse_dev.gitea import GiteaClient, GiteaError pytestmark = pytest.mark.integration diff --git a/uv.lock b/uv.lock index 3d109c2..15e1c25 100644 --- a/uv.lock +++ b/uv.lock @@ -115,6 +115,16 @@ wheels = [ name = "nisse-core" version = "0.1.0" source = { editable = "nisse-core" } +dependencies = [ + { name = "httpx" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pyyaml", specifier = ">=6" }, +] [[package]] name = "nisse-dev"