From a818b43cc51166ab88bf69abdf264480d7d554aa Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Sun, 6 Sep 2026 09:06:52 +0900 Subject: [PATCH] Integrate runtime-v2 starter baseline and verification tooling --- Cargo.lock | 14 + Cargo.toml | 1 + RUNTIME-V2.md | 85 + behavior/klbr_hooks/__init__.py | 1 + behavior/klbr_hooks/defaults.py | 20 + behavior/manifest.json | 1 + docs/runtime-v2/HANDOFF.md | 158 ++ docs/runtime-v2/PROTOCOL.md | 138 ++ docs/runtime-v2/PROVENANCE.md | 12 + docs/runtime-v2/STATUS.md | 133 ++ docs/runtime-v2/design/behavior-contracts.md | 1559 +++++++++++++++++ docs/runtime-v2/design/hooks-and-policy.md | 223 +++ docs/runtime-v2/design/implementation-plan.md | 604 +++++++ .../design/lucid-code-skill-pack-main.tar.gz | Bin 0 -> 29290 bytes docs/runtime-v2/design/revision-notes.md | 33 + docs/runtime-v2/evidence/checks-python.log | 53 + docs/runtime-v2/evidence/checks.json | 34 + .../evidence/full-checks-python.log | 53 + docs/runtime-v2/evidence/full-checks.json | 38 + klbr-runtime/AGENTS.md | 15 + klbr-runtime/Cargo.toml | 16 + klbr-runtime/examples/workbench.rs | 74 + klbr-runtime/migrations/001_runtime.sql | 77 + klbr-runtime/src/behavior.rs | 164 ++ klbr-runtime/src/hooks.rs | 162 ++ klbr-runtime/src/lib.rs | 12 + klbr-runtime/src/protocol.rs | 261 +++ klbr-runtime/src/session.rs | 294 ++++ klbr-runtime/src/store.rs | 561 ++++++ klbr-runtime/src/worker.rs | 488 ++++++ klbr-runtime/tests/protocol.rs | 62 + klbr-runtime/tests/session.rs | 301 ++++ klbr-runtime/tests/store.rs | 288 +++ python/klbr-runtime/AGENTS.md | 12 + python/klbr-runtime/pyproject.toml | 17 + python/klbr-runtime/src/klbr/__init__.py | 4 + python/klbr-runtime/src/klbr/hooks.py | 113 ++ python/klbr-runtime/src/klbr/local.py | 24 + python/klbr-runtime/src/klbr/py.typed | 0 python/klbr-runtime/src/klbr/runtime.py | 20 + .../klbr-runtime/src/klbr_runtime/__init__.py | 1 + .../klbr-runtime/src/klbr_runtime/__main__.py | 120 ++ .../klbr-runtime/src/klbr_runtime/context.py | 150 ++ .../klbr-runtime/src/klbr_runtime/kernel.py | 69 + .../klbr-runtime/src/klbr_runtime/policy.py | 45 + python/klbr-runtime/src/klbr_runtime/py.typed | 0 .../klbr-runtime/src/klbr_runtime/release.py | 67 + python/klbr-runtime/src/klbr_runtime/wire.py | 97 + python/klbr-runtime/tests/peer.py | 147 ++ python/klbr-runtime/tests/test_contracts.py | 177 ++ python/klbr-runtime/tests/test_processes.py | 241 +++ tests/runtime-fixtures/behavior.json | 3 + tests/runtime-fixtures/envelopes.json | 126 ++ tools/check_runtime.py | 129 ++ 54 files changed, 7497 insertions(+) create mode 100644 RUNTIME-V2.md create mode 100644 behavior/klbr_hooks/__init__.py create mode 100644 behavior/klbr_hooks/defaults.py create mode 100644 behavior/manifest.json create mode 100644 docs/runtime-v2/HANDOFF.md create mode 100644 docs/runtime-v2/PROTOCOL.md create mode 100644 docs/runtime-v2/PROVENANCE.md create mode 100644 docs/runtime-v2/STATUS.md create mode 100644 docs/runtime-v2/design/behavior-contracts.md create mode 100644 docs/runtime-v2/design/hooks-and-policy.md create mode 100644 docs/runtime-v2/design/implementation-plan.md create mode 100644 docs/runtime-v2/design/lucid-code-skill-pack-main.tar.gz create mode 100644 docs/runtime-v2/design/revision-notes.md create mode 100644 docs/runtime-v2/evidence/checks-python.log create mode 100644 docs/runtime-v2/evidence/checks.json create mode 100644 docs/runtime-v2/evidence/full-checks-python.log create mode 100644 docs/runtime-v2/evidence/full-checks.json create mode 100644 klbr-runtime/AGENTS.md create mode 100644 klbr-runtime/Cargo.toml create mode 100644 klbr-runtime/examples/workbench.rs create mode 100644 klbr-runtime/migrations/001_runtime.sql create mode 100644 klbr-runtime/src/behavior.rs create mode 100644 klbr-runtime/src/hooks.rs create mode 100644 klbr-runtime/src/lib.rs create mode 100644 klbr-runtime/src/protocol.rs create mode 100644 klbr-runtime/src/session.rs create mode 100644 klbr-runtime/src/store.rs create mode 100644 klbr-runtime/src/worker.rs create mode 100644 klbr-runtime/tests/protocol.rs create mode 100644 klbr-runtime/tests/session.rs create mode 100644 klbr-runtime/tests/store.rs create mode 100644 python/klbr-runtime/AGENTS.md create mode 100644 python/klbr-runtime/pyproject.toml create mode 100644 python/klbr-runtime/src/klbr/__init__.py create mode 100644 python/klbr-runtime/src/klbr/hooks.py create mode 100644 python/klbr-runtime/src/klbr/local.py create mode 100644 python/klbr-runtime/src/klbr/py.typed create mode 100644 python/klbr-runtime/src/klbr/runtime.py create mode 100644 python/klbr-runtime/src/klbr_runtime/__init__.py create mode 100644 python/klbr-runtime/src/klbr_runtime/__main__.py create mode 100644 python/klbr-runtime/src/klbr_runtime/context.py create mode 100644 python/klbr-runtime/src/klbr_runtime/kernel.py create mode 100644 python/klbr-runtime/src/klbr_runtime/policy.py create mode 100644 python/klbr-runtime/src/klbr_runtime/py.typed create mode 100644 python/klbr-runtime/src/klbr_runtime/release.py create mode 100644 python/klbr-runtime/src/klbr_runtime/wire.py create mode 100644 python/klbr-runtime/tests/peer.py create mode 100644 python/klbr-runtime/tests/test_contracts.py create mode 100644 python/klbr-runtime/tests/test_processes.py create mode 100644 tests/runtime-fixtures/behavior.json create mode 100644 tests/runtime-fixtures/envelopes.json create mode 100644 tools/check_runtime.py diff --git a/Cargo.lock b/Cargo.lock index 7be05d6..e4b1744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1433,6 +1433,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "klbr-runtime" +version = "0.1.0" +dependencies = [ + "anyhow", + "rusqlite", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", + "uuid", +] + [[package]] name = "lazy_static" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index cdc7e9f..24aead6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "klbr-core", + "klbr-runtime", "klbr-bench", "klbr-daemon", "klbr-discord", diff --git a/RUNTIME-V2.md b/RUNTIME-V2.md new file mode 100644 index 0000000..99271ce --- /dev/null +++ b/RUNTIME-V2.md @@ -0,0 +1,85 @@ +# klbr runtime starter — code before the next plan + +This is a **non-production implementation slice** of the Rust-engine / Python-policy design. +It is additive: the existing daemon, provider adapters, Discord integration, UI and legacy +memory database have not been migrated or replaced. + +**Validation:** 48 Python tests passed in the producing environment. These exercise real +Python subprocesses, the exact SQL migration, and protocol fixtures. **Rust was not compiled, +formatted, linted or executed:** the environment has no Cargo/rustc, and compiler download +was unavailable. Seventeen Rust tests are included but unrun. Read that distinction literally. + +Start with [implementation status](docs/runtime-v2/STATUS.md) and the +[next-agent handoff](docs/runtime-v2/HANDOFF.md). The [wire contract](docs/runtime-v2/PROTOCOL.md) +explains the boundary without making you infer it from tests. + +## Run what is testable without Rust + +From the repository root, including from Nushell: + +```text +python tools/check_runtime.py +``` + +Python >=3.11 and a Unix platform are required. No pip installation, provider key, Discord +bot, network service or dependency resolver is used by these Python tests. + +For the first real Rust validation, use a machine with Cargo/rustc, the C toolchain needed by +bundled SQLite, and the repository's toolchain requirements: + +```text +python tools/check_runtime.py --rust --format --report /tmp/klbr-checks.json +``` + +`--format` explicitly formats only `klbr-runtime`. Without it, rustfmt runs in check mode. +The original Linux Cargo configuration expects clang and the wild linker. To use `cc` +instead, without changing that configuration: + +```text +python tools/check_runtime.py --rust --format --portable-linker --report /tmp/klbr-checks.json +``` + +A Rust-requested check **fails** when Cargo/rustc are unavailable; it never reports them as +passed/skipped inside an otherwise green integration result. Missing crate sources may +need the network on the validating machine. Cargo.lock's new local-package entry was edited +and structurally checked here, not regenerated by Cargo. No new registry dependencies were +introduced by this crate. + +After Rust validation: + +```text +cargo run --locked -p klbr-runtime --example workbench +``` + +The example makes a temporary **new-format** database, evaluates cells, explicitly publishes +one local message, switches to a holding Python guard without discarding a variable, and +yields. It uses no model and sends nothing to Discord. With the portable linker override +in Nushell, the equivalent launch is: + +```nu +with-env { CARGO_ENCODED_RUSTFLAGS: "", CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: "cc" } { + cargo run --locked -p klbr-runtime --example workbench +} +``` + +## The actual source map + +| Path | Responsibility | +|---|---| +| `klbr-runtime/src/protocol.rs` | Typed IDs, framed/versioned messages, role/outcome validation | +| `klbr-runtime/src/worker.rs` | Dedicated control socket, bounded process actor, deadlines and stop path | +| `klbr-runtime/src/store.rs` and `migrations/` | Separate SQLite format, durable admission, executions, local effects and waits | +| `klbr-runtime/src/session.rs` | Host-owned execution, explicit sends through a separate guard, hook epoch pinning | +| `klbr-runtime/src/hooks.rs` | Two real decision slots; fail-closed delivery review | +| `klbr-runtime/src/behavior.rs` | Content-addressed snapshots, separate from database activation authority | +| `python/klbr-runtime/src/klbr_runtime/` | Persistent evaluator and hook-worker roles | +| `python/klbr-runtime/src/klbr/` | `local.send`, terminal `runtime.wait`, typed hooks and discovery | +| `behavior/` | Editable Python defaults for attention and delivery | +| `tests/runtime-fixtures/` | Shared wire values and source fingerprint | +| `python/klbr-runtime/tests/peer.py` | **Test double only** for the protocol host; not a second production orchestrator | +| `klbr-runtime/tests/` | Authored, unrun Rust/store/end-to-end integration tests | + +There is no LCM, provider loop, client adapter, Discord rewrite, RLM, tree-sitter port, +durable observer dispatcher or Python behavior-publishing SDK in this slice. The host-side +revision activation API exists; the complete autonomous edit/test/publish workflow does not. +Do not mistake an available hook name in the design documents for an implemented hook. diff --git a/behavior/klbr_hooks/__init__.py b/behavior/klbr_hooks/__init__.py new file mode 100644 index 0000000..f833843 --- /dev/null +++ b/behavior/klbr_hooks/__init__.py @@ -0,0 +1 @@ +"""Editable policies. Durable state belongs to the Rust host.""" diff --git a/behavior/klbr_hooks/defaults.py b/behavior/klbr_hooks/defaults.py new file mode 100644 index 0000000..ef10287 --- /dev/null +++ b/behavior/klbr_hooks/defaults.py @@ -0,0 +1,20 @@ +"""Compatibility defaults: optional ambient participation, deliberate speech.""" +from klbr.hooks import (AttentionDecision, AttentionRequest, DeliveryDecision, + DeliveryRequest, HookContext) + + +def attention(request: AttentionRequest, context: HookContext) -> AttentionDecision: + match request.source: + case "operator": + return AttentionDecision.wake("operator") + case "discord.dm" | "discord.mention": + return AttentionDecision.wake("directed") + case "discord.ambient": + return AttentionDecision.defer(8) + raise ValueError("unknown source; never silently discard it") + + +def delivery(request: DeliveryRequest, context: HookContext) -> DeliveryDecision: + # Additional policy belongs here. Rust still validates the destination, + # execution authority and receipt; allowing is not evidence of delivery. + return DeliveryDecision.allow() diff --git a/behavior/manifest.json b/behavior/manifest.json new file mode 100644 index 0000000..7715f7e --- /dev/null +++ b/behavior/manifest.json @@ -0,0 +1 @@ +{"version":1,"hooks":{"attention.plan":"klbr_hooks.defaults:attention","delivery.review":"klbr_hooks.defaults:delivery"}} diff --git a/docs/runtime-v2/HANDOFF.md b/docs/runtime-v2/HANDOFF.md new file mode 100644 index 0000000..97c8aa5 --- /dev/null +++ b/docs/runtime-v2/HANDOFF.md @@ -0,0 +1,158 @@ +# Handoff: continue from the source, not from a blank page + +Read `RUNTIME-V2.md`, `STATUS.md`, and the tests before expanding the scope. The archived +v2 plans retain the product direction; they are not evidence of implemented behavior. +The uploaded Lucid pack is retained as `design/lucid-code-skill-pack-main.tar.gz`; the original +v2 plan, hook spec and behavior source trace are beside it. Read those contract/skill sources +before changing the architecture. + +This starter covers parts of plan slices 1, 2, 3 and 3a, plus a narrow local-effect/wait slice +of 4 and host-side hook activation from 9. No numbered slice should be marked fully complete. + +## First task: establish the Rust baseline + +Use the user's actual toolchain/dev environment. Run: + +```text +python tools/check_runtime.py --rust --format --report /tmp/klbr-runtime-checks.json +``` + +Use `--portable-linker` when clang/wild are absent. It overrides the original repository +configuration for this command only. Do not rewrite the existing flake just to make a +temporary machine's linker happy. Do not use a Bash-only invocation in user-facing instructions; +the helper launches subprocesses directly and works from Nushell. + +Fix compilation, formatting, real integration failures and lockfile resolution before +adding features. The producer could not run Cargo, so this is actual work, not ceremonial +verification. Record exact Rust, Cargo, Python and linked SQLite versions. The SQLite guard +is intentional; validate a fixed runtime rather than weakening the guard. If a dependency +needs an update, make it explicit and regenerate Cargo.lock on this machine. + +Rust test entrypoints: + +- `tests/protocol.rs`: shared envelopes, framing failures, IDs, role/output boundaries. +- `tests/store.rs`: snapshot hash, source/session dedupe, owner lock, legacy DB refusal, + local receipt identity, wait/input ordering, reopen without replay. +- `tests/session.rs`: real Rust/Python execution, separate review, fail-closed guards, + invalid activation, blocked worker deadlines, admission during blocked hooks, pinned + in-flight revisions, independent stop, and terminal shutdown. + +Run the `workbench` example only after these checks. It is a no-network demonstration, +not an agent or an interactive client. The Python peer is a test double, and must not be +promoted into a production Python orchestrator to sidestep a Rust integration problem. + +## Next coherent change: daemon-owned session and inbox coordinator + +Add a single-owner registry in the existing daemon. The integration should own one new +runtime `Store` and one `SessionRuntime` for each active session. Preserve the current one-root +agent across local and Discord conversations; a channel is routing provenance, not a new +agent by default. Use a separate new runtime database filename and do not open the legacy +memory DB through this constructor. + +Durably accept authenticated input before acknowledging it. Pass a canonical source dedupe +key, source kind, immutable conversation identity, author and receipt timestamps. Admission +must not wait for a Python hook. Channels/notifications are wake signals, not the only data. +Wire `attention.plan` only after admission. A failure retains pending work; the basic operator +control path never requires a successful policy callback. + +Do not call `acknowledge_inputs` merely because attention was planned or a cell ran. Add a +transactional turn/attention-frame record linking the consumed input IDs, routing handles, +context epoch and policy revision. Acknowledgement is a consequence of this accepted frame. +The current API comment marks this boundary rather than pretending it is implemented. + +Provide operator stop and activation/recovery endpoints independently of the model. Protect +these endpoints. The library's `shutdown` is not by itself a complete daemon drain protocol; +await accepted host tasks and finish their durable outcomes before exiting the service. + +Acceptance: burst inputs during blocked work, cancel/replace a kernel, and still find each +accepted input and its disposition. No remote sends in this stage. + +## Then wire the model loop, scratchpad and the existing client + +Adapt the existing provider boundary instead of copying the old memory/tool coupling. +Persist complete provider blocks/tool-call IDs before execution. Use one model-visible +Python tool. Ordinary assistant text goes to typed scratchpad records, not automatic speech. +Partial stream text remains partial. Preserve the user's scratchpad diagnostics without +misrepresenting that text as delivered to a conversation. + +`local.send` already has a host-side durable publication contract. Teach the client to +render that lane separately from scratchpad and cell output, using event sequence cursors. +A reconnect replays completed durable records; transient token animation can remain lossy. +Add protocol adapter tests rather than reexporting provider/storage types into `klbr-ipc`. + +Replace tool-name string matches (`wait_and_continue`, `local_send`, restart controls, etc.) +in the new loop with explicit outcomes/commands. Do not leave the old tool registry active +under the new Python executor. Add `model.plan`, `context.prepare` and `turn.next` when those +actual domains exist, with documented failure semantics and editable Python defaults. + +Acceptance: scratchpad + cells + one explicit send yields one conversational message; a +terminal wait ends code, resumes only from a new wake/model boundary, and consumes no idle +inference loop. Persisted provider sequences remain valid after interruption. + +## Build complete revision editing around the existing activation primitive + +The next SDK work is `behavior.draft/publish/activate/rollback` and `hooks.bind/test/trace`, +not a new loader. Existing code already snapshots sources and activates host-side candidates. +Stage normal source files and tests in a writable draft, retain the base epoch, run trusted +fake-effect validation, publish immutable content, then call the existing guarded switch. +The agent should be able to edit ordinary hook/skill code without touching Rust. + +Do not accept a candidate's own boolean claim that it passed trusted checks. Add environment +and component fingerprints so a shared dependency change replaces every affected process +role. Keep source-only compatible hook activation separate from workbench replacement. + +Expand startup from “fail on corrupt active snapshot” to a persisted last-good recovery path +that does not need user hooks to succeed. Expose failed candidate diagnostics and invocation +trace references. Do not replay old events solely because a handler revision changed. + +Acceptance: the agent edits a hook through the SDK, tests/activates it, keeps a live workbench +object, and sees new behavior in the next cell. An in-flight cell remains pinned. A bad +candidate or broken active revision remains repairable without model cooperation. + +## Discord, LCM, then durable jobs/children + +Bring the existing Discord/Twilight transport behind the new domain commands; preserve the +social semantics recorded in the design's behavior companion. Add canonical conversations, +immutable attention frames, per-effect destinations/reply anchors, and an outbox with honest +pending/attempting/confirmed/failed/unknown states. Do not generalize the current *local* +atomic publication into a claim of atomic remote send. Only consume reply state when actual +receipt evidence permits it. Keep ambient silence and explicit ignore/reaction decisions. + +For LCM, first replace discarded large output with artifact spooling. Then add immutable +structured session items, chronological summary coverage, transactional context replacement, +lexical retrieval and exact source reads. Maintain provider-valid tool groups. Preserve the +difference between a scratchpad draft and a confirmed message across summaries. Compaction +must not depend on reflection, embeddings, skill editing, or note-garden reconciliation. +The current append-only event table is a useful substrate, not a completed context manager. + +For jobs/RLM, add explicit identities, revision/workspace pinning, budgets and completion +notifications. Never implement durability by pickling a coroutine or replaying arbitrary +cells. Add durable observers only with an atomic acknowledgement/state/proposed-command +commit and stable causation/action identities. The current prototype deliberately does not +implement generic transforms/observers with vague dictionaries and no durability contract. + +Move useful tree-sitter vocabulary into Python skills using maintained Python bindings. +Preserve version-bound source/symbol ranges and checked patches rather than recreating each +old JSON tool as a Python function. No tree-sitter capability was ported in this starter. + +## Review/failure work before unattended deployment + +Process groups/cgroups and tracked command ownership are needed before arbitrary subprocess +work can be cleaned up reliably. Today only the direct Python child is killed. Add bounded +artifact disk use and input/output backpressure, auth on operator APIs, global process and +model budgets, current runtime health, hook backlog diagnostics and explicit cleanup policy. + +Exercise actual kill/fault windows: SQLite commit-before-notify; local publish-before-reply; +wait-before-wake; hook activation during an execution; corrupt active release; truncated +control frames; failed artifact writes; a handler catching cancellation; and daemon shutdown +with an accepted execution still completing. Verify backups with artifacts/releases before +cutover. Keep the old production database and owner stopped, never two live senders. + +## What was deliberately not done here + +No old production paths were deleted, no key was used, no migration ran against user state, +and no existing note was reinterpreted as truth. No Git remote or live beads tool was +available in the supplied archive/environment. No commits were pushed or issue IDs invented. +Use the repository's actual issue workflow when continuing; this file is an artifact handoff, +not a replacement issue database. Source was written anew for this slice, not copied from +Prime's implementation. Consult upstream licenses before later borrowing source. diff --git a/docs/runtime-v2/PROTOCOL.md b/docs/runtime-v2/PROTOCOL.md new file mode 100644 index 0000000..18ee2d2 --- /dev/null +++ b/docs/runtime-v2/PROTOCOL.md @@ -0,0 +1,138 @@ +# Runtime control protocol v1 + +Implementation authority: `klbr-runtime/src/protocol.rs` and the Python runtime, checked +against `tests/runtime-fixtures/envelopes.json`. This protocol is local to the starter, +not a claimed compatible implementation of Prime or the old klbr IPC protocol. + +## Transport and bounds + +One dedicated Unix-domain stream socket per worker, located in a private temporary directory +created by the host. A random startup token binds the expected subprocess handshake. This +is distinct from stdout/stderr, and is not a network-accessible service. Processes run with +same-user trust; the token and framing are not an OS sandbox. + +Each frame is four unsigned big-endian bytes giving the UTF-8 JSON payload length, followed +by that many bytes. Length must be 1..1,048,576. All outer fields are required: + +```json +{"version":1,"session_id":"root","generation":"g-1","message":{"kind":"shutdown"}} +``` + +Identifiers are 1..128 ASCII letters/digits/underscore/dot/hyphen. Unknown envelope and typed +message fields fail. Version, generation and session mismatches fail. NaN/Infinity are not +valid wire numbers. Python's frame decoder rejects duplicate JSON object keys; Rust derives +reject duplicate typed fields. Arbitrary nested `serde_json::Value` payload maps are not yet +a duplicate-key equivalence guarantee between parsers; strengthen the shared decoder before +accepting third-party wire producers. This is a trusted local protocol, not a claim of a +fully fuzzed hostile-input parser. + +On EOF, bad framing, timed-out/abandoned Rust exchange, or identity mismatch, invalidate the +connection/generation. Never attempt to resume a new frame after a partial payload. Output +limits: cell source <=262,144 bytes; returned text <=65,536 bytes across <=256 records; +error text <=65,536 bytes. The Python runtime currently returns output on completion, not as +a continuous stream. Excess is counted and discarded. Raw/native stdout/stderr are continuously +drained by the Rust worker into separate bounded 8 KiB diagnostic tails. + +## Message vocabulary + +`hello`: token, role (`workbench` or `hooks`), revision (null for workbench), and slots +(empty for workbench; exactly `attention.plan`, `delivery.review` for this hook worker). + +`execute`: operation `id` and `code`, allowed in workbench only. One foreground operation +per worker. Namespace and future flags persist. The final expression has a displayed repr; +`_` retains its last non-None value. Evaluation is arbitrary CPython, not sandboxed Python. + +`invoke`: operation `id`, exact `slot`, typed domain `input`, hook role only. A handler is an +importable source entrypoint, not a pickled closure. The workbench is never made to execute +an authoritative send-review hook behind the cell waiting for that review. + +`host_request`: request `id`, `execution_id`, and `request`. Implemented request variants: + +```json +{"method":"local.send","text":"hello"} +{"method":"runtime.wait","seconds":null,"reason":"waiting for input"} +{"method":"hooks.list"} +{"method":"hooks.describe","slot":"delivery.review"} +``` + +`host_reply` repeats request/execution identity and contains either +`{"kind":"ok","value":...}` or `{"kind":"error","code":"...","message":"..."}`. +The worker host enforces a maximum of 128 requests per foreground cell. Pending replies +are correlated by identity, not arrival order. The Python reader keeps running while a cell +awaits an RPC. Late replies after cancellation are discarded rather than satisfying another +cell's request. + +`completed` repeats the operation `id` with one of: + +```json +{"kind":"cell","status":"ok","output":[{"stream":"result","text":"42"}],"truncated_bytes":0,"error":null} +{"kind":"hook","value":{"action":"allow"}} +{"kind":"failed","error":"worker-level failure description"} +``` + +Cell statuses: `ok`, `error`, `yielded`, `interrupted`. Output streams: `stdout`, `stderr`, +`result`. Worker death has no invented completion; the host creates a failed/interrupted +record from its own observation. A worker cannot claim a committed yield without the host +having accepted the wait. + +`interrupt` targets one operation ID. Python supports cooperative task cancellation; the +current Rust management API conservatively terminates the generation instead. `shutdown` +requests clean worker exit. Rust retains the final hard-stop authority when code blocks or +catches cancellation. Only the direct child is currently managed, not all descendants. + +## Scope, waiting and receipts + +Host effects require the current live execution plus matching generation. Background tasks +inherit an origin for diagnostics, but that scope expires when the foreground cell ends. +After terminal wait, catching the interpreter sentinel does not restore scope authority. +The host also rejects further requests from a yielded execution. + +Wait seconds are null or finite >0 and <=600. The host records the deadline/state and checks +for already-pending input in one transaction. No suspended Python continuation is persisted. +Wakeups start a future model/cell boundary; code below `await runtime.wait(...)` does not +run normally. The caller must not mistake ordinary `asyncio.sleep` for this durable wait. + +Local send receipt is `effect_id`, `status` (`confirmed` or `held`), `event_id` (nullable), +and `reason` (nullable). Confirmed means a durable operator-lane event exists. Held is not +approval, queue-success or proof anyone received the message. There is no Discord transport +in this slice. A retry with the same execution/request identity returns the existing receipt; +a deliberately new request can send identical text again. + +## Implemented decision slots + +`attention.plan` input: positive event_id, host-classified source (`operator`, `discord.dm`, +`discord.mention`, `discord.ambient`), nonnegative receipt timestamp. Result: wake with priority, +defer with bounded seconds, or ignore with reason. Operator attention cannot be suppressed +or forged from an external source by returning a priority. This is a policy API awaiting +authenticated ingress/model-coordinator integration. + +`delivery.review` input: effect_id, destination=`operator`, bounded message text. Result: +`allow` or `hold` with a reason. Any invalid/unavailable required review holds the send. +No host-effect scope is given to a hook. Additional domain hooks should be added alongside +the owning implementation, not as no-op placeholders. + +The host's two-second invocation budget includes queueing. One bounded actor serves a hook +worker; a hung callback can delay other calls until timeout invalidates that generation. +There is no separate per-handler scheduling or automatic hook restart yet. + +## Source revisions and activation + +A manifest contains version 1 and exactly the two implemented entrypoints: + +```json +{"version":1,"hooks":{"attention.plan":"klbr_hooks.defaults:attention","delivery.review":"klbr_hooks.defaults:delivery"}} +``` + +Handlers receive `(request, context)` and return the corresponding SDK decision dataclass, +synchronously or asynchronously. Startup validates the import/call signature, not semantic +behavior. Imports themselves are trusted executable code and can fail or block. + +Source identity hashes sorted POSIX relative filenames and exact bytes. For each file feed +SHA-256: u64BE name-byte-length, UTF-8 name, u64BE content-length, content. Source snapshots +allow `.py`, `.json`, `.md`, maximum 128 files/2 MiB, no symlinks. Run source tests with `-B` +or put caches outside the draft. The shared `behavior.json` fixture fixes the initial digest. + +Database epoch is activation authority; a path/symlink is not a second active pointer. Candidate +handshake precedes the compare-and-swap switch. Each executing cell pins its policy object, +so later hooks in that cell use the same revision even during activation. A hook-only change +does not replace the workbench. A broader environment change is not yet implemented. diff --git a/docs/runtime-v2/PROVENANCE.md b/docs/runtime-v2/PROVENANCE.md new file mode 100644 index 0000000..5efe58e --- /dev/null +++ b/docs/runtime-v2/PROVENANCE.md @@ -0,0 +1,12 @@ +# Source provenance + +The base is the user's supplied `klbr.tar`, SHA-256 `6c7e4e0b7a1d1b860017d07b1f02b5c0adc708ccbcb3693c53bccd509841cd8c`. + +The new code was authored for this implementation slice. No Prime runtime source was copied. +The uploaded Lucid pack and v2 architecture/hook plans informed the implementation. Archived +plans are in `design/`; current truth is in `STATUS.md` and the actual source/tests. + +The delivery includes a full modified source archive and a Git-compatible patch against this +exact supplied snapshot. No remote repository was modified. Temporary build-maker scripts and +Python bytecode are not part of the deliverable. No compiler installation succeeded in this +environment; the included evidence explicitly separates Python checks from unrun Rust tests. diff --git a/docs/runtime-v2/STATUS.md b/docs/runtime-v2/STATUS.md new file mode 100644 index 0000000..35cb4fe --- /dev/null +++ b/docs/runtime-v2/STATUS.md @@ -0,0 +1,133 @@ +# Runtime v2 — implementation status + +Date: September 6, 2026. Status: additive starter; **not ready for production cutover**. +This document supersedes implementation-status claims in the archived design documents, +not the user's intended experience. Keep the scratchpad, deliberate speech, social Discord +participation, and Rust-mechanism/Python-policy split. + +## Evidence, not aspirations + +The producer ran `python tools/check_runtime.py --report docs/runtime-v2/evidence/checks.json`. +The report and its adjacent full log are included. A second invocation with `--rust` is +recorded in `evidence/full-checks.json`: Python passed again, and the requested combined +check exited nonzero because Cargo/rustc were unavailable. It does not report a green Rust +build. The Python suite contains: + +- 26 tests using the **real** workbench/hook subprocesses with a **test-only** host peer. +- 22 tests for framing, typed Python values, content identity and the exact shipped SQL migration. + +All 48 passed. This is not 48 Rust tests. The peer does not exercise `SessionRuntime`, Rust +process timeouts, rusqlite transactions, epoch compare-and-swap, or direct-child reaping. + +Seventeen Rust tests are written in `klbr-runtime/tests/` (3 protocol, 6 storage/release, +8 session tests). None ran here. Cargo, rustc, rustfmt, Clippy and Nushell were unavailable. +Rust source was reviewed manually, but syntax/type errors remain possible until compiled. +The original `Cargo.lock` was given a local `klbr-runtime` package stanza; the helper verifies +that its named dependencies uniquely resolve to existing lock entries. That is not Cargo +resolver validation. `cargo test --locked` is a first-agent gate, not an already-passed claim. + +Python tests used Python 3.13.5 and Python-linked SQLite 3.46.1. The latter is used **only for +serial, in-memory schema tests**. The proposed Rust deployment checks the actual SQLite +runtime for the documented WAL-reset fix (3.51.3+, or the 3.50.7/3.44.6 fixed branches). +Do not relax this guard to make an older test host green. Verify the bundled library on the +machine that first runs Rust. See https://sqlite.org/wal.html for the upstream advisory. + +No real model calls, Discord traffic, production database migration, NixOS deployment, +resource benchmark, backup restore, filesystem fault injection or long-duration soak was run. + +## What is implemented in source + +**Python runtime, executed in tests.** Separate workbench/hook process roles share bounded +JSON framing over a Unix-domain socket. Workbench globals and future flags survive cells; +there is top-level await, expression display, typed outcomes and background asyncio work. +The control channel is independent of stdout/stderr. Python output has byte/record bounds; +late background output retains an origin instead of becoming the next cell's output. +`SystemExit` and ordinary exceptions become cell outcomes; native process death remains death. + +`local.send` is an explicit RPC, not `print`. `runtime.wait` is a terminal yield, not a long +Python sleep. Execution scopes expire, including references inherited by background tasks. +Late replies after cancellation do not satisfy another execution's request. The SDK offers +`hooks.list` and `hooks.describe`. Hook handlers receive typed snapshots and return typed +decisions; no active effect scope is supplied to the hook role. + +**Rust host, authored but unexecuted.** `Worker` supervises the real Python executable, +validates handshake/identity, keeps a bounded request queue and diagnostic tails, enforces +absolute deadlines and invalidates a broken generation. A separate control path stops the +worker without asking a hook. Mid-exchange cancellation kills the process instead of trying +to reuse a potentially half-read frame. The first Rust stop implementation is hard-stop; +Python supports cooperative interruption, but host-driven in-place reuse is not yet shipped. + +`Store` opens a new-format SQLite file, takes a cooperating single-owner lock, and routes +transactions through bounded blocking tasks. Dropping the caller does not cancel an accepted +SQLite write or release its owner lock before the write finishes. Originals are append-only. +Source keys deduplicate within a session, not by identical content. Unfinished executions +are reconciled on reopen without replaying Python. The old memory database is refused, not +implicitly migrated. + +Local sends have proposal, confirmed or held records. Required review runs outside any +transaction/session mutation lock in the separate hook worker. Final local publication, +receipt and review trace commit together. Here confirmed means durably published to the +operator lane, **not** that a client connected or a human read it. There are no remote effects +in this implementation, so the local ledger must not be advertised as a Discord outbox. + +Host waits check pending input in the same transaction as changing wait state; admission +wakes an existing wait. Persisted deadlines can be consumed idempotently with `wake_due`. +There is no autonomous scheduler calling that function yet. Input consumption is an explicit +host API, not automatically inferred from a cell or scratchpad. + +`SessionRuntime` binds these mechanisms into a host-side slice. It owns accepted execution +rather than the caller's connection, pins a policy revision for each cell and its sends, +and rejects a second foreground cell. Hard interruption can be followed by a fresh workbench; +shutdown cannot silently revive it. Hook-only activation starts and checks a candidate worker +before a database epoch switch. Old cells retain their policy reference; compatible workbench +globals remain intact. At most one activation is admitted per session at a time. + +## The two actual hook slots + +`attention.plan`: Python defaults prioritize operator input and directed Discord-shaped +inputs, and propose an eight-second ambient defer. Rust validates results, including operator +priority. It is an exposed/tested policy primitive, **not yet integrated into real ingress or +a model scheduler**. Source classification must come from a future authenticated adapter, +never from an arbitrary text label supplied by an external participant. + +`delivery.review`: Allow or hold an explicit local send. A handler exception, malformed +result or unavailable/timed-out worker causes a held operation. No accidental approval. +This hook is connected to the authored Rust send path, and its Python side was executed. + +No empty registrations were added for every future design slot. Ordered transforms and +durable observers require their own state/commit contracts and are deliberately absent. + +## Limits that must remain visible + +- Output beyond the preview bound is **discarded with a truncation count**, not spooled to + an artifact. Therefore this slice is not a lossless-output or LCM implementation. +- Background tasks are ephemeral. Direct filesystem/network/subprocess effects are not + transactional and are never automatically replayed. No heap checkpoint exists. +- Processes are trusted same-user Unix processes, not sandboxes. The host clears the worker + environment to a limited set, but filesystem access can still expose user credentials. + No cgroup/RLIMIT quotas or process-group descendant cleanup are implemented. +- A deadline kills the direct Python child. Descendant process ownership needs an explicit + deployment/job design before untrusted or unattended arbitrary workloads. +- The hook worker is not automatically restarted after a deadline. Subsequent required + reviews hold until a fresh policy worker is activated/restarted. Core control stays usable. +- Candidate import/signature/handshake validation is not semantic validation. A Python + handler returning an invalid decision can pass startup and fail at invocation, then hold. + There is no complete draft/publish/test UI or SDK, persisted last-good boot fallback, or + durable observer state migration/rollback workflow yet. +- Source snapshots cover a small `.py`/`.json`/`.md` tree, reject symlinks, and exclude bytecode + by requiring clean source. Published bytes must be protected by deployment permissions. + Hash verification is not a defense against a malicious same-user writer during import. + Release files/directories are synced before activation, but storage crash durability was + not tested. No garbage collection or environment fingerprint/lock migration exists yet. +- One `SessionRuntime` per session must be owned by a future daemon registry. Global worker + budgets, fairness, authentication, reconnect replay and a permanent service lifecycle are + not provided by the library alone. Database uniqueness is not that whole registry. +- SQL state transitions and Rust supervision still need real integration/fault tests. + Manually invoking the migration with Python is not a substitute for them. + +## Boundaries preserved for the next agent + +The legacy `klbr-core`, `klbr-daemon`, `klbr-discord`, `klbr-ipc`, UI and production schema +are unchanged. Root Cargo files merely include the new crate and its existing dependencies. +The new crate is separate on purpose so it can be compiled/tested before touching the old +runtime. It is not intended to become a second permanent competing orchestration dialect. diff --git a/docs/runtime-v2/design/behavior-contracts.md b/docs/runtime-v2/design/behavior-contracts.md new file mode 100644 index 0000000..84657e4 --- /dev/null +++ b/docs/runtime-v2/design/behavior-contracts.md @@ -0,0 +1,1559 @@ +# klbr behavior contracts and source evidence +Date: September 6, 2026. Companion to `implementation-plan.md`. +These are excerpts from the supplied archives, not a verified current upstream checkout. Original archive files were not modified. Line numbers refer to each named source file, not this document. Source inspection establishes implementation/intended behavior where shown; no new Rust tests, replacement-runtime tests, live Discord calls or crash-recovery tests were run. Proposed behavior and known deviations are labeled in the plan. +## Archive identities +- `klbr.tar` — SHA-256 `6c7e4e0b7a1d1b860017d07b1f02b5c0adc708ccbcb3693c53bccd509841cd8c` +- `lucid-code-skill-pack-main.tar.gz` — SHA-256 `f69331424eea813ce3c687d1e20b3e9856b9e1250c12bdad045028973333ef00` + +## Evidence map +- **K01** — Scratchpad, explicit speech, and participant instructions. +- **K02** — Scratch output, input buffering, and delivery nudges. +- **K03** — Operator-visible scratchpad diagnostics. +- **K04** — Discord configuration defaults. +- **K05** — Discord intake, batching, and wake admission. +- **K06** — Send, reply references, and acted marking. +- **K07** — Attention statuses, aging, reactions, and explicit decisions. +- **K08** — Short conversation aliases, implicit destination, and reply threading. +- **K09** — Discord tool surface and stale description. +- **K10** — Operator preemption versus queued external events. +- **K11** — Wait-and-continue semantics. +- **K12** — Continuous autonomous loop and tool-name lifecycle. +- **K13** — Soul updates and host restart. +- **K14** — Code-intelligence vocabulary and mutation limitations. +- **K15** — Lucid: meaningful states, ownership, vocabulary, and behavior changes. + +## K01 — Scratchpad, explicit speech, and participant instructions + +The instructions establish deliberate sends and autonomous participation. Their claim that scratchpad is invisible conflicts with the web implementation in K03. Preserve the useful delivery distinction, not that inaccurate description. + +Source: `klbr-core/src/instructions.md:1–83` + +```text + 1 | ## scratchpad + 2 | + 3 | *plain assistant text* that you output is treated as your *scratchpad*. it is + 4 | never shown to anyone, only you will be aware of it. so, trying to respond to + 5 | anyone using plain assistant text will not work (that is only possible via + 6 | tools, see "input sources"). and such it is a scratchpad for you. you can use + 7 | it to organize your thoughts or whatever you want to put there; it is a secret. + 8 | + 9 | ## input sources & event loop + 10 | + 11 | all incoming messages are observations/events, you cannot respond to any of + 12 | these by writing out assistant text. you should always choose an action: + 13 | - call `local_send` to speak to the local operator. + 14 | - call `discord_send` to speak on discord. + 15 | - call `wait_and_continue` to stay quiet and wait. + 16 | - call memory/tools when useful. + 17 | + 18 | local operator messages arrive as `...`. discord messages arrive as `` blocks containing either a `` or + 21 | `` payload. within these tags, individual messages are + 22 | structured as ``. + 23 | if a message's context is `dm` or `mention`, it is addressed to you; respond by + 24 | calling `discord_send`. if it is tagged as `guild`, it is ambient chatter and + 25 | replying is optional. remember, you exist as a participant, not a helpdesk. + 26 | + 27 | plain assistant text is never sent to anyone. it is scratchpad only, no users + 28 | will see what is in the scratchpad, it is a secret. thinking about what to say + 29 | and actually saying it are different steps, do both by writing scratchpad if + 30 | useful, then calling the relevant send tool. remember, if you don't call the + 31 | relevant send tool, any authors of that event won't see anything. + 32 | + 33 | some inputs may arrive wrapped in `...` tags. these are + 34 | automated system nudges and operational reminders from the harness (such as idle + 35 | time checks or formatting warnings), not direct messages from the operator. do + 36 | not write conversational replies to system nudges; respond by calling the + 37 | requested tool (like `wait_and_continue` to yield) or addressing the operational + 38 | trigger. + 39 | + 40 | you runs continuously. if you have nothing to do, want to wait for user input, + 41 | or wait for external events, you MUST call `wait_and_continue` before you end + 42 | your turn. when you call `wait_and_continue`, the wait will automatically be + 43 | interrupted early by any incoming event or message, so you don't have to worry + 44 | about "polling" unless the situation requires it. + 45 | + 46 | ### action examples + 47 | + 48 | operator event: + 49 | ```xml + 50 | + 51 | hi + 52 | + 53 | ``` + 54 | bad: writing `hey` as plain assistant text. + 55 | good: call `local_send({"content":"hey"})`, then call `wait_and_continue({"seconds":60,"reason":"waiting for incoming events"})` if there is nothing else to do. + 56 | + 57 | idle: + 58 | bad: writing `still here` as plain assistant text. + 59 | good: call `wait_and_continue({"seconds":60,"reason":"waiting for incoming events"})`. + 60 | + 61 | discord dm: + 62 | bad: writing a discord reply as plain assistant text. + 63 | good: call `discord_send(...)`. + 64 | + 65 | ### discord behavior + 66 | + 67 | you exist on discord as a participant, not a helpdesk. read the room. reply when + 68 | you have something to say, stay quiet when you don't. message batches are + 69 | context, not commands. channel messages show you what's happening, they're not + 70 | requests for you to respond. use judgment: would i say something if i were + 71 | sitting in this server? if no, move on. DMs and mentions are signals. if someone + 72 | DMs you, @s you, or asks something directed at you, respond via discord_send. + 73 | mark it acted (auto-detects from source_item_id). if a pending item doesn't + 74 | warrant a response, discord_mark it ignored. never write replies in local chat. + 75 | use your tools. assistant text is just your scratchpad in these cases, not a + 76 | message anyone can see. thinking about what to say and actually saying it are + 77 | different steps, do both. make your own calls. don't ask the operator whether to + 78 | reply. they're not here to approve your social decisions. remember where things + 79 | happened. tag durable memories with scope: source:discord, conversation:, + 80 | guild:, channel:, thread:, author:. conversations bleed into + 81 | each other without tags. look before guessing. use the backread tool when + 82 | context is lacking. filling in gaps with assumptions is how you get things wrong + 83 | about people. +``` + +## K02 — Scratch output, input buffering, and delivery nudges + +The stream emits a distinct ScratchToken. The non-preemptive stash is a single Option. A filled stash can consume another event without retaining it; this is source inspection, not a new executed regression test. Turn-end nudges enforce explicit delivery rather than forwarding scratch. + +Source: `klbr-core/src/agent/turn.rs:49–83` + +```text + 49 | loop { + 50 | tokio::select! { + 51 | biased; + 52 | interrupt = self.rx.recv() => { + 53 | match interrupt { + 54 | Some(int) if int.is_preemptive() => { + 55 | tracing::info!(source = int.source_tag(), "preemptive interrupt during stream; aborting"); + 56 | stream_task.abort(); + 57 | cancelled_by = Some(int); + 58 | break; + 59 | } + 60 | Some(int) => { + 61 | // non-preemptive (e.g. ExternalEvent) — stash for after turn + 62 | if stashed_interrupt.is_none() { + 63 | *stashed_interrupt = Some(int); + 64 | } + 65 | } + 66 | None => {} // channel closed = shutdown + 67 | } + 68 | } + 69 | ev = tok_rx.recv() => { + 70 | match ev { + 71 | None => break, + 72 | Some(LlmEvent::ThinkToken(tok)) => { + 73 | thinking.push_str(&tok); + 74 | let _ = self.output.send(AgentEvent::ThinkToken(tok)); + 75 | } + 76 | Some(LlmEvent::Token(tok)) => { + 77 | response.push_str(&tok); + 78 | let _ = self.output.send(AgentEvent::ScratchToken(tok)); + 79 | } + 80 | Some(LlmEvent::Usage(usage)) => { + 81 | ctx.update_tokens(&usage); + 82 | } + 83 | Some(LlmEvent::ToolCalls(calls)) => { +``` + +Source: `klbr-core/src/agent/turn.rs:502–539` + +```text + 502 | // plain assistant text is scratchpad, not delivered speech. + 503 | let has_final_text = !iteration_response.is_empty(); + 504 | if has_final_text || !iteration_thinking.is_empty() { + 505 | ctx.push_assistant( + 506 | &scratchpad_context(&iteration_response), + 507 | (!iteration_thinking.is_empty()).then_some(iteration_thinking.as_str()), + 508 | ); + 509 | } + 510 | + 511 | if has_final_text + 512 | && is_local_message + 513 | && !local_send_called + 514 | && !local_send_nudge_injected + 515 | { + 516 | local_send_nudge_injected = true; + 517 | push_visible_nudge( + 518 | ctx, + 519 | &self.output, + 520 | &plain_text_local_nudge_message(&iteration_response), + 521 | ); + 522 | tracing::warn!("local_send nudge: assistant emitted plain text without local_send"); + 523 | continue; + 524 | } + 525 | + 526 | if has_final_text && is_discord_event && !discord_send_called && !discord_nudge_injected + 527 | { + 528 | discord_nudge_injected = true; + 529 | push_visible_nudge( + 530 | ctx, + 531 | &self.output, + 532 | &plain_text_discord_nudge_message(&iteration_response), + 533 | ); + 534 | tracing::warn!( + 535 | "discord_send nudge: assistant emitted plain text without discord_send" + 536 | ); + 537 | continue; + 538 | } + 539 | +``` + +## K03 — Operator-visible scratchpad diagnostics + +The web client handles reasoning, scratchpad, and visible text separately and renders scratchpad. This is why the plan keeps diagnostic visibility while excluding automatic conversational delivery. + +Source: `klbr-web/src/App.svelte:693–737` + +```text + 693 | case "think_token": { + 694 | const assistant = tokenEntry(session); + 695 | assistant.step = "reasoning"; + 696 | assistant.thinkingExpanded = true; + 697 | const last = assistant.items[assistant.items.length - 1]; + 698 | if (last?.kind === "think") { + 699 | last.content += msg.content; + 700 | } else { + 701 | assistant.items.push({ + 702 | kind: "think", + 703 | content: msg.content, + 704 | }); + 705 | } + 706 | break; + 707 | } + 708 | case "scratch_token": { + 709 | const assistant = tokenEntry(session); + 710 | assistant.step = "scratchpad"; + 711 | const last = assistant.items[assistant.items.length - 1]; + 712 | if (last?.kind === "scratchpad") { + 713 | last.content += msg.content; + 714 | } else { + 715 | assistant.items.push({ + 716 | kind: "scratchpad", + 717 | content: msg.content, + 718 | }); + 719 | } + 720 | break; + 721 | } + 722 | case "token": { + 723 | const assistant = tokenEntry(session); + 724 | assistant.step = "response"; + 725 | const last = assistant.items[assistant.items.length - 1]; + 726 | if (last?.kind === "text") { + 727 | last.content += msg.content; + 728 | } else { + 729 | assistant.items.push({ + 730 | kind: "text", + 731 | content: msg.content, + 732 | }); + 733 | } + 734 | break; + 735 | } + 736 | case "tool_call_started": { + 737 | const assistant = tokenEntry(session); +``` + +Source: `klbr-web/src/App.svelte:2182–2188` + +```text +2182 | {:else if item.kind === "scratchpad" && item.content.trim()} +2183 |
+2184 |
+2185 | scratchpad +2186 |
+2187 |
{stripScratchpadTags(item.content)}
+2188 |
+``` + +## K04 — Discord configuration defaults + +The checked defaults are ingest all, ambient wake enabled, bots excluded, 8-second batching, 600-second pending timeout, and configurable channel filters. These are compatibility choices, not recommended universal values. + +Source: `klbr-discord/src/lib.rs:99–140` + +```text + 99 | impl DiscordConfig { + 100 | pub fn from_env() -> Result> { + 101 | let enabled = env_bool("KLBR_DISCORD_ENABLED"); + 102 | if enabled == Some(false) { + 103 | return Ok(None); + 104 | } + 105 | + 106 | let token = std::env::var("KLBR_DISCORD_TOKEN") + 107 | .or_else(|_| std::env::var("DISCORD_TOKEN")) + 108 | .ok(); + 109 | + 110 | let Some(token) = token else { + 111 | if enabled == Some(true) { + 112 | anyhow::bail!("KLBR_DISCORD_ENABLED is set but KLBR_DISCORD_TOKEN is missing"); + 113 | } + 114 | return Ok(None); + 115 | }; + 116 | + 117 | let event_mode = match std::env::var("KLBR_DISCORD_EVENT_MODE") + 118 | .unwrap_or_else(|_| "all".to_string()) + 119 | .to_ascii_lowercase() + 120 | .as_str() + 121 | { + 122 | "all" => DiscordEventMode::All, + 123 | "mentions" | "mention" | "" => DiscordEventMode::Mentions, + 124 | other => anyhow::bail!( + 125 | "invalid KLBR_DISCORD_EVENT_MODE={other:?}; expected 'mentions' or 'all'" + 126 | ), + 127 | }; + 128 | + 129 | Ok(Some(Self { + 130 | token, + 131 | source: std::env::var("KLBR_DISCORD_SOURCE").unwrap_or_else(|_| "discord".to_string()), + 132 | wake_on_ambient: env_bool("KLBR_DISCORD_WAKE_ON_AMBIENT").unwrap_or(true), + 133 | event_mode, + 134 | include_bot_messages: env_bool("KLBR_DISCORD_INCLUDE_BOTS").unwrap_or(false), + 135 | batch_interval: env_duration_secs("KLBR_DISCORD_BATCH_SECS", 8)?, + 136 | channel_ids: env_channel_ids("KLBR_DISCORD_CHANNEL_IDS")?, + 137 | pending_timeout: env_optional_duration_secs("KLBR_DISCORD_PENDING_TIMEOUT_SECS", 600)?, + 138 | })) + 139 | } + 140 | } +``` + +## K05 — Discord intake, batching, and wake admission + +Current batching begins after receiving the first message. A directed message arriving inside an already-started ambient delay can wait for that delay. The plan preserves batching but separates durable admission from presentation and improves directed-message responsiveness. + +Source: `klbr-discord/src/lib.rs:275–336` + +```text + 275 | fn spawn_batcher( + 276 | self: Arc, + 277 | mut batch_rx: mpsc::Receiver, + 278 | interrupt_tx: mpsc::Sender, + 279 | output_tx: broadcast::Sender, + 280 | ) -> JoinHandle<()> { + 281 | tokio::spawn(async move { + 282 | let mut batch_count = 0usize; + 283 | while let Some(first) = batch_rx.recv().await { + 284 | let needs_wake = first.is_dm || first.mentions_bot; + 285 | let mut batch = vec![first]; + 286 | if !needs_wake { + 287 | tokio::time::sleep(self.config.batch_interval).await; + 288 | } + 289 | + 290 | while let Ok(message) = batch_rx.try_recv() { + 291 | batch.push(message); + 292 | } + 293 | + 294 | let count = batch.len(); + 295 | let include_instructions = batch_count % 6 == 0 || count >= 5; + 296 | batch_count += 1; + 297 | if let Some(timeout) = self.config.pending_timeout { + 298 | match self.inbox.demote_stale_pending(timeout.as_secs() as i64) { + 299 | Ok(n) if n > 0 => tracing::debug!(n, "auto-demoted stale pending items"), + 300 | Err(err) => tracing::warn!(?err, "discord pending auto-demote failed"), + 301 | _ => {} + 302 | } + 303 | } + 304 | self.note_batch_targets(&batch); + 305 | let new_pending_count = match self.note_inbox_batch(&batch) { + 306 | Ok(count) => count, + 307 | Err(err) => { + 308 | tracing::warn!(?err, "discord inbox update failed"); + 309 | 0 + 310 | } + 311 | }; + 312 | let pending = match self.inbox.pending_items(6) { + 313 | Ok(items) => items, + 314 | Err(err) => { + 315 | tracing::warn!(?err, "discord pending preview failed"); + 316 | Vec::new() + 317 | } + 318 | }; + 319 | let event = self.batch_event(&batch, &pending, include_instructions); + 320 | let should_wake = needs_wake + 321 | || should_wake_discord_batch(self.config.wake_on_ambient, new_pending_count); + 322 | if should_wake { + 323 | let interrupt = self.batch_interrupt(&batch, &pending, include_instructions); + 324 | if let Err(err) = interrupt_tx.send(interrupt).await { + 325 | tracing::warn!(?err, "discord batch send failed"); + 326 | break; + 327 | } + 328 | } + 329 | let _ = output_tx.send(event); + 330 | tracing::debug!( + 331 | message_count = count, + 332 | new_pending_count, + 333 | needs_wake, + 334 | should_wake, + 335 | "processed discord message batch" + 336 | ); +``` + +Source: `klbr-discord/src/lib.rs:456–492` + +```text + 456 | async fn incoming_message( + 457 | &self, + 458 | message: &twilight_model::gateway::payload::incoming::MessageCreate, + 459 | ) -> Option { + 460 | if message.author.id == self.bot_user_id { + 461 | return None; + 462 | } + 463 | if message.author.bot && !self.config.include_bot_messages { + 464 | return None; + 465 | } + 466 | + 467 | let guild_id = message.guild_id.map(|id| id.to_string()); + 468 | let channel_id = message.channel_id.to_string(); + 469 | let channel_filter_enabled = !self.config.channel_ids.is_empty(); + 470 | let channel_allowed = + 471 | channel_filter_enabled && self.config.channel_ids.contains(&channel_id); + 472 | if channel_filter_enabled && !channel_allowed { + 473 | return None; + 474 | } + 475 | self.note_seen_message(&channel_id, &message.id.to_string()); + 476 | + 477 | let mentions_bot = message + 478 | .mentions + 479 | .iter() + 480 | .any(|mention| mention.id == self.bot_user_id); + 481 | let is_dm = message.guild_id.is_none(); + 482 | if !should_ingest_discord_message( + 483 | self.config.event_mode, + 484 | channel_filter_enabled, + 485 | channel_allowed, + 486 | is_dm, + 487 | mentions_bot, + 488 | ) { + 489 | return None; + 490 | } + 491 | + 492 | let conversation_id = self.conversation_id_for_channel(&channel_id); +``` + +## K06 — Send, reply references, and acted marking + +The send path resolves a destination and source item and marks the associated item acted after a successful response. Reply-anchor selection currently mutates state before the send is known to have succeeded. The replacement ties consumption and disposition to confirmed effects. + +Source: `klbr-discord/src/lib.rs:589–640` + +```text + 589 | async fn try_discord_send(&self, args: Value) -> Result { + 590 | let channel_id: Id = self.resolve_send_channel(&args)?; + 591 | let conversation_id = self.conversation_id_for_channel(&channel_id.to_string()); + 592 | let content = string_arg(&args, "content")?; + 593 | validate_message_content(content)?; + 594 | let source_item_id = self.resolve_source_item_id(&args, &channel_id.to_string())?; + 595 | let allowed_mentions = AllowedMentions::default(); + 596 | let reference_message_id = self + 597 | .choose_reply_reference(&channel_id.to_string()) + 598 | .map(|id| id.parse::()) + 599 | .transpose() + 600 | .context("stored Discord reply target was not a snowflake")? + 601 | .map(Id::::new); + 602 | + 603 | let mut request = self + 604 | .http + 605 | .create_message(channel_id) + 606 | .content(content) + 607 | .allowed_mentions(Some(&allowed_mentions)); + 608 | if let Some(message_id) = reference_message_id { + 609 | request = request.reply(message_id); + 610 | } + 611 | + 612 | let message = request + 613 | .await + 614 | .context("discord create message failed")? + 615 | .model() + 616 | .await + 617 | .context("failed to decode created Discord message")?; + 618 | + 619 | let mut result = format!( + 620 | "sent conv:{} msg:{} reply_to:{} time:{}", + 621 | conversation_id, + 622 | message.id, + 623 | reference_message_id + 624 | .map(|id| id.to_string()) + 625 | .unwrap_or_else(|| "none".to_string()), + 626 | format_discord_timestamp(message.timestamp) + 627 | ); + 628 | if let Some(item_id) = source_item_id { + 629 | self.inbox.mark( + 630 | &item_id, + 631 | "acted", + 632 | if reference_message_id.is_some() { + 633 | "replied" + 634 | } else { + 635 | "messaged" + 636 | }, + 637 | Some("responded via discord_send"), + 638 | )?; + 639 | result.push_str(&format!(" item:{item_id} acted")); + 640 | } +``` + +Source: `klbr-discord/src/lib.rs:796–812` + +```text + 796 | fn resolve_source_item_id(&self, args: &Value, channel_id: &str) -> Result> { + 797 | if let Some(item_id) = optional_string_arg(args, "source_item_id")? { + 798 | return Ok(Some(normalize_item_id(item_id).to_string())); + 799 | } + 800 | + 801 | let reply_target = { + 802 | let state = self.state.lock().expect("discord runtime state poisoned"); + 803 | state.reply_target_by_channel.get(channel_id).cloned() + 804 | }; + 805 | if let Some(message_id) = reply_target { + 806 | if let Some(item_id) = self.inbox.pending_item_for_message(&message_id)? { + 807 | return Ok(Some(item_id)); + 808 | } + 809 | } + 810 | + 811 | // fallback: most recent pending DM item for this channel + 812 | self.inbox.pending_dm_item_for_channel(channel_id) +``` + +## K07 — Attention statuses, aging, reactions, and explicit decisions + +Attention state is distinct from the event ingestion queue. DM/mention items become pending; aging produces seen/noted. The plan retains these visible semantics but records automatic aging as such rather than claiming a model read. + +Source: `klbr-discord/src/inbox.rs:51–88` + +```text + 51 | CREATE INDEX IF NOT EXISTS idx_discord_inbox_channel + 52 | ON discord_inbox_items(channel_id, status, last_seen_ts DESC);", + 53 | )?; + 54 | Ok(()) + 55 | } + 56 | + 57 | pub(super) fn upsert_pending( + 58 | &self, + 59 | message: &DiscordIncomingMessage, + 60 | ) -> Result> { + 61 | let Some(bucket) = pending_bucket(message) else { + 62 | return Ok(None); + 63 | }; + 64 | + 65 | let conn = self.conn.lock().unwrap(); + 66 | conn.execute( + 67 | "INSERT INTO discord_inbox_items ( + 68 | item_id, conversation_id, channel_id, message_id, author_name, + 69 | content, bucket, status, action, message_ts + 70 | ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', 'none', ?8) + 71 | ON CONFLICT(item_id) DO UPDATE SET + 72 | conversation_id = excluded.conversation_id, + 73 | channel_id = excluded.channel_id, + 74 | author_name = excluded.author_name, + 75 | content = excluded.content, + 76 | bucket = excluded.bucket, + 77 | last_seen_ts = unixepoch()", + 78 | params![ + 79 | message.message_id, + 80 | message.conversation_id, + 81 | message.channel_id, + 82 | message.message_id, + 83 | message.author_name, + 84 | message.content, + 85 | bucket, + 86 | message.timestamp, + 87 | ], + 88 | )?; +``` + +Source: `klbr-discord/src/inbox.rs:110–126` + +```text + 110 | SET status = 'seen', + 111 | action = CASE WHEN action = 'none' THEN 'noted' ELSE action END, + 112 | decision_ts = unixepoch(), + 113 | last_seen_ts = unixepoch() + 114 | WHERE status = 'pending' + 115 | AND message_ts <= unixepoch() - ?1", + 116 | params![older_than_secs], + 117 | )?; + 118 | Ok(changed) + 119 | } + 120 | + 121 | pub(super) fn pending_item_for_message(&self, message_id: &str) -> Result> { + 122 | self.conn + 123 | .lock() + 124 | .unwrap() + 125 | .query_row( + 126 | "SELECT item_id +``` + +Source: `klbr-discord/src/lib.rs:709–746` + +```text + 709 | async fn try_discord_react(&self, args: Value) -> Result { + 710 | let channel_id: Id = self.resolve_channel(&args, true)?; + 711 | let message_id: Id = normalized_id_arg(&args, "message_id")?; + 712 | let emoji = string_arg(&args, "emoji")?; + 713 | let reaction = parse_reaction(emoji)?; + 714 | + 715 | self.http + 716 | .create_reaction(channel_id, message_id, &reaction) + 717 | .await + 718 | .context("discord create reaction failed")?; + 719 | let conversation_id = self + 720 | .existing_conversation_id_for_channel(&channel_id.to_string()) + 721 | .unwrap_or_else(|| channel_id.to_string()); + 722 | + 723 | Ok(format!("reacted conv:{conversation_id} msg:{message_id}")) + 724 | } + 725 | + 726 | async fn discord_mark(&self, args: Value) -> String { + 727 | match self.try_discord_mark(args).await { + 728 | Ok(result) => result, + 729 | Err(err) => format!("error: {err}"), + 730 | } + 731 | } + 732 | + 733 | async fn try_discord_mark(&self, args: Value) -> Result { + 734 | let item_id = string_arg(&args, "item_id")?; + 735 | let status = args["status"].as_str().unwrap_or("seen"); + 736 | let action = args["action"].as_str().unwrap_or_else(|| match status { + 737 | "acted" => "replied", + 738 | "ignored" => "dismissed", + 739 | _ => "none", + 740 | }); + 741 | let note = optional_string_arg(&args, "note")?; + 742 | self.inbox.mark(item_id, status, action, note)?; + 743 | Ok(format!( + 744 | "marked item:{item_id} status:{status} action:{action}" + 745 | )) + 746 | } +``` + +Source: `klbr-discord/src/lib.rs:908–938` + +```text + 908 | fn pending_bucket(message: &DiscordIncomingMessage) -> Option<&'static str> { + 909 | if message.author_is_bot { + 910 | return None; + 911 | } + 912 | if message.is_dm { + 913 | return Some("dm"); + 914 | } + 915 | if message.mentions_bot { + 916 | return Some("mention"); + 917 | } + 918 | None + 919 | } + 920 | + 921 | fn should_ingest_discord_message( + 922 | event_mode: DiscordEventMode, + 923 | channel_filter_enabled: bool, + 924 | channel_allowed: bool, + 925 | is_dm: bool, + 926 | mentions_bot: bool, + 927 | ) -> bool { + 928 | if is_dm || mentions_bot { + 929 | return true; + 930 | } + 931 | if channel_filter_enabled && channel_allowed { + 932 | return true; + 933 | } + 934 | event_mode == DiscordEventMode::All + 935 | } + 936 | + 937 | fn should_wake_discord_batch(wake_on_ambient: bool, new_pending_count: usize) -> bool { + 938 | wake_on_ambient || new_pending_count > 0 +``` + +## K08 — Short conversation aliases, implicit destination, and reply threading + +The current adapter uses in-memory short labels and active-channel state. The plan preserves convenient aliases and smart reply behavior while making identities durable and default destinations immutable per attention frame. + +Source: `klbr-discord/src/lib.rs:862–906` + +```text + 862 | fn choose_reply_reference_from_state(state: &mut DiscordState, channel_id: &str) -> Option { + 863 | let target = state.reply_target_by_channel.remove(channel_id)?; + 864 | let latest = state.latest_message_by_channel.get(channel_id); + 865 | if latest.is_some_and(|latest| latest != &target) { + 866 | Some(target) + 867 | } else { + 868 | None + 869 | } + 870 | } + 871 | + 872 | fn conversation_id_for_channel_from_state(state: &mut DiscordState, channel_id: &str) -> String { + 873 | if let Some(id) = state.conversation_by_channel.get(channel_id) { + 874 | return id.clone(); + 875 | } + 876 | + 877 | state.next_conversation_id += 1; + 878 | let id = format!("d{}", state.next_conversation_id); + 879 | state + 880 | .conversation_by_channel + 881 | .insert(channel_id.to_string(), id.clone()); + 882 | state + 883 | .channel_by_conversation + 884 | .insert(id.clone(), channel_id.to_string()); + 885 | id + 886 | } + 887 | + 888 | fn note_batch_targets_in_state<'a>( + 889 | state: &mut DiscordState, + 890 | latest_by_channel: HashMap<&'a str, &'a str>, + 891 | ) { + 892 | state.active_channel_id = if latest_by_channel.len() == 1 { + 893 | latest_by_channel + 894 | .keys() + 895 | .next() + 896 | .map(|channel_id| (*channel_id).to_string()) + 897 | } else { + 898 | None + 899 | }; + 900 | + 901 | for (channel_id, message_id) in latest_by_channel { + 902 | state + 903 | .reply_target_by_channel + 904 | .insert(channel_id.to_string(), message_id.to_string()); + 905 | } + 906 | } +``` + +Source: `klbr-discord/src/lib.rs:948–990` + +```text + 948 | fn resolve_channel_arg_from_state( + 949 | state: &DiscordState, + 950 | args: &Value, + 951 | allow_active: bool, + 952 | ) -> Result { + 953 | if let Some(conversation_id) = optional_string_arg(args, "conversation_id")? { + 954 | return resolve_conversation_channel_from_state(state, conversation_id); + 955 | } + 956 | + 957 | if let Some(channel_id) = optional_string_arg(args, "channel_id")? { + 958 | if parse_discord_id::(channel_id, "Discord channel").is_ok() { + 959 | return Ok(channel_id.to_string()); + 960 | } + 961 | return resolve_conversation_channel_from_state(state, channel_id).with_context(|| { + 962 | format!( + 963 | "channel_id {channel_id:?} is neither a Discord snowflake nor a known conversation id" + 964 | ) + 965 | }); + 966 | } + 967 | + 968 | if allow_active { + 969 | if let Some(channel_id) = &state.active_channel_id { + 970 | return Ok(channel_id.clone()); + 971 | } + 972 | } + 973 | + 974 | anyhow::bail!( + 975 | "conversation_id or channel_id is required when the current Discord batch spans multiple conversations" + 976 | ) + 977 | } + 978 | + 979 | fn resolve_conversation_channel_from_state( + 980 | state: &DiscordState, + 981 | raw_conversation_id: &str, + 982 | ) -> Result { + 983 | let conversation_id = normalize_conversation_id(raw_conversation_id); + 984 | let Some(channel_id) = state.channel_by_conversation.get(conversation_id) else { + 985 | anyhow::bail!("unknown Discord conversation_id {conversation_id:?}"); + 986 | }; + 987 | Ok(channel_id.clone()) + 988 | } + 989 | + 990 | fn normalize_conversation_id(raw: &str) -> &str { +``` + +## K09 — Discord tool surface and stale description + +The old send description says ordinary assistant text goes to local chat; K01–K03 demonstrate why this wording must not survive the migration. Existing operations include send, backread, reaction, conversation listing, and pending-item marking. + +Source: `klbr-discord/src/tool_defs.rs:1–124` + +```text + 1 | use klbr_core::models::ToolDef; + 2 | use serde_json::json; + 3 | + 4 | pub(super) fn discord_send_def() -> ToolDef { + 5 | ToolDef::function( + 6 | "discord_send", + 7 | "send a Discord message. use this when a Discord response is useful; many ambient batches need no reply. plain assistant text only goes to the local chat. conversation_id is optional when the current Discord batch has a single conversation. pass source_item_id when responding to a pending item; successful sends mark that item acted, so do not call discord_mark afterward.", + 8 | json!({ + 9 | "type": "object", + 10 | "properties": { + 11 | "conversation_id": { + 12 | "type": "string", + 13 | "description": "Discord conversation id from the batch, like d1 or conv:d1; omit only for a single-conversation batch" + 14 | }, + 15 | "content": { + 16 | "type": "string", + 17 | "description": "message content to send, max 2000 characters" + 18 | }, + 19 | "source_item_id": { + 20 | "type": "string", + 21 | "description": "optional pending inbox item id shown as msg:; marks it acted after sending" + 22 | } + 23 | }, + 24 | "required": ["content"] + 25 | }), + 26 | ) + 27 | } + 28 | + 29 | pub(super) fn discord_fetch_history_def() -> ToolDef { + 30 | ToolDef::function( + 31 | "discord_fetch_history", + 32 | "fetch recent Discord message history for a conversation. use this when the current batch lacks enough context. conversation_id is optional when the current batch has a single conversation.", + 33 | json!({ + 34 | "type": "object", + 35 | "properties": { + 36 | "conversation_id": { + 37 | "type": "string", + 38 | "description": "Discord conversation id from the batch, like d1 or conv:d1; omit only for a single-conversation batch" + 39 | }, + 40 | "before_message_id": { + 41 | "type": "string", + 42 | "description": "optional msg: from the batch; fetch messages before it" + 43 | }, + 44 | "after_message_id": { + 45 | "type": "string", + 46 | "description": "optional msg: from the batch; fetch messages after it" + 47 | }, + 48 | "limit": { + 49 | "type": "integer", + 50 | "description": "number of messages to fetch, 1-100; default 20" + 51 | } + 52 | }, + 53 | "required": [] + 54 | }), + 55 | ) + 56 | } + 57 | + 58 | pub(super) fn discord_react_def() -> ToolDef { + 59 | ToolDef::function( + 60 | "discord_react", + 61 | "add a reaction to a Discord message. prefer real unicode emoji like 😍, 👍, or ❤️. only use a custom emoji string like <:name:id> when the exact string came from Discord; never invent custom emoji ids. conversation_id is optional when the current Discord batch has a single conversation.", + 62 | json!({ + 63 | "type": "object", + 64 | "properties": { + 65 | "conversation_id": { + 66 | "type": "string", + 67 | "description": "Discord conversation id from the batch, like d1 or conv:d1; omit only for a single-conversation batch" + 68 | }, + 69 | "message_id": { + 70 | "type": "string", + 71 | "description": "msg: of the message to react to, as shown in the batch" + 72 | }, + 73 | "emoji": { + 74 | "type": "string", + 75 | "description": "unicode emoji is preferred; custom emoji must be an exact Discord form <:name:id> / , copied from Discord rather than guessed" + 76 | } + 77 | }, + 78 | "required": ["message_id", "emoji"] + 79 | }), + 80 | ) + 81 | } + 82 | + 83 | pub(super) fn discord_list_conversations_def() -> ToolDef { + 84 | ToolDef::function( + 85 | "discord_list_conversations", + 86 | "list known Discord conversations with their ids, type (dm/guild), and last activity. use this to find a conversation_id when it is not in the current batch.", + 87 | json!({ + 88 | "type": "object", + 89 | "properties": {}, + 90 | "required": [] + 91 | }), + 92 | ) + 93 | } + 94 | + 95 | pub(super) fn discord_mark_def() -> ToolDef { + 96 | ToolDef::function( + 97 | "discord_mark", + 98 | "mark a pending Discord inbox item as seen, acted, or ignored so future batches remember the decision. use this when you are not sending a Discord response, especially ignored when you deliberately choose not to respond. successful discord_send already marks the item acted.", + 99 | json!({ + 100 | "type": "object", + 101 | "properties": { + 102 | "item_id": { + 103 | "type": "string", + 104 | "description": "pending Discord inbox message id, either raw snowflake or the displayed msg:" + 105 | }, + 106 | "status": { + 107 | "type": "string", + 108 | "enum": ["pending", "seen", "acted", "ignored"], + 109 | "description": "new item status" + 110 | }, + 111 | "action": { + 112 | "type": "string", + 113 | "enum": ["none", "replied", "messaged", "dismissed", "noted"], + 114 | "description": "optional decision/action label" + 115 | }, + 116 | "note": { + 117 | "type": "string", + 118 | "description": "optional short decision note" + 119 | } + 120 | }, + 121 | "required": ["item_id", "status"] + 122 | }), + 123 | ) + 124 | } +``` + +## K10 — Operator preemption versus queued external events + +The existing interrupt contract distinguishes operator/management preemption from external events. Preserve that priority, not the single-slot event-loss implementation. + +Source: `klbr-core/src/interrupt.rs:1–22` + +```text + 1 | use crate::harness_block::{self, DEFAULT_FORMAT_STYLE}; + 2 | use tokio::sync::mpsc; + 3 | + 4 | #[derive(Debug, Clone)] + 5 | pub enum Interrupt { + 6 | Message { source: String, content: String }, + 7 | ExternalEvent(ExternalEvent), + 8 | Reset, + 9 | Compact, + 10 | DebugReflectRequest, + 11 | UpdateSoul { content: String }, + 12 | } + 13 | + 14 | #[derive(Debug, Clone)] + 15 | pub struct ExternalEvent { + 16 | pub source: String, + 17 | pub conversation_id: String, + 18 | pub content: String, + 19 | pub author_id: Option, + 20 | pub author_name: Option, + 21 | pub message_id: Option, + 22 | pub timestamp: Option, +``` + +Source: `klbr-core/src/interrupt.rs:86–97` + +```text + 86 | /// Returns true if this interrupt should preempt an in-progress LLM stream or tool execution. + 87 | /// Operator messages (from TUI, web, etc.) and management commands are preemptive. + 88 | /// Automated external events are not — they queue behind the current turn. + 89 | pub fn is_preemptive(&self) -> bool { + 90 | matches!( + 91 | self, + 92 | Interrupt::Message { .. } + 93 | | Interrupt::Reset + 94 | | Interrupt::Compact + 95 | | Interrupt::UpdateSoul { .. } + 96 | ) + 97 | } +``` + +## K11 — Wait-and-continue semantics + +The old wait is a special runtime action with bounded duration and input interruption. The new Python spelling is a terminal yield backed by a durable wakeup, not a durable suspended Python stack. Rejecting invalid or self-spinning delays is a deliberate change. + +Source: `klbr-core/src/tools/wait_and_continue.rs:12–39` + +```text + 12 | + 13 | const MAX_WAIT_MS: u64 = 600_000; + 14 | + 15 | pub fn tool() -> Tool { + 16 | Tool::new(definition(), exec) + 17 | } + 18 | + 19 | fn definition() -> ToolDef { + 20 | ToolDef::function( + 21 | "wait_and_continue", + 22 | "yield control and wait for an incoming event or a bounded timeout. if an event arrives, the harness injects it before the next assistant turn. if no event arrives before the timeout, the turn suspends silently. requires timeout_ms (max 600000) or seconds.", + 23 | json!({ + 24 | "type": "object", + 25 | "properties": { + 26 | "timeout_ms": { + 27 | "type": "integer", + 28 | "description": "milliseconds to wait before continuing, max 600000" + 29 | }, + 30 | "seconds": { + 31 | "type": "number", + 32 | "description": "optional seconds form; ignored if timeout_ms is provided" + 33 | }, + 34 | "reason": { + 35 | "type": "string", + 36 | "description": "optional short reason for waiting" + 37 | } + 38 | } + 39 | }), +``` + +Source: `klbr-core/src/tools/wait_and_continue.rs:61–152` + +```text + 61 | format!("waited {waited_ms}ms; continue") + 62 | } else { + 63 | format!("waited {waited_ms}ms ({reason}); continue") + 64 | } + 65 | } + 66 | + 67 | pub fn timeout_duration(args: &serde_json::Value) -> Result { + 68 | if let Some(timeout_ms) = args["timeout_ms"].as_u64() { + 69 | return Ok(Duration::from_millis(timeout_ms.min(MAX_WAIT_MS))); + 70 | } + 71 | + 72 | if !args["seconds"].is_null() { + 73 | let seconds = args["seconds"] + 74 | .as_f64() + 75 | .ok_or_else(|| "seconds must be a number".to_string())?; + 76 | if !seconds.is_finite() { + 77 | return Err("seconds must be finite".to_string()); + 78 | } + 79 | let millis = (seconds.max(0.0) * 1000.0).round() as u64; + 80 | return Ok(Duration::from_millis(millis.min(MAX_WAIT_MS))); + 81 | } + 82 | + 83 | Err("duration is required (either 'timeout_ms' or 'seconds' must be specified)".to_string()) + 84 | } + 85 | + 86 | pub enum RuntimeWaitResult { + 87 | Timeout(String), + 88 | Incoming { + 89 | result: String, + 90 | interrupt: Interrupt, + 91 | }, + 92 | Reset, + 93 | Compact, + 94 | UpdateSoul(String), + 95 | } + 96 | + 97 | pub async fn execute_runtime( + 98 | arguments: &str, + 99 | rx: &mut mpsc::Receiver, + 100 | ) -> RuntimeWaitResult { + 101 | let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); + 102 | let timeout = match timeout_duration(&args) { + 103 | Ok(timeout) => timeout, + 104 | Err(err) => return RuntimeWaitResult::Timeout(format!("error: {err}")), + 105 | }; + 106 | + 107 | let start_time = std::time::Instant::now(); + 108 | let mut sleep = Box::pin(tokio::time::sleep(timeout)); + 109 | tokio::select! { + 110 | _ = &mut sleep => { + 111 | let elapsed = start_time.elapsed(); + 112 | let elapsed_str = format_duration_human(elapsed); + 113 | let current_time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(); + 114 | RuntimeWaitResult::Timeout(format!( + 115 | "waited {elapsed_str}; no incoming event arrived; current time: {current_time}" + 116 | )) + 117 | } + 118 | interrupt = rx.recv() => { + 119 | let elapsed = start_time.elapsed(); + 120 | let elapsed_str = format_duration_human(elapsed); + 121 | let current_time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(); + 122 | match interrupt { + 123 | Some(Interrupt::Reset) => RuntimeWaitResult::Reset, + 124 | Some(Interrupt::Compact) => RuntimeWaitResult::Compact, + 125 | Some(Interrupt::UpdateSoul { content }) => RuntimeWaitResult::UpdateSoul(content), + 126 | Some(Interrupt::DebugReflectRequest) => RuntimeWaitResult::Timeout( + 127 | format!("debug reflection request received while waiting; current time: {current_time}; elapsed since last turn: {elapsed_str}; try again after this turn") + 128 | ), + 129 | Some(interrupt) => { + 130 | let source = interrupt.source_tag().to_string(); + 131 | RuntimeWaitResult::Incoming { + 132 | result: format!( + 133 | "interrupted early by incoming {source} event after {elapsed_str}; current time: {current_time}" + 134 | ), + 135 | interrupt, + 136 | } + 137 | } + 138 | None => RuntimeWaitResult::Timeout(format!("interrupt channel closed; current time: {current_time}; elapsed since last turn: {elapsed_str}; continue")), + 139 | } + 140 | } + 141 | } + 142 | } + 143 | + 144 | pub fn wait_timeout_without_event(result: &str) -> bool { + 145 | result.contains("no incoming event arrived") + 146 | } + 147 | + 148 | pub fn format_duration_human(d: Duration) -> String { + 149 | let secs = d.as_secs(); + 150 | if secs == 0 { + 151 | return format!("{}ms", d.as_millis()); + 152 | } +``` + +## K12 — Continuous autonomous loop and tool-name lifecycle + +The loop can act without a new user request. Its special handling of tool names must be replaced coherently rather than leaving a second orchestration model under the Python executor. + +Source: `klbr-core/src/agent.rs:278–342` + +```text + 278 | + 279 | handle_stream_failure_backoff(&ctx, &mut failure_count).await; + 280 | + 281 | while let Some(curr_int) = next_interrupt { + 282 | if !matches!( + 283 | curr_int, + 284 | Interrupt::Message { .. } | Interrupt::ExternalEvent(_) + 285 | ) { + 286 | self.handle_system_interrupt( + 287 | curr_int, + 288 | &mut ctx, + 289 | &mut turn_count, + 290 | &mut runtime_soul, + 291 | &tool_ctx, + 292 | &llm, + 293 | ) + 294 | .await?; + 295 | break; + 296 | } + 297 | last_event_at = std::time::Instant::now(); + 298 | skip_idle_nudge = true; + 299 | next_interrupt = self + 300 | .process_interrupt_and_run_turn( + 301 | curr_int, + 302 | &llm, + 303 | &tool_ctx, + 304 | &mut ctx, + 305 | &mut turn_count, + 306 | &mut runtime_soul, + 307 | &router, + 308 | &mut consecutive_waits, + 309 | ) + 310 | .await?; + 311 | + 312 | if handle_stream_failure_backoff(&ctx, &mut failure_count).await { + 313 | break; + 314 | } + 315 | } + 316 | } else { + 317 | // Nothing pending. Only inject the nudge if: + 318 | // - we didn't just process a real event (model gets one free turn to + 319 | // call wait_and_continue on its own before we prompt it), AND + 320 | // - the model didn't already end on a wait timeout (it knows what to do). + 321 | let should_nudge = !skip_idle_nudge && !is_completed_wait_tool_result(&ctx); + 322 | skip_idle_nudge = false; + 323 | if should_nudge { + 324 | let elapsed = last_event_at.elapsed(); + 325 | push_visible_nudge(&mut ctx, &self.output, &idle_nudge_message(elapsed)); + 326 | } + 327 | let mut next_interrupt = self + 328 | .run_turn( + 329 | &llm, + 330 | &tool_ctx, + 331 | &mut ctx, + 332 | &mut turn_count, + 333 | &mut runtime_soul, + 334 | None, + 335 | false, + 336 | false, + 337 | &mut consecutive_waits, + 338 | ) + 339 | .await?; + 340 | + 341 | handle_stream_failure_backoff(&ctx, &mut failure_count).await; + 342 | +``` + +Source: `klbr-core/src/agent/turn.rs:160–222` + +```text + 160 | if name == "restart_harness" { + 161 | should_restart = true; + 162 | } + 163 | if name != "wait_and_continue" { + 164 | *consecutive_waits = 0; + 165 | } + 166 | if name == "discord_send" { + 167 | *discord_send_called = true; + 168 | } + 169 | if name == "local_send" { + 170 | *local_send_called = true; + 171 | } + 172 | let args = call.function.arguments.clone(); + 173 | if name != "local_send" { + 174 | let _ = self.output.send(AgentEvent::ToolCall { + 175 | name: name.clone(), + 176 | args: args.clone(), + 177 | }); + 178 | } + 179 | + 180 | let wait_result = if name == "wait_and_continue" { + 181 | Some(tools::execute_wait_and_continue(&args, &mut self.rx).await) + 182 | } else { + 183 | None + 184 | }; + 185 | let mut incoming_after_tool = None; + 186 | let mut suspend_after_wait = false; + 187 | let mut sent_local_content = None; + 188 | let result = if name == "local_send" { + 189 | let delivery = tools::local_send_runtime_delivery(&args); + 190 | if let Some(err) = &delivery.error { + 191 | tracing::warn!(err = %err, "local_send call had invalid arguments"); + 192 | } + 193 | sent_local_content = delivery.sent_content; + 194 | delivery.result + 195 | } else { + 196 | match wait_result { + 197 | Some(WaitAndContinueResult::Timeout(mut result)) => { + 198 | suspend_after_wait = tools::wait_timeout_without_event(&result); + 199 | if suspend_after_wait { + 200 | *consecutive_waits += 1; + 201 | if *consecutive_waits >= 5 && *consecutive_waits % 5 == 0 { + 202 | let nudge = format!( + 203 | "\n\nnudge: you have called wait_and_continue {} consecutive times without taking other actions. to conserve local resources, please increase the wait duration in your next wait_and_continue call (e.g. wait_and_continue(duration=\"10m\")).", + 204 | *consecutive_waits + 205 | ); + 206 | result.push_str(&nudge); + 207 | } + 208 | } else { + 209 | *consecutive_waits = 0; + 210 | } + 211 | result + 212 | } + 213 | Some(WaitAndContinueResult::Incoming { result, interrupt }) => { + 214 | incoming_after_tool = Some(interrupt); + 215 | result + 216 | } + 217 | Some(WaitAndContinueResult::Reset) => { + 218 | ctx.clear(); + 219 | *turn_count = 0; + 220 | *runtime_soul = self.sys_prompt()?; + 221 | ctx.update_soul(runtime_soul, &[]); + 222 | save_context_snapshot(&self.memory, ctx); +``` + +## K13 — Soul updates and host restart + +The checked code combines stored soul with compiled instructions, supports UpdateSoul, and has a restart tool. The redesign keeps editable identity and explicit upgrades while splitting prompt updates, kernel replacement, and host deployment into distinct operations. + +Source: `klbr-core/src/agent.rs:60–70` + +```text + 60 | pub fn sys_prompt(&self) -> Result { + 61 | let soul = self + 62 | .memory + 63 | .soul_text()? + 64 | .unwrap_or_else(|| crate::config::DEFAULT_SOUL.to_string()); + 65 | let instructions = include_str!("instructions.md").replace("{{name}}", &self.config.name); + 66 | Ok(format!("{soul}\n\n{instructions}")) + 67 | } + 68 | + 69 | pub async fn run(mut self) -> Result<()> { + 70 | let mut runtime_soul = self.sys_prompt()?; +``` + +Source: `klbr-core/src/agent.rs:395–413` + +```text + 395 | Interrupt::UpdateSoul { content } => { + 396 | self.memory.set_soul_text(&content)?; + 397 | *runtime_soul = self.sys_prompt()?; + 398 | let pinned = self.memory.pinned_memories().unwrap_or_default(); + 399 | ctx.update_soul(runtime_soul, &pinned); + 400 | save_context_snapshot(&self.memory, ctx); + 401 | let _ = self.output.send(AgentEvent::Status("soul updated".into())); + 402 | } + 403 | Interrupt::Reset => { + 404 | ctx.clear(); + 405 | *turn_count = 0; + 406 | *runtime_soul = self.sys_prompt()?; + 407 | ctx.update_soul(runtime_soul, &[]); + 408 | save_context_snapshot(&self.memory, ctx); + 409 | let _ = self.output.send(AgentEvent::Reset); + 410 | let _ = self.output.send(AgentEvent::Status("context reset".into())); + 411 | } + 412 | Interrupt::Compact => { + 413 | let _ = self.output.send(AgentEvent::Status("compacting...".into())); +``` + +Source: `klbr-core/src/tools/restart_harness.rs:12–58` + +```text + 12 | } + 13 | + 14 | fn definition() -> ToolDef { + 15 | ToolDef::function( + 16 | "restart_harness", + 17 | "restart the klbr agent daemon/harness. use this after making modifications to the harness codebase (which you have compiled successfully) to reload the harness with the new binary. this replaces the current process image and preserves state via database snapshotted context.", + 18 | json!({ + 19 | "type": "object", + 20 | "properties": {} + 21 | }), + 22 | ) + 23 | } + 24 | + 25 | fn exec( + 26 | _args: serde_json::Value, + 27 | _ctx: ToolContext, + 28 | ) -> Pin + Send>> { + 29 | Box::pin(async { "harness restarting... websocket will reconnect.".to_string() }) + 30 | } + 31 | + 32 | #[cfg(unix)] + 33 | pub fn perform() -> ! { + 34 | let exe = match std::env::current_exe() { + 35 | Ok(path) => path, + 36 | Err(e) => { + 37 | tracing::error!("failed to get current executable path: {e}"); + 38 | std::process::exit(1); + 39 | } + 40 | }; + 41 | let args: Vec = std::env::args().skip(1).collect(); + 42 | + 43 | let now = std::time::SystemTime::now() + 44 | .duration_since(std::time::UNIX_EPOCH) + 45 | .map(|d| d.as_secs()) + 46 | .unwrap_or_default(); + 47 | + 48 | tracing::info!("restarting harness: {:?} with args {:?}", exe, args); + 49 | + 50 | use std::os::unix::process::CommandExt; + 51 | let mut cmd = std::process::Command::new(exe); + 52 | cmd.args(&args); + 53 | cmd.env("KLBR_RESTART_TIMESTAMP", now.to_string()); + 54 | + 55 | let err = cmd.exec(); + 56 | tracing::error!("exec failed: {err}"); + 57 | std::process::exit(1); + 58 | } +``` + +## K14 — Code-intelligence vocabulary and mutation limitations + +Preserve source ranges, symbols and diagnostics in Python. Definition/reference matching is best-effort. The selected edit path writes the whole updated file and replaces the tag range; it is not a version-checked compiler-backed body edit. + +Source: `klbr-core/src/code_intel.rs:43–77` + +```text + 43 | pub struct SourceRange { + 44 | pub start_byte: usize, + 45 | pub end_byte: usize, + 46 | pub start_line: usize, + 47 | pub start_column: usize, + 48 | pub end_line: usize, + 49 | pub end_column: usize, + 50 | } + 51 | + 52 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + 53 | #[serde(rename_all = "snake_case")] + 54 | pub enum SymbolRole { + 55 | Definition, + 56 | Reference, + 57 | } + 58 | + 59 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + 60 | pub struct CodeSymbol { + 61 | pub name: String, + 62 | pub name_path: Vec, + 63 | pub kind: String, + 64 | pub role: SymbolRole, + 65 | pub range: SourceRange, + 66 | pub name_range: SourceRange, + 67 | pub docs: Option, + 68 | } + 69 | + 70 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + 71 | pub struct SymbolExtraction { + 72 | pub language: ResolvedLanguage, + 73 | pub queries: QuerySupport, + 74 | pub status: ParseStatus, + 75 | pub symbols: Vec, + 76 | } + 77 | +``` + +Source: `klbr-core/src/code_intel.rs:113–130` + +```text + 113 | pub fn definitions_named<'a>(symbols: &'a [CodeSymbol], name: &str) -> Vec<&'a CodeSymbol> { + 114 | let name = name.trim(); + 115 | symbols + 116 | .iter() + 117 | .filter(|symbol| { + 118 | symbol.role == SymbolRole::Definition + 119 | && (symbol.name == name || symbol_path(symbol) == name) + 120 | }) + 121 | .collect() + 122 | } + 123 | + 124 | pub fn references_named<'a>(symbols: &'a [CodeSymbol], name: &str) -> Vec<&'a CodeSymbol> { + 125 | let name = name.trim(); + 126 | symbols + 127 | .iter() + 128 | .filter(|symbol| symbol.role == SymbolRole::Reference && symbol.name == name) + 129 | .collect() + 130 | } +``` + +Source: `klbr-core/src/tools/symbol_edit.rs:29–54` + +```text + 29 | fn replace_definition() -> ToolDef { + 30 | ToolDef::function( + 31 | "replace_symbol_body", + 32 | "replace the exact source range for one file-local tree-sitter symbol", + 33 | json!({ + 34 | "type": "object", + 35 | "properties": { + 36 | "path": { + 37 | "type": "string", + 38 | "description": "absolute or relative path to the file" + 39 | }, + 40 | "name_path": { + 41 | "type": "string", + 42 | "description": "symbol name or file-local path" + 43 | }, + 44 | "body": { + 45 | "type": "string", + 46 | "description": "replacement source body" + 47 | }, + 48 | "language": { + 49 | "type": "string", + 50 | "description": "optional tree-sitter language name override" + 51 | } + 52 | }, + 53 | "required": ["path", "name_path", "body"] + 54 | }), +``` + +Source: `klbr-core/src/tools/symbol_edit.rs:155–205` + +```text + 155 | async fn execute_edit(args: serde_json::Value, edit: SymbolEdit) -> String { + 156 | let path = match args["path"].as_str() { + 157 | Some(path) => path, + 158 | None => return "error: missing required arg 'path'".into(), + 159 | }; + 160 | let name_path = match args["name_path"].as_str().map(str::trim) { + 161 | Some(name_path) if !name_path.is_empty() => name_path, + 162 | None => return "error: missing required arg 'name_path'".into(), + 163 | Some(_) => return "error: missing required arg 'name_path'".into(), + 164 | }; + 165 | let language = args["language"].as_str(); + 166 | + 167 | let source = match tokio::fs::read_to_string(path).await { + 168 | Ok(source) => source, + 169 | Err(err) => return format!("error: {err}"), + 170 | }; + 171 | let updated = match edit_source(path, &source, language, name_path, edit) { + 172 | Ok(updated) => updated, + 173 | Err(err) => return err, + 174 | }; + 175 | match tokio::fs::write(path, updated).await { + 176 | Ok(()) => "OK".into(), + 177 | Err(err) => format!("error: {err}"), + 178 | } + 179 | } + 180 | + 181 | fn edit_source( + 182 | path: &str, + 183 | source: &str, + 184 | language: Option<&str>, + 185 | name_path: &str, + 186 | edit: SymbolEdit, + 187 | ) -> Result { + 188 | let extraction = + 189 | extract_symbols(Some(path), source, language).map_err(|err| format!("error: {err}"))?; + 190 | let matches = matching_symbols(&extraction.symbols, name_path, false); + 191 | if matches.is_empty() { + 192 | return Err(format!("error: symbol not found: {name_path}")); + 193 | } + 194 | if matches.len() > 1 { + 195 | let choices = format_symbol_choices(&matches, 12); + 196 | return Err(format!("error: ambiguous symbol: {choices}")); + 197 | } + 198 | + 199 | let range = &matches[0].range; + 200 | validate_range(source, range)?; + 201 | Ok(match edit { + 202 | SymbolEdit::Replace { body } => replace_range(source, range, &body), + 203 | SymbolEdit::InsertBefore { text } => insert_before_range(source, range, &text), + 204 | SymbolEdit::InsertAfter { text } => insert_after_range(source, range, &text), + 205 | }) +``` + +## K15 — Lucid: meaningful states, ownership, vocabulary, and behavior changes + +These passages inform the proposed separation of history, operational state, live computation and released behavior. A shorter source tree is not the objective; neither preserving incidental bugs nor gratuitously changing semantics is justified. + +Source: `plugins/lucid-code/skills/lucid-code/SKILL.md:41–60` + +```text + 41 | ## Represent truth + 42 | + 43 | - Parse weak or untrusted values at the boundary into representations that preserve + 44 | what was learned. + 45 | - Represent every meaningful state, including legitimate drafts, pending work, + 46 | unknowns, failures, and partial external input. Exclude combinations for which the + 47 | domain has no valid behavior. + 48 | - Choose the most precise representation that makes legal operations direct and illegal + 49 | combinations unavailable. Opaque or refined values, role types, closed variants, + 50 | typestate, phantom parameters, smart constructors, builders, and schemas are ordinary + 51 | tools. A type may be valuable solely because it carries a role, unit, identity, proof, + 52 | capability, or protocol state. Expose type machinery when callers can use the + 53 | distinction or the compiler can enforce it. + 54 | - Make state authority explicit. When the domain has one source of truth, derive + 55 | secondary views unless caching, materialization, indexing, or boundary semantics make + 56 | another representation better. When authority is replicated or distributed, encode + 57 | ownership, merge, and consistency semantics rather than pretending it is singular. + 58 | - Expose failure and partiality where callers can act on them. Prefer total public and + 59 | domain APIs when no valid caller can satisfy a hidden precondition. A partial interior + 60 | operation is fine when its proof is local and stable and failure means an internal +``` + +Source: `plugins/lucid-code/skills/lucid-code/SKILL.md:110–141` + +```text + 110 | + 111 | A refactor may change observable mechanics and even behavior when the old behavior is + 112 | buggy, incidental, or superseded by the requested design. Understand the change, + 113 | migrate affected APIs and tests, and make a material contract change explicit. Do not + 114 | preserve an inefficiency because it is observable, and do not change behavior casually + 115 | because the new spelling is prettier. Benchmark only when a decision or performance + 116 | claim depends on a non-obvious empirical tradeoff. + 117 | + 118 | An abstraction earns its place by creating a better language, carrying a law or proof, + 119 | hiding knowledge, enabling composition, translating a boundary, or localizing real + 120 | variation. One use or one implementation can be enough. Several uses or + 121 | implementations do not rescue an abstraction that gives callers nothing. + 122 | + 123 | Dependencies are imported vocabulary and capability; dependency avoidance is not a + 124 | design objective. Do not penalize packages merely for being dependencies or treat + 125 | equivalent local machinery as free. Use or add the package that gives the best + 126 | whole-program result through notation, types, algorithms, correctness, performance, + 127 | interoperability, tooling, or maintenance. Syntax and ergonomics are sufficient benefits; + 128 | a dependency need not do something impossible to hand-write. Evaluate actual constraints + 129 | such as runtime support, bundle or build impact, licensing, privileges, and supply-chain + 130 | exposure instead of treating dependency count as quality. + 131 | + 132 | Search the repository and ecosystem when a library or established vocabulary could + 133 | materially improve the design. During implementation, make and apply the dependency or + 134 | notation choice directly; `$dependency-recon` is the recommendation-only workflow for + 135 | requests that ask for research without edits. Multiple libraries are fine when they own + 136 | distinct concepts or compose coherently; overlap is a cost only where users must + 137 | translate between competing dialects. + 138 | + 139 | Wrappers, facades, aliases, and re-exports may improve grammar, composition, imports, + 140 | errors, policy, or ownership. A thin layer is valuable when the use site becomes better, + 141 | even if the underlying capability remains visible. +``` + +Source: `plugins/lucid-code/skills/lucid-code/references/semantic-compression.md:19–34` + +```text + 19 | closure, not by the size of the mechanism in isolation. + 20 | + 21 | ## Separate contract from accidents + 22 | + 23 | Before preserving behavior, classify it: + 24 | + 25 | | Kind | Treatment | + 26 | |---|---| + 27 | | Explicit requirement, external protocol, relied-on compatibility, safety or integrity property | Preserve unless the task changes it | + 28 | | Valuable but redesignable API or execution property | Preserve or improve deliberately; migrate consumers | + 29 | | Bug, incidental ordering, avoidable work, historical test artifact, or leaky implementation detail | Replace when the new design is better | + 30 | + 31 | Treat published, documented, stable, or externally consumed surfaces as compatibility + 32 | promises unless the task authorizes migration. Internal visibility or a passing test is + 33 | not automatically a contract; undocumented behavior can still be contractual when real + 34 | consumers rely on it. Use repository evidence and task context rather than labels alone. +``` diff --git a/docs/runtime-v2/design/hooks-and-policy.md b/docs/runtime-v2/design/hooks-and-policy.md new file mode 100644 index 0000000..eba3ca7 --- /dev/null +++ b/docs/runtime-v2/design/hooks-and-policy.md @@ -0,0 +1,223 @@ +# klbr v2: Rust mechanisms, Python policies and lifecycle hooks + +Date: September 6, 2026 +Status: proposed implementation contract; no replacement runtime or hook tests have been implemented or run. +Scope: amendment to the supplied implementation plan following the operator's clarification that Rust is an intentional reliability/performance foundation and Python hooks must expose core behavioral decisions. + +## Decision + +Retain Rust as the permanent owner of durable state, transport, resource accounting, lifecycle transitions, cancellation and recovery. Make ordinary policy extensible through typed Python hooks from the first runtime slice. Python should change not only the tools available to the model but how the harness prepares context, chooses work, selects model profiles, handles outcomes and participates socially. + +This is a mechanism/policy split, not a claim that language choice proves reliability. Safe Rust excludes data races, but not arbitrary race conditions, deadlocks or incorrect business logic [H1]. Its ownership/resource model and lack of a mandatory tracing garbage collector make it a reasonable deliberate choice for the long-running host [H2]. No Rust-versus-Python benchmark of this harness has been performed. Neither HTTP/model latency nor external services become faster merely by moving orchestration into Rust. + +Hooks cover meaningful lifecycle boundaries, not every private function. A documented hook is an extension contract with a specific input, result and failure policy. Internal allocation, storage mechanics and transport framing stay refactorable. The goal is that a new behavior composes existing domain operations, not that Python receives an untyped mutable reference to the entire agent. + +## 1. Three hook contracts + +### Decision slots + +A decision slot selects a plan before a state transition or operation. Exactly one selected top-level handler owns a slot in each scope/revision. Examples: attention selection, model request planning, continuation versus waiting, and effect admission. + +The handler receives an immutable snapshot plus bounded read capabilities and returns a slot-specific closed result. It does not mutate host state directly. Depending on the slot, results include a validated plan, a delay with a persisted deadline, rejection, a request for explicitly scheduled follow-up work, or use of the slot's documented default. There is no universal truthy/falsey return convention. + +Composition is ordinary Python: a selected handler can call helpers or an editable default implementation. Do not make implicit import order decide which competing policy wins. Reject conflicting top-level registrations at activation. + +### Ordered transforms + +A transform contributes a typed patch to a representation or proposal: context additions, a bounded display preview, a summarization request, or an outgoing message draft. Its patch names the base version it expects. Multiple transforms run in an explicit manifest order; invalid ordering or conflicts are diagnosed at publication or application. + +Do not expose arbitrary JSON Patch over the database. Each domain defines legal modifications. Context contributions cannot alter archival originals, forge a receipt, reclassify Discord text as an operator instruction, or silently remove required provider protocol blocks. Outgoing-text transforms do not change the original scratchpad or a historical delivery record. Store the final requested payload and transformation provenance. + +### Post-commit observers + +Observers consume committed facts: an input was admitted, a cell completed, an effect was confirmed, a child finished, or a revision activated. They do not veto or retroactively change the event. Several independent observers may subscribe. + +A durable observer returns bounded namespaced state updates and proposed commands. Rust validates and commits those together with that observer delivery's completion. Important effects use the existing outbox/job operations rather than an untracked network call from the callback. Long work is scheduled as a job; the observer is not an unbounded secondary agent loop. + +Interactive event subscriptions are a separate convenience: the workbench can inspect or react to events while alive. They are best-effort, may disappear on restart, and are not used for core progress or durable obligations. + +## 2. Execution placement: accessible from the kernel, independent of its availability + +Use the existing Python runtime package in two process roles: + +- **Workbench:** the model's persistent namespace, foreground cells and explicitly ephemeral background tasks. +- **Hook worker:** a supervised persistent process that imports published handler entrypoints and answers bounded invocations. It has no model loop, source transport, scheduler or independently authoritative database. + +One long-lived worker per needed compatible hook revision is the starting implementation; do not spawn a process per callback or eagerly create one per hook. Cap retained workers, lazily start a worker for a pinned revision when needed, and evict idle processes without removing their durable registrations. Current ingress policy and older in-flight work may temporarily require different revisions. Workers share the runner's framing, schema checks, diagnostics and supervisor rather than a new service stack. + +This extra process has a concrete reason. A foreground cell may await a host send; the host may need to run a send-review hook. Queuing that review as another foreground cell in the waiting workbench creates a circular wait. A CPU-blocking cell can also prevent another asyncio task on its event loop from running [H3]. An independent hook worker breaks that dependency, but the host must also continue serving allowed read RPC while awaiting its answer and must not hold a session lock or SQL transaction across the invocation. + +The hook worker has bounded request queues and deadlines. Short decision work has priority over observer backlog. Observers that need extended processing schedule a job. One process initially means one blocked hook can delay other hooks until its host deadline; that is explicit, not a claim of per-handler isolation. On an unresponsive worker, invalidate its generation, reject late results, kill/restart it, and resolve affected calls using their individual failure contracts. Split worker pools only if measured queueing or isolation requirements warrant it. + +Workbench-only hooks such as `kernel.initialize` can populate globals during bootstrap, before the kernel becomes ready. Their failure affects readiness and follows the candidate/restart policy. They are not a backdoor to run a required ingress or delivery hook inside a busy cell. + +## 3. The first public hook surface + +These names are proposed contract names, not existing APIs. Add a domain's hooks in the same slice that implements that domain. Do not build an empty implementation for every planned slot up front. + +| Domain | Proposed extension points | Python can decide or contribute | Rust retains | +|---|---|---|---| +| Accepted input | `input.accepted` observer; `attention.plan` decision | Tag/enrich projections, wake or batch ambient work, prioritize directed work, explicit defer/ignore dispositions | Admission before notification; immutable author/source classification; dedupe; canonical routing; no silent deletion | +| Turn lifecycle | `turn.prepare` transform; `turn.next` decision; `turn.completed` observer | Task-specific guidance; continue/wait/propose a routine; summarize a result for later processing | Session state machine, durable wait/wakeup, operator preemption, no-progress ceilings and spending limits | +| Context/history | `context.prepare` transforms; `history.rank` optional ranking slot | Relevant project instructions and scoped evidence, supplementary retrieval priorities, bounded display choices | Access scope; raw-history preservation; core chronological coverage; provider-valid sequences; final input budget | +| Model calls | `model.plan` decision; `model.completed` / `model.failed` observers | Select an allowed model profile, purpose-specific parameters, bounded fallback/backoff plan | Credentials, actual provider invocation/serialization, accounting, configured limits and cancellation | +| Cell execution | `execution.review` decision; `execution.completed` observer; `result.present` transform | Additional gate, explicit source rewrite with before/after provenance, outcome follow-up, model-visible preview | Execution identity, original code/result retention, generation checks, output caps, cancellation and supervision | +| Explicit delivery | `delivery.prepare` transforms; `delivery.review` decision; `delivery.completed` observer | Formatting, smart-reply policy, delay, suppress or reject a proposed send; explicit next actions after a receipt | Original effect identity; allowed destination; current anchor validation; transport/rate limits; receipt-derived outcome | +| LCM | `compaction.plan` decision; `summary.prepare` transform; `compaction.completed` observer | Early compaction trigger, choose among eligible ranges, adjust summarizer profile/prompt, schedule optional later analysis | Forced pressure relief, source relationships, legal contiguous ranges, bounded retries, atomic context swap | +| Jobs and children | `job.plan` decision; `job.completed` / `job.failed` observers | Child profiles, suggested budgets, workspace presets, result handling, bounded retry proposals | Actual admission, shared resource ceilings, revision pinning, worktree ownership and durable outcomes | +| Runtime/revisions | `runtime.started`, `kernel.reset`, `revision.activated` observers; workbench `kernel.initialize` | Initialization helpers, cleanup proposals, diagnostics, targeted reconstruction | Recovery boot, operator controls, last-good selection, candidate validation and process termination | + +A lifecycle observer may run well after the fact; its event time and handling time are distinct. A completion observer must not be misrepresented as part of the operation's atomic commit. Proposed side effects can be rejected after validation without erasing the observed fact. + +Do not add mandatory per-token Python round trips. Model transport and token animation remain Rust-owned; an optional stream subscription is batched and explicitly best-effort. Transform completed or bounded chunks outside the token transport loop. A persistent filter requiring additional latency would be a separately documented mode, not an invisible default. + +## 4. Ship editable default policies + +Make existing compatible behavior the initial Python policy library, not a large inaccessible set of Rust decisions surrounded by notification callbacks. Good examples are Discord ambient batching, useful context additions, model-profile selection, smart reply preferences and deciding when an autonomous turn yields. + +Default ordinary policies live in versioned Python source. Project/custom handlers can call them or replace their slot. The compatibility fixtures remain the authority on the intended initial experience, including explicit speech and ambient silence. + +Rust keeps only the minimal unavailable-policy fallback and non-negotiable constraints. Do not maintain a second Rust implementation of every Python policy. A missing optional enrichment can mean no extra context. A missing ordinary attention policy can leave work pending or use the documented basic directed/ambient schedule. A missing required delivery guard means the send is held, not permitted by pretending the guard approved it. + +What can change without Rust: batching rules, attention ranking within limits, model profile rules, task-specific context, result rendering, extra execution restrictions, reply preparation, child defaults and follow-up routines. What still needs a core change: a new durable primitive, new protocol authority, different archival invariants, or a transport capability absent from both current host services and the supported Python source/job interface. + +## 5. Public Python experience + +The workbench gets discovery, dry-run and editing APIs. Durable registrations are module entrypoints inside an immutable behavior revision, never pickled closures or references to a cell's globals. A temporary callback is useful for experiments but has a visibly different lifetime. + +Illustrative handler source, using the proposed SDK: + +```python +# klbr_hooks/social.py +from klbr.hooks import AttentionDecision, AttentionRequest, HookContext +from klbr_hooks.defaults import attention as default_attention + +async def attention(req: AttentionRequest, ctx: HookContext) -> AttentionDecision: + # Wake means become eligible at a legal scheduling boundary, not cancel + # the current cell or force the agent to send a response. + if req.event.kind in {"discord.dm", "discord.mention"}: + return AttentionDecision.wake(priority="directed") + return await default_attention(req, ctx) +``` + +Illustrative workbench workflow: + +```python +from klbr import behavior, hooks + +spec = await hooks.describe("attention.plan") +draft = await behavior.draft() # records its base activation/revision + +# The handler file and its tests are edited in draft.path using normal Python. +await hooks.bind( + draft=draft.id, + slot="attention.plan", + handler="klbr_hooks.social:attention", +) + +report = await hooks.test(draft=draft.id, fixtures="social_attention") +if not report.passed: + raise RuntimeError(report.summary) + +# Publishing also runs host-owned compatibility/smoke validation; this +# Python branch is not the authority on whether the candidate is valid. +revision = await behavior.publish(draft.id) +await behavior.activate(revision.id) +``` + +`hooks.describe` reports input/result schemas, examples, phase, execution placement, ordering, fallback, deadline, supported SDK reads and required scope. `hooks.list` shows selected handlers and revision identities. `hooks.trace` explains a concrete invocation using source references, returned decision, validation, elapsed time and fallback. `hooks.test` runs in an isolated candidate with captured inputs and fake effects; it does not resend historical messages. + +Python type hints are ergonomics, not a trust boundary. Validate wire input and returned variants in Rust. Centralize protocol schemas/fixtures, derive useful Python types and docs, and check compatibility. Use normal Python helpers for local composition, not another workflow language or generic hook-to-hook graph. + +## 6. Invocation, commit and state contracts + +A host invocation records an ID, slot/schema version, logical handler ID, hook revision, scope/session, causative event or operation, source version, worker generation and deadline. Inputs are bounded snapshots or scoped artifact/history references. The host records the accepted decision or fallback needed to explain/recover an actual operation. Avoid duplicating entire prompts in operational logs. + +The ordering for a decision is: + +1. Prepare the immutable decision input and record its operation identity. +2. Release database transactions and domain mutation locks. +3. Invoke the pinned worker with a host-owned deadline. Serve only the bounded reads permitted to that slot. +4. Validate schema, scope, budget and current source version. Discard an answer from an expired invocation or stale worker. Reject stale changes, or resnapshot/retry within a separate bounded limit. +5. Commit the chosen plan and its provenance with the appropriate domain transition. Perform external work afterward through the existing outbox/job model. + +A read-dependent decision identifies relevant read versions. Commit must validate the versions that affect its correctness, not just the first input payload. Time is supplied as snapshot data when deterministic testing matters; an operation requiring fresh time is revalidated at the host. + +Decision/transform SDKs are read-only apart from returning their proposal. They cannot recursively invoke the operation currently being gated. Model inference, network fetches and expensive analysis should be deferred to tracked jobs when not part of the declared bounded read contract. Do not let a model-planning hook call the same model-planning path recursively. Causation IDs, bounded chain depth and command-rate budgets prevent accidental feedback loops from consuming unlimited resources. + +Durable observers are delivered at least once until success or an explicit paused/dead-letter disposition. Use per-observer delivery identities and checkpoints, with ordering stated per partition; there is no implicit total-order promise across independent sessions. A failed observer must not block unrelated subscriptions or ingress. A paused/dead-letter record is visible and retains the event for explicit recovery; it is not silent successful delivery. + +For each observer delivery, atomically commit its validated namespaced state writes, proposed command records and acknowledgement. A crash after that commit does not enqueue the commands again. A crash before it permits retry. Stable command keys derive from logical observer ID, causative event ID and action key, not an attempt number. A handler revision alone does not make old events new work. Intentional historical reprocessing requires an explicit replay identity and effect policy; dry-run is the default. + +Namespaced state has a schema version, owner and optimistic version checks. It holds small hook-owned data, not a duplicate authoritative inbox, context or receipt ledger. Migrations run at activation boundaries; use backward-compatible state while old revisions remain pinned, or pause/drain affected consumers before a breaking migration. Do not promise automatic rollback of arbitrary state transformations. + +Ordinary Python can bypass an SDK in a trusted same-user process. Read-only callback conventions and declared commands do not prove purity or security. Strong enforcement requires OS isolation. Arbitrary filesystem/network effects made directly by a handler remain outside the atomic observer commit and may have unknown outcomes after failure. + +## 7. Failure policies must be explicit per slot + +| Failure | Required behavior | +|---|---| +| Optional context addition fails or times out | Record degradation; continue with a provider-valid, budgeted base context. Do not fabricate the missing evidence. | +| Required execution/delivery review is unavailable | Hold the specific operation with an explicit reason. Do not convert failure into approval. Allow operator repair through the independent management path. | +| Attention policy fails | Keep accepted work; use its configured basic fallback or pause. Operator preemption and receipt/storage continue regardless. | +| Model profile is unsupported or over budget | Reject the plan; use an explicitly configured permitted fallback or pause. No silent switch to an arbitrary costly model. | +| LCM policy refuses compaction above a hard budget | Apply the host's bounded pressure-relief path. A hook cannot disable convergence or mutate sources. | +| Observer fails repeatedly | Back off, then pause/dead-letter that subscription with retained evidence. Do not poison the input journal or recursively notify itself forever. | +| Worker is CPU-bound, blocked or exits | Enforce deadline from Rust; invalidate generation; terminate/restart when necessary. A cooperative Python timeout is not the only protection. | +| Candidate hooks fail validation/startup | Keep old activation; surface diagnostics. Recovery does not require running broken candidate hooks. | + +Separate a hook's stated preference from authority. An extension cannot give itself new credentials, lift its own hard spending ceiling, alter another principal's scope, or override operator recovery merely by returning a plan. These are validated host/administrative contracts; the deployment's OS trust model still matters. + +## 8. Activation without unnecessary kernel resets + +Extend the existing single authoritative behavior activation record to reference component revisions: instructions, settings, skills/runtime environment, and hooks. This is one versioned activation manifest, not multiple mutable sources of truth. + +A hook-only source edit with unchanged compatible skill/environment dependencies prepares a candidate hook worker and changes future hook invocations at a safe boundary. It need not discard the workbench's globals. Skill/environment edits still use a fresh workbench where required. If a shared imported module or dependency changes, explicitly replace all affected process roles; do not call it hook-only just because the edited file lives in a hook directory. Keep hook and workbench entrypoint dependencies separable and record their content/environment fingerprints. + +Pin one behavior epoch to a model attempt and its effects. A mid-cell activation does not cause the latter half of that cell's send path to use a new mandatory guard unexpectedly. Global ingress decisions pin the ingress epoch selected for their decision after admission; children can remain on an older authorized epoch. Explicit emergency changes cancel or invalidate in-flight work rather than silently mutating its contract. + +Observer activation records a cutover cursor per ordered subscription partition. Existing pending deliveries retain their assigned handler revision; later deliveries use the new one. A new subscription begins at its declared start cursor, normally activation, rather than secretly replaying all history. Old revisions remain available until their pinned work completes, is cancelled, or is explicitly migrated. + +Boot recovery, last-good activation, operator disable/rollback and worker termination do not call mandatory user hooks. Hooks can observe those transitions afterward; a broken extension must not veto its own repair. Use fresh module processes for published revisions, not universal `importlib.reload`, whose documented reference/instance behavior does not provide an atomic old-to-new switch [H4]. + +## 9. Implementation changes to the original slices + +**Slice 0:** add characterization fixtures for attention, model/context preparation, explicit-send distinctions and hook failure outcomes. Preserve behavior, not incidental Rust placement. + +**Slice 2:** add `hooks/spec`, invocation/result variants, epoch identity, declared capabilities and failure policies alongside the kernel/client contracts. Add hook-decision and observer-state schema in the relevant store migrations. Domain state machines must await external decisions without holding their authoritative lock/transaction. + +**Slice 3a, before the replacement turn loop:** build hook-worker mode using the same Python runtime transport/supervisor, importable entrypoints, a bounded dispatcher and fake-host tests. Bootstrap static published hook manifests before exposing live editing. Verify a blocked workbench cannot block a hook and a blocked hook cannot block durable ingress or operator management. + +**Slice 4:** wire turn, context/model and execution hooks into the actual replacement loop. Ship minimal editable default policies; do not implement a full inaccessible Rust policy and postpone extensibility indefinitely. + +**Slices 6–8:** add Discord delivery/attention, LCM and result-presentation hooks with their owner domains. Every slot must document what can change and what remains host-owned. Put contextual code intelligence behind Python skills, not extra core hooks for parser internals. + +**Slice 9:** extend candidate validation, discovery, dry-run, observer-state compatibility, component activation, revision pinning and rollback. Prove a hook-only edit leaves a compatible workbench running. + +**Slices 10–11:** integrate durable observer commands with jobs and children. Enforce recursive-work budgets and explicit completion ordering. Hooks request these operations; they do not maintain another scheduler. + +**Slice 12:** stress the combined system under blocked cells/hooks, input bursts, repeated reloads and reconnects. Record no-hook versus default-hook latency and memory before choosing process counts or claiming a performance result. Include observer backlog and pinned revision garbage collection in diagnostics/cutover. + +## 10. Decisive acceptance tests + +1. **No circular wait:** a workbench cell awaits a send; Rust invokes its required review in the independent hook worker; the decision and receipt return without a second foreground cell. +2. **Host remains available:** block the workbench, then block the hook worker in a separate test. Incoming events are still persisted; cancellation/rollback commands remain serviceable; affected operations use the stated fallback or hold outcome. +3. **Typed boundaries:** wrong variants, overlarge patches, forged sources, forbidden destinations, over-budget model plans and expired worker replies are rejected without partial domain mutation. +4. **Known failure, not accidental approval:** a broken required send gate holds delivery; a broken optional context hook yields an explicit degradation and a valid base request. +5. **Observer crash windows:** kill before/after committing observer acknowledgement/state/commands. Host commands are not duplicated locally, while genuinely uncertain external delivery remains represented as uncertain. +6. **Stable activation:** edit a hook while a cell is running; that cell remains pinned, future work adopts the new revision, and a hook-only compatible change preserves the workbench. A bad candidate never becomes active. +7. **No phantom replay:** update an observer's source, restart, and confirm already completed event deliveries are not repeated because a revision changed. Explicit replay is separately identified and dry-run by default. +8. **No self-amplification:** an observer reacting to a completed job cannot recursively create unbounded jobs or hook-failure notifications. Causation filters and hard budgets end the cycle visibly. +9. **Behavior coverage without Rust edits:** change ambient batching, model-profile selection, project context, outgoing formatting, an extra execution check and child-result handling entirely in Python. Core invariant/compatibility tests continue to pass. +10. **Measured overhead:** compare no hooks, shipped defaults and intentionally slow hooks using latency distributions, idle CPU/RSS, per-turn serialization size, worker queue delay, restart counts and root responsiveness. Targets are selected from measured deployment behavior, not an invented throughput claim. + +## Sources and evidence limits + +The behavior compatibility facts and original source identities remain in `behavior-contracts.md`. The prior plan was read directly; this amendment changes its earlier recommendation to defer a policy boundary. No implementation claim is based on a newly tested klbr runtime. + +H1 — Rustonomicon, data races versus general race conditions: https://doc.rust-lang.org/nomicon/races.html +H2 — Rust project, ownership/reliability and performance goals: https://rust-lang.org/ +H3 — Python asyncio development documentation, scheduling and blocking code: https://docs.python.org/3/library/asyncio-dev.html +H4 — Python importlib documentation, reload semantics: https://docs.python.org/3/library/importlib.html + +The supplied Lucid code skill and semantic-compression guide inform explicit ownership, legal state/result types and the public extension vocabulary. jyn's essay informs keeping policy independent from mechanism rather than optimizing only for file or line count. Ideonomy material was read as conceptual background, not installed or executed as a plugin and not used as evidence that the proposed design is correct. diff --git a/docs/runtime-v2/design/implementation-plan.md b/docs/runtime-v2/design/implementation-plan.md new file mode 100644 index 0000000..596acf8 --- /dev/null +++ b/docs/runtime-v2/design/implementation-plan.md @@ -0,0 +1,604 @@ +# klbr v2: a persistent agent with a Rust core and Python policy hooks + +Date: September 6, 2026 +Revision: 2 — deliberate Rust foundation; first-class Python lifecycle/policy hooks. +Status: proposed architecture and ordered implementation plan; not an implemented or tested replacement. +Basis: the supplied `klbr.tar`, supplied Lucid pack, the prior Prime source audit, and primary-source documentation listed at the end. Existing source findings and proposed changes are distinguished throughout. This revision incorporates the operator’s explicit preference for Rust reliability/performance and broad Python hooks. The hook contract is specified in `hooks-and-policy.md`; the delta and reading order are in `revision-notes.md`. This supersedes the earlier recommendation to defer a policy boundary. Earlier source findings are retained, not claimed to have been newly runtime-tested. + +## 1. Architecture decision + +Keep one continuous agent that participates through local chat and Discord, thinks in an explicit scratchpad, chooses when to speak, and can work or wait without a connected client. Replace the fragile orchestration underneath that experience. + +Use a Rust daemon for durable state, provider execution, scheduling mechanisms, delivery, accounting and process supervision. Give each active agent session a separate persistent CPython workbench. Expose core behavioral decisions and lifecycle boundaries as typed Python hooks executed by a supervised hook worker using the same runtime package. Make policies, capabilities, reusable procedures, prompts and ordinary settings editable without rebuilding Rust. Use an LCM-inspired history layer, not the current reflection/note-garden pipeline, as the foundation of continuity. + +The central distinctions are **durable agent identity and commitments versus disposable live computation**, and **Rust mechanisms/invariants versus Python policies/skills**. Rust is a deliberate long-term choice for typed resource ownership, memory/data-race safety and efficient always-on execution, not merely a way to salvage existing code. These strengths do not prove recovery correctness or eliminate general race conditions; those require explicit contracts and tests. No comparative performance benchmark has been run. The Python heap is useful but is not the agent’s only memory. [W10, W11] + +The root agent is one session spanning the operator and admitted Discord conversations. A Discord channel is a provenance/routing identity, not automatically a new agent. Explicitly spawned children get their own sessions, histories, kernels, budgets, and working directories. This preserves the current continuous-participant model rather than quietly replacing it with a chatbot per channel. + +At deployment there is one daemon, one SQLite database, an artifact directory, a versioned behavior directory, the existing web client, and supervised Python processes. No broker, distributed workflow engine, service mesh or plugin marketplace is required. A small typed hook registry and dispatcher is part of the core, not a second agent orchestrator. + +### What this keeps + +Scratchpad-versus-speech separation; the operator's scratchpad diagnostics; explicit local and Discord sends; optional participation in ambient chatter; attention to DMs and mentions; Discord backreading, reactions, pending-item decisions and conversational reply threading; operator preemption; interruptible waiting; an editable soul; client independence; structured coding operations; and explicit self-upgrades. + +### What this changes + +Accepted inputs become durable before scheduling. Session persistence is actually session-keyed. Sends become recorded effects with honest completion states. Context compaction becomes an isolated archival operation. Python routines become normal versioned source files. Lifecycle operations are typed host commands rather than string matches on tool names. Hook decisions are explicit typed proposals; post-commit observers request follow-up work through the same durable operations. Default social/context/model policies are editable Python rather than a hidden Rust policy layer. + +### What this deliberately does not include initially + +A second general-purpose agent loop in Python; automatic reflection after every turn; autonomous mutation of archival originals; arbitrary Python-heap checkpoint/replay; whole-runtime hot reload; semantic memory graphs; mandatory embeddings; a scheduler based on arbitrary pickled continuations; or an extensible Rust tool registry for each new Python ability. + +## 2. Preserve the experience, not incidental mechanics + +Source IDs below refer to `behavior-contracts.md`. That companion contains exact source paths, line ranges, archive hashes, and excerpts. + +### 2.1 Scratchpad and speech + +**Existing:** ordinary assistant output is treated as scratchpad. `local_send` and `discord_send` are explicit delivery actions. The source instructions call the scratchpad secret, but the implementation emits `ScratchToken`, and the web client renders a scratchpad block. Thus “not a sent message” is accurate; “invisible to the operator” is not. [K01–K03] + +**Contract:** retain three separate things: model-authored scratchpad, provider-supplied reasoning data where available, and externally delivered messages. Do not collapse them into an assistant-text string. Scratchpad remains visible in authenticated operator diagnostics, never automatically sent to Discord or inserted into the local conversational-output lane. + +Persist completed scratchpad records, including text emitted alongside tool calls. Preserve partial-stream records as partial when interrupted; do not manufacture a completed message. During context reconstruction and summarization, label scratchpad as the agent's draft/thought, not evidence that anyone was told anything. External delivery is supported by an effect receipt, not the wording of a scratchpad. + +Do not turn this feature into a second mutable `scratchpad.md` memory system. Its primary representation is a typed part of the session transcript. Ordinary notebooks or files may still be used when useful. + +`print(...)`, expression display, stdout, and stderr are workbench output. They are not local speech either. An absent send is not repaired by forwarding text automatically. + +Keep at most one delivery reminder per unresolved addressed input/attention frame, not merely one global reminder for a whole mixed-channel turn. The reminder should offer an explicit send or an intentional disposition. It must not force a reply to every ambient batch. Exhausting reminders ends in an explicit idle/pause state rather than a repeated nudge loop. + +### 2.2 Discord as participation + +**Existing:** messages carry DM, mention, or guild context; the instructions explicitly encourage participation rather than helpdesk behavior. The integration has send, backread, react, list-conversations, and mark operations. Successful sends mark the associated pending item acted. Ambient guild messages are not pending items. [K01, K04–K09] + +Preserve those behaviors through Python objects. A DM or mention should demand attention, not mechanically mandate a particular generated response. The agent may respond, react and explicitly record a decision, or deliberately dismiss an inappropriate/irrelevant item. It need not ask the operator for permission to exercise ordinary social judgment. + +Preserve the current configuration defaults for the first cutover: ingest mode `all`, ambient waking enabled, bot messages excluded, ambient batching interval 8 seconds, and pending aging threshold 600 seconds. Preserve configured channel filters. These values are compatibility defaults, not claims of optimal social policy. [K04] + +Preserve the short conversation labels, but persist their mapping to canonical source/account/channel identifiers. Today's in-memory `d1`, `d2`, ... map is not an adequate lifetime identity across restarts. Store canonical IDs; treat short IDs as stable display aliases, never infer a routing identity from their spelling. [K08] + +Keep the smart reply behavior: ordinarily send a plain message when the channel has not advanced since the attended message; attach a reply reference when subsequent messages make the target ambiguous. Resolve and reserve the anchor for the specific effect, and consume it only on confirmed delivery—not before a network attempt. [K08] + +The current default-channel behavior must become **frame-local**. Omitting a destination is permitted only when the current immutable attention frame has exactly one Discord conversation. A later incoming batch must not change where code from the earlier frame sends. Explicit conversation handles remain usable for proactive messages. Validate that a supplied pending-item ID belongs to the destination. + +Persist accepted source messages before batching. Batches are scheduling/presentation views over those records, not the only surviving copy. A newly arriving directed message should flush a waiting ambient batch promptly; do not preserve the current accidental delay when a DM arrives during an ambient sleep. Apply rate limits and per-source fairness without dropping stored messages. + +Preserve pending/seen/acted/ignored and explicit reopening. Keep automatic aging compatible at first, but record `decision_origin=aging`, not a fictitious claim that the model read the item. Aging, explicit dismissal, successful delivery, and deliberate manual marking are different evidence. A reaction does not silently imply that an item was replied to; combine it with an explicit decision when desired. + +Backread is a bounded query with source IDs and before/after pagination. Reading history is not accepting new work or marking items acted. Preserve Unicode/custom reaction handling. Record attachment metadata immediately; perform bounded media fetching/enrichment separately so one slow attachment cannot prevent reception of following events. A persisted URL is not a promise that its bytes will remain available indefinitely. + +The existing send tool description incorrectly says plain assistant text goes to local chat. Remove that stale wording along with the old tool schema. The new docs should be generated from the actual speech/scratchpad contract. [K09] + +### 2.3 Preemption, waiting, and autonomous work + +**Existing:** operator messages and management commands preempt execution; external events queue behind the active turn. Waiting is interrupted by input and supports a bounded timeout. The existing outer loop can continue autonomously without a new user request. [K10–K12] + +Preserve those priorities. Persist an operator event before requesting cancellation. A cancellation ends an attempt; it does not erase the interrupted task, input, or previously committed effects. External Discord input normally waits for the next boundary, but is received and stored promptly. + +Preserve the ability to do sustained autonomous work. Do not impose “one user message, one final answer, stop.” The host continues while a turn is productive and within configured budgets; explicit waiting suspends it. Budget exhaustion, repeated empty completions, repeated errors, or repeated nudges produce a visible pause/backoff instead of a hidden infinite inference loop. Resumption of the interrupted objective is a recorded session concern, not a promise that a Python stack survives. + +Expose `await runtime.wait(seconds=60, reason="...")` as **a terminal yield from the current cell and agent attempt**, not `asyncio.sleep`. The host records the wait and closes the tool execution with a yielded outcome. A new event or deadline later produces a new model step with its wake reason. Code after `runtime.wait` is not run. The runner and SDK implement this using an explicit control outcome; the host rejects subsequent effect requests from the yielded execution. + +Retain the 600-second cap for that compatibility wait. Add `seconds=None` for indefinite event-driven waiting. Reject negative/non-finite durations; reject a zero-duration self-wake loop rather than retaining clamping as an accidental behavior. Long-term reminders belong to durable schedules, not a sleeping kernel. Ordinary `asyncio.sleep` remains available for ephemeral computations. + +Model a wakeup and pending-input check in the same transactional scheduling decision. If input arrives before the wait commits, the host sees the pending inbox; if it arrives after, ingress wakes the waiting session. This prevents a lost-wakeup gap. Timeout delivery is deduplicated. Timers use persisted deadlines; the live process uses monotonic time for elapsed measurements. + +### 2.4 Soul, reset, manual compaction, restart + +**Existing:** soul text is editable, while general instructions are compiled through `include_str!`. The old restart operation replaces the daemon process image after compilation. [K13] + +Keep the soul editor and move evolving instructions out of the Rust binary. Preserve one authoritative version of each document; do not create a bidirectionally synchronized SQLite/file garden. Drafts live in files. Published immutable revisions are activated through one host record. The UI edits through the same revision path as the agent. + +Separate operations that were previously conflated: reset the active context, restart a kernel, activate a behavior revision, and upgrade/restart the host. Reset should start a new context epoch without deleting archival evidence or silently cancelling durable jobs. A destructive purge must be a separate explicit administrative action. Manual compaction uses the same LCM implementation as automatic compaction. + +A normal Python capability improvement should not reconnect Discord or restart the daemon. A host upgrade still requires a build and an explicit deployment path; Python cannot eliminate the compilation requirement for an actual Rust change. + +## 3. Ownership and boundaries + +### 3.1 Rust host + +The host owns admitted ingress; session/attempt state; provider requests and credentials; the single model-visible `python` executor; cell admission and cancellation; durable inbox/outbox transitions; LCM source relationships and context assembly; wakeups and job records; active behavior revision selection; hook invocation/validation and observer delivery state; and the client event stream. + +Retain suitable provider adapters, HTTP/WebSocket infrastructure, configuration parsers, Discord/Twilight integration, and existing web UI components after characterization tests. Do not rewrite a working Discord transport into Python merely to satisfy language uniformity: the agent-facing Discord vocabulary can be Python while the durable transport stays Rust. + +Keep modules centered on real domains. Initially `klbr-core` can contain `runtime`, `store`, `history`, `kernel`, `hooks`, `delivery`, `jobs`, `behavior`, and the existing provider boundary. There is no requirement to make each module a separate crate. Make `klbr-ipc` independent of `klbr-core` storage/provider implementation types. + +The host has an explicit lifecycle machine and validated transition contracts. Python decision hooks choose legal next actions, transforms contribute to context/output proposals, and observers react to committed outcomes. Batching, attention, model selection, compaction preferences and turn-continuation policy are editable Python plus settings. Persistence laws, authority checks, hard resource ceilings and effect accounting remain host-owned. Rust applies a narrow documented fallback if a policy is unavailable; do not duplicate every ordinary Python policy in Rust. + +### 3.2 Python package and behavior checkout + +Use two Python responsibilities, not two orchestrators: + +- `klbr_runtime`: the kernel/hook-worker runners, shared transport, and typed host SDK. It is versioned with the host protocol and updated deliberately. Its distribution provides the public `klbr` import used in the examples; this is a facade over the same SDK, not a second implementation. Runner internals are not the everyday skill API. +- The behavior checkout: normal importable `klbr_skills` routines/source tooling and `klbr_hooks` policy/observer modules, including the editable defaults. Projects may have their own modules and instructions. These are normal Python packages/modules, not additional schedulers. + +A behavior checkout also holds the soul, operational/social guidance, summarization prompt, child profiles, and settings. Static skill descriptions/docstrings provide discovery; do not import every skill for discovery, since imports may have effects. Keep only a bounded index in the model context. Load full skill instructions explicitly when needed, and expose import paths and useful examples. + +Keep data/results in artifacts or scoped durable application state. A useful one-off function may begin in a live cell; making it an enduring capability means saving its source, tests, and dependencies. Notebook globals do not become a second source of deployment truth. + +No hook registration is needed to add `klbr_skills.my_project.run_checks()`; automatic lifecycle invocation, unlike an ordinary function call, requires a named hook binding in the published manifest. The addition is an ordinary module. New persistent external integrations may be supervised Python source jobs with checkpointed cursors and a narrow ingress API; they need not add a bespoke Rust model tool. + +### 3.3 Kernel protocol + +Launch CPython as a subprocess, not embedded through PyO3. Use a persistent namespace and top-level await. One foreground cell executes at a time per kernel. Background asyncio tasks are supported but explicitly ephemeral. Interrupt suspended awaits cooperatively; escalate an unresponsive computation to process termination and record a kernel-reset event. + +Use an inherited dedicated bidirectional control socket/pipe, separate from stdout/stderr. Frame typed messages with bounded lengths; include protocol version, session, kernel generation, execution ID, request ID, and message kind. Handshake before admitting work. Reject old-generation or completed-execution effect requests. Do not deserialize pickles from the worker. + +Representative commands are `execute`, `interrupt`, `shutdown`; representative events are output, host request, execution completion, and kernel exit. Host requests expose a small closed vocabulary of durable domain operations. Ordinary file parsing, HTTP reads, and computation do not each need a host method. + +Bound previews and spool larger output. Record which execution/background task produced output; do not accidentally attribute late background output to the next cell. A kernel protocol cannot make arbitrary native output perfectly attributable; retain an explicit unassigned/background category when necessary. + +Host-owned credentials should not be casually copied into the kernel environment. However, a same-user unrestricted subprocess is not a sandbox. This plan's first implementation is a trusted personal-agent deployment; real isolation requires operating-system restrictions beyond an SDK. Do not promise that typed handles alone prevent arbitrary filesystem/network access or cross-channel disclosure. + +### 3.4 First-class Python hooks + +The detailed, normative proposed contract is in `hooks-and-policy.md`. Distinguish one-owner decision slots, explicitly ordered representation transforms, and asynchronous post-commit observers. All receive typed immutable inputs or scoped references; Rust validates outputs before changing authoritative state. Durable registration is an importable entrypoint pinned to a published revision, not a closure living in the REPL. + +The workbench can discover, edit, test and activate hooks. Required core callbacks run in a separate persistent hook-worker process using the same runtime package and framed protocol. This avoids making core progress depend on a busy workbench or calling back into a kernel waiting for its own host request. It is a bounded callback evaluator, not a second orchestration service. Workbench-local initialization/interactive subscriptions are explicitly different lifetimes. [W12] + +Never await a callback while holding a database transaction or authoritative mutation lock. Enforce deadlines and generation checks from Rust; keep ingress, cancellation and repair controls independent. An optional enrichment failure can omit enrichment; a required send/execution guard failure holds the operation. Recorded observer state/commands/acknowledgement commit together. Long callbacks request tracked jobs instead of becoming new unbounded loops. + +Ship editable defaults for ordinary policies. Hooks cover attention, turns, context, model planning, cells/results, delivery, LCM, jobs/children and revision/runtime events as each domain lands. They do not expose database commits or raw host state for arbitrary mutation. Preserve scratchpad-versus-speech and routing authority across every transform. Do not put mandatory Python calls on each streamed token. + +## 4. Durable data and recovery model + +Use SQLite with explicit migrations, foreign keys, transactional state transitions, a bounded writer path, configured busy handling, and an intentional durability mode. WAL with synchronous FULL is an appropriate starting candidate on local storage, subject to integration testing and actual filesystem guarantees. Do not perform network calls or run a summarizer inside a database transaction. [W08] + +Pin and check the actual SQLite runtime, not only the Rust crate version. The WAL documentation records a WAL-reset corruption fix in SQLite 3.51.3 and later, with listed backports to 3.44.6 and 3.50.7. Require a verified fixed build for deployment, record `sqlite_version()` and build provenance in diagnostics, and test the selected writer/checkpoint policy. Do not infer the bundled SQLite version from an unverified dependency name. [W08] + +Use an append-only journal for historical facts and ordinary relational tables for current operational state. This is not a requirement to build a generic event-sourcing framework or reconstruct every table by replaying all history. Store source content once and reference it from presentation/projection records. + +### Minimum logical families + +| Family | Responsibility | +|---|---| +| events + session_items | Canonical typed source/model/cell/delivery records; ordered session membership points to records instead of duplicating content. Preserve occurred-at and received-at times separately. | +| sessions + attempts + executions | Parentage, context epoch/version, state, model request identity, kernel generation, behavior revision, attempt outcome, execution/receipt linkage. | +| inbox | Durable ingress scheduling state: pending, claimed, consumed, cancelled or paused. Claiming is not successful completion. | +| conversations + attention_items | Canonical destinations and display aliases; DM/mention attention decisions independent of inbox scheduling. | +| outbox | A specific requested effect and its pending/attempting/confirmed/failed/unknown outcome, destination, receipt and idempotency key. | +| summaries + summary_children + context_items | Derived summary text, ordered coverage, and the current chronological representation. | +| jobs + wakeups | Child/command/routine/source jobs, deadlines, cancellation, checkpoints and notifications. | +| artifacts | Immutable content references, hashes, media metadata, storage state and provenance. | +| behavior_releases + activation state | Candidate/previous/active component revisions, one authoritative activation manifest, test evidence, compatibility and pinning. | +| hook_invocations + observer_deliveries + hook_state | Decision input/version/revision, accepted result or fallback, observer checkpoints/dispositions, bounded versioned namespaced state, and atomic proposed-command acknowledgement. | +| search projections | Rebuildable lexical indexes over appropriately scoped originals and summaries. | + +Table names are suggested; the listed ownership and transitions are the contract. Avoid one generic JSON blob that erases distinctions between waiting, running, failed, and unknown. JSON is appropriate for versioned external payloads and bounded routine-owned data. + +### Admission and execution + +1. Authenticate/classify an input and store its canonical event, source dedupe key, and inbox state atomically. Acknowledge a local submission only after commit. For Discord, this guarantee begins at local durable admission; Gateway delivery is not a transactional acknowledgment protocol with the local database. +2. Claim work for a session/attempt transactionally. Channels notify the scheduler; they are not the only copy of work. Use one active model request/foreground cell per session. +3. Freeze an attention frame containing the admitted event IDs, routing handles and the behavior/context versions used for the model request. +4. Persist a complete validated model response/tool call before execution. Preserve provider-specific content blocks or opaque metadata where required for valid continuation; do not assume every provider can be flattened to role/content strings. +5. Journal cell acceptance and execute under a specific generation. Persist tool completion before submitting the next model request. +6. On an interrupted tool sequence, render an explicit interrupted/unknown result under the existing tool-call ID when the adapter supports that reconstruction. Do not execute partially streamed tool arguments, silently drop the group, or claim success. +7. Resume from committed boundaries. No automatic replay of arbitrary cells. Model inference may be retried as a new attempt, with any known actions and uncertainty included in the context. + +### Effects and honest receipts + +A request is deduplicated by its persisted operation identity, not by identical message text. Reconnecting the SDK with the same request ID returns the existing result. Intentionally sending identical text twice remains possible through two distinct actions. + +`local.send` succeeds when the visible local delivery is durably published to the operator stream. It does not prove that a browser was connected or a human read it. The UI can replay it later. + +`discord.send` must not report delivery merely because it enqueued an outbox row. It returns a confirmed receipt, an explicitly pending/unknown result, or a typed failure. Store network receipt and automatic pending-item transition together. If the response was received but bookkeeping fails, keep the delivery evidence and repair the bookkeeping rather than encouraging another send. + +Use a stable Discord nonce with `enforce_nonce` when supported by the selected adapter/API. Discord documents uniqueness checking only over the past few minutes; it is not indefinite exactly-once delivery. After a crash in an ambiguous window, use available receipt/backread evidence and otherwise surface `unknown`, rather than blindly retrying indefinitely. Verify whether the pinned Twilight request builder supports the needed fields; extend that one adapter request if necessary, not the whole integration. [W05] + +Hook-generated tracked effects use this same outbox and retain their causative event/handler identity. Committing an observer acknowledgement, scoped state and proposed command records is one local transaction; a worker retry does not mint new logical commands. Hook revisions alone do not cause historical replay. Tracked effects do not cover arbitrary Python side effects. A cell can change files or talk to another service directly; after process failure those outcomes may be unknown. Useful durable services should route important mutations through an outbox-aware routine/host capability, but the plan does not claim universal exactly-once execution. + +### Client stream + +Expose durable event cursors for input acceptance, delivered speech, completed scratchpad/cells, job transitions, and recovery notices. Reconnect with a cursor plus a bounded snapshot. Streaming token deltas are best-effort; completed records are durable. Deduplicate by event ID. A lost token animation does not imply a lost message. Authenticate the operator diagnostics stream; never expose its raw contents to Discord participants. + +## 5. LCM-inspired history, with scratchpad semantics intact + +The borrowed idea is immutable originals plus a compact active representation and navigable source relationships. The proposed implementation is narrower than the full LCM paper: a chronological summary tree, bounded direct reads into Python, and no required map operator or embedding model. This is LCM-inspired, not a claim to reproduce its implementation or benchmark results. The paper itself distinguishes retained originals from guaranteed successful model recall. [W03] + +### Representation and invariants + +Keep recent original session items and older summary references in order. A summary covers a contiguous run of complete message/tool groups or earlier summaries. The host stores the ordered source relationships; the model writes only the summary text. Do not let generated text invent coverage IDs. + +Every session item within the active epoch's historical coverage is represented exactly once, directly or through one ancestor. A context reset deliberately starts a new epoch; the excluded older epoch remains archived, not magically covered. The selected input to a summarizer and the context version are fixed. Generate outside a transaction; validate and atomically commit the new summary plus the replacement range. If the version/range changed, discard or retry against the current state. Never mutate original records to achieve compaction. Python can contribute retrieval/context patches, compaction preferences and summary prompts through the domain hooks; Rust still enforces chronological coverage, legal ranges, source authority, provider validity and the final input budget. + +Summary prompt requirements include: distinguish operator requests from external observations; label scratchpad as tentative internal text; distinguish a proposed send from a confirmed delivery; retain corrections, unresolved questions, useful paths, artifact identities and uncertainties; and preserve source/channel/author attribution. Operational truth such as pending jobs and outbox status is still read from explicit records, not from this summary. + +### Budget and convergence + +Account for the complete provider input, loaded guidance and tool schema, reserved output, and a safety margin. Use adapter-aware estimates and recorded usage; do not assume byte length is an exact token count. + +Begin with synchronous between-step compaction. Summarize the oldest eligible complete block using a tool-free call. Make at most two bounded model attempts; if they fail, exceed their allotted output, or fail to shrink the representation, insert an explicit degraded reference stub. The host guarantees a measured strict reduction or returns a context-budget error. If only fixed instructions/current mandatory input remain and do not fit, fail honestly rather than looping. + +An optional Python compaction policy can request earlier compaction or select among eligible ranges. It cannot suppress the hard-budget pressure-relief path or extend retry limits indefinitely. Under the threshold, do not run a summarizer or reflection as a routine tax. Add background soft-threshold compaction only after the synchronous representation and commit semantics are proven. Reserve model capacity for actual addressed input so compaction/children cannot starve it. + +### Retrieval and artifacts + +Expose bounded `history.search`, `history.describe`, `history.read`, and `history.children`. Search returns IDs, short previews, scope and continuations. An exact reference remains resolvable without semantic search. Python can load a larger range under explicit byte limits, reduce it programmatically, or give references to a child. Merely assigning text to a variable does not insert all of it into the model's next prompt. Display uses separate preview limits. + +Begin with lexical search plus hierarchy navigation. Add embeddings only after task-based evidence shows a retrieval gap. Do not require embeddings for admission, compaction, startup, or recovering a known source ID. + +Enforce visibility scope before returning search results or summaries to restricted children. A mixed-source summary is not safe for a narrower grant merely because its metadata has several labels. Return it only to a principal authorized for all its covered scopes, or return narrower source material. The unified root can know multiple conversations, but this is not a guarantee against the model accidentally disclosing cross-channel information. + +Spool large tool output into immutable artifacts with bounded previews. Preserve a referenced file version through an artifact or stable revision when historical bytes matter; label ordinary workspace paths as live references. Retention and disk quotas are explicit. A delete/purge policy changes the advertised lossless-retention boundary and must leave a tombstone/reason rather than a fake retrievable reference. Logs, exports and backups must treat scratchpad and credentials as sensitive. + +## 6. The Python language presented to the agent + +These are proposed API contracts, not currently installed functions. Use typed result objects with useful `repr`, docstrings and inspectable signatures. Keep ordinary Python objects and upstream APIs available where appropriate; do not wrap everything into JSON dictionaries. + +```python +from klbr import local, discord, runtime, history, code, agents, jobs, behavior + +# A local response; print() would remain internal workbench output. +receipt = await local.send("i found the issue; testing the fix now") + +# Current-frame defaulting is allowed only for one unambiguous conversation. +conversation = discord.current() +recent = await conversation.history(limit=20) +receipt = await conversation.send("oh, that makes sense") + +# Silence is a real action; a reaction need not manufacture a written reply. +await conversation.react(message_id, "❤️") +await discord.mark(item_id, status="ignored", note="nothing useful to add") + +# Search data without dumping the entire archive into the prompt. +hits = await history.search("previous build failure", scope=history.project("klbr"), limit=8) +source = await history.read(hits[0].ref, max_bytes=24_000) +print(source.text[:2_000]) + +# Persistent delegation: spawn means admitted, not finished. +child = await agents.spawn( + task="investigate the cancellation path", + refs=[source.ref], + profile="code-review", + workspace="read_only", +) +print(child.id) + +# Terminal yield: later events cause a new model step, not continuation below here. +await runtime.wait(seconds=60, reason="waiting for results or messages") +``` + +The example is a vocabulary sketch, not one recommended combined cell; actual work should use separate cells and validate query results before indexing them. When omitted, the send source item may be inferred only from the immutable attention frame under the compatibility rules. An explicit `source_item` must identify an existing attention item in the same conversation. Backread messages are not all automatically pending items. + +Calling `agents.get(child_id)` or `jobs.get(job_id)` reconstructs a handle after a kernel restart. `child.result()` may await the host's durable result while a kernel is alive, but the suspended Python continuation itself is not persisted. Long waits should usually yield the agent and receive a completion event. Children report to the parent; external speech requires an explicit delegated capability rather than being silently enabled. + +### Hook discovery and editing + +Expose `hooks.list`, `hooks.describe`, `hooks.bind` on a draft, `hooks.test` with fake effects, and `hooks.trace` for recorded invocations. Publish/activate through the existing behavior workflow. Full proposed shapes, slot names and failure rules are in `hooks-and-policy.md`. Ordinary skills remain importable functions; only code the host invokes automatically needs a hook binding. + +### Structured coding operations + +Use Python tree-sitter bindings and provisioned grammars. Compare the current language-pack surface with direct grammar packages; choose on the required query coverage, offline installation and native-runtime compatibility, not dependency count. Verify Rust, Python, TypeScript/TSX and the repository languages actually used. No runtime grammar download should be required for the shipped coding skill. [W06] + +```python +source = code.open("src/agent.rs") +symbol = source.symbols.exact("run_turn").one() +print(symbol.text) +patch = symbol.replace_declaration(new_declaration) +print(patch.diff) +patch.apply() +``` + +`source` binds bytes, language detection/provenance and a content hash. `symbol` binds byte ranges, kind, query provenance, and diagnostics to that snapshot. `patch` carries the expected base hash and explicit edits. Ambiguous matches, missing queries, changed source, and overlapping edits are separate errors. Explore fuzzily; mutate exactly. + +Preserve raw queries and best-effort reference search without marketing it as compiler-backed resolution. Distinguish replacing a complete declaration from replacing its body; the existing `replace_symbol_body` path replaces the tagged range and should not be ported under a misleading name. [K14] + +Support UTF-8 byte ranges, multiline definitions, partially invalid source, and a diff-before-apply workflow. Use checked single-file replacement; preserve permissions and choose symlink behavior explicitly. Ordinary content-hash checks are not an atomic cross-process CAS against arbitrary editors. Separate modifying agents into worktrees and serialize changes within a shared workspace. Multi-file patch application must either expose partial progress or implement a real bounded transaction/recovery mechanism; do not imply filesystem-wide atomicity. + +For commands, expose structured argv/cwd/env and bounded output. Keep direct subprocess access for disposable work, and host-managed jobs for work that must be tracked. An optional shell helper must use explicitly selected Nushell rather than silently falling back to Bash. The repository's existing Bash build scripts are separate compatibility dependencies, not the agent's assumed interactive shell. + +## 7. Self-editability without self-corrupting recovery + +The kernel alone is sufficient for live experimentation. Persistent self-editability additionally needs a place for source, discovery, dependency identity, activation, and recovery. Build those as a small release workflow, not a model-driven refinement subsystem. + +### Editable layers + +| Change | Activation | Survives restart? | +|---|---|---| +| Function/object created in a cell | Immediately in that kernel | No, unless saved explicitly | +| Ordinary skill/routine source | Stage and test; load in a new/pinned kernel revision | Yes | +| Durable hook source/bindings | Stage and test; activate a candidate hook worker at a defined epoch boundary | Yes; a compatible hook-only change does not reset the workbench | +| Soul or instruction document | New published document revision at next model boundary | Yes | +| Batching/wake/model-profile settings | Validate and activate at a defined safe boundary | Yes | +| New persistent event source | Registered supervised Python entrypoint plus scoped state | Yes, according to the source's checkpoint/restart contract | +| Kernel runner / SDK / host binary | Compatibility-tested runtime deployment | Yes; Rust changes still need compilation | + +Make routine workspace and skill editing autonomous by default in this personal deployment. Do not require the operator to approve normal Discord participation or every useful helper. Keep active recovery binaries, access policy, credentials and destructive migrations outside the routine behavior-edit path. This separates reversible everyday changes from changing the machinery that makes rollback possible; it does not claim that an unrestricted same-user agent is technically unable to edit protected paths. + +### Revision layout and authority + +Use a normal Git checkout for drafts and reviews. A published revision contains immutable source/document content, a dependency/interpreter fingerprint, host/SDK protocol compatibility, and test results. Source-only revisions may share an already-built compatible environment. Dependency changes stage a different environment; they never `pip install` into the live kernel's environment. + +The database activation record is the only authority for the active behavior manifest. It references immutable document/settings, skill/environment and hook component revisions. Compute the corresponding immutable paths from that record; do not introduce independently mutable activation authorities. Record component/import dependency fingerprints so a genuinely hook-only change can be distinguished from a shared-dependency change. A convenience symlink may exist for humans, but it is not another independently mutable source of truth. Every attempt, kernel and job records its pinned relevant revisions; each hook invocation records its selected handler revision and behavior epoch. + +Use Nix to pin the host, interpreter and native dependencies on the NixOS deployment. Validate any uv-managed Python environment under that pinned interpreter/native closure; do not assume every downloaded wheel works on NixOS merely because it works on another Linux distribution. uv's lock/sync workflow is suitable for Python-only dependency pinning where verified, but production launches must not re-resolve or mutate dependencies. [W07] + +### Change workflow + +1. Create a draft from the currently active revision, recording the parent revision. +2. Edit normal source files or documents. Make the diff and intended behavior change available to the operator. +3. Validate schema, imports, syntax, targeted tests and SDK compatibility in a candidate process. Run the trusted smoke tests with fake host effects; no test should send real Discord messages by accident. The candidate's own assertion that it passed tests is not authoritative evidence. +4. Publish immutable content and prepare its environment. Reject stale-parent activation unless it is explicitly rebased/merged; two children must not silently overwrite each other's behavior changes. +5. For prompt-only changes, switch the document revision at the next provider request. For hook-only source/binding changes with unchanged compatible skill/environment dependencies, validate/start a candidate hook worker and switch future invocations at the declared epoch boundary without resetting the workbench. For skill/environment or shared-dependency changes, replace all affected process roles at safe boundaries. Do not tear down working processes before replacements can initialize. Emit an activation record, plus a kernel-reset record only when the namespace was actually replaced. Pin the old epoch through in-flight attempts/effects; observer cutover preserves assigned pending deliveries and uses a declared cursor for new deliveries. +6. Keep existing jobs/children on their pinned revision unless explicitly migrated. New jobs inherit the creating session's revision; a session may explicitly adopt the active revision. Do not move existing job code underneath a running process. +7. On startup/import/handshake failure, retain or restore the last known-good revision without requiring the agent to reason its way out. On later behavioral regression, expose explicit rollback and an optional bounded crash-loop fallback; passing startup tests does not prove semantic improvement. +8. Rollback changes future behavior. It does not erase events, reverse sent messages, roll back source repositories edited by a skill, or resurrect a lost Python heap. + +Namespaced hook state needs an explicit schema and compatibility/migration policy. Keep it backward-compatible while older revisions still have work, or explicitly drain/pause affected consumers. Operator recovery/disable/rollback never requires invoking candidate-controlled hooks. + +Do not use `importlib.reload` as the correctness basis. Python documents that references and existing instances can retain old definitions after a module reload. Allow it as an explicit interactive experiment, but use fresh kernels/processes for published revisions. [W04] + +The common case is a new Python skill, improved instructions or a changed routine—not a Rust rewrite. Keep the rare host-upgrade route documented and separate: build a candidate artifact, test compatibility, quiesce and checkpoint the running host, restart under the service manager, and recover. Initially prefer migrations that remain backward-readable during the rollback window. Restoring an old database backup after new deliveries is not a safe generic downgrade strategy. + +## 8. Children, durable jobs, and new event sources + +Use one job model with distinct kinds where their semantics differ: child agent, external command, scheduled Python routine, and persistent input source. Do not pretend that an arbitrary subprocess can resume at an instruction boundary after a crash. + +For a child, atomically create the job and child session before returning the spawn handle. Pin its behavior, model profile, budget and input references. Completion stores a result/artifact and a deduplicated parent notification together. The parent discovers the result even if its kernel is replaced. Cancellation propagates explicitly; shared/dependent jobs have stated ownership instead of being killed accidentally when one caller disconnects. + +Bound concurrency, depth, total descendants, tokens, wall-clock execution and output. Reserve capacity for the root. All provider calls from children pass through the host so accounting and limits apply across the whole tree. A sentence explaining why a subtask is smaller is not a termination proof. + +A modifying child receives its own worktree. A read-only child must have its actual access level stated; a prompt alone does not enforce read-only OS access. Default children receive only supplied evidence/capabilities, not the entire root scratchpad or unrestricted external-send authority. + +A durable routine is an importable entrypoint plus serializable input/checkpoint state, not a pickled closure. Store its revision and explicit recovery policy: retry-safe, resume-from-checkpoint, or manual/unknown after interruption. Schedules use persisted UTC deadlines and explicit local-timezone metadata when requested. Define missed-deadline behavior; default one-shot jobs fire once after recovery rather than producing an unbounded catch-up burst. + +A new Python source runs as a supervised job and calls a bounded ingress API with a stable source key. Where supported, commit admitted events and the source checkpoint together. Separate receipt/ingress authority from arbitrary operator authority: an external adapter cannot make a trusted operator instruction by putting `` in its payload. This permits a new notification/feed integration without rewriting the daemon. + +Jobs and sources are optional extensions after the single-session foundation, not an excuse to build a generic workflow DSL. Start with spawn/get/cancel, a scheduled entrypoint, and emit/checkpoint. Add a host-managed map primitive only after repeated batch workloads justify it. + +## 9. Ordered implementation slices + +Each slice is intended to be reviewable and to leave one coherent contract. Dependencies are explicit. Use fake providers, clocks and transports until a slice's acceptance gate calls for an integration test. These are implementation steps, not claims that the tests have already been run. + +### 0 — Freeze valuable behavior before replacing internals + +**Touch:** `klbr-core/src/instructions.md`, `agent.rs`, `agent/turn.rs`, `interrupt.rs`, local/wait/restart tools; `klbr-discord/src/{lib,inbox,formatting,tool_defs}.rs`; `klbr-web/src/App.svelte`; `klbr-ipc/src/lib.rs`. + +Extract the contracts in section 2 into fixtures and focused assertions. Cover scratch-only output, scratch plus a tool call, explicit local sends, single- and mixed-conversation Discord frames, delayed replies, ignored items, operator preemption, waits, soul updates and reconnect behavior. Capture realistic redacted provider-response fixtures. Label accidental behavior separately: lost interrupts, singleton snapshots, hidden latest-channel routing, stale tool wording and tool-name-driven lifecycle branches are not invariants to preserve. + +Include hook fallback/hold behavior, stable ordering and decision fixtures for social attention/context/model planning; record source/version provenance so replay tests can use fake reads/effects. + +**Gate:** the fixture suite describes the desired experience, including deliberate deviations. A failing existing test that encodes a documented bug is not a reason to preserve the bug. + +### 1 — Establish durable identities and a new store boundary + +**Depends on:** 0. +**Add:** `klbr-core/src/store/`, versioned SQL migrations, typed IDs/state variants, a storage test harness. + +Implement events, sessions, attempts, inbox, executions, conversations and outbox first; add other domain tables in their owning slices. Store durable input before scheduler notification. Give source admission a uniqueness key and preserve both source and receipt timestamps. Move away from `context_snapshots.id = 1`. + +Keep the old production database untouched while developing the new format. Use a new database and deterministic fixture importer for early tests, not concurrent dual writes. Define a single writer/transaction boundary and reconnect behavior after disk or database errors. + +**Gate:** the runtime SQLite build has the required fixes; duplicate ingress returns one accepted identity; a crash after commit but before notification does not lose work; a burst of events during an active attempt is preserved; two sessions cannot overwrite each other's context. + +### 2 — Define the protocol and explicit lifecycle machine + +**Depends on:** 1. +**Touch/add:** `klbr-ipc/src/lib.rs`, `klbr-core/src/runtime/`, `klbr-core/src/kernel/protocol.rs`, web protocol types/fixtures. + +Remove IPC reexports of core storage/provider types. Define typed client commands, source events, attention frames, host effects, kernel generations and completed outcomes. Keep wire-format evolution versioned. Use one schema/fixture source to check Python and TypeScript consumers; choose code generation only where it removes actual duplicated contracts. + +Implement session transitions such as ready, running, waiting, paused and stopped, with attempt/execution outcomes represented separately. Model cancellation as a request/acknowledged outcome, not a magical guarantee that all external effects were undone. + +Define typed hook slots, decisions/transforms/observers, invocation/schema IDs, declared SDK reads, deadlines, failure contracts and behavior epochs alongside the protocol. Add the minimal invocation/observer-state persistence required by the next slice. Authoritative state transitions await decisions outside their lock/transaction and validate source versions on return. + +**Gate:** invalid state transitions and stale-generation host requests are rejected; two ingress sources cannot race the active destination; management commands do not mutate the store behind the runtime's transaction boundary. + +### 3 — Build the supervised persistent Python workbench + +**Depends on:** 2. +**Add:** `python/klbr-runtime/` with runner, framing, display/output handling and SDK transport; `klbr-core/src/kernel/` supervision. + +Implement handshake, persistent globals, top-level await, final-expression display, output spooling, errors, cancellation, background task labeling and shutdown. Start with a small useful environment, then provision the actual libraries/grammars needed. Port independently useful Prime evaluator ideas only after reviewing their license and coupling; do not transplant the surrounding AgentSession implementation. + +Expose one model tool `python(code=...)`. Record full validated tool calls and completion records. Reuse provider adapters behind an explicit interface rather than rebuilding the whole provider stack. + +**Gate:** variables survive across cells; await/background work functions; stdout cannot forge control frames; output stays bounded; interrupting a blocked kernel yields an explicit reset; one crashed kernel leaves the daemon and other sessions alive. + +### 3a — Build the hook worker and editable policy boundary + +**Depends on:** 2–3. +**Add:** `klbr-core/src/hooks/{spec,dispatch,observations}.rs`, the hook-worker mode under `python/klbr-runtime/`, typed SDK/discovery fixtures, and initial `klbr_hooks` modules in the behavior checkout. Names describe responsibilities, not a required file count. + +Implement importable entrypoints, single-owner policy slots, explicitly ordered transforms, observer deliveries, bounded invocation queues, host-enforced deadlines, source-version validation, generation invalidation and independent repair controls. Start with a static published manifest; live editing arrives in slice 9. Share transport/supervision with the workbench rather than introducing a service framework. An ordinary optional callback does not need a model call. + +Ship the first minimal editable defaults and fake-host fixture runner. Decision/transform handlers return plans rather than directly sending messages. Durable observer acknowledgement/state/proposed commands commit atomically; expensive work is a tracked job once that domain is available. Define unavailable-policy fallbacks before integrating each slot. + +**Gate:** a busy workbench cannot block a send-review hook; a blocked hook cannot block durable ingress or operator cancellation; bad/stale replies cannot partially mutate state; a required guard failure holds its operation; already committed observer commands are not duplicated on retry. Record overhead rather than claiming a performance improvement. + +### 4 — Rebuild the turn loop around explicit effects and yield + +**Depends on:** 1–3a. +**Replace:** relevant `agent/turn.rs` and `agent.rs` flow; `all_tools()` registration in the new runtime. + +Assemble a raw-history context initially. Persist model text as scratchpad, not speech. Execute only the Python tool. Route local sends, waits and control operations through the SDK/host command path. Implement terminal wait semantics, durable wakeups, preemption and bounded no-progress behavior. + +Integrate `turn.prepare`, `turn.next`, `context.prepare`, `model.plan`, `execution.review` and completion hooks into the real turn loop now, using the defaults shipped in Python. Do not hide all ordinary policy in Rust and postpone the extension boundary. Preserve immutable attempt epochs, kernel-request identity and context/source versions. + +Retain autonomous work across multiple model steps. Resolve delivery reminders against typed attention/effect records instead of variables like `discord_send_called`. Finish complete tool groups for provider replay; journal aborted attempts honestly. + +**Gate:** a model that only emits prose cannot accidentally send a message; a wait does not occupy a Python sleep; an input arriving during the wait transition is not stranded; interrupted work retains its source and existing receipts; no empty-response loop consumes unlimited inference. + +### 5 — Reconnect the local UI to durable, distinct output lanes + +**Depends on:** 4. +**Touch:** `klbr-daemon/src/daemon.rs`, `klbr-web/src/{App.svelte,lib/protocol.ts,lib/ui-types.ts}`, relevant CSS/components. + +Preserve the visible distinction among scratchpad, provider reasoning, tool execution and delivered text. Add session/revision/kernel indicators and explicit pending/failed/unknown delivery cards. Replace broadcast-only reconnect behavior with cursored durable replay plus optional token deltas. Route reset, soul edits and compaction commands through the host. + +**Gate:** disconnect during generation, reconnect after completion, and observe one completed scratchpad/cell/delivery record; a hidden/incomplete stream does not masquerade as sent speech; the operator can stop a malfunctioning agent without model cooperation. + +### 6 — Move existing Discord semantics onto the new inbox/outbox + +**Hook integration:** Wire `input.accepted`, `attention.plan`, `delivery.prepare`, `delivery.review` and receipt observers. Initial Python defaults match the frozen social fixtures. All destination, pending-item and reply-anchor mutations remain validated Rust operations; a failed guard never turns scratchpad into speech. + +**Depends on:** 1, 2, 4; use 5 for operator diagnostics. +**Touch:** `klbr-discord/src/{lib,inbox,formatting,tool_defs}.rs`; add Python Discord facade. + +Retain Twilight transport and existing parsing/formatting/reaction knowledge. Replace direct memory-tool registration with typed host commands. Persist conversation aliases, source admission, frame membership, reply anchors, attention decisions and effects. Fix default-destination races, consume reply anchors after delivery, and atomically record receipts plus auto-marking. Preserve configured defaults and expose them outside compiled code. + +Store raw incoming metadata before batching/enrichment. Implement bounded backread and artifact-backed media handling. Add the nonce behavior only after verifying the pinned adapter; distinguish ambiguity from a definitive failure. Include Gateway reconnect/resume handling and a recorded resynchronization gap when the remote service cannot provide every missed event. Do not promise full offline capture of all Discord activity. [W09] + +**Gate:** mixed conversations cannot be confused; a DM wakes an ambient batch promptly; repeated source messages are not duplicated; ignored items stay ignored; a successful send marks only its associated item; retries preserve identity; process failure after remote acceptance does not trigger an unqualified second send. Test against a fake transport first, then a controlled bot/channel with explicit test messages. + +### 7 — Add LCM history and remove compaction's learning dependencies + +**Hook integration:** Wire context/ranking contributions and `compaction.plan`, `summary.prepare` and post-compaction observation. Hooks may alter policy and representations, not authoritative source relationships, hard-budget convergence or compaction commit semantics. + +**Depends on:** 4; use 6 fixtures for mixed-source history. +**Add:** `klbr-core/src/history/{store,compact,assemble,search}.rs` or equivalent modules; Python history facade. +**Replace:** `agent/compaction.rs` and live reflection/recollection injection on this path. + +Implement the chronological hierarchy and active-context coverage invariant. Preserve structured tool groups, scratchpad visibility and effect evidence. Build tool-free bounded summarization with compare-and-swap commit, degraded fallback and a real budget error. Provide exact-reference reads and lexical search. Add large-output artifact references and privacy-aware traversal. + +Do not port note-garden reconciliation, embedding requirements, reranking, or automatic reflection into this layer. Keep old memory available through a read-only import/archive path later. + +**Gate:** property tests recover every covered source item exactly once; a failed/crashed summarizer leaves a valid context; compaction never reports a draft send as confirmed; a restricted child cannot receive a mixed-scope summary; repeated compaction can still recover a correction, an exact command and its receipt. Test actual retrieval quality with recorded tasks separately from structural correctness. + +### 8 — Build the Python coding vocabulary and ordinary skills + +**Hook integration:** Wire `result.present` and outcome observers where useful. Keep complete original code/results and bounded derived previews separately. Do not add hooks around every tree-sitter internal function. + +**Depends on:** 3, 4; 7 supplies historical references. +**Add:** `python/klbr-skills/`, coding fixtures, provisioned grammar environment; move useful query definitions from the old tree-sitter code. + +Implement source snapshots, structural queries, symbol selection, diagnostics, checked patches, structured command results and skill discovery. Preserve useful filesystem operations without preserving each tool's JSON schema. Reuse upstream Python libraries directly where they provide the right language. Add concise import examples to the operational instructions. + +**Gate:** a realistic Rust/TypeScript/Python edit can be inspected, patched and tested through Python; Unicode and ambiguous definitions behave correctly; a stale patch fails; unavailable syntax queries are not mistaken for empty results; a new helper becomes discoverable without a Rust code change. + +### 9 — Ship everyday self-editing and version activation + +**Hook integration:** Extend revision management for component fingerprints, durable hook bindings, dry-run traces, state compatibility and observer activation cursors. Prove that a compatible hook-only edit replaces only the worker, and that old attempts/pending observer deliveries keep their recorded epoch. Never let user hooks veto repair/rollback. + +**Depends on:** 5 and 8; 7 supplies source evidence for changes. +**Add:** `klbr-core/src/behavior/`, behavior checkout/release format, trusted candidate validator, UI revision view; Python behavior facade. + +Externalize the soul and evolving operational instructions. Add draft/publish/validate/activate/rollback. Pin revisions per attempt/kernel; handle prompt-only activation without needless kernel reset. Prepare candidate kernels before switching Python revisions. Keep environment resolution outside live execution. Build source-only edit tests that explicitly do not invoke Cargo. + +Retain a host-admin path for stopping, rolling back and upgrading without the model. Keep private profiles, tokens and test recordings out of public Git commits. + +**Gate:** the agent edits a skill, runs checks, publishes it, adopts it and uses it after restart without rebuilding Rust; a syntax error cannot strand the daemon; a stale candidate cannot overwrite a newer revision; startup failure restores the previous revision; existing jobs remain pinned; rollback does not alter the archival transcript. + +### 10 — Add durable jobs, schedules and resumable Python sources + +**Hook integration:** Use the same jobs/outbox for hook-generated follow-up actions; add `job.plan` and completion/failure observers. Enforce causal loop limits and command quotas. Observers request jobs rather than maintaining another scheduler. + +**Depends on:** 1, 2, 9. +**Add:** `klbr-core/src/jobs/`, scoped routine state/checkpoint operations; Python jobs/source facades. + +Implement tracked commands and importable routines, durable one-shot deadlines, explicit retry policy, checkpoints, cancellation and deduplicated completion events. Introduce source jobs that admit external events with checkpointed cursors. A host restart may restart/reconcile a job; it does not guarantee an arbitrary child process continued running. + +**Gate:** a scheduled routine still fires once after a host restart; an expired deadline does not cause a catch-up storm; non-retry-safe interrupted work becomes unknown/manual rather than silently rerun; a Python source resumes from its saved cursor and deduplicates repeated input. + +### 11 — Add RLM-style child sessions using the same primitives + +**Hook integration:** Expose child planning and result/failure hooks through the job family. Validate suggested budgets and scopes in Rust; reserve root capacity regardless of hook preferences. + +**Depends on:** 7, 9, 10. +**Add:** `klbr-core/src/runtime/children.rs` or equivalent; Python agents facade and child profiles. + +Implement spawn/get/result/cancel, parent notification, evidence grants, per-child kernel/history and worktree assignment. Enforce global and subtree budgets at admission and before provider requests. Make detached versus parent-owned work explicit. Reserve root responsiveness. Child defaults inherit the parent's pinned behavior, not a moving global environment. + +**Gate:** child results survive parent kernel replacement; children do not overwrite root context; cancellation and budget limits terminate further admission; two modifying children never share an unintended workspace; restricted children cannot send on the root's external channels without delegation. + +### 12 — Import existing data, prove recovery, and cut over + +**Depends on:** all required preceding slices. +**Add:** deterministic importer, consistency checker, backup/restore and service deployment procedures; port only meaningful old benchmarks. + +Back up the old SQLite database consistently and retain the archive. Import soul, configuration, source history, actual delivered messages, artifacts and intentional guidance where identifiable. Treat recollections and experimental memory cards as derived/imported material, not equivalent to raw evidence. Preserve stable old reference aliases where unambiguous. Keep ambiguous legacy Discord aliases session/import-scoped rather than guessing one global mapping. + +Run the new daemon against fixtures and then a side-effect-disabled replay. Do not shadow it against live sources with real sends enabled. At cutover, stop the old ingress/agent owner, take the final consistent snapshot, import/verify, start the new daemon, reconcile source cursors/pending deliveries and verify counts. Never run two active senders for the same agent during this transition. + +Prove failure windows: commit-before-notify, remote-send-before-receipt, summary-before-context-swap, wait-before-wakeup, candidate-start-before-activation, kernel failure mid-cell, disk-full while spooling, and websocket lag/reconnect. Run a soak test under a synthetic mixture of idle time, coding tasks, ambient chatter, directed messages and child completions. A proposed minimum 24-hour soak is a release test, not proof of perpetual reliability. + +Remove the superseded live tool registry, tool-name lifecycle branches, old reflection-compaction linkage and deprecated memory write paths after successful cutover. Retain read-only migration fixtures/format support as needed. Do not keep two permanent dialects for send, wait, history or behavior activation. + +**Gate:** restored backups are readable; every locally acknowledged input is accounted for; all effects have honest outcomes; the old and new processes cannot both deliver; the agent's scratchpad/social behavior matches the compatibility fixtures; routine self-editing works on the target NixOS deployment. + +### Deployment and diagnostics included in cutover + +Extend the existing `flake.nix` for a pinned host/interpreter/native closure; add a new NixOS service module rather than relying on an interactive development shell for production. Keep daemon state, artifacts, published behavior revisions and writable drafts in explicit directories. The module must provide service-manager restart, bounded shutdown, credential loading, and resource controls for kernels/jobs. This is a new deliverable, not a claim that the current flake already implements it. A Mac development launch should use the same protocol/behavior fixtures without pretending Linux-specific confinement is portable. + +Expose hook diagnostics as part of the same status surface: selected handler/epoch, invocation latency and fallbacks, held operations, observer backlog/dead-letter records, worker generation/restarts, pinned revisions and bounded state size. + +Expose an operator status view and machine-readable diagnostics: active and last-good revisions; session/kernel state; oldest queued input; directed attention backlog; pending/unknown effects; current jobs and wakeups; context budget and compaction failures; model usage; kernel restarts; database and artifact usage. Correlate records with session/attempt/execution/effect IDs, not unstructured log text alone. Redact credentials and avoid duplicating raw private conversation content into operational logs. Pause new work explicitly on storage failure; never silently acknowledge input that could not be retained. + +Use a consistent SQLite backup method and test restoration with artifacts and referenced release manifests. Copying only the live `.db` file in WAL mode is not a backup contract. Garbage collection must understand retained history, active/pinned jobs and rollback revisions before deleting blobs or environments. Establish a storage budget and make any retention/purge rule explicit; ordinary context compaction must never be an implicit archival deletion policy. [W08] + +## 10. Acceptance scenarios that define success + +### “Think, speak, and wait” + +An operator message arrives. The model writes scratchpad, runs two cells, explicitly sends one local message, and yields. The operator sees the scratchpad diagnostics and one delivered message. A disconnected client later reconstructs the completed records. The agent is idle without repeated empty model requests. + +### “Ambient room, directed DM” + +Ambient messages arrive in two Discord channels; the agent is allowed to stay quiet. A DM arrives during batching. It is admitted immediately and presented with its own destination. The agent replies to that DM, not the most recently active guild channel. Only the correct pending item becomes acted. A separate ignored mention stays ignored across restart. + +### “Self-improvement survives amnesia” + +The agent finds a repeated workflow problem, edits a Python skill and its tests, publishes a revision and starts a new kernel. Its old globals are gone. Its skill, LCM history, soul, pending work and effect receipts survive. The new import works without Cargo. A deliberately broken subsequent candidate fails and does not replace it. + +### “Not said versus said” + +A scratchpad contains a draft saying a message was sent, but the outbox receipt says failure or unknown. After several compactions, the agent can recover the source and still distinguishes intention from actual delivery. It does not tell the operator a fictional conversation happened. + +### “Killed at the worst instant” + +The host dies just after a Discord request may have succeeded. Recovery does not blindly replay the Python cell. It uses the persisted effect identity and available reconciliation evidence. If the delivery cannot be established, the system shows uncertainty. A missing guarantee is represented rather than hidden. + +### “Long-lived work without a long-lived stack” + +A child and a scheduled routine outlive the root kernel. Their identities, revisions and outcomes remain queryable. The root wakes to a deduplicated completion event and continues from artifacts and history, without needing its former Python coroutine. + +### “Self-edit the policy, retain the workbench” + +The agent edits attention/model/context policy in Python, validates it against captured fixtures with fake effects, and activates a compatible hook-only revision. Existing variables remain in the workbench; an in-flight attempt keeps its old policy epoch and future attempts use the new one. A broken candidate does not replace working hooks or disable operator repair. + +### “No callback circular wait” + +A workbench cell awaits a host send that requires Python review. Review runs in the independent hook worker without holding the host state lock. Its valid answer allows the tracked operation to proceed. When review hangs, its deadline produces a held operation, while new inputs and operator controls remain available. + +### “Observer crash and revision replay” + +Crash an observer before and after its atomic state/command/acknowledgement commit. Local follow-up commands are not duplicated; unresolved external effects remain honestly uncertain. A new handler revision does not silently redeliver already completed history. An explicit replay is identified and uses fake effects by default. + +## 11. Tradeoffs and the cases that would change this recommendation + +Rust is now an explicit architectural requirement, not a provisional salvage decision. Its type/resource model and efficient always-on execution are valuable, while logical recovery and service behavior still need tests. Python handles everyday policy as well as skills. New durable primitives or changes to host invariants still require normal Rust development; the hook boundary is not a claim that all future core work disappears. [W10, W11] + +The supervised hook worker is included from slice 3a. It adds a process boundary and bounded dispatch/activation state, but has no agent loop, independent database authority or scheduler. Its concrete benefit is that core callbacks do not depend on the interactive kernel being available. Reuse the runtime protocol/supervisor and measure the overhead. Do not grow it into a second orchestrator. [W12] + +Fresh kernels for affected skill/environment revisions sacrifice live handles and caches. Hook-only compatible revisions replace their worker instead; prompt-only changes need no Python restart. Use component fingerprints, cheap reconstruction helpers and long-lived host jobs to minimize disruption. Transparent heap migration is not a hidden prerequisite for self-editing. + +One unified root session preserves the current agent experience but permits information from multiple conversations to share its working context. Scoped child access and explicit destinations reduce accidental mixing; they do not constitute mathematical noninterference for the root model. Stricter isolation would require separate principals/sessions and is an explicit product change. + +SQLite, a persistent Python environment and a library-rich coding skill may not minimize repository size or dependency closure. Evaluate them by the boundaries they simplify, real installation footprint, startup behavior, memory growth and maintenance. Do not claim a line-count reduction is a reliability result. [W01, K15] + +## 12. Completion and scope notes + +This plan is implementable in ordered slices, but none of the replacement runtime, migrations, protocol or recovery tests described here were executed in preparing it. The attached source was inspected; excerpts are in the companion. The prior Prime audit applies to the saved snapshot identified there, not verified current upstream HEAD. No new claim of Prime test coverage is made here. + +Public API shapes shown in section 6 and `hooks-and-policy.md` are proposed. The hook addendum and integrated revision are design work only; document-consistency checks are not runtime validation. Package capabilities and lifecycle caveats were checked against primary documentation; exact versions and NixOS compatibility must be pinned and tested during the relevant slice. The archive includes existing code and implementation-status documents, not a production database; import outcomes cannot be validated from it alone. + +The design's intended result is **the same continuous, socially autonomous agent, with inspectable thought, deliberate speech, durable history and commitments, and a Python working vocabulary it can improve itself**. The part it improves most often should be easy to change; the part responsible for surviving those changes should stay understandable and recoverable. + +## Sources + +K01–K15: exact supplied-source excerpts and SHA-256 identities in `behavior-contracts.md`. The supplied Lucid pack's `lucid-code`, `lucid-review`, `dependency-recon`, and semantic-compression guide informed contract/accident separation, domain notation, dependency choice, and state ownership. The prior uploaded audits remain supplementary evidence, not substitute source for the scratchpad/Discord inspection. + +W01 — jyn, “simple is not small”: https://jyn.dev/simple-is-not-the-same-as-small/ +W02 — Ideonomy README, plain skill and methods catalog (Grace Kind / Patrick Gunkel framework; read as idea-space material rather than an implementation dependency): https://github.com/latentwill/ideonomy-skill +W03 — Ehrlich and Blackman, “LCM: Lossless Context Management,” architecture and limitations: https://arxiv.org/html/2605.04050v1 +W04 — Python `importlib.reload` behavior and caveats: https://docs.python.org/3/library/importlib.html +W05 — Discord Message Resource, create-message, reply/allowed-mention fields and bounded nonce uniqueness: https://docs.discord.com/developers/resources/message +W06 — Primary Python tree-sitter bindings and language-pack documentation: https://github.com/tree-sitter/py-tree-sitter and https://github.com/xberg-io/tree-sitter-language-pack +W07 — uv project and locked synchronization semantics: https://docs.astral.sh/uv/guides/projects/ and https://docs.astral.sh/uv/concepts/projects/sync/ +W08 — SQLite WAL, synchronous and backup guarantees: https://sqlite.org/wal.html ; https://sqlite.org/pragma.html#pragma_synchronous ; https://sqlite.org/backup.html +W09 — Discord Gateway connection and resume behavior: https://docs.discord.com/developers/events/gateway + +W10 — Rustonomicon, data races versus other concurrency errors: https://doc.rust-lang.org/nomicon/races.html +W11 — Rust project, ownership/performance/reliability rationale: https://rust-lang.org/ +W12 — Python asyncio, cooperative scheduling and blocking code: https://docs.python.org/3/library/asyncio-dev.html diff --git a/docs/runtime-v2/design/lucid-code-skill-pack-main.tar.gz b/docs/runtime-v2/design/lucid-code-skill-pack-main.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..99813f8d3f8c456ce83c37c7c28ac725f7dab16b GIT binary patch literal 29290 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

bO9)@YdTaO-ut0-(Y8- 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 R&+V^g$ga=!WSGFn0085vpril* literal 0 HcmV?d00001 diff --git a/docs/runtime-v2/design/revision-notes.md b/docs/runtime-v2/design/revision-notes.md new file mode 100644 index 0000000..46179eb --- /dev/null +++ b/docs/runtime-v2/design/revision-notes.md @@ -0,0 +1,33 @@ +# klbr plan v2 — what changed + +Date: September 6, 2026. Design revision only; no klbr runtime implementation or failure testing was performed. + +## Read this version + +`implementation-plan.md` is the integrated full plan. `hooks-and-policy.md` defines the detailed extension contract. `behavior-contracts.md` preserves the original source evidence unchanged. + +The earlier plan suggested deferring a policy boundary and mainly making skills/settings editable. That is superseded: Python policy hooks are now a first-class requirement implemented before the replacement turn loop. Rust is a deliberate foundation for typed resource ownership, reliability engineering and efficient always-on execution, not merely a way to reuse an existing codebase. + +## Architecture + +One Rust daemon owns persistence, provider/transport execution, state transitions, resource limits, receipts, scheduling mechanisms and recovery. Python owns ordinary policies, skills and guidance. One persistent workbench per active agent session provides interactive computation. A supervised persistent hook-worker role evaluates published callbacks independently of that workbench, using the same Python runtime and transport. It is not another agent orchestrator. + +The host exposes single-owner typed decision slots, ordered representation transforms and asynchronous post-commit observers. Meaningful hook families cover input/attention, turns, context/history, model planning, execution/results, explicit delivery, LCM, jobs/children and runtime/revisions. Rust validates each proposed change against domain invariants. The public hook vocabulary is broad; arbitrary internal functions and database writes are not public mutation points. + +Default attention/context/model/social policies are ordinary editable Python. Durable hooks are published module entrypoints, not cell-local closures. The workbench can discover, edit, test, bind and activate them. Ordinary callable skills still need no hook registration. + +## The important failure boundary + +A Python cell awaiting `discord.send` must not wait for a review callback queued behind itself in the same workbench. The independent hook worker solves that placement problem. Host invocation also releases transactions/mutation locks, permits its bounded read RPC and uses Rust-owned deadlines; otherwise moving the callback alone would not solve the deadlock. + +Optional hook failure may produce documented degradation. Required send/execution review failure holds the specific operation. Durable ingress and operator cancellation/rollback remain available even when every Python process is unhealthy. Post-commit observers can retry without duplicating atomically recorded host commands; arbitrary direct Python effects still do not gain an exactly-once guarantee. + +## Self-editability improvement + +A single authoritative activation manifest references instructions/settings, skills/environment and hooks. A compatible hook-only edit activates a new worker without clearing workbench variables. Shared dependency changes replace all affected roles. In-flight attempts/effects remain epoch-pinned; observer activation has explicit cursors and does not replay old events merely because code changed. Broken candidate hooks cannot veto their own repair. + +## Plan delta + +Slice 2 now defines hook contracts and invocation state. New slice 3a implements the worker, defaults and dispatcher. Slice 4 uses hooks in the real turn loop rather than deferring them. Subsequent domain slices add their own hooks. Slice 9 adds component activation, dry runs and state/replay compatibility. Cutover adds hook latency, queue/backlog and blocked-worker tests. + +The primary success case is now: improve attention, context/model policy, delivery formatting or result handling entirely in Python, activate it without Cargo, preserve a compatible live workbench, and keep accepted work and honest receipts through a failed next edit. diff --git a/docs/runtime-v2/evidence/checks-python.log b/docs/runtime-v2/evidence/checks-python.log new file mode 100644 index 0000000..5c06766 --- /dev/null +++ b/docs/runtime-v2/evidence/checks-python.log @@ -0,0 +1,53 @@ +test_duplicate_keys_and_nonfinite_values_rejected (test_contracts.FrameTests.test_duplicate_keys_and_nonfinite_values_rejected) ... ok +test_length_prefix_roundtrip_unicode (test_contracts.FrameTests.test_length_prefix_roundtrip_unicode) ... ok +test_oversize_rejected_before_payload_read (test_contracts.FrameTests.test_oversize_rejected_before_payload_read) ... ok +test_shared_golden_frames (test_contracts.FrameTests.test_shared_golden_frames) ... ok +test_truncated_frames_fail (test_contracts.FrameTests.test_truncated_frames_fail) ... ok +test_burst_has_one_inbox_row_per_input (test_contracts.SchemaTests.test_burst_has_one_inbox_row_per_input) ... ok +test_confirmed_effect_requires_receipt (test_contracts.SchemaTests.test_confirmed_effect_requires_receipt) ... ok +test_dedup_is_by_request_not_message_text (test_contracts.SchemaTests.test_dedup_is_by_request_not_message_text) ... ok +test_foreign_keys_prevent_orphaned_inbox (test_contracts.SchemaTests.test_foreign_keys_prevent_orphaned_inbox) ... ok +test_impossible_ready_with_deadline_is_rejected (test_contracts.SchemaTests.test_impossible_ready_with_deadline_is_rejected) ... ok +test_originals_cannot_be_updated_or_deleted (test_contracts.SchemaTests.test_originals_cannot_be_updated_or_deleted) ... ok +test_publication_transaction_rolls_back_receipt_and_event_together (test_contracts.SchemaTests.test_publication_transaction_rolls_back_receipt_and_event_together) ... ok +test_schema_identity (test_contracts.SchemaTests.test_schema_identity) ... ok +test_source_identity_is_unique_per_session (test_contracts.SchemaTests.test_source_identity_is_unique_per_session) ... ok +test_unfinished_execution_unique_with_independent_sessions (test_contracts.SchemaTests.test_unfinished_execution_unique_with_independent_sessions) ... ok +test_yield_does_not_open_a_second_foreground_slot (test_contracts.SchemaTests.test_yield_does_not_open_a_second_foreground_slot) ... ok +test_behavior_identity_changes_with_source (test_contracts.ValueTests.test_behavior_identity_changes_with_source) ... ok +test_behavior_root_symlinks_rejected (test_contracts.ValueTests.test_behavior_root_symlinks_rejected) ... ok +test_behavior_symlinks_rejected (test_contracts.ValueTests.test_behavior_symlinks_rejected) ... ok +test_decision_shapes_are_explicit (test_contracts.ValueTests.test_decision_shapes_are_explicit) ... ok +test_oversized_behavior_file_rejected (test_contracts.ValueTests.test_oversized_behavior_file_rejected) ... ok +test_shared_behavior_digest (test_contracts.ValueTests.test_shared_behavior_digest) ... ok +test_bad_candidate_does_not_affect_existing_worker (test_processes.PolicyProcessTests.test_bad_candidate_does_not_affect_existing_worker) ... ok +test_blocked_policy_does_not_block_workbench_and_can_be_killed (test_processes.PolicyProcessTests.test_blocked_policy_does_not_block_workbench_and_can_be_killed) ... ok +test_editable_defaults_match_attention_contract (test_processes.PolicyProcessTests.test_editable_defaults_match_attention_contract) ... ok +test_hook_cannot_use_workbench_effect_sdk (test_processes.PolicyProcessTests.test_hook_cannot_use_workbench_effect_sdk) ... ok +test_hook_worker_available_while_workbench_cpu_blocked (test_processes.PolicyProcessTests.test_hook_worker_available_while_workbench_cpu_blocked) ... ok +test_replacing_hook_worker_preserves_workbench_heap (test_processes.PolicyProcessTests.test_replacing_hook_worker_preserves_workbench_heap) ... ok +test_send_waits_for_independent_hook_without_circular_wait (test_processes.PolicyProcessTests.test_send_waits_for_independent_hook_without_circular_wait) ... ok +test_wrong_hook_return_is_failure_not_approval (test_processes.PolicyProcessTests.test_wrong_hook_return_is_failure_not_approval) ... ok +test_background_effects_expire_with_originating_cell (test_processes.WorkbenchTests.test_background_effects_expire_with_originating_cell) ... ok +test_background_output_is_not_reattributed_to_next_cell (test_processes.WorkbenchTests.test_background_output_is_not_reattributed_to_next_cell) ... ok +test_catching_yield_does_not_restore_effect_scope (test_processes.WorkbenchTests.test_catching_yield_does_not_restore_effect_scope) ... ok +test_cooperative_interrupt_preserves_globals (test_processes.WorkbenchTests.test_cooperative_interrupt_preserves_globals) ... ok +test_explicit_send_roundtrip (test_processes.WorkbenchTests.test_explicit_send_roundtrip) ... ok +test_future_flags_survive_cells (test_processes.WorkbenchTests.test_future_flags_survive_cells) ... ok +test_host_error_is_inspectable_and_kernel_recovers (test_processes.WorkbenchTests.test_host_error_is_inspectable_and_kernel_recovers) ... ok +test_invalid_wait_values_never_reach_host (test_processes.WorkbenchTests.test_invalid_wait_values_never_reach_host) ... ok +test_late_reply_after_interrupt_is_discarded (test_processes.WorkbenchTests.test_late_reply_after_interrupt_is_discarded) ... ok +test_many_alternating_writes_bound_record_count (test_processes.WorkbenchTests.test_many_alternating_writes_bound_record_count) ... ok +test_native_exit_does_not_claim_completion (test_processes.WorkbenchTests.test_native_exit_does_not_claim_completion) ... ok +test_output_is_bounded_and_truncation_explicit (test_processes.WorkbenchTests.test_output_is_bounded_and_truncation_explicit) ... ok +test_persistent_namespace_and_last_expression (test_processes.WorkbenchTests.test_persistent_namespace_and_last_expression) ... ok +test_print_and_native_stdout_cannot_send_or_forge_control (test_processes.WorkbenchTests.test_print_and_native_stdout_cannot_send_or_forge_control) ... ok +test_stale_generation_is_connection_fatal (test_processes.WorkbenchTests.test_stale_generation_is_connection_fatal) ... ok +test_syntax_and_runtime_errors_do_not_destroy_namespace (test_processes.WorkbenchTests.test_syntax_and_runtime_errors_do_not_destroy_namespace) ... ok +test_top_level_await_and_background_computation (test_processes.WorkbenchTests.test_top_level_await_and_background_computation) ... ok +test_wait_is_terminal_not_a_sleep (test_processes.WorkbenchTests.test_wait_is_terminal_not_a_sleep) ... ok + +---------------------------------------------------------------------- +Ran 48 tests in 16.187s + +OK diff --git a/docs/runtime-v2/evidence/checks.json b/docs/runtime-v2/evidence/checks.json new file mode 100644 index 0000000..57a47b2 --- /dev/null +++ b/docs/runtime-v2/evidence/checks.json @@ -0,0 +1,34 @@ +{ + "created_at_utc": "2026-09-05T21:52:17.203334+00:00", + "python": "3.13.5 (main, Jul 15 2026, 20:25:40) [GCC 14.2.0]", + "platform": "Linux-6.18.35-x86_64-with-glibc2.41", + "python_sqlite": "3.46.1", + "python_sqlite_scope": "serial in-memory migration tests only; not runtime WAL qualification", + "cargo_available": false, + "rustc_available": false, + "rust_checks_requested": false, + "steps": [ + { + "name": "python", + "argv": [ + "/opt/pyvenv/bin/python", + "-B", + "-m", + "unittest", + "discover", + "-s", + "python/klbr-runtime/tests", + "-v" + ], + "exit_code": 0, + "elapsed_seconds": 16.787, + "log": "checks-python.log" + } + ], + "manifests": { + "status": "passed", + "meaning": "manifest/lock-reference consistency only; Cargo resolver not run" + }, + "rust_status": "not run; pass --rust on a machine with Cargo/rustc", + "requested_checks_passed": true +} diff --git a/docs/runtime-v2/evidence/full-checks-python.log b/docs/runtime-v2/evidence/full-checks-python.log new file mode 100644 index 0000000..98b58ff --- /dev/null +++ b/docs/runtime-v2/evidence/full-checks-python.log @@ -0,0 +1,53 @@ +test_duplicate_keys_and_nonfinite_values_rejected (test_contracts.FrameTests.test_duplicate_keys_and_nonfinite_values_rejected) ... ok +test_length_prefix_roundtrip_unicode (test_contracts.FrameTests.test_length_prefix_roundtrip_unicode) ... ok +test_oversize_rejected_before_payload_read (test_contracts.FrameTests.test_oversize_rejected_before_payload_read) ... ok +test_shared_golden_frames (test_contracts.FrameTests.test_shared_golden_frames) ... ok +test_truncated_frames_fail (test_contracts.FrameTests.test_truncated_frames_fail) ... ok +test_burst_has_one_inbox_row_per_input (test_contracts.SchemaTests.test_burst_has_one_inbox_row_per_input) ... ok +test_confirmed_effect_requires_receipt (test_contracts.SchemaTests.test_confirmed_effect_requires_receipt) ... ok +test_dedup_is_by_request_not_message_text (test_contracts.SchemaTests.test_dedup_is_by_request_not_message_text) ... ok +test_foreign_keys_prevent_orphaned_inbox (test_contracts.SchemaTests.test_foreign_keys_prevent_orphaned_inbox) ... ok +test_impossible_ready_with_deadline_is_rejected (test_contracts.SchemaTests.test_impossible_ready_with_deadline_is_rejected) ... ok +test_originals_cannot_be_updated_or_deleted (test_contracts.SchemaTests.test_originals_cannot_be_updated_or_deleted) ... ok +test_publication_transaction_rolls_back_receipt_and_event_together (test_contracts.SchemaTests.test_publication_transaction_rolls_back_receipt_and_event_together) ... ok +test_schema_identity (test_contracts.SchemaTests.test_schema_identity) ... ok +test_source_identity_is_unique_per_session (test_contracts.SchemaTests.test_source_identity_is_unique_per_session) ... ok +test_unfinished_execution_unique_with_independent_sessions (test_contracts.SchemaTests.test_unfinished_execution_unique_with_independent_sessions) ... ok +test_yield_does_not_open_a_second_foreground_slot (test_contracts.SchemaTests.test_yield_does_not_open_a_second_foreground_slot) ... ok +test_behavior_identity_changes_with_source (test_contracts.ValueTests.test_behavior_identity_changes_with_source) ... ok +test_behavior_root_symlinks_rejected (test_contracts.ValueTests.test_behavior_root_symlinks_rejected) ... ok +test_behavior_symlinks_rejected (test_contracts.ValueTests.test_behavior_symlinks_rejected) ... ok +test_decision_shapes_are_explicit (test_contracts.ValueTests.test_decision_shapes_are_explicit) ... ok +test_oversized_behavior_file_rejected (test_contracts.ValueTests.test_oversized_behavior_file_rejected) ... ok +test_shared_behavior_digest (test_contracts.ValueTests.test_shared_behavior_digest) ... ok +test_bad_candidate_does_not_affect_existing_worker (test_processes.PolicyProcessTests.test_bad_candidate_does_not_affect_existing_worker) ... ok +test_blocked_policy_does_not_block_workbench_and_can_be_killed (test_processes.PolicyProcessTests.test_blocked_policy_does_not_block_workbench_and_can_be_killed) ... ok +test_editable_defaults_match_attention_contract (test_processes.PolicyProcessTests.test_editable_defaults_match_attention_contract) ... ok +test_hook_cannot_use_workbench_effect_sdk (test_processes.PolicyProcessTests.test_hook_cannot_use_workbench_effect_sdk) ... ok +test_hook_worker_available_while_workbench_cpu_blocked (test_processes.PolicyProcessTests.test_hook_worker_available_while_workbench_cpu_blocked) ... ok +test_replacing_hook_worker_preserves_workbench_heap (test_processes.PolicyProcessTests.test_replacing_hook_worker_preserves_workbench_heap) ... ok +test_send_waits_for_independent_hook_without_circular_wait (test_processes.PolicyProcessTests.test_send_waits_for_independent_hook_without_circular_wait) ... ok +test_wrong_hook_return_is_failure_not_approval (test_processes.PolicyProcessTests.test_wrong_hook_return_is_failure_not_approval) ... ok +test_background_effects_expire_with_originating_cell (test_processes.WorkbenchTests.test_background_effects_expire_with_originating_cell) ... ok +test_background_output_is_not_reattributed_to_next_cell (test_processes.WorkbenchTests.test_background_output_is_not_reattributed_to_next_cell) ... ok +test_catching_yield_does_not_restore_effect_scope (test_processes.WorkbenchTests.test_catching_yield_does_not_restore_effect_scope) ... ok +test_cooperative_interrupt_preserves_globals (test_processes.WorkbenchTests.test_cooperative_interrupt_preserves_globals) ... ok +test_explicit_send_roundtrip (test_processes.WorkbenchTests.test_explicit_send_roundtrip) ... ok +test_future_flags_survive_cells (test_processes.WorkbenchTests.test_future_flags_survive_cells) ... ok +test_host_error_is_inspectable_and_kernel_recovers (test_processes.WorkbenchTests.test_host_error_is_inspectable_and_kernel_recovers) ... ok +test_invalid_wait_values_never_reach_host (test_processes.WorkbenchTests.test_invalid_wait_values_never_reach_host) ... ok +test_late_reply_after_interrupt_is_discarded (test_processes.WorkbenchTests.test_late_reply_after_interrupt_is_discarded) ... ok +test_many_alternating_writes_bound_record_count (test_processes.WorkbenchTests.test_many_alternating_writes_bound_record_count) ... ok +test_native_exit_does_not_claim_completion (test_processes.WorkbenchTests.test_native_exit_does_not_claim_completion) ... ok +test_output_is_bounded_and_truncation_explicit (test_processes.WorkbenchTests.test_output_is_bounded_and_truncation_explicit) ... ok +test_persistent_namespace_and_last_expression (test_processes.WorkbenchTests.test_persistent_namespace_and_last_expression) ... ok +test_print_and_native_stdout_cannot_send_or_forge_control (test_processes.WorkbenchTests.test_print_and_native_stdout_cannot_send_or_forge_control) ... ok +test_stale_generation_is_connection_fatal (test_processes.WorkbenchTests.test_stale_generation_is_connection_fatal) ... ok +test_syntax_and_runtime_errors_do_not_destroy_namespace (test_processes.WorkbenchTests.test_syntax_and_runtime_errors_do_not_destroy_namespace) ... ok +test_top_level_await_and_background_computation (test_processes.WorkbenchTests.test_top_level_await_and_background_computation) ... ok +test_wait_is_terminal_not_a_sleep (test_processes.WorkbenchTests.test_wait_is_terminal_not_a_sleep) ... ok + +---------------------------------------------------------------------- +Ran 48 tests in 15.714s + +OK diff --git a/docs/runtime-v2/evidence/full-checks.json b/docs/runtime-v2/evidence/full-checks.json new file mode 100644 index 0000000..1224202 --- /dev/null +++ b/docs/runtime-v2/evidence/full-checks.json @@ -0,0 +1,38 @@ +{ + "created_at_utc": "2026-09-05T21:49:44.459865+00:00", + "python": "3.13.5 (main, Jul 15 2026, 20:25:40) [GCC 14.2.0]", + "platform": "Linux-6.18.35-x86_64-with-glibc2.41", + "python_sqlite": "3.46.1", + "python_sqlite_scope": "serial in-memory migration tests only; not runtime WAL qualification", + "cargo_available": false, + "rustc_available": false, + "rust_checks_requested": true, + "steps": [ + { + "name": "python", + "argv": [ + "/opt/pyvenv/bin/python", + "-B", + "-m", + "unittest", + "discover", + "-s", + "python/klbr-runtime/tests", + "-v" + ], + "exit_code": 0, + "elapsed_seconds": 16.332, + "log": "full-checks-python.log" + }, + { + "name": "rust", + "exit_code": 127, + "reason": "Cargo/rustc unavailable; no Rust tests or integration checks ran" + } + ], + "manifests": { + "status": "passed", + "meaning": "manifest/lock-reference consistency only; Cargo resolver not run" + }, + "requested_checks_passed": false +} diff --git a/klbr-runtime/AGENTS.md b/klbr-runtime/AGENTS.md new file mode 100644 index 0000000..be05c0c --- /dev/null +++ b/klbr-runtime/AGENTS.md @@ -0,0 +1,15 @@ +# New runtime slice + +Read `../RUNTIME-V2.md`, `../docs/runtime-v2/STATUS.md` and `HANDOFF.md` first. +This crate is an additive replacement boundary, not yet the live daemon. + +The producer had no Rust toolchain. Compile, format and run the real Rust tests before +expanding the design; do not cite the Python peer tests as Rust integration coverage. +Keep core invariants in Rust and ordinary policies in editable Python. No SQL transaction +or domain mutation lock across a hook. No automatic replay of arbitrary cells. Preserve +scratchpad/workbench-output versus explicit speech. A missing required guard holds its effect. +Hook-only replacement pins in-flight cells and must not wipe a compatible workbench namespace. + +Add hooks only with their real domain, typed input/result and failure contract. Do not add +an empty facade for every future slot. The Python test peer is not production orchestration. +Do not migrate or replace the old memory DB until a deterministic importer/cutover is tested. diff --git a/klbr-runtime/Cargo.toml b/klbr-runtime/Cargo.toml new file mode 100644 index 0000000..06e680a --- /dev/null +++ b/klbr-runtime/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "klbr-runtime" +version = "0.1.0" +edition = "2021" +rust-version = "1.89" +publish = false + +[dependencies] +anyhow.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +tokio.workspace = true +uuid.workspace = true diff --git a/klbr-runtime/examples/workbench.rs b/klbr-runtime/examples/workbench.rs new file mode 100644 index 0000000..cb7e73f --- /dev/null +++ b/klbr-runtime/examples/workbench.rs @@ -0,0 +1,74 @@ +//! Run from the workspace root: cargo run -p klbr-runtime --example workbench +//! No providers, credentials or real Discord effects are used. +use anyhow::Result; +use klbr_runtime::{ + behavior::Release, protocol::SessionId, session::SessionRuntime, store::Store, + worker::WorkerConfig, +}; +use std::{fs, path::PathBuf, time::Duration}; + +#[tokio::main] +async fn main() -> Result<()> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf(); + let state = tempfile::tempdir()?; + let release = Release::publish(&root.join("behavior"), &state.path().join("releases"))?; + let store = Store::open(state.path().join("runtime.sqlite"))?; + let session = SessionRuntime::start( + store, + SessionId::fresh(), + WorkerConfig::source_tree(&root), + release, + ) + .await?; + session + .store + .scratchpad( + &session.id, + "i am preparing a small experiment, not sending this text".into(), + ) + .await?; + let first = session + .execute( + "answer = 6 * 7\nprint('workbench output, not speech')\nanswer".into(), + Duration::from_secs(10), + ) + .await?; + let second = session.execute("from klbr import local, hooks\nreceipt = await local.send(f'the answer is {answer}')\nreceipt".into(),Duration::from_secs(10)).await?; + // A hook-only source change; the variable `answer` stays in the workbench. + let draft = state.path().join("draft"); + fs::create_dir_all(draft.join("klbr_hooks"))?; + fs::copy( + root.join("behavior/manifest.json"), + draft.join("manifest.json"), + )?; + fs::copy( + root.join("behavior/klbr_hooks/__init__.py"), + draft.join("klbr_hooks/__init__.py"), + )?; + let mut source = fs::read_to_string(root.join("behavior/klbr_hooks/defaults.py"))?; + source.push_str("\nasync def delivery(request, context):\n return DeliveryDecision.hold('testing a new guard')\n"); + fs::write(draft.join("klbr_hooks/defaults.py"), source)?; + let candidate = Release::publish(&draft, &state.path().join("releases"))?; + session + .activate_hooks(session.hook_epoch().await, candidate) + .await?; + let held = session + .execute( + "assert answer == 42\n(await local.send('this stays held')).status".into(), + Duration::from_secs(10), + ) + .await?; + let third = session.execute("from klbr import runtime\nawait runtime.wait(reason='waiting for an input')\nraise RuntimeError('must not run')".into(),Duration::from_secs(10)).await?; + for report in [first, second, held, third] { + println!("{}", serde_json::to_string_pretty(&report)?); + } + println!( + "durable history:\n{}", + serde_json::to_string_pretty(&session.store.history(&session.id, 0, 100).await?)? + ); + session.shutdown().await; + Ok(()) +} diff --git a/klbr-runtime/migrations/001_runtime.sql b/klbr-runtime/migrations/001_runtime.sql new file mode 100644 index 0000000..5e9d527 --- /dev/null +++ b/klbr-runtime/migrations/001_runtime.sql @@ -0,0 +1,77 @@ +-- New runtime database only. Never apply this migration to the legacy memory DB. +BEGIN IMMEDIATE; +PRAGMA application_id = 1263288882; -- KLB2 +PRAGMA user_version = 1; + +CREATE TABLE releases ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL +) STRICT; + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + state TEXT NOT NULL DEFAULT 'ready' CHECK(state IN ('ready','waiting')), + wake_at_ms INTEGER, + hook_revision TEXT REFERENCES releases(id), + hook_epoch INTEGER NOT NULL DEFAULT 0 CHECK(hook_epoch >= 0), + CHECK(state = 'waiting' OR wake_at_ms IS NULL) +) STRICT; + +CREATE TABLE events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + kind TEXT NOT NULL CHECK(kind IN ( + 'input','scratchpad','execution.started','execution.completed', + 'local.sent','delivery.held','kernel.reset','session.waited', + 'session.woke','hooks.activated' + )), + source_key TEXT, + payload TEXT NOT NULL CHECK(json_valid(payload)), + created_ms INTEGER NOT NULL CHECK(created_ms >= 0), + UNIQUE(session_id, source_key) +) STRICT; +CREATE INDEX events_session_order ON events(session_id, seq); +CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN + SELECT RAISE(ABORT, 'historical events are immutable'); +END; +CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN + SELECT RAISE(ABORT, 'historical events are immutable'); +END; + +CREATE TABLE inbox ( + event_id INTEGER PRIMARY KEY REFERENCES events(seq), + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','consumed')) +) STRICT; + +CREATE TABLE executions ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + generation TEXT NOT NULL, + hook_revision TEXT NOT NULL REFERENCES releases(id), + state TEXT NOT NULL CHECK(state IN ('running','ok','error','yielded','interrupted')), + started_event INTEGER NOT NULL UNIQUE REFERENCES events(seq), + completed_event INTEGER UNIQUE REFERENCES events(seq) +) STRICT; +CREATE UNIQUE INDEX one_running_execution ON executions(session_id) WHERE completed_event IS NULL; + +CREATE TABLE effects ( + id TEXT PRIMARY KEY, + execution_id TEXT NOT NULL REFERENCES executions(id), + request_id TEXT NOT NULL, + text TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('proposed','confirmed','held')), + event_id INTEGER UNIQUE REFERENCES events(seq), + reason TEXT, + UNIQUE(execution_id,request_id), + CHECK((status = 'confirmed' AND event_id IS NOT NULL AND reason IS NULL) + OR (status = 'held' AND event_id IS NULL AND reason IS NOT NULL) + OR (status = 'proposed' AND event_id IS NULL AND reason IS NULL)) +) STRICT; + +CREATE TABLE hook_traces ( + effect_id TEXT PRIMARY KEY REFERENCES effects(id), + revision TEXT NOT NULL REFERENCES releases(id), + decision TEXT NOT NULL CHECK(json_valid(decision)), + fault TEXT +) STRICT; +COMMIT; diff --git a/klbr-runtime/src/behavior.rs b/klbr-runtime/src/behavior.rs new file mode 100644 index 0000000..59cbd81 --- /dev/null +++ b/klbr-runtime/src/behavior.rs @@ -0,0 +1,164 @@ +//! Content-addressed behavior snapshots, separate from activation authority. +use crate::protocol::HookSlot; +use anyhow::{ensure, Context, Result}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs, + io::Write, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Debug)] +pub struct Release { + pub id: String, + pub path: PathBuf, + pub hooks: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + version: u16, + hooks: BTreeMap, +} + +fn collect( + root: &Path, + directory: &Path, + entries: &mut Vec<(String, Vec)>, + bytes: &mut usize, +) -> Result<()> { + for entry in fs::read_dir(directory)? { + let entry = entry?; + let metadata = entry.file_type()?; + ensure!( + !metadata.is_symlink(), + "behavior snapshots cannot contain symlinks" + ); + if metadata.is_dir() { + collect(root, &entry.path(), entries, bytes)?; + continue; + } + ensure!(metadata.is_file(), "behavior entry must be a regular file"); + let path = entry.path(); + ensure!( + matches!( + path.extension().and_then(|s| s.to_str()), + Some("py" | "json" | "md") + ), + "unsupported behavior file" + ); + ensure!( + entry.metadata()?.len() <= 2_097_152, + "behavior file too large" + ); + let content = fs::read(&path)?; + *bytes += content.len(); + ensure!( + *bytes <= 2_097_152 && entries.len() < 128, + "behavior snapshot exceeds limits" + ); + let name = path + .strip_prefix(root)? + .to_str() + .context("non-UTF8 behavior path")? + .replace('\\', "/"); + entries.push((name, content)); + } + Ok(()) +} + +fn read(root: &Path) -> Result<(String, Vec<(String, Vec)>, Manifest)> { + ensure!( + !fs::symlink_metadata(root)?.file_type().is_symlink(), + "behavior root cannot be a symlink" + ); + let mut entries = Vec::new(); + let mut total = 0; + collect(root, root, &mut entries, &mut total)?; + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut digest = Sha256::new(); + for (name, content) in &entries { + digest.update((name.len() as u64).to_be_bytes()); + digest.update(name.as_bytes()); + digest.update((content.len() as u64).to_be_bytes()); + digest.update(content); + } + let manifest: Manifest = serde_json::from_slice( + &entries + .iter() + .find(|(name, _)| name == "manifest.json") + .context("missing manifest.json")? + .1, + )?; + ensure!( + manifest.version == 1, + "unsupported behavior manifest version" + ); + ensure!( + manifest.hooks.len() == 2 + && manifest.hooks.contains_key(&HookSlot::Attention) + && manifest.hooks.contains_key(&HookSlot::Delivery), + "both implemented hook slots are required" + ); + Ok((format!("{:x}", digest.finalize()), entries, manifest)) +} + +impl Release { + /// Snapshot draft bytes once, then atomically publish their directory. No + /// active pointer is changed here. Deployment must protect published paths. + pub fn publish(source: &Path, releases: &Path) -> Result { + let (id, entries, _) = read(source)?; + fs::create_dir_all(releases)?; + let destination = releases.join(&id); + if !destination.exists() { + let candidate = tempfile::Builder::new() + .prefix(".candidate-") + .tempdir_in(releases)?; + for (name, content) in entries { + let path = candidate.path().join(name); + fs::create_dir_all(path.parent().context("file has no parent")?)?; + let mut file = fs::File::create(&path)?; + file.write_all(&content)?; + file.sync_all()?; + } + sync_directories(candidate.path())?; + // fs::rename is atomic within this directory. A competing publisher + // may have won; verify its content rather than overwrite blindly. + if let Err(error) = fs::rename(candidate.path(), &destination) { + if !destination.is_dir() { + return Err(error.into()); + } + } + } + fs::File::open(releases)?.sync_all()?; + let release = Self::load(&destination)?; + ensure!(release.id == id, "existing published snapshot has changed"); + Ok(release) + } + + pub fn load(path: &Path) -> Result { + let (id, _, manifest) = read(path)?; + Ok(Self { + id, + path: path.canonicalize()?, + hooks: manifest.hooks, + }) + } +} + +// Files were synced when written. Sync directory entries bottom-up before +// publishing, then sync the release parent after rename. Actual crash durability +// remains subject to the filesystem and storage stack. +fn sync_directories(path: &Path) -> Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + sync_directories(&entry.path())?; + } + } + fs::File::open(path)?.sync_all()?; + Ok(()) +} diff --git a/klbr-runtime/src/hooks.rs b/klbr-runtime/src/hooks.rs new file mode 100644 index 0000000..fe6fc82 --- /dev/null +++ b/klbr-runtime/src/hooks.rs @@ -0,0 +1,162 @@ +//! Policies return proposals. Validation and failure disposition belong here. +use crate::{ + behavior::Release, + protocol::*, + worker::{no_host_operations, WorkCommand, Worker, WorkerConfig}, +}; +use anyhow::{bail, ensure, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{sync::Arc, time::Duration}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum DeliveryDecision { + Allow, + Hold { reason: String }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum AttentionDecision { + Wake { priority: Priority }, + Defer { seconds: f64 }, + Ignore { reason: String }, +} +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Priority { + Operator, + Directed, + Ambient, +} + +#[derive(Clone, Debug)] +pub struct ReviewedDelivery { + pub decision: DeliveryDecision, + pub fault: Option, +} + +pub struct Policy { + pub release: Release, + pub worker: Worker, +} +impl Policy { + pub async fn start( + config: &WorkerConfig, + session: SessionId, + release: Release, + ) -> Result> { + let worker = Worker::spawn(config, session, Role::Hooks, Some(&release)).await?; + Ok(Arc::new(Self { release, worker })) + } + + async fn invoke(&self, slot: HookSlot, input: Value) -> Result { + let outcome = self + .worker + .call( + WorkCommand::Invoke { + id: OperationId::fresh(), + slot, + input, + }, + Duration::from_secs(2), + no_host_operations(), + ) + .await?; + match outcome { + Outcome::Hook { value } => Ok(value), + Outcome::Failed { error } => bail!("hook failed: {error}"), + _ => bail!("invalid policy result"), + } + } + + /// Unavailable required review means hold, never accidental approval. + pub async fn review(&self, effect: &EffectId, text: &str) -> ReviewedDelivery { + let result = async { + let value = self + .invoke( + HookSlot::Delivery, + json!({"effect_id": effect, "destination":"operator", "text":text}), + ) + .await?; + let decision: DeliveryDecision = serde_json::from_value(value)?; + if let DeliveryDecision::Hold { reason } = &decision { + ensure!( + !reason.trim().is_empty() && reason.len() <= 2048, + "invalid hold reason" + ); + } + Ok::<_, anyhow::Error>(decision) + } + .await; + match result { + Ok(decision) => ReviewedDelivery { + decision, + fault: None, + }, + Err(error) => ReviewedDelivery { + decision: DeliveryDecision::Hold { + reason: "required delivery policy unavailable or invalid".into(), + }, + fault: Some(format!("{error:#}")), + }, + } + } + + /// This slice exposes and validates attention policy but does not yet own a + /// model scheduler. The caller must not acknowledge input merely for a plan. + pub async fn attention( + &self, + event_id: i64, + source: &str, + received_ms: i64, + ) -> Result { + let decision: AttentionDecision = serde_json::from_value( + self.invoke( + HookSlot::Attention, + json!({"event_id":event_id,"source":source,"received_ms":received_ms}), + ) + .await?, + )?; + match &decision { + AttentionDecision::Defer { seconds } => ensure!( + seconds.is_finite() && *seconds > 0.0 && *seconds <= 600.0, + "invalid defer duration" + ), + AttentionDecision::Ignore { reason } => ensure!( + !reason.trim().is_empty() && reason.len() <= 2048, + "invalid ignore reason" + ), + AttentionDecision::Wake { + priority: Priority::Operator, + } => ensure!( + source == "operator", + "external input cannot request operator authority" + ), + _ => (), + } + if source == "operator" { + ensure!( + matches!( + decision, + AttentionDecision::Wake { + priority: Priority::Operator + } + ), + "operator attention cannot be suppressed" + ); + } + Ok(decision) + } + + pub fn describe(&self, slot: HookSlot) -> Value { + json!({"slot":slot,"kind":"decision","placement":"hook_worker","revision":self.release.id, + "handler":self.release.hooks.get(&slot),"deadline_ms":2000, + "failure":match slot { HookSlot::Delivery => "hold_effect", HookSlot::Attention => "retain_pending_input" }, + "host_effects":false}) + } + pub fn list(&self) -> Value { + json!({"revision":self.release.id,"slots":[self.describe(HookSlot::Attention),self.describe(HookSlot::Delivery)]}) + } +} diff --git a/klbr-runtime/src/lib.rs b/klbr-runtime/src/lib.rs new file mode 100644 index 0000000..546402e --- /dev/null +++ b/klbr-runtime/src/lib.rs @@ -0,0 +1,12 @@ +//! New runtime boundary, intentionally independent of the legacy memory/tool core. +//! +//! This crate is a development slice, not yet the production daemon. See +//! docs/runtime-v2/STATUS.md for executed checks and integration boundaries. +#![forbid(unsafe_code)] + +pub mod behavior; +pub mod hooks; +pub mod protocol; +pub mod session; +pub mod store; +pub mod worker; diff --git a/klbr-runtime/src/protocol.rs b/klbr-runtime/src/protocol.rs new file mode 100644 index 0000000..fe2abc8 --- /dev/null +++ b/klbr-runtime/src/protocol.rs @@ -0,0 +1,261 @@ +//! Host-owned wire vocabulary. Python values never deserialize into host code. +use anyhow::{bail, ensure, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{fmt, str::FromStr}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +pub const VERSION: u16 = 1; +pub const MAX_FRAME: usize = 1_048_576; +pub const MAX_CODE: usize = 262_144; +pub const MAX_OUTPUT: usize = 65_536; + +macro_rules! identity { + ($name:ident) => { + #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(try_from = "String", into = "String")] + pub struct $name(String); + impl $name { + pub fn fresh() -> Self { + Self(uuid::Uuid::new_v4().to_string()) + } + pub fn as_str(&self) -> &str { + &self.0 + } + } + impl TryFrom for $name { + type Error = String; + fn try_from(value: String) -> std::result::Result { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) + { + return Err(concat!("invalid ", stringify!($name)).into()); + } + Ok(Self(value)) + } + } + impl From<$name> for String { + fn from(id: $name) -> Self { + id.0 + } + } + impl FromStr for $name { + type Err = String; + fn from_str(value: &str) -> std::result::Result { + value.to_owned().try_into() + } + } + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} +identity!(SessionId); +identity!(Generation); +identity!(OperationId); +identity!(RequestId); +identity!(EffectId); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Role { + Workbench, + Hooks, +} +impl Role { + pub fn argument(self) -> &'static str { + match self { + Self::Workbench => "workbench", + Self::Hooks => "hooks", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum HookSlot { + #[serde(rename = "attention.plan")] + Attention, + #[serde(rename = "delivery.review")] + Delivery, +} +impl HookSlot { + pub fn name(self) -> &'static str { + match self { + Self::Attention => "attention.plan", + Self::Delivery => "delivery.review", + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Envelope { + pub version: u16, + pub session_id: SessionId, + pub generation: Generation, + pub message: Message, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Message { + Hello { + token: String, + role: Role, + revision: Option, + slots: Vec, + }, + Execute { + id: OperationId, + code: String, + }, + Invoke { + id: OperationId, + slot: HookSlot, + input: Value, + }, + Interrupt { + id: OperationId, + }, + Shutdown, + HostRequest { + id: RequestId, + execution_id: OperationId, + request: HostRequest, + }, + HostReply { + id: RequestId, + execution_id: OperationId, + reply: HostReply, + }, + Completed { + id: OperationId, + outcome: Outcome, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "method", deny_unknown_fields)] +pub enum HostRequest { + #[serde(rename = "local.send")] + LocalSend { text: String }, + #[serde(rename = "runtime.wait")] + Wait { + seconds: Option, + reason: String, + }, + #[serde(rename = "hooks.list")] + HooksList, + #[serde(rename = "hooks.describe")] + HooksDescribe { slot: HookSlot }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum HostReply { + Ok { value: Value }, + Error { code: String, message: String }, +} +impl HostReply { + pub fn error(code: &str, message: impl Into) -> Self { + Self::Error { + code: code.into(), + message: message.into(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Outcome { + Cell { + status: CellStatus, + output: Vec, + truncated_bytes: u64, + error: Option, + }, + Hook { + value: Value, + }, + Failed { + error: String, + }, +} +impl Outcome { + pub fn validate(&self, role: Role) -> Result<()> { + match self { + Self::Cell { output, error, .. } => { + ensure!(role == Role::Workbench, "cell result from a hook worker"); + ensure!(output.len() <= 256, "too many output records"); + ensure!( + output.iter().map(|o| o.text.len()).sum::() <= MAX_OUTPUT, + "output limit exceeded" + ); + ensure!( + error.as_ref().is_none_or(|e| e.len() <= 65_536), + "oversized error" + ); + } + Self::Hook { .. } => ensure!(role == Role::Hooks, "hook result from workbench"), + Self::Failed { error } => ensure!(error.len() <= 65_536, "oversized failure"), + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CellStatus { + Ok, + Error, + Yielded, + Interrupted, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Output { + pub stream: OutputStream, + pub text: String, +} +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OutputStream { + Stdout, + Stderr, + Result, +} + +pub async fn write_frame(writer: &mut W, value: &Envelope) -> Result<()> { + let bytes = serde_json::to_vec(value)?; + ensure!( + !bytes.is_empty() && bytes.len() <= MAX_FRAME, + "outbound frame exceeds limit" + ); + writer.write_u32(bytes.len() as u32).await?; + writer.write_all(&bytes).await?; + writer.flush().await?; + Ok(()) +} + +/// A timeout/drop during this operation invalidates the connection. The worker +/// actor destroys it rather than reading a new header from a partial payload. +pub async fn read_frame(reader: &mut R) -> Result { + let length = reader.read_u32().await? as usize; + ensure!( + (1..=MAX_FRAME).contains(&length), + "invalid inbound frame length" + ); + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).await?; + let envelope: Envelope = serde_json::from_slice(&bytes)?; + if envelope.version != VERSION { + bail!("unsupported protocol version"); + } + Ok(envelope) +} diff --git a/klbr-runtime/src/session.rs b/klbr-runtime/src/session.rs new file mode 100644 index 0000000..5e3130d --- /dev/null +++ b/klbr-runtime/src/session.rs @@ -0,0 +1,294 @@ +//! A concrete vertical slice: durable cells, explicit local delivery through a +//! separate policy worker, terminal waits, and hook-only revision replacement. +//! This is not a model loop or a Discord adapter. +use crate::{ + behavior::Release, + hooks::Policy, + protocol::*, + store::{PreparedEffect, Store}, + worker::{HostCall, HostService, WorkCommand, Worker, WorkerConfig}, +}; +use anyhow::{ensure, Context, Result}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::sync::{Mutex, RwLock, Semaphore}; + +struct ActivePolicy { + epoch: i64, + policy: Arc, +} + +pub struct SessionRuntime { + pub id: SessionId, + pub store: Store, + config: WorkerConfig, + workbench: Mutex, + policy: RwLock, + foreground: Arc, + activation_gate: Arc, + stopped: AtomicBool, + storage_failed: AtomicBool, +} + +#[derive(Debug, Serialize)] +pub struct ExecutionReport { + pub id: OperationId, + pub generation: Generation, + pub hook_revision: String, + pub outcome: Outcome, +} + +impl SessionRuntime { + pub async fn start( + store: Store, + id: SessionId, + config: WorkerConfig, + initial: Release, + ) -> Result> { + store.ensure_session(&id).await?; + let (epoch, release) = match store.activation(&id).await? { + Some(active) => { + let release = Release::load(&active.path)?; + ensure!( + release.id == active.revision, + "active release bytes have changed" + ); + (active.epoch, release) + } + None => (0, initial), + }; + let policy = Policy::start(&config, id.clone(), release).await?; + let epoch = if epoch == 0 { + store.activate(&id, 0, policy.release.clone()).await? + } else { + epoch + }; + let workbench = Worker::spawn(&config, id.clone(), Role::Workbench, None).await?; + store.append(&id, "kernel.reset", json!({"generation":workbench.generation(),"reason":"session start; heap not restored"})).await?; + Ok(Arc::new(Self { + id, + store, + config, + workbench: Mutex::new(workbench), + policy: RwLock::new(ActivePolicy { epoch, policy }), + foreground: Arc::new(Semaphore::new(1)), + activation_gate: Arc::new(Semaphore::new(1)), + stopped: AtomicBool::new(false), + storage_failed: AtomicBool::new(false), + })) + } + + pub async fn hook_epoch(&self) -> i64 { + self.policy.read().await.epoch + } + pub async fn hooks(&self) -> Value { + self.policy.read().await.policy.list() + } + + /// Candidate import/handshake precedes activation. Existing cells retain the + /// Arc they pinned; this operation does not replace their workbench. + /// Once accepted, caller cancellation does not interrupt the DB/cache switch. + pub async fn activate_hooks( + self: &Arc, + expected_epoch: i64, + release: Release, + ) -> Result { + ensure!( + !self.stopped.load(Ordering::Acquire), + "session has shut down" + ); + let permit = self + .activation_gate + .clone() + .try_acquire_owned() + .context("another hook activation is in progress")?; + let this = self.clone(); + tokio::spawn(async move { + let _permit = permit; + let candidate = Policy::start(&this.config, this.id.clone(), release).await?; + let mut active = this.policy.write().await; + ensure!( + !this.stopped.load(Ordering::Acquire), + "session has shut down" + ); + ensure!(active.epoch == expected_epoch, "activation conflict"); + let epoch = this + .store + .activate(&this.id, expected_epoch, candidate.release.clone()) + .await?; + *active = ActivePolicy { + epoch, + policy: candidate, + }; + Ok::<_, anyhow::Error>(epoch) + }) + .await + .context("activation task panicked")? + } + + /// The host task owns accepted execution, not the caller's connection. + /// Dropping this future does not replay or abandon the cell. Interrupt through + /// the independent stop path; every cell also has a host-owned deadline. + pub async fn execute( + self: &Arc, + code: String, + budget: Duration, + ) -> Result { + ensure!( + !self.stopped.load(Ordering::Acquire), + "session has shut down" + ); + ensure!(code.len() <= MAX_CODE, "cell is too large"); + ensure!( + budget > Duration::ZERO && budget <= Duration::from_secs(3600), + "invalid cell deadline" + ); + ensure!( + !self.storage_failed.load(Ordering::Acquire), + "session paused after storage failure" + ); + let permit = self + .foreground + .clone() + .try_acquire_owned() + .context("session already has a foreground cell")?; + let this = self.clone(); + tokio::spawn(async move { + let _permit = permit; + this.execute_owned(code, budget).await + }) + .await + .context("execution task panicked")? + } + + async fn execute_owned( + self: Arc, + code: String, + budget: Duration, + ) -> Result { + let policy = self.policy.read().await.policy.clone(); + let worker = { + let mut worker = self.workbench.lock().await; + ensure!( + !self.stopped.load(Ordering::Acquire), + "session has shut down" + ); + if !worker.is_alive() { + *worker = + Worker::spawn(&self.config, self.id.clone(), Role::Workbench, None).await?; + self.store.append(&self.id,"kernel.reset",json!({"generation":worker.generation(),"reason":"previous worker ended; code was not replayed"})).await?; + } + worker.clone() + }; + let id = OperationId::fresh(); + let generation = worker.generation().clone(); + let revision = policy.release.id.clone(); + self.store + .begin(&self.id, &id, &generation, revision.clone(), code.clone()) + .await?; + let service = self.service(generation.clone(), policy); + let outcome = match worker + .call( + WorkCommand::Execute { + id: id.clone(), + code, + }, + budget, + service, + ) + .await + { + Ok(outcome) => outcome, + Err(error) => Outcome::Failed { + error: format!("{error:#}"), + }, + }; + if let Err(error) = self.store.finish(&id, outcome.clone()).await { + self.storage_failed.store(true, Ordering::Release); + worker.stop().await; + return Err(error.context("cannot persist execution completion; session paused")); + } + Ok(ExecutionReport { + id, + generation, + hook_revision: revision, + outcome, + }) + } + + fn service(self: &Arc, generation: Generation, policy: Arc) -> HostService { + let this = self.clone(); + Arc::new(move |call: HostCall| { + let (this, generation, policy) = (this.clone(), generation.clone(), policy.clone()); + Box::pin(async move { + let result = async { + match call.request { + HostRequest::LocalSend { text } => { + let receipt = match this + .store + .prepare_send( + &this.id, + &call.execution_id, + &generation, + call.id, + text.clone(), + ) + .await? + { + PreparedEffect::Existing(receipt) => receipt, + PreparedEffect::New(effect) => { + // No database transaction or session lock is + // held while awaiting the separate worker. + let review = policy.review(&effect, &text).await; + this.store + .publish_send( + &this.id, + &call.execution_id, + &generation, + effect, + policy.release.id.clone(), + review, + ) + .await? + } + }; + Ok(serde_json::to_value(receipt)?) + } + HostRequest::Wait { seconds, reason } => { + this.store + .wait(&this.id, &call.execution_id, &generation, seconds, reason) + .await + } + HostRequest::HooksList => Ok(policy.list()), + HostRequest::HooksDescribe { slot } => Ok(policy.describe(slot)), + } + } + .await; + match result { + Ok(value) => HostReply::Ok { value }, + Err(error) => HostReply::error("host_operation_failed", format!("{error:#}")), + } + }) + }) + } + + /// Management bypasses policy hooks. A broken hook cannot veto its repair. + pub async fn interrupt(&self) { + let worker = self.workbench.lock().await.clone(); + worker.stop().await; + } + + pub async fn shutdown(&self) { + self.stopped.store(true, Ordering::Release); + self.interrupt().await; + let policy = self.policy.read().await.policy.clone(); + policy.worker.stop().await; + } +} diff --git a/klbr-runtime/src/store.rs b/klbr-runtime/src/store.rs new file mode 100644 index 0000000..15dcde5 --- /dev/null +++ b/klbr-runtime/src/store.rs @@ -0,0 +1,561 @@ +//! One authoritative SQLite connection, used only inside bounded blocking jobs. +//! Network calls, Python execution and hooks never run inside a transaction. +use crate::{ + behavior::Release, + hooks::{DeliveryDecision, ReviewedDelivery}, + protocol::*, +}; +use anyhow::{anyhow, bail, ensure, Context, Result}; +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + fs::{self, File}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::Semaphore; + +const APPLICATION_ID: i64 = 1263288882; +pub const MIGRATION: &str = include_str!("../migrations/001_runtime.sql"); + +#[derive(Clone)] +pub struct Store { + connection: Arc>, + gate: Arc, + _owner: Arc, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Event { + pub id: i64, + pub kind: String, + pub payload: Value, + pub created_ms: i64, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Receipt { + pub effect_id: EffectId, + pub status: ReceiptStatus, + pub event_id: Option, + pub reason: Option, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReceiptStatus { + Confirmed, + Held, +} +pub enum PreparedEffect { + New(EffectId), + Existing(Receipt), +} +#[derive(Clone, Debug)] +pub struct Activation { + pub epoch: i64, + pub revision: String, + pub path: PathBuf, +} + +pub fn now_ms() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_millis() + .try_into()?) +} + +fn event(tx: &Transaction<'_>, session: &str, kind: &str, payload: &Value) -> Result { + tx.execute( + "INSERT INTO events(session_id,kind,payload,created_ms) VALUES(?1,?2,?3,?4)", + params![session, kind, serde_json::to_string(payload)?, now_ms()?], + )?; + Ok(tx.last_insert_rowid()) +} + +fn live(tx: &Transaction<'_>, session: &str, execution: &str, generation: &str) -> Result<()> { + let count: i64 = tx.query_row("SELECT count(*) FROM executions WHERE id=?1 AND session_id=?2 AND generation=?3 AND state='running' AND completed_event IS NULL", + params![execution, session, generation], |row| row.get(0))?; + ensure!( + count == 1, + "execution authority has ended or belongs to another generation" + ); + Ok(()) +} + +impl Store { + /// Open only the new runtime format. This refuses an unrelated populated DB. + /// Call once per daemon; clones share its connection and admission bound. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + ensure!( + path != Path::new(":memory:"), + "runtime store requires a durable file" + ); + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + fs::create_dir_all(parent)?; + let path = parent + .canonicalize()? + .join(path.file_name().context("database filename required")?); + if let Ok(metadata) = fs::symlink_metadata(&path) { + ensure!( + !metadata.file_type().is_symlink(), + "database symlink aliases are not supported" + ); + } + let mut lock_path = path.as_os_str().to_os_string(); + lock_path.push(".owner.lock"); + let owner = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(PathBuf::from(lock_path))?; + owner + .try_lock() + .map_err(|error| anyhow!("database is owned elsewhere or locking failed: {error:?}"))?; + // Refuse an unsupported SQLite runtime before modifying even a new DB. + let v = rusqlite::version_number(); + ensure!( + v >= 3_051_003 + || (3_050_007..3_051_000).contains(&v) + || (3_044_006..3_045_000).contains(&v), + "SQLite {} lacks a verified WAL-reset fix", + rusqlite::version() + ); + let mut connection = Connection::open(path)?; + connection.busy_timeout(Duration::from_secs(5))?; + connection.pragma_update(None, "foreign_keys", "ON")?; + let app: i64 = connection.pragma_query_value(None, "application_id", |row| row.get(0))?; + let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + if app == 0 && version == 0 { + let tables: i64 = connection.query_row( + "SELECT count(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'", + [], + |r| r.get(0), + )?; + ensure!( + tables == 0, + "refusing to initialize over a legacy or unrelated database" + ); + connection.execute_batch(MIGRATION)?; + } else { + ensure!( + app == APPLICATION_ID && version == 1, + "unrecognized runtime database version" + ); + } + connection.pragma_update(None, "journal_mode", "WAL")?; + connection.pragma_update(None, "synchronous", "FULL")?; + let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let interrupted: Vec<(String, String, String)> = { + let mut query = tx.prepare( + "SELECT id,session_id,state FROM executions WHERE completed_event IS NULL", + )?; + let rows = query + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .collect::>()?; + rows + }; + for (id, session, prior) in interrupted { + let state = if prior == "yielded" { + "yielded" + } else { + "interrupted" + }; + let seq = event( + &tx, + &session, + "execution.completed", + &json!({"execution_id":id,"state":state,"reason":"host restarted; code was not replayed"}), + )?; + tx.execute("UPDATE executions SET state=CASE WHEN state='yielded' THEN 'yielded' ELSE 'interrupted' END,completed_event=?2 WHERE id=?1", params![id,seq])?; + tx.execute("UPDATE effects SET status='held',reason='host restarted before local publication' WHERE execution_id=?1 AND status='proposed'", [id])?; + } + tx.commit()?; + Ok(Self { + connection: Arc::new(Mutex::new(connection)), + gate: Arc::new(Semaphore::new(8)), + _owner: Arc::new(owner), + }) + } + + async fn with( + &self, + run: impl FnOnce(&mut Connection) -> Result + Send + 'static, + ) -> Result { + let permit = self.gate.clone().acquire_owned().await?; + let connection = self.connection.clone(); + let owner = self._owner.clone(); + tokio::task::spawn_blocking(move || { + // Cancellation drops interest, not an in-flight database write. + // Keep the ownership lock until the blocking transaction finishes. + let _owner = owner; + let _permit = permit; + let mut connection = connection + .lock() + .map_err(|_| anyhow!("runtime database lock poisoned"))?; + run(&mut connection) + }) + .await + .context("runtime database task panicked")? + } + + pub async fn ensure_session(&self, session: &SessionId) -> Result<()> { + let session = session.to_string(); + self.with(move |db| { + db.execute( + "INSERT INTO sessions(id) VALUES(?1) ON CONFLICT DO NOTHING", + [session], + )?; + Ok(()) + }) + .await + } + + /// Returns only after source+inbox commit. The channel/scheduler is not the + /// source of truth. Equal content with different source keys stays distinct. + pub async fn admit( + &self, + session: &SessionId, + source_key: String, + payload: Value, + ) -> Result { + ensure!( + !source_key.is_empty() && source_key.len() <= 512, + "invalid source key" + ); + ensure!( + serde_json::to_vec(&payload)?.len() <= MAX_CODE, + "input payload too large" + ); + let session = session.to_string(); + self.with(move |db| { + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let previous: Option<(i64,String)> = tx.query_row("SELECT seq,payload FROM events WHERE session_id=?1 AND source_key=?2", params![session,source_key], |r| Ok((r.get(0)?,r.get(1)?))).optional()?; + if let Some((id, prior)) = previous { + ensure!(serde_json::from_str::(&prior)? == payload, "source key reused with different content"); + return Ok(id); + } + tx.execute("INSERT INTO events(session_id,kind,source_key,payload,created_ms) VALUES(?1,'input',?2,?3,?4)", params![session,source_key,serde_json::to_string(&payload)?,now_ms()?])?; + let id = tx.last_insert_rowid(); + tx.execute("INSERT INTO inbox(event_id) VALUES(?1)", [id])?; + let changed = tx.execute("UPDATE sessions SET state='ready',wake_at_ms=NULL WHERE id=?1 AND state='waiting'", [&session])?; + if changed == 1 { event(&tx, &session, "session.woke", &json!({"reason":"input","event_id":id}))?; } + tx.commit()?; + Ok(id) + }).await + } + + pub async fn pending(&self, session: &SessionId, limit: usize) -> Result> { + ensure!((1..=1024).contains(&limit), "invalid inbox query limit"); + let session = session.to_string(); + self.with(move |db| { + let mut query = db.prepare("SELECT i.event_id FROM inbox i JOIN events e ON e.seq=i.event_id WHERE e.session_id=?1 AND i.status='pending' ORDER BY i.event_id LIMIT ?2")?; + let result = query.query_map(params![session,limit as i64], |r| r.get(0))?.collect::>()?; + Ok(result) + }).await + } + + /// Host-only acknowledgement; not exposed to the kernel. A future turn + /// coordinator should call this only after persisting its input consumption. + pub async fn acknowledge_inputs(&self, session: &SessionId, ids: Vec) -> Result<()> { + ensure!(ids.len() <= 1024, "too many input acknowledgements"); + let session = session.to_string(); + self.with(move |db| { + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + for id in ids { + let n = tx.execute("UPDATE inbox SET status='consumed' WHERE event_id=?1 AND status='pending' AND EXISTS(SELECT 1 FROM events WHERE seq=?1 AND session_id=?2)", params![id,session])?; + ensure!(n == 1, "acknowledgement is stale or belongs to another session"); + } + tx.commit()?; Ok(()) + }).await + } + + pub async fn scratchpad(&self, session: &SessionId, text: String) -> Result { + ensure!(text.len() <= MAX_CODE, "scratchpad too large"); + self.append(session, "scratchpad", json!({"text":text})) + .await + } + + pub async fn append( + &self, + session: &SessionId, + kind: &'static str, + payload: Value, + ) -> Result { + let session = session.to_string(); + self.with(move |db| { + let tx = db.transaction()?; + let id = event(&tx, &session, kind, &payload)?; + tx.commit()?; + Ok(id) + }) + .await + } + + pub async fn history( + &self, + session: &SessionId, + after: i64, + limit: usize, + ) -> Result> { + ensure!( + after >= 0 && (1..=1024).contains(&limit), + "invalid history page" + ); + let session = session.to_string(); + self.with(move |db| { + let mut query = db.prepare("SELECT seq,kind,payload,created_ms FROM events WHERE session_id=?1 AND seq>?2 ORDER BY seq LIMIT ?3")?; + let raw: Vec<(i64,String,String,i64)> = query.query_map(params![session,after,limit as i64], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)))?.collect::>()?; + raw.into_iter().map(|(id,kind,payload,created_ms)| Ok(Event {id,kind,payload:serde_json::from_str(&payload)?,created_ms})).collect() + }).await + } + + pub async fn activation(&self, session: &SessionId) -> Result> { + let session = session.to_string(); + self.with(move |db| { + Ok(db.query_row("SELECT s.hook_epoch,r.id,r.path FROM sessions s JOIN releases r ON r.id=s.hook_revision WHERE s.id=?1", [session], |r| Ok(Activation {epoch:r.get(0)?,revision:r.get(1)?,path:PathBuf::from(r.get::<_,String>(2)?)})).optional()?) + }).await + } + + pub async fn activate( + &self, + session: &SessionId, + expected_epoch: i64, + release: Release, + ) -> Result { + let session = session.to_string(); + let path = release + .path + .to_str() + .context("release path is not UTF-8")? + .to_owned(); + self.with(move |db| { + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("INSERT INTO releases(id,path) VALUES(?1,?2) ON CONFLICT DO NOTHING", params![release.id,path])?; + let changed = tx.execute("UPDATE sessions SET hook_revision=?1,hook_epoch=hook_epoch+1 WHERE id=?2 AND hook_epoch=?3", params![release.id,session,expected_epoch])?; + ensure!(changed == 1, "activation conflict: expected epoch is stale"); + event(&tx,&session,"hooks.activated",&json!({"revision":release.id,"epoch":expected_epoch+1}))?; + tx.commit()?; + Ok(expected_epoch+1) + }).await + } + + pub async fn begin( + &self, + session: &SessionId, + id: &OperationId, + generation: &Generation, + revision: String, + code: String, + ) -> Result<()> { + ensure!(code.len() <= MAX_CODE, "cell source exceeds limit"); + let (session, id, generation) = + (session.to_string(), id.to_string(), generation.to_string()); + self.with(move |db| { + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let seq = event(&tx,&session,"execution.started",&json!({"execution_id":id,"generation":generation,"revision":revision,"code":code}))?; + tx.execute("INSERT INTO executions(id,session_id,generation,hook_revision,state,started_event) VALUES(?1,?2,?3,?4,'running',?5)", params![id,session,generation,revision,seq])?; + tx.commit()?; Ok(()) + }).await + } + + pub async fn finish(&self, id: &OperationId, outcome: Outcome) -> Result<()> { + let id = id.to_string(); + self.with(move |db| { + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let (session,prior,completed): (String,String,Option) = tx.query_row("SELECT session_id,state,completed_event FROM executions WHERE id=?1", [&id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?; + ensure!(completed.is_none(), "execution already completed"); + let state = if prior == "yielded" { "yielded" } else { match &outcome { + Outcome::Cell {status:CellStatus::Ok,..} => "ok", + Outcome::Cell {status:CellStatus::Yielded,..} => bail!("worker claimed an uncommitted yield"), + Outcome::Cell {status:CellStatus::Error,..} => "error", + _ => "interrupted", + } }; + let seq = event(&tx,&session,"execution.completed",&json!({"execution_id":id,"state":state,"outcome":outcome}))?; + tx.execute("UPDATE executions SET state=?2,completed_event=?3 WHERE id=?1", params![id,state,seq])?; + tx.execute("UPDATE effects SET status='held',reason='execution ended before publication' WHERE execution_id=?1 AND status='proposed'", [&id])?; + tx.commit()?; Ok(()) + }).await + } + + pub async fn prepare_send( + &self, + session: &SessionId, + execution: &OperationId, + generation: &Generation, + request: RequestId, + text: String, + ) -> Result { + ensure!( + !text.trim().is_empty() && text.len() <= MAX_OUTPUT, + "invalid local message" + ); + let (session, execution, generation) = ( + session.to_string(), + execution.to_string(), + generation.to_string(), + ); + self.with(move |db| { + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + live(&tx,&session,&execution,&generation)?; + let prior: Option<(String,String,String,Option,Option)> = tx.query_row("SELECT id,text,status,event_id,reason FROM effects WHERE execution_id=?1 AND request_id=?2", params![execution,request.as_str()], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?; + if let Some((id,prior_text,status,event_id,reason)) = prior { + ensure!(prior_text == text, "effect identity reused with changed text"); + let status = match status.as_str() { "confirmed" => ReceiptStatus::Confirmed, "held" => ReceiptStatus::Held, _ => bail!("effect review is already in progress") }; + return Ok(PreparedEffect::Existing(Receipt {effect_id:id.try_into().map_err(|e:String| anyhow!(e))?,status,event_id,reason})); + } + let id = EffectId::fresh(); + tx.execute("INSERT INTO effects(id,execution_id,request_id,text,status) VALUES(?1,?2,?3,?4,'proposed')", params![id.as_str(),execution,request.as_str(),text])?; + tx.commit()?; Ok(PreparedEffect::New(id)) + }).await + } + + pub async fn publish_send( + &self, + session: &SessionId, + execution: &OperationId, + generation: &Generation, + effect: EffectId, + revision: String, + review: ReviewedDelivery, + ) -> Result { + let (session, execution, generation) = ( + session.to_string(), + execution.to_string(), + generation.to_string(), + ); + self.with(move |db| { + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + live(&tx, &session, &execution, &generation)?; + let expected: String = tx.query_row( + "SELECT hook_revision FROM executions WHERE id=?1", + [&execution], + |r| r.get(0), + )?; + ensure!( + expected == revision, + "delivery policy does not match the execution's pinned revision" + ); + let text: String = tx.query_row( + "SELECT text FROM effects WHERE id=?1 AND execution_id=?2 AND status='proposed'", + params![effect.as_str(), execution], + |r| r.get(0), + )?; + tx.execute( + "INSERT INTO hook_traces(effect_id,revision,decision,fault) VALUES(?1,?2,?3,?4)", + params![ + effect.as_str(), + revision, + serde_json::to_string(&review.decision)?, + review.fault + ], + )?; + let receipt = match review.decision { + DeliveryDecision::Allow => { + let id = event( + &tx, + &session, + "local.sent", + &json!({"effect_id":effect,"text":text}), + )?; + tx.execute( + "UPDATE effects SET status='confirmed',event_id=?2 WHERE id=?1", + params![effect.as_str(), id], + )?; + Receipt { + effect_id: effect, + status: ReceiptStatus::Confirmed, + event_id: Some(id), + reason: None, + } + } + DeliveryDecision::Hold { reason } => { + event( + &tx, + &session, + "delivery.held", + &json!({"effect_id":effect,"reason":reason}), + )?; + tx.execute( + "UPDATE effects SET status='held',reason=?2 WHERE id=?1", + params![effect.as_str(), reason], + )?; + Receipt { + effect_id: effect, + status: ReceiptStatus::Held, + event_id: None, + reason: Some(reason), + } + } + }; + tx.commit()?; + Ok(receipt) + }) + .await + } + + pub async fn wait( + &self, + session: &SessionId, + execution: &OperationId, + generation: &Generation, + seconds: Option, + reason: String, + ) -> Result { + ensure!( + !reason.trim().is_empty() && reason.len() <= 2048, + "invalid wait reason" + ); + ensure!( + seconds.is_none_or(|s| s.is_finite() && s > 0.0 && s <= 600.0), + "invalid wait duration" + ); + let (session, execution, generation) = ( + session.to_string(), + execution.to_string(), + generation.to_string(), + ); + self.with(move |db| { + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + live(&tx,&session,&execution,&generation)?; + let pending: i64 = tx.query_row("SELECT count(*) FROM inbox i JOIN events e ON e.seq=i.event_id WHERE e.session_id=?1 AND i.status='pending'", [&session], |r|r.get(0))?; + let deadline = seconds.map(|s| now_ms().map(|n| n + (s * 1000.0).ceil() as i64)).transpose()?; + tx.execute("UPDATE sessions SET state=?2,wake_at_ms=?3 WHERE id=?1", params![session,if pending > 0 {"ready"} else {"waiting"},if pending > 0 {None} else {deadline}])?; + tx.execute("UPDATE executions SET state='yielded' WHERE id=?1", [&execution])?; + let result = json!({"pending_input":pending > 0,"wake_at_ms":if pending > 0 {None} else {deadline},"reason":reason}); + event(&tx,&session,"session.waited",&result)?; + tx.commit()?; Ok(result) + }).await + } + + /// A scheduler will call this on startup and at the next deadline. Committing + /// the ready state and wake event together makes repeated ticks idempotent. + pub async fn wake_due(&self, now: i64) -> Result> { + ensure!(now >= 0, "invalid clock"); + self.with(move |db| { + let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let sessions: Vec = { + let mut query = + tx.prepare("SELECT id FROM sessions WHERE state='waiting' AND wake_at_ms<=?1")?; + let rows = query + .query_map([now], |r| r.get(0))? + .collect::>()?; + rows + }; + for session in &sessions { + tx.execute( + "UPDATE sessions SET state='ready',wake_at_ms=NULL WHERE id=?1", + [session], + )?; + event(&tx, session, "session.woke", &json!({"reason":"deadline"}))?; + } + tx.commit()?; + Ok(sessions) + }) + .await + } +} diff --git a/klbr-runtime/src/worker.rs b/klbr-runtime/src/worker.rs new file mode 100644 index 0000000..b4e0d54 --- /dev/null +++ b/klbr-runtime/src/worker.rs @@ -0,0 +1,488 @@ +//! Bounded process actor. Only this actor owns the socket and child lifetime. +//! +//! Every interrupted/expired exchange invalidates the stream and terminates the +//! worker. Reusing a half-read frame or accepting its late effects is forbidden. +use crate::{behavior::Release, protocol::*}; +use anyhow::{anyhow, bail, ensure, Context, Result}; +use std::{ + collections::BTreeMap, + future::Future, + path::PathBuf, + pin::Pin, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt}, + net::{UnixListener, UnixStream}, + process::Command, + sync::{mpsc, oneshot, watch}, + time::{timeout, timeout_at, Instant}, +}; + +#[derive(Clone)] +pub struct WorkerConfig { + pub python: PathBuf, + pub runner: PathBuf, + pub cwd: PathBuf, + pub environment: BTreeMap, + pub startup_timeout: Duration, +} +impl WorkerConfig { + pub fn source_tree(root: &std::path::Path) -> Self { + let environment = [ + "PATH", + "HOME", + "LANG", + "LC_ALL", + "TMPDIR", + "LD_LIBRARY_PATH", + "DYLD_LIBRARY_PATH", + ] + .into_iter() + .filter_map(|name| std::env::var(name).ok().map(|value| (name.into(), value))) + .collect(); + Self { + python: std::env::var_os("KLBR_PYTHON") + .map(PathBuf::from) + .unwrap_or_else(|| "python3".into()), + runner: root.join("python/klbr-runtime/src/klbr_runtime/__main__.py"), + cwd: root.into(), + environment, + startup_timeout: Duration::from_secs(10), + } + } +} + +#[derive(Clone, Debug)] +pub struct HostCall { + pub id: RequestId, + pub execution_id: OperationId, + pub request: HostRequest, +} +pub type HostFuture = Pin + Send>>; +pub type HostService = Arc HostFuture + Send + Sync>; + +pub fn no_host_operations() -> HostService { + Arc::new(|_| { + Box::pin(async { HostReply::error("forbidden", "this role has no host-effect capability") }) + }) +} + +#[derive(Clone, Debug)] +pub enum WorkCommand { + Execute { + id: OperationId, + code: String, + }, + Invoke { + id: OperationId, + slot: HookSlot, + input: serde_json::Value, + }, +} +impl WorkCommand { + fn id(&self) -> &OperationId { + match self { + Self::Execute { id, .. } | Self::Invoke { id, .. } => id, + } + } + fn message(&self) -> Message { + match self { + Self::Execute { id, code } => Message::Execute { + id: id.clone(), + code: code.clone(), + }, + Self::Invoke { id, slot, input } => Message::Invoke { + id: id.clone(), + slot: *slot, + input: input.clone(), + }, + } + } +} +struct Work { + command: WorkCommand, + deadline: Instant, + service: HostService, + reply: oneshot::Sender>, +} + +#[derive(Default)] +struct Tails { + stdout: Vec, + stderr: Vec, +} +#[derive(Clone, Debug)] +pub struct Diagnostics { + pub stdout_tail: String, + pub stderr_tail: String, +} + +struct Inner { + queue: mpsc::Sender, + stop: watch::Sender, + stopped: watch::Receiver, + alive: Arc, + tails: Arc>, + generation: Generation, +} +impl Drop for Inner { + fn drop(&mut self) { + let _ = self.stop.send(true); + } +} +#[derive(Clone)] +pub struct Worker(Arc); + +struct Life { + alive: Arc, + stopped: watch::Sender, +} +impl Drop for Life { + fn drop(&mut self) { + self.alive.store(false, Ordering::Release); + let _ = self.stopped.send(true); + } +} +struct DrainTasks(Vec>); +impl Drop for DrainTasks { + fn drop(&mut self) { + for task in &self.0 { + task.abort(); + } + } +} + +async fn drain(mut reader: R, tails: Arc>, stderr: bool) { + let mut buffer = [0; 4096]; + loop { + let count = match reader.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + let Ok(mut tails) = tails.lock() else { break }; + let tail = if stderr { + &mut tails.stderr + } else { + &mut tails.stdout + }; + tail.extend_from_slice(&buffer[..count]); + let excess = tail.len().saturating_sub(8192); + tail.drain(..excess); + } +} + +impl Worker { + pub async fn spawn( + config: &WorkerConfig, + session_id: SessionId, + role: Role, + release: Option<&Release>, + ) -> Result { + ensure!( + (role == Role::Hooks) == release.is_some(), + "hook role requires a release; workbench does not" + ); + let directory = tempfile::Builder::new().prefix("klbr-").tempdir()?; + let socket_path = directory.path().join("control.sock"); + let listener = UnixListener::bind(&socket_path)?; + let generation = Generation::fresh(); + let token = uuid::Uuid::new_v4().to_string(); + let mut command = Command::new(&config.python); + command + .args(["-I", "-B", "-u"]) + .arg(&config.runner) + .arg("--socket") + .arg(&socket_path) + .arg("--role") + .arg(role.argument()) + .arg("--session") + .arg(session_id.as_str()) + .arg("--generation") + .arg(generation.as_str()) + .arg("--token") + .arg(&token) + .current_dir(&config.cwd) + .env_clear() + .envs(&config.environment) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + if let Some(release) = release { + command + .arg("--behavior") + .arg(&release.path) + .arg("--revision") + .arg(&release.id); + } + let mut child = command.spawn().context("launch Python worker")?; + let tails = Arc::new(Mutex::new(Tails::default())); + let drains = DrainTasks(vec![ + tokio::spawn(drain( + child.stdout.take().context("missing stdout")?, + tails.clone(), + false, + )), + tokio::spawn(drain( + child.stderr.take().context("missing stderr")?, + tails.clone(), + true, + )), + ]); + let handshake = async { + let (mut stream, _) = listener.accept().await?; + let envelope = read_frame(&mut stream).await?; + ensure!( + envelope.session_id == session_id && envelope.generation == generation, + "foreign handshake identity" + ); + match envelope.message { + Message::Hello { + token: actual_token, + role: actual_role, + revision, + slots, + } => { + ensure!( + actual_token == token && actual_role == role, + "worker handshake mismatch" + ); + ensure!( + revision.as_deref() == release.map(|r| r.id.as_str()), + "worker revision mismatch" + ); + let expected = if role == Role::Hooks { + vec![HookSlot::Attention, HookSlot::Delivery] + } else { + vec![] + }; + ensure!(slots == expected, "worker hook contract mismatch"); + } + _ => bail!("expected hello"), + } + Ok::<_, anyhow::Error>(stream) + }; + let startup = tokio::select! { + result = timeout(config.startup_timeout, handshake) => + result.map_err(|_| anyhow!("worker handshake timed out")).and_then(|result| result), + status = child.wait() => Err(anyhow!("worker exited before ready: {:?}", status)), + }; + let mut stream = match startup { + Ok(stream) => stream, + Err(error) => { + let _ = child.kill().await; + let _ = child.wait().await; + let diagnostic = tails + .lock() + .ok() + .map(|t| String::from_utf8_lossy(&t.stderr).into_owned()) + .unwrap_or_default(); + return Err( + error.context(format!("worker startup failed; stderr tail: {diagnostic}")) + ); + } + }; + let (queue, mut requests) = mpsc::channel::(16); + let (stop, mut stopping) = watch::channel(false); + let (stopped_tx, stopped) = watch::channel(false); + let alive = Arc::new(AtomicBool::new(true)); + let life = Life { + alive: alive.clone(), + stopped: stopped_tx, + }; + let identity = (session_id, generation.clone()); + tokio::spawn(async move { + let life_guard = life; + let _directory = directory; + let _drains = drains; + loop { + let work = tokio::select! { + biased; + _ = stopping.changed() => break, + status = child.wait() => { let _ = status; break; } + work = requests.recv() => match work { Some(work) => work, None => break }, + }; + let Work { + command, + deadline, + service, + mut reply, + } = work; + if reply.is_closed() { + continue; + } + if Instant::now() >= deadline { + let _ = reply.send(Err(anyhow!("worker queue deadline exceeded"))); + continue; + } + let result = tokio::select! { + biased; + _ = stopping.changed() => Err(anyhow!("worker stopped by operator")), + _ = reply.closed() => Err(anyhow!("caller cancelled; worker generation invalidated")), + result = timeout_at(deadline, exchange(&mut stream, &identity, role, &command, &service)) => + result.map_err(|_| anyhow!("worker deadline exceeded; generation invalidated")).and_then(|r| r), + }; + let failed = result.is_err(); + if failed { + life_guard.alive.store(false, Ordering::Release); + } + let _ = reply.send(result); + if failed { + break; + } + } + // Stop admissions before draining the queue, including process-exit + // paths that did not finish a request. + life_guard.alive.store(false, Ordering::Release); + requests.close(); + // No reuse of a socket with an unknown framing/cancellation state. + let _ = child.kill().await; + let _ = child.wait().await; + while let Ok(work) = requests.try_recv() { + let _ = work.reply.send(Err(anyhow!("worker generation has ended"))); + } + }); + Ok(Self(Arc::new(Inner { + queue, + stop, + stopped, + alive, + tails, + generation, + }))) + } + + pub fn generation(&self) -> &Generation { + &self.0.generation + } + pub fn is_alive(&self) -> bool { + self.0.alive.load(Ordering::Acquire) && !*self.0.stop.borrow() + } + pub fn diagnostics(&self) -> Diagnostics { + match self.0.tails.lock() { + Ok(t) => Diagnostics { + stdout_tail: String::from_utf8_lossy(&t.stdout).into(), + stderr_tail: String::from_utf8_lossy(&t.stderr).into(), + }, + Err(_) => Diagnostics { + stdout_tail: String::new(), + stderr_tail: "diagnostic lock poisoned".into(), + }, + } + } + + pub async fn call( + &self, + command: WorkCommand, + budget: Duration, + service: HostService, + ) -> Result { + ensure!(self.is_alive(), "worker generation is unavailable"); + let (reply, receive) = oneshot::channel(); + self.0 + .queue + .try_send(Work { + command, + deadline: Instant::now() + budget, + service, + reply, + }) + .map_err(|_| anyhow!("worker queue full or closed"))?; + receive + .await + .context("worker ended before completing request")? + } + + /// Independent of the request queue. Initially uses a hard process stop; + /// cooperative in-place cancellation is supported by Python but not trusted + /// as the host's recovery guarantee in this slice. + pub async fn stop(&self) { + let _ = self.0.stop.send(true); + let mut stopped = self.0.stopped.clone(); + let _ = stopped.wait_for(|value| *value).await; + } +} + +async fn exchange( + stream: &mut UnixStream, + identity: &(SessionId, Generation), + role: Role, + command: &WorkCommand, + service: &HostService, +) -> Result { + ensure!( + matches!( + (role, command), + (Role::Workbench, WorkCommand::Execute { .. }) + | (Role::Hooks, WorkCommand::Invoke { .. }) + ), + "command is not allowed for worker role" + ); + if let WorkCommand::Execute { code, .. } = command { + ensure!(code.len() <= MAX_CODE, "cell too large"); + } + let envelope = |message| Envelope { + version: VERSION, + session_id: identity.0.clone(), + generation: identity.1.clone(), + message, + }; + write_frame(stream, &envelope(command.message())).await?; + let mut requests = 0; + let mut yielded = false; + loop { + let frame = read_frame(stream).await?; + ensure!( + frame.session_id == identity.0 && frame.generation == identity.1, + "stale worker envelope" + ); + match frame.message { + Message::Completed { id, outcome } => { + ensure!(&id == command.id(), "wrong completed operation"); + outcome.validate(role)?; + return Ok(outcome); + } + Message::HostRequest { + id, + execution_id, + request, + } => { + ensure!( + role == Role::Workbench && &execution_id == command.id(), + "foreign host request" + ); + requests += 1; + ensure!(requests <= 128, "per-cell host-request budget exhausted"); + let is_wait = matches!(request, HostRequest::Wait { .. }); + let reply = if yielded { + HostReply::error("scope_closed", "execution has already yielded") + } else { + service(HostCall { + id: id.clone(), + execution_id: execution_id.clone(), + request, + }) + .await + }; + if is_wait && matches!(reply, HostReply::Ok { .. }) { + yielded = true; + } + write_frame( + stream, + &envelope(Message::HostReply { + id, + execution_id, + reply, + }), + ) + .await?; + } + _ => bail!("unexpected worker message"), + } + } +} diff --git a/klbr-runtime/tests/protocol.rs b/klbr-runtime/tests/protocol.rs new file mode 100644 index 0000000..536ecf3 --- /dev/null +++ b/klbr-runtime/tests/protocol.rs @@ -0,0 +1,62 @@ +use anyhow::Result; +use klbr_runtime::protocol::*; +use serde_json::{json, Value}; +use tokio::io::{duplex, AsyncWriteExt}; + +fn fixtures() -> Vec { + serde_json::from_str(include_str!("../../tests/runtime-fixtures/envelopes.json")).unwrap() +} + +#[tokio::test] +async fn golden_envelopes_match_python_and_roundtrip_framing() -> Result<()> { + for value in fixtures() { + let expected: Envelope = serde_json::from_value(value.clone())?; + assert_eq!(serde_json::to_value(&expected)?, value); + let (mut writer, mut reader) = duplex(MAX_FRAME + 4); + write_frame(&mut writer, &expected).await?; + drop(writer); + assert_eq!(serde_json::to_value(read_frame(&mut reader).await?)?, value); + } + Ok(()) +} + +#[tokio::test] +async fn oversized_and_truncated_frames_fail() -> Result<()> { + for (length, payload) in [ + (MAX_FRAME as u32 + 1, vec![]), + (20, b"{}".to_vec()), + (0, vec![]), + ] { + let (mut writer, mut reader) = duplex(64); + writer.write_u32(length).await?; + writer.write_all(&payload).await?; + drop(writer); + assert!(read_frame(&mut reader).await.is_err()); + } + Ok(()) +} + +#[test] +fn unknown_fields_invalid_identities_and_wrong_outcome_roles_fail() { + let mut value = fixtures().remove(0); + value["extra"] = json!(true); + assert!(serde_json::from_value::(value).is_err()); + for id in ["", "bad/id", "with a space", "emoji-🌙"] { + assert!(id.parse::().is_err()); + } + assert!("root-1".parse::().is_ok()); + let hook = Outcome::Hook { + value: json!({"action":"allow"}), + }; + assert!(hook.validate(Role::Workbench).is_err()); + let oversized = Outcome::Cell { + status: CellStatus::Ok, + output: vec![Output { + stream: OutputStream::Stdout, + text: "x".repeat(MAX_OUTPUT + 1), + }], + truncated_bytes: 0, + error: None, + }; + assert!(oversized.validate(Role::Workbench).is_err()); +} diff --git a/klbr-runtime/tests/session.rs b/klbr-runtime/tests/session.rs new file mode 100644 index 0000000..12bceea --- /dev/null +++ b/klbr-runtime/tests/session.rs @@ -0,0 +1,301 @@ +//! Actual Rust -> Python integration tests. No provider or Discord credentials. +//! These were authored but NOT RUN in the artifact-building environment. +use anyhow::{anyhow, Result}; +use klbr_runtime::{ + behavior::Release, + protocol::*, + session::{ExecutionReport, SessionRuntime}, + store::Store, + worker::WorkerConfig, +}; +use serde_json::json; +use std::{fs, path::PathBuf, sync::Arc, time::Duration}; +use tokio::time::{sleep, timeout}; + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .into() +} +fn output(report: &ExecutionReport) -> String { + match &report.outcome { + Outcome::Cell { output, .. } => output.iter().map(|o| o.text.as_str()).collect(), + other => panic!("expected cell, got {other:?}"), + } +} +fn ok(report: &ExecutionReport) { + assert!( + matches!( + &report.outcome, + Outcome::Cell { + status: CellStatus::Ok, + .. + } + ), + "{report:?}" + ); +} +struct Rig { + temp: tempfile::TempDir, + session: Arc, +} +impl Rig { + async fn new() -> Result { + let temp = tempfile::tempdir()?; + let initial = Release::publish(&root().join("behavior"), &temp.path().join("releases"))?; + let store = Store::open(temp.path().join("runtime.sqlite"))?; + let session = SessionRuntime::start( + store, + SessionId::fresh(), + WorkerConfig::source_tree(&root()), + initial, + ) + .await?; + Ok(Self { temp, session }) + } + fn changed(&self, name: &str, body: &str) -> Result { + let source = self.temp.path().join(name); + fs::create_dir_all(source.join("klbr_hooks"))?; + fs::copy( + root().join("behavior/manifest.json"), + source.join("manifest.json"), + )?; + fs::copy( + root().join("behavior/klbr_hooks/__init__.py"), + source.join("klbr_hooks/__init__.py"), + )?; + let mut defaults = fs::read_to_string(root().join("behavior/klbr_hooks/defaults.py"))?; + defaults.push_str(body); + fs::write(source.join("klbr_hooks/defaults.py"), defaults)?; + Release::publish(&source, &self.temp.path().join("releases")) + } + async fn run(&self, code: &str) -> Result { + self.session + .execute(code.into(), Duration::from_secs(10)) + .await + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn persistent_cells_explicit_speech_and_terminal_wait() -> Result<()> { + let rig = Rig::new().await?; + ok(&rig.run("answer = 42\nprint('not speech')\nanswer").await?); + let report = rig + .run("from klbr import local\nr = await local.send(str(answer))\nr.status") + .await?; + ok(&report); + assert!(output(&report).contains("confirmed")); + let history = rig.session.store.history(&rig.session.id, 0, 100).await?; + assert_eq!(history.iter().filter(|e| e.kind == "local.sent").count(), 1); + let waiting=rig.run("from klbr import runtime\nawait runtime.wait(reason='idle')\nawait local.send('incorrect')").await?; + assert!(matches!( + waiting.outcome, + Outcome::Cell { + status: CellStatus::Yielded, + .. + } + )); + assert_eq!( + rig.session + .store + .history(&rig.session.id, 0, 100) + .await? + .iter() + .filter(|e| e.kind == "local.sent") + .count(), + 1 + ); + rig.session.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bad_guard_holds_instead_of_approving_and_hook_change_keeps_globals() -> Result<()> { + let rig = Rig::new().await?; + ok(&rig + .run("kept = object()\noriginal_identity = id(kept)") + .await?); + let broken = rig.changed( + "bad-return", + "\nasync def delivery(req, ctx):\n return True\n", + )?; + rig.session + .activate_hooks(rig.session.hook_epoch().await, broken) + .await?; + let report=rig.run("from klbr import local\nassert id(kept) == original_identity\nr = await local.send('must be held')\nr.status").await?; + ok(&report); + assert!(output(&report).contains("held")); + assert_eq!( + rig.session + .store + .history(&rig.session.id, 0, 100) + .await? + .iter() + .filter(|e| e.kind == "local.sent") + .count(), + 0 + ); + rig.session.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn invalid_candidate_does_not_activate_or_destroy_workbench() -> Result<()> { + let rig = Rig::new().await?; + ok(&rig.run("kept = 42").await?); + let epoch = rig.session.hook_epoch().await; + let broken = rig.changed("syntax-error", "\nthis is not valid python !\n")?; + assert!(rig.session.activate_hooks(epoch, broken).await.is_err()); + assert_eq!(rig.session.hook_epoch().await, epoch); + assert!(output(&rig.run("kept").await?).contains("42")); + rig.session.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn host_deadline_replaces_stuck_workbench_without_replaying_code() -> Result<()> { + let rig = Rig::new().await?; + let report = rig + .session + .execute("while True: pass".into(), Duration::from_millis(200)) + .await?; + assert!(matches!(report.outcome, Outcome::Failed { .. })); + let next = rig.run("40 + 2").await?; + ok(&next); + assert_ne!(report.generation, next.generation); + assert!(output(&next).contains("42")); + rig.session.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn blocked_hook_does_not_block_admission_and_fails_closed() -> Result<()> { + let rig = Rig::new().await?; + let marker = rig.temp.path().join("hook-entered"); + let body=format!("\nasync def delivery(req, ctx):\n from pathlib import Path\n Path({:?}).touch()\n while True: pass\n",marker.to_str().unwrap()); + let hanging = rig.changed("hang", &body)?; + rig.session + .activate_hooks(rig.session.hook_epoch().await, hanging) + .await?; + let session = rig.session.clone(); + let execution = tokio::spawn(async move { + session + .execute( + "from klbr import local\nr = await local.send('held after timeout')\nr.status" + .into(), + Duration::from_secs(10), + ) + .await + }); + timeout(Duration::from_secs(5), async { + while !marker.exists() { + sleep(Duration::from_millis(5)).await; + } + }) + .await?; + timeout( + Duration::from_secs(1), + rig.session.store.admit( + &rig.session.id, + "during-hook".into(), + json!({"text":"new input"}), + ), + ) + .await??; + let report = timeout(Duration::from_secs(8), execution).await???; + ok(&report); + assert!(output(&report).contains("held")); + assert_eq!( + rig.session.store.pending(&rig.session.id, 10).await?.len(), + 1 + ); + rig.session.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn in_flight_cell_pins_old_guard_and_next_cell_uses_new_guard() -> Result<()> { + let rig = Rig::new().await?; + let boundary = rig.temp.path().join("continue-cell"); + let code=format!("import asyncio\nfrom pathlib import Path\nfrom klbr import local\nwhile not Path({:?}).exists():\n await asyncio.sleep(0.01)\nr = await local.send('old pinned guard')\nr.status",boundary.to_str().unwrap()); + let session = rig.session.clone(); + let task = tokio::spawn(async move { session.execute(code, Duration::from_secs(20)).await }); + timeout(Duration::from_secs(5), async { + loop { + if rig + .session + .store + .history(&rig.session.id, 0, 100) + .await? + .iter() + .any(|e| e.kind == "execution.started") + { + return Ok::<_, anyhow::Error>(()); + } + sleep(Duration::from_millis(10)).await; + } + }) + .await??; + let revision = rig.changed( + "hold", + "\nasync def delivery(req, ctx):\n return DeliveryDecision.hold('new guard')\n", + )?; + rig.session + .activate_hooks(rig.session.hook_epoch().await, revision.clone()) + .await?; + fs::write(boundary, "continue")?; + let old = task.await??; + ok(&old); + assert!(output(&old).contains("confirmed")); + assert_ne!(old.hook_revision, revision.id); + let next = rig.run("(await local.send('new guard')).status").await?; + ok(&next); + assert!(output(&next).contains("held")); + assert_eq!(next.hook_revision, revision.id); + rig.session.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn operator_stop_remains_available_while_python_is_cpu_bound() -> Result<()> { + let rig = Rig::new().await?; + let session = rig.session.clone(); + let task = tokio::spawn(async move { + session + .execute("while True: pass".into(), Duration::from_secs(30)) + .await + }); + timeout(Duration::from_secs(5), async { + loop { + if rig + .session + .store + .history(&rig.session.id, 0, 100) + .await? + .iter() + .any(|e| e.kind == "execution.started") + { + break; + } + sleep(Duration::from_millis(10)).await; + } + Ok::<_, anyhow::Error>(()) + }) + .await??; + timeout(Duration::from_secs(2), rig.session.interrupt()).await?; + let report = timeout(Duration::from_secs(5), task).await???; + if !matches!(report.outcome, Outcome::Failed { .. }) { + return Err(anyhow!("stopped code should not claim completion")); + } + rig.session.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn shutdown_does_not_silently_respawn_the_kernel() -> Result<()> { + let rig = Rig::new().await?; + rig.session.shutdown().await; + assert!(rig.run("42").await.is_err()); + Ok(()) +} diff --git a/klbr-runtime/tests/store.rs b/klbr-runtime/tests/store.rs new file mode 100644 index 0000000..fbe9f91 --- /dev/null +++ b/klbr-runtime/tests/store.rs @@ -0,0 +1,288 @@ +//! These tests exercise rusqlite/Store, unlike Python's migration-only tests. +use anyhow::{anyhow, Result}; +use klbr_runtime::{ + behavior::Release, + hooks::{DeliveryDecision, ReviewedDelivery}, + protocol::*, + store::{PreparedEffect, ReceiptStatus, Store}, +}; +use serde_json::json; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .into() +} +fn sid(value: &str) -> SessionId { + value.parse().unwrap() +} +fn done(status: CellStatus) -> Outcome { + Outcome::Cell { + status, + output: vec![], + truncated_bytes: 0, + error: None, + } +} +fn publish(path: &Path) -> Result { + Release::publish(&root().join("behavior"), path) +} +async fn setup(store: &Store, session: &SessionId, release: &Release) -> Result<()> { + store.ensure_session(session).await?; + store.activate(session, 0, release.clone()).await?; + Ok(()) +} +async fn begin( + store: &Store, + session: &SessionId, + release: &Release, +) -> Result<(OperationId, Generation)> { + let (operation, generation) = (OperationId::fresh(), Generation::fresh()); + store + .begin( + session, + &operation, + &generation, + release.id.clone(), + "print('not speech')".into(), + ) + .await?; + Ok((operation, generation)) +} + +#[test] +fn release_identity_matches_shared_python_fixture_and_detects_tampering() -> Result<()> { + let temporary = tempfile::tempdir()?; + let release = publish(temporary.path())?; + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../../tests/runtime-fixtures/behavior.json"))?; + assert_eq!(release.id, fixture["sha256"].as_str().unwrap()); + let repeated = publish(temporary.path())?; + assert_eq!(release.id, repeated.id); + fs::write(release.path.join("klbr_hooks/defaults.py"), "# tampered\n")?; + assert!(publish(temporary.path()).is_err()); + Ok(()) +} + +#[tokio::test] +async fn admission_is_deduplicated_session_scoped_and_survives_reopen() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("runtime.sqlite"); + let store = Store::open(&path)?; + let (root, child) = (sid("root"), sid("child")); + store.ensure_session(&root).await?; + store.ensure_session(&child).await?; + let first = store + .admit(&root, "source-0".into(), json!({"n":0})) + .await?; + assert_eq!( + first, + store + .admit(&root, "source-0".into(), json!({"n":0})) + .await? + ); + assert!(store + .admit(&root, "source-0".into(), json!({"n":9})) + .await + .is_err()); + store + .admit(&child, "source-0".into(), json!({"n":0})) + .await?; + let mut tasks = Vec::new(); + for i in 1..200 { + let (store, root) = (store.clone(), root.clone()); + tasks.push(tokio::spawn(async move { + store + .admit(&root, format!("source-{i}"), json!({"n":i})) + .await + })); + } + for task in tasks { + task.await??; + } + assert_eq!(store.pending(&root, 1024).await?.len(), 200); + assert_eq!(store.pending(&child, 1024).await?.len(), 1); + drop(store); + let reopened = Store::open(&path)?; + assert_eq!(reopened.pending(&root, 1024).await?.len(), 200); + Ok(()) +} + +#[test] +fn second_owner_and_unrelated_database_are_refused() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("runtime.sqlite"); + let owner = Store::open(&path)?; + assert!(Store::open(&path).is_err()); + drop(owner); + Store::open(&path)?; + let legacy = temp.path().join("legacy.sqlite"); + let db = rusqlite::Connection::open(&legacy)?; + db.execute_batch( + "CREATE TABLE important_data(value TEXT); INSERT INTO important_data VALUES('preserve');", + )?; + assert!(Store::open(&legacy).is_err()); + assert_eq!( + db.query_row("SELECT value FROM important_data", [], |row| row + .get::<_, String>(0))?, + "preserve" + ); + Ok(()) +} + +#[tokio::test] +async fn receipts_are_separate_from_scratchpad_and_dedupe_by_request_not_text() -> Result<()> { + let temp = tempfile::tempdir()?; + let release = publish(&temp.path().join("releases"))?; + let store = Store::open(temp.path().join("runtime.sqlite"))?; + let root = sid("root"); + setup(&store, &root, &release).await?; + store + .scratchpad(&root, "i drafted a message but have not sent it".into()) + .await?; + let (op, generation) = begin(&store, &root, &release).await?; + let request = RequestId::fresh(); + let effect = match store + .prepare_send(&root, &op, &generation, request.clone(), "hello".into()) + .await? + { + PreparedEffect::New(id) => id, + _ => return Err(anyhow!("expected newly prepared effect")), + }; + assert_eq!( + store + .history(&root, 0, 100) + .await? + .iter() + .filter(|e| e.kind == "local.sent") + .count(), + 0 + ); + let receipt = store + .publish_send( + &root, + &op, + &generation, + effect.clone(), + release.id.clone(), + ReviewedDelivery { + decision: DeliveryDecision::Allow, + fault: None, + }, + ) + .await?; + assert_eq!(receipt.status, ReceiptStatus::Confirmed); + match store + .prepare_send(&root, &op, &generation, request, "hello".into()) + .await? + { + PreparedEffect::Existing(again) => assert_eq!(again.effect_id, effect), + _ => return Err(anyhow!("request retry must not create another effect")), + } + assert!(matches!( + store + .prepare_send(&root, &op, &generation, RequestId::fresh(), "hello".into()) + .await?, + PreparedEffect::New(_) + )); + store.finish(&op, done(CellStatus::Ok)).await?; + assert!(store + .prepare_send( + &root, + &op, + &generation, + RequestId::fresh(), + "too late".into() + ) + .await + .is_err()); + let history = store.history(&root, 0, 100).await?; + assert_eq!(history.iter().filter(|e| e.kind == "local.sent").count(), 1); + assert_eq!(history.iter().filter(|e| e.kind == "scratchpad").count(), 1); + Ok(()) +} + +#[tokio::test] +async fn waits_do_not_lose_input_on_either_side_of_the_commit() -> Result<()> { + let temp = tempfile::tempdir()?; + let release = publish(&temp.path().join("releases"))?; + let store = Store::open(temp.path().join("runtime.sqlite"))?; + let root = sid("root"); + setup(&store, &root, &release).await?; + let input = store.admit(&root, "before".into(), json!({})).await?; + let (op, generation) = begin(&store, &root, &release).await?; + let result = store + .wait(&root, &op, &generation, None, "idle".into()) + .await?; + assert_eq!(result["pending_input"], true); + store.finish(&op, done(CellStatus::Yielded)).await?; + store.acknowledge_inputs(&root, vec![input]).await?; + let (op, generation) = begin(&store, &root, &release).await?; + assert_eq!( + store + .wait(&root, &op, &generation, Some(30.0), "later".into()) + .await?["pending_input"], + false + ); + store.finish(&op, done(CellStatus::Yielded)).await?; + store.admit(&root, "after".into(), json!({})).await?; + assert!(store + .history(&root, 0, 100) + .await? + .iter() + .any(|e| e.kind == "session.woke" && e.payload["reason"] == "input")); + assert!(store.wake_due(i64::MAX).await?.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn recovery_closes_unknown_executions_without_replay_and_keeps_waits() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("runtime.sqlite"); + let release = publish(&temp.path().join("releases"))?; + let store = Store::open(&path)?; + let (root, child) = (sid("root"), sid("child")); + setup(&store, &root, &release).await?; + setup(&store, &child, &release).await?; + let (op, generation) = begin(&store, &root, &release).await?; + store + .prepare_send( + &root, + &op, + &generation, + RequestId::fresh(), + "not yet published".into(), + ) + .await?; + let (child_op, child_gen) = begin(&store, &child, &release).await?; + store + .wait(&child, &child_op, &child_gen, Some(1.0), "timer".into()) + .await?; + drop(store); + let store = Store::open(&path)?; + let history = store.history(&root, 0, 100).await?; + assert_eq!( + history + .iter() + .filter(|e| e.kind == "execution.started") + .count(), + 1 + ); + assert_eq!(history.iter().filter(|e| e.kind == "local.sent").count(), 0); + assert!(history + .iter() + .any(|e| e.kind == "execution.completed" && e.payload["state"] == "interrupted")); + let history = store.history(&child, 0, 100).await?; + assert!(history + .iter() + .any(|e| e.kind == "execution.completed" && e.payload["state"] == "yielded")); + assert_eq!(store.wake_due(i64::MAX).await?, vec!["child"]); + assert!(store.wake_due(i64::MAX).await?.is_empty()); + begin(&store, &root, &release).await?; + Ok(()) +} diff --git a/python/klbr-runtime/AGENTS.md b/python/klbr-runtime/AGENTS.md new file mode 100644 index 0000000..46e758d --- /dev/null +++ b/python/klbr-runtime/AGENTS.md @@ -0,0 +1,12 @@ +# Python runtime slice + +Read `../../RUNTIME-V2.md` and `../../docs/runtime-v2/STATUS.md` first. +Use `python tools/check_runtime.py` from the repository root. No pip install is needed. +The `tests/peer.py` host is a test double; do not mistake it for the Rust runtime. + +Keep the workbench namespace separate from required hook execution. No protocol over stdout. +Keep correlation IDs, generation checks, output bounds and expired background-effect scopes. +Terminal wait is a host-owned yield, not a Python sleep. All durable state lives in the host; +there is no pickle/heap-restoration contract. Published hooks are source entrypoints, not +closures capturing cell globals. New imports remain ordinary Python vocabulary, not one Rust +tool registration per helper. Local receipts are not remote delivery semantics. diff --git a/python/klbr-runtime/pyproject.toml b/python/klbr-runtime/pyproject.toml new file mode 100644 index 0000000..c6d19d4 --- /dev/null +++ b/python/klbr-runtime/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "klbr-runtime" +version = "0.1.0" +description = "Supervised Python workbench and policy worker for klbr" +requires-python = ">=3.11" +dependencies = [] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +klbr = ["py.typed"] +klbr_runtime = ["py.typed"] diff --git a/python/klbr-runtime/src/klbr/__init__.py b/python/klbr-runtime/src/klbr/__init__.py new file mode 100644 index 0000000..eb7b083 --- /dev/null +++ b/python/klbr-runtime/src/klbr/__init__.py @@ -0,0 +1,4 @@ +"""The workbench vocabulary. Importable without an active connection.""" +from . import hooks, local, runtime + +__all__ = ["hooks", "local", "runtime"] diff --git a/python/klbr-runtime/src/klbr/hooks.py b/python/klbr-runtime/src/klbr/hooks.py new file mode 100644 index 0000000..f7499f5 --- /dev/null +++ b/python/klbr-runtime/src/klbr/hooks.py @@ -0,0 +1,113 @@ +"""Typed policy vocabulary and workbench discovery. + +Handlers receive snapshots and return decisions. They have no foreground cell +scope and cannot call local.send/runtime.wait through this SDK. +""" +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Literal + +from klbr_runtime.context import scope +from klbr_runtime.wire import fields, identifier, text + + +@dataclass(frozen=True, slots=True) +class HookContext: + invocation_id: str + session_id: str + revision: str + + +@dataclass(frozen=True, slots=True) +class DeliveryRequest: + effect_id: str + destination: Literal["operator"] + text: str + + @classmethod + def parse(cls, value: dict) -> DeliveryRequest: + fields(value, {"effect_id", "destination", "text"}) + if value["destination"] != "operator": + raise ValueError("this slice implements local delivery only") + return cls(identifier(value["effect_id"]), "operator", text(value["text"], 65_536)) + + +@dataclass(frozen=True, slots=True) +class DeliveryDecision: + action: Literal["allow", "hold"] + reason: str | None = None + + @classmethod + def allow(cls) -> DeliveryDecision: + return cls("allow") + + @classmethod + def hold(cls, reason: str) -> DeliveryDecision: + return cls("hold", reason) + + def to_wire(self) -> dict: + if self.action == "allow" and self.reason is None: + return {"action": "allow"} + if self.action == "hold" and isinstance(self.reason, str) and self.reason.strip(): + return {"action": "hold", "reason": text(self.reason, 2048)} + raise ValueError("invalid delivery decision") + + +@dataclass(frozen=True, slots=True) +class AttentionRequest: + event_id: int + source: Literal["operator", "discord.dm", "discord.mention", "discord.ambient"] + received_ms: int + + @classmethod + def parse(cls, value: dict) -> AttentionRequest: + fields(value, {"event_id", "source", "received_ms"}) + if type(value["event_id"]) is not int or value["event_id"] <= 0: + raise ValueError("invalid event sequence") + if type(value["received_ms"]) is not int or value["received_ms"] < 0: + raise ValueError("invalid receipt time") + if value["source"] not in {"operator", "discord.dm", "discord.mention", "discord.ambient"}: + raise ValueError("unknown source classification") + return cls(**value) + + +@dataclass(frozen=True, slots=True) +class AttentionDecision: + action: Literal["wake", "defer", "ignore"] + priority: Literal["operator", "directed", "ambient"] | None = None + seconds: float | None = None + reason: str | None = None + + @classmethod + def wake(cls, priority: Literal["operator", "directed", "ambient"]) -> AttentionDecision: + return cls("wake", priority=priority) + + @classmethod + def defer(cls, seconds: float) -> AttentionDecision: + return cls("defer", seconds=seconds) + + @classmethod + def ignore(cls, reason: str) -> AttentionDecision: + return cls("ignore", reason=reason) + + def to_wire(self) -> dict: + if self.action == "wake" and self.priority in {"operator", "directed", "ambient"} and self.seconds is None and self.reason is None: + return {"action": "wake", "priority": self.priority} + if self.action == "defer" and type(self.seconds) in {int, float} and math.isfinite(self.seconds) and 0 < self.seconds <= 600 and self.priority is None and self.reason is None: + return {"action": "defer", "seconds": self.seconds} + if self.action == "ignore" and self.reason and self.priority is None and self.seconds is None: + return {"action": "ignore", "reason": text(self.reason, 2048)} + raise ValueError("invalid attention decision") + + +async def list() -> dict: + """Inspect implemented slots and the revision pinned to this execution.""" + return await scope().request({"method": "hooks.list"}) + + +async def describe(slot: str) -> dict: + if slot not in {"attention.plan", "delivery.review"}: + raise ValueError(f"unknown hook slot: {slot!r}") + return await scope().request({"method": "hooks.describe", "slot": slot}) diff --git a/python/klbr-runtime/src/klbr/local.py b/python/klbr-runtime/src/klbr/local.py new file mode 100644 index 0000000..a565321 --- /dev/null +++ b/python/klbr-runtime/src/klbr/local.py @@ -0,0 +1,24 @@ +"""Explicit operator speech. stdout, results and scratchpad are not speech.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from klbr_runtime.context import scope +from klbr_runtime.wire import text + + +@dataclass(frozen=True, slots=True) +class Receipt: + effect_id: str + status: Literal["confirmed", "held"] + event_id: int | None + reason: str | None + + +async def send(content: str) -> Receipt: + value = text(content, 65_536) + if not value.strip(): + raise ValueError("cannot send an empty message") + result = await scope().request({"method": "local.send", "text": value}) + return Receipt(**result) diff --git a/python/klbr-runtime/src/klbr/py.typed b/python/klbr-runtime/src/klbr/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/klbr-runtime/src/klbr/runtime.py b/python/klbr-runtime/src/klbr/runtime.py new file mode 100644 index 0000000..3f2002a --- /dev/null +++ b/python/klbr-runtime/src/klbr/runtime.py @@ -0,0 +1,20 @@ +"""Host-owned terminal yield, not a durable Python sleep.""" +from __future__ import annotations + +import math +from typing import NoReturn + +from klbr_runtime.context import TurnYield, scope +from klbr_runtime.wire import text + + +async def wait(seconds: float | None = None, *, reason: str) -> NoReturn: + if seconds is not None and (type(seconds) not in {int, float} or not math.isfinite(seconds) or not 0 < seconds <= 600): + raise ValueError("wait must be indefinite or within (0, 600] seconds") + why = text(reason, 2048) + if not why.strip(): + raise ValueError("wait requires a reason") + execution = scope() + await execution.request({"method": "runtime.wait", "seconds": seconds, "reason": why}) + execution.terminal = "yielded" + raise TurnYield() diff --git a/python/klbr-runtime/src/klbr_runtime/__init__.py b/python/klbr-runtime/src/klbr_runtime/__init__.py new file mode 100644 index 0000000..2c3a770 --- /dev/null +++ b/python/klbr-runtime/src/klbr_runtime/__init__.py @@ -0,0 +1 @@ +"""Execution machinery, not the owner of agent history or scheduling.""" diff --git a/python/klbr-runtime/src/klbr_runtime/__main__.py b/python/klbr-runtime/src/klbr_runtime/__main__.py new file mode 100644 index 0000000..915093e --- /dev/null +++ b/python/klbr-runtime/src/klbr_runtime/__main__.py @@ -0,0 +1,120 @@ +"""Entrypoint for both supervised roles. Python >=3.11, Unix control socket.""" +from __future__ import annotations + +# Source-tree launch works with Python -I -B; no inherited PYTHONPATH required. +if __package__ in {None, ""}: + import sys + from pathlib import Path + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + __package__ = "klbr_runtime" + +import argparse +import asyncio +import contextlib +import sys +import traceback +from pathlib import Path + +from klbr.hooks import HookContext +from .context import Bridge, RoutedText +from .kernel import Kernel +from .policy import Policy +from .wire import MAX_CODE, ProtocolError, Wire, fields, identifier, text + + +async def serve(args: argparse.Namespace) -> None: + policy = Policy(Path(args.behavior), args.revision) if args.role == "hooks" else None + reader, writer = await asyncio.open_unix_connection(args.socket) + wire = Wire(reader, writer, args.session, args.generation) + bridge, kernel = Bridge(wire), None + if args.role == "workbench": + kernel = Kernel(bridge) + await wire.send({"kind": "hello", "token": args.token, "role": args.role, + "revision": args.revision, "slots": sorted(policy.handlers) if policy else []}) + original_out, original_err = sys.stdout, sys.stderr + sys.stdout, sys.stderr = RoutedText("stdout", original_out), RoutedText("stderr", original_err) + active: asyncio.Task | None = None + active_id: str | None = None + + async def run(message: dict) -> None: + try: + if message["kind"] == "execute": + assert kernel is not None + outcome = await kernel.execute(message["id"], message["code"]) + else: + assert policy is not None + ctx = HookContext(message["id"], args.session, args.revision) + value = await policy.invoke(message["slot"], message["input"], ctx) + outcome = {"kind": "hook", "value": value} + except asyncio.CancelledError: + outcome = {"kind": "failed", "error": "hook invocation interrupted"} + except BaseException as error: + outcome = {"kind": "failed", "error": "".join(traceback.format_exception(error))[-16_384:]} + await wire.send({"kind": "completed", "id": message["id"], "outcome": outcome}) + + try: + while True: + message = await wire.receive() + kind = message["kind"] + if kind == "host_reply": + bridge.reply(message) + elif kind == "interrupt": + fields(message, {"kind", "id"}) + if active and not active.done() and message["id"] == active_id: + active.cancel() + elif kind == "shutdown": + fields(message, {"kind"}) + break + elif kind in {"execute", "invoke"}: + if kind == "execute": + fields(message, {"kind", "id", "code"}) + if kernel is None: + raise ProtocolError("hook worker cannot execute cells") + text(message["code"], MAX_CODE) + else: + fields(message, {"kind", "id", "slot", "input"}) + if policy is None: + raise ProtocolError("workbench cannot run required policy hooks") + text(message["slot"], 128) + identifier(message["id"]) + if active and not active.done(): + raise ProtocolError("concurrent foreground operation rejected") + if active: + active.result() # Propagate previous transport errors. + active_id = message["id"] + active = asyncio.create_task(run(message)) + else: + raise ProtocolError(f"unexpected host message: {kind}") + finally: + if active and not active.done(): + active.cancel() + # Host enforces a hard process deadline; do not depend on cooperative + # cancellation when arbitrary Python catches CancelledError. + sys.stdout, sys.stderr = original_out, original_err + writer.close() + with contextlib.suppress(ConnectionError): + await writer.wait_closed() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--socket", required=True) + parser.add_argument("--role", choices=["workbench", "hooks"], required=True) + parser.add_argument("--session", required=True) + parser.add_argument("--generation", required=True) + parser.add_argument("--token", required=True) + parser.add_argument("--behavior") + parser.add_argument("--revision") + args = parser.parse_args() + if args.role == "hooks" and (not args.behavior or not args.revision): + parser.error("hook workers need a behavior snapshot and revision") + sys.dont_write_bytecode = True + try: + asyncio.run(serve(args)) + except (ProtocolError, ConnectionError, asyncio.IncompleteReadError) as error: + print(f"klbr worker disconnected: {error}", file=sys.stderr) + raise SystemExit(2) from error + + +if __name__ == "__main__": + main() diff --git a/python/klbr-runtime/src/klbr_runtime/context.py b/python/klbr-runtime/src/klbr_runtime/context.py new file mode 100644 index 0000000..22cf2a1 --- /dev/null +++ b/python/klbr-runtime/src/klbr_runtime/context.py @@ -0,0 +1,150 @@ +"""Execution-scoped RPC authority and bounded output, not persistent memory.""" +from __future__ import annotations + +import asyncio +import contextvars +import io +import uuid +from dataclasses import dataclass, field +from typing import Any + +from .wire import Wire, fields + +OUTPUT_LIMIT = 65_536 +OUTPUT_RECORD_LIMIT = 256 + + +class ScopeClosed(RuntimeError): + """The originating foreground execution no longer owns host-effect authority.""" + + +class HostError(RuntimeError): + def __init__(self, code: str, message: str): + super().__init__(f"{code}: {message}") + self.code = code + + +class TurnYield(BaseException): + """Interpreter control outcome. Not a pickled/resumable continuation.""" + + +@dataclass +class ExecutionScope: + id: str + bridge: Bridge + open: bool = True + terminal: str | None = None + output: list[dict[str, str]] = field(default_factory=list) + stored_bytes: int = 0 + truncated_bytes: int = 0 + + def write(self, stream: str, value: str) -> None: + raw = value.encode("utf-8", errors="replace") + room = max(0, OUTPUT_LIMIT - self.stored_bytes) + kept = raw[:room].decode("utf-8", errors="ignore") + count = len(kept.encode("utf-8")) + if kept: + if self.output and self.output[-1]["stream"] == stream: + self.output[-1]["text"] += kept + elif len(self.output) < OUTPUT_RECORD_LIMIT: + self.output.append({"stream": stream, "text": kept}) + else: + count = 0 + self.stored_bytes += count + self.truncated_bytes += len(raw) - count + + async def request(self, request: dict) -> Any: + if not self.open or self.terminal is not None: + raise ScopeClosed("host operations require a live, non-yielded foreground execution") + return await self.bridge.request(self, request) + + +current: contextvars.ContextVar[ExecutionScope | None] = contextvars.ContextVar("klbr_execution", default=None) + + +def scope() -> ExecutionScope: + value = current.get() + if value is None or not value.open or value.terminal is not None: + raise ScopeClosed("no active workbench execution; hooks return proposals instead of calling effects") + return value + + +class Bridge: + def __init__(self, wire: Wire): + self.wire = wire + self.pending: dict[str, tuple[str, asyncio.Future]] = {} + + async def request(self, execution: ExecutionScope, request: dict) -> Any: + request_id = str(uuid.uuid4()) + future = asyncio.get_running_loop().create_future() + self.pending[request_id] = (execution.id, future) + try: + await self.wire.send({"kind": "host_request", "id": request_id, + "execution_id": execution.id, "request": request}) + return await future + finally: + self.pending.pop(request_id, None) + if not future.done(): + future.cancel() + + def reply(self, message: dict) -> None: + fields(message, {"kind", "id", "execution_id", "reply"}) + entry = self.pending.get(message["id"]) + if entry is None: # A cancellation may precede its already-in-flight reply. + return + execution_id, future = entry + if execution_id != message["execution_id"] or future.done(): + return + reply = message["reply"] + if reply.get("kind") == "ok": + fields(reply, {"kind", "value"}) + future.set_result(reply["value"]) + elif reply.get("kind") == "error": + fields(reply, {"kind", "code", "message"}) + future.set_exception(HostError(reply["code"], reply["message"])) + else: + raise ValueError("invalid host reply") + + def close_execution(self, execution: ExecutionScope) -> None: + execution.open = False + for request_id, (execution_id, future) in list(self.pending.items()): + if execution_id == execution.id: + if not future.done(): + future.cancel() + self.pending.pop(request_id, None) + + +class RoutedText(io.TextIOBase): + """Python text is cell output. Native fd writes remain raw diagnostics. + + Late background output is diagnostic, tagged with its originating execution; + it must not become the output of whichever cell happens to run next. + """ + def __init__(self, stream: str, original: io.TextIOBase): + self.stream, self.original = stream, original + + @property + def encoding(self) -> str: + return "utf-8" + + def writable(self) -> bool: + return True + + def fileno(self) -> int: + return self.original.fileno() + + def flush(self) -> None: + self.original.flush() + + def write(self, value: str) -> int: + if not isinstance(value, str): + raise TypeError("text output requires str") + execution = current.get() + if execution is not None and execution.open: + execution.write(self.stream, value) + else: + origin = execution.id if execution else "unassigned" + # The host continuously drains this pipe, retaining only a bounded tail. + self.original.write(f"[background origin={origin}] {value[:4096]}") + self.original.flush() + return len(value) diff --git a/python/klbr-runtime/src/klbr_runtime/kernel.py b/python/klbr-runtime/src/klbr_runtime/kernel.py new file mode 100644 index 0000000..3a09cf7 --- /dev/null +++ b/python/klbr-runtime/src/klbr_runtime/kernel.py @@ -0,0 +1,69 @@ +"""Persistent CPython evaluator; all history and recovery ownership stays outside.""" +from __future__ import annotations + +import __future__ +import ast +import asyncio +import inspect +import linecache +import traceback +from collections import deque + +from .context import Bridge, ExecutionScope, TurnYield, current + +_FUTURE_MASK = sum(getattr(__future__, name).compiler_flag for name in __future__.all_feature_names) + + +class Kernel: + def __init__(self, bridge: Bridge): + self.bridge = bridge + self.globals: dict = {"__name__": "__klbr_workbench__", "__builtins__": __builtins__} + self.flags = ast.PyCF_ALLOW_TOP_LEVEL_AWAIT + self._source_names: deque[str] = deque() + + def display(self, value: object) -> None: + if value is not None: + self.globals["_"] = value + execution = current.get() + if execution is not None: + execution.write("result", repr(value)) + + async def execute(self, operation_id: str, source: str) -> dict: + execution = ExecutionScope(operation_id, self.bridge) + token = current.set(execution) + status, error = "ok", None + filename = f"" + linecache.cache[filename] = (len(source), None, source.splitlines(True), filename) + self._source_names.append(filename) + while len(self._source_names) > 64: + linecache.cache.pop(self._source_names.popleft(), None) + try: + tree = compile(source, filename, "exec", flags=self.flags | ast.PyCF_ONLY_AST, dont_inherit=True) + if tree.body and isinstance(tree.body[-1], ast.Expr): + expression = tree.body[-1] + tree.body[-1] = ast.copy_location(ast.Expr(value=ast.Call( + func=ast.Name(id="__klbr_display__", ctx=ast.Load()), + args=[expression.value], keywords=[])), expression) + ast.fix_missing_locations(tree) + self.globals["__klbr_display__"] = self.display + code = compile(tree, filename, "exec", flags=self.flags, dont_inherit=True) + self.flags |= code.co_flags & _FUTURE_MASK + result = eval(code, self.globals) + if inspect.isawaitable(result): + await result + except TurnYield: + status = "yielded" + except asyncio.CancelledError: + status = "interrupted" + except BaseException as failure: + # SystemExit/KeyboardInterrupt are cell outcomes, not instructions to + # kill the host. os._exit and native failures still kill the worker. + status = "error" + error = "".join(traceback.format_exception(failure))[-16_384:] + finally: + self.bridge.close_execution(execution) + current.reset(token) + # Catching the yield sentinel does not restore effect authority. + status = execution.terminal or status + return {"kind": "cell", "status": status, "output": execution.output, + "truncated_bytes": execution.truncated_bytes, "error": error} diff --git a/python/klbr-runtime/src/klbr_runtime/policy.py b/python/klbr-runtime/src/klbr_runtime/policy.py new file mode 100644 index 0000000..6581119 --- /dev/null +++ b/python/klbr-runtime/src/klbr_runtime/policy.py @@ -0,0 +1,45 @@ +"""One persistent policy process; no scheduler or agent loop.""" +from __future__ import annotations + +import importlib +import inspect +import sys +from pathlib import Path + +# Load the SDK before adding editable behavior to the import path. +from klbr.hooks import (AttentionDecision, AttentionRequest, DeliveryDecision, + DeliveryRequest, HookContext) +from .release import fingerprint, manifest + + +class Policy: + def __init__(self, root: Path, revision: str): + if fingerprint(root) != revision: + raise ValueError("behavior content does not match its revision fingerprint") + registrations = manifest(root) + sys.path.insert(0, str(root.resolve())) + self.revision = revision + self.handlers = {} + for slot, entrypoint in registrations.items(): + module, name = entrypoint.split(":") + handler = getattr(importlib.import_module(module), name) + if not callable(handler): + raise ValueError(f"not a callable: {entrypoint}") + # Enforce the two-argument use site without evaluating the callback. + inspect.signature(handler).bind(object(), object()) + self.handlers[slot] = handler + + async def invoke(self, slot: str, value: dict, context: HookContext) -> dict: + match slot: + case "delivery.review": + request, expected = DeliveryRequest.parse(value), DeliveryDecision + case "attention.plan": + request, expected = AttentionRequest.parse(value), AttentionDecision + case _: + raise ValueError(f"unsupported hook slot: {slot}") + result = self.handlers[slot](request, context) + if inspect.isawaitable(result): + result = await result + if not isinstance(result, expected): + raise TypeError(f"{slot} must return {expected.__name__}, not {type(result).__name__}") + return result.to_wire() diff --git a/python/klbr-runtime/src/klbr_runtime/py.typed b/python/klbr-runtime/src/klbr_runtime/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/klbr-runtime/src/klbr_runtime/release.py b/python/klbr-runtime/src/klbr_runtime/release.py new file mode 100644 index 0000000..e39e5a2 --- /dev/null +++ b/python/klbr-runtime/src/klbr_runtime/release.py @@ -0,0 +1,67 @@ +"""Content identity shared with Rust's behavior::Release. + +This module verifies a supplied immutable snapshot. It does not choose the active +revision or mutate a host database. Filesystem permissions are not a sandbox. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +MAX_FILES = 128 +MAX_BYTES = 2_097_152 +SLOTS = {"attention.plan", "delivery.review"} + + +def files(root: Path) -> list[tuple[str, bytes]]: + if root.is_symlink() or not root.is_dir(): + raise ValueError("behavior root must be a real directory") + result = [] + total = 0 + for path in sorted(root.rglob("*")): + if path.is_symlink(): + raise ValueError("behavior snapshots cannot contain symlinks") + if path.is_dir(): + continue + if path.suffix not in {".py", ".json", ".md"}: + raise ValueError(f"unsupported behavior file: {path.name}") + if not path.is_file() or path.stat().st_size > MAX_BYTES: + raise ValueError("behavior file is not regular or exceeds limit") + # Read one byte beyond the remaining bound to catch concurrent growth + # without allocating an arbitrarily large draft file. + with path.open("rb") as stream: + content = stream.read(MAX_BYTES - total + 1) + total += len(content) + if total > MAX_BYTES or len(result) >= MAX_FILES: + raise ValueError("behavior snapshot exceeds limits") + result.append((path.relative_to(root).as_posix(), content)) + return sorted(result) + + +def fingerprint(root: Path) -> str: + digest = hashlib.sha256() + for name, content in files(root): + name_bytes = name.encode("utf-8") + digest.update(len(name_bytes).to_bytes(8, "big")) + digest.update(name_bytes) + digest.update(len(content).to_bytes(8, "big")) + digest.update(content) + return digest.hexdigest() + + +def manifest(root: Path) -> dict[str, str]: + from .wire import fields + value = fields(json.loads((root / "manifest.json").read_text()), {"version", "hooks"}) + if type(value["version"]) is not int or value["version"] != 1: + raise ValueError("unsupported behavior manifest") + hooks = value["hooks"] + if not isinstance(hooks, dict) or set(hooks) != SLOTS: + raise ValueError(f"this slice requires exactly {sorted(SLOTS)}") + for entrypoint in hooks.values(): + if not isinstance(entrypoint, str) or entrypoint.count(":") != 1: + raise ValueError("expected module:function entrypoint") + module, function = entrypoint.split(":") + if not module.startswith("klbr_hooks.") or not all(x.isidentifier() for x in module.split(".")) or not function.isidentifier(): + raise ValueError("hook entrypoints must be functions in klbr_hooks modules") + return hooks diff --git a/python/klbr-runtime/src/klbr_runtime/wire.py b/python/klbr-runtime/src/klbr_runtime/wire.py new file mode 100644 index 0000000..ea5fd62 --- /dev/null +++ b/python/klbr-runtime/src/klbr_runtime/wire.py @@ -0,0 +1,97 @@ +"""Versioned, bounded JSON frames over a dedicated control socket. + +stdout is deliberately NOT a control channel. Any framing or identity error is +fatal to the connection; never try to resynchronize a partially read frame. +""" +from __future__ import annotations + +import asyncio +import json +import re +import struct +from typing import Any + +VERSION = 1 +MAX_FRAME = 1_048_576 +MAX_CODE = 262_144 +_ID = re.compile(r"[A-Za-z0-9_.-]{1,128}\Z") + + +class ProtocolError(Exception): + pass + + +def identifier(value: Any) -> str: + if not isinstance(value, str) or not _ID.fullmatch(value): + raise ProtocolError("invalid protocol identifier") + return value + + +def fields(value: Any, required: set[str], optional: set[str] = frozenset()) -> dict: + if not isinstance(value, dict) or not required <= value.keys() or value.keys() - required - optional: + raise ProtocolError(f"expected fields {sorted(required)}, optional {sorted(optional)}") + return value + + +def text(value: Any, maximum: int = MAX_CODE) -> str: + if not isinstance(value, str) or len(value.encode("utf-8")) > maximum: + raise ProtocolError("expected bounded UTF-8 text") + return value + + +def _unique(pairs: list[tuple[str, Any]]) -> dict: + result = {} + for key, value in pairs: + if key in result: + raise ProtocolError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def _nonfinite(value: str) -> Any: + raise ProtocolError(f"non-finite JSON number: {value}") + + +def encode(value: dict) -> bytes: + body = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + if not 0 < len(body) <= MAX_FRAME: + raise ProtocolError("frame exceeds byte limit") + return struct.pack(">I", len(body)) + body + + +async def read_frame(reader: asyncio.StreamReader) -> dict: + size = struct.unpack(">I", await reader.readexactly(4))[0] + if not 0 < size <= MAX_FRAME: + raise ProtocolError("invalid frame length") + raw = await reader.readexactly(size) + try: + return json.loads(raw.decode("utf-8"), object_pairs_hook=_unique, parse_constant=_nonfinite) + except (ValueError, UnicodeError, RecursionError) as error: + raise ProtocolError("invalid JSON frame") from error + + +class Wire: + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, + session_id: str, generation: str): + self.reader, self.writer = reader, writer + self.session_id = identifier(session_id) + self.generation = identifier(generation) + self._write_lock = asyncio.Lock() + + async def send(self, message: dict) -> None: + packet = encode({"version": VERSION, "session_id": self.session_id, + "generation": self.generation, "message": message}) + async with self._write_lock: + self.writer.write(packet) + await self.writer.drain() + + async def receive(self) -> dict: + packet = fields(await read_frame(self.reader), {"version", "session_id", "generation", "message"}) + if type(packet["version"]) is not int or packet["version"] != VERSION: + raise ProtocolError("unsupported protocol version") + if packet["session_id"] != self.session_id or packet["generation"] != self.generation: + raise ProtocolError("stale or foreign worker identity") + message = packet["message"] + if not isinstance(message, dict) or not isinstance(message.get("kind"), str): + raise ProtocolError("missing message kind") + return message diff --git a/python/klbr-runtime/tests/peer.py b/python/klbr-runtime/tests/peer.py new file mode 100644 index 0000000..59bb1c7 --- /dev/null +++ b/python/klbr-runtime/tests/peer.py @@ -0,0 +1,147 @@ +"""Test-only protocol peer. This is NOT a Python replacement for the Rust host. + +It exercises the shipped subprocesses without Rust/provider availability. Rust +storage, worker supervision and SessionRuntime are tested separately by Cargo. +""" +from __future__ import annotations + +import asyncio +import contextlib +import os +import shutil +import sys +import tempfile +import uuid +from pathlib import Path +from typing import Awaitable, Callable + +from klbr_runtime.release import fingerprint +from klbr_runtime.wire import Wire + +ROOT = Path(__file__).resolve().parents[3] +RUNNER = ROOT / "python/klbr-runtime/src/klbr_runtime/__main__.py" +Service = Callable[[dict], Awaitable[dict]] + + +def behavior_copy(target: Path, delivery: str | None = None) -> Path: + shutil.copytree(ROOT / "behavior", target) + if delivery is not None: + with (target / "klbr_hooks/defaults.py").open("a") as out: + out.write("\n" + delivery + "\n") + return target + + +class Peer: + def __init__(self, role: str = "workbench", behavior: Path | None = None): + self.role, self.behavior = role, behavior + self.session, self.generation = "test-session", str(uuid.uuid4()) + self.token = str(uuid.uuid4()) + self.directory = tempfile.TemporaryDirectory(prefix="klbr-test-") + self.process = None + self.wire = None + self.server = None + self.drains = [] + self.raw = {"stdout": bytearray(), "stderr": bytearray()} + self.requests = [] + + async def start(self) -> Peer: + loop = asyncio.get_running_loop() + accepted = loop.create_future() + def accept(reader, writer): + if accepted.done(): + writer.close() + else: + accepted.set_result((reader, writer)) + socket_path = str(Path(self.directory.name) / "control.sock") + self.server = await asyncio.start_unix_server(accept, socket_path) + args = [sys.executable, "-I", "-B", "-u", str(RUNNER), "--socket", socket_path, + "--role", self.role, "--session", self.session, "--generation", self.generation, + "--token", self.token] + revision = None + if self.behavior is not None: + revision = fingerprint(self.behavior) + args += ["--behavior", str(self.behavior), "--revision", revision] + environment = {key: os.environ[key] for key in + ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH") + if key in os.environ} + self.process = await asyncio.create_subprocess_exec(*args, env=environment, stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + async def drain(name, stream): + while chunk := await stream.read(4096): + self.raw[name] += chunk + del self.raw[name][:-8192] + self.drains = [asyncio.create_task(drain("stdout", self.process.stdout)), + asyncio.create_task(drain("stderr", self.process.stderr))] + exited = asyncio.create_task(self.process.wait()) + try: + done, _ = await asyncio.wait([accepted, exited], timeout=3, return_when=asyncio.FIRST_COMPLETED) + if accepted not in done: + raise RuntimeError("worker failed before hello: " + self.raw["stderr"].decode(errors="replace")) + reader, writer = accepted.result() + self.wire = Wire(reader, writer, self.session, self.generation) + hello = await asyncio.wait_for(self.wire.receive(), 3) + assert hello == {"kind":"hello", "token":self.token, "role":self.role, + "revision":revision,"slots":["attention.plan","delivery.review"] if revision else []}, hello + except BaseException: + await self.close() + raise + finally: + exited.cancel() + with contextlib.suppress(asyncio.CancelledError): + await exited + return self + + async def close(self) -> None: + if self.wire: + self.wire.writer.close() + with contextlib.suppress(ConnectionError): + await self.wire.writer.wait_closed() + if self.process and self.process.returncode is None: + self.process.kill() + await self.process.wait() + if self.server: + self.server.close() + await self.server.wait_closed() + for task in self.drains: + with contextlib.suppress(asyncio.CancelledError): + await task + self.directory.cleanup() + + async def __aenter__(self): + return await self.start() + + async def __aexit__(self, *_): + await self.close() + + async def begin(self, code: str) -> str: + operation_id = str(uuid.uuid4()) + await self.wire.send({"kind":"execute", "id":operation_id, "code":code}) + return operation_id + + async def finish(self, operation_id: str, service: Service | None = None) -> dict: + while True: + message = await self.wire.receive() + if message["kind"] == "completed": + assert message["id"] == operation_id + return message["outcome"] + assert message["kind"] == "host_request", message + assert message["execution_id"] == operation_id + self.requests.append(message) + if service is None: + raise AssertionError(f"unexpected host request: {message}") + reply = await service(message) + await self.wire.send({"kind":"host_reply", "id":message["id"], + "execution_id":operation_id, "reply":reply}) + + async def execute(self, code: str, service: Service | None = None, timeout: float = 3) -> dict: + operation_id = await self.begin(code) + return await asyncio.wait_for(self.finish(operation_id, service), timeout) + + async def invoke(self, slot: str, value: dict, timeout: float = 3) -> dict: + operation_id = str(uuid.uuid4()) + await self.wire.send({"kind":"invoke","id":operation_id,"slot":slot,"input":value}) + return await asyncio.wait_for(self.finish(operation_id), timeout) + + +def output(outcome: dict, stream: str | None = None) -> str: + return "".join(record["text"] for record in outcome.get("output", []) if stream is None or record["stream"] == stream) diff --git a/python/klbr-runtime/tests/test_contracts.py b/python/klbr-runtime/tests/test_contracts.py new file mode 100644 index 0000000..1ed02a2 --- /dev/null +++ b/python/klbr-runtime/tests/test_contracts.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import asyncio +import json +import sqlite3 +import struct +import tempfile +import unittest +from pathlib import Path + +from klbr.hooks import AttentionDecision, DeliveryDecision, describe +from klbr_runtime.release import fingerprint +from klbr_runtime.wire import MAX_FRAME, ProtocolError, encode, read_frame +from peer import ROOT, behavior_copy + + +class FrameTests(unittest.IsolatedAsyncioTestCase): + async def read(self,data): + reader=asyncio.StreamReader();reader.feed_data(data);reader.feed_eof() + return await read_frame(reader) + + async def test_length_prefix_roundtrip_unicode(self): + value={"test":"hello 🌙"} + frame=encode(value) + self.assertEqual(struct.unpack('>I',frame[:4])[0],len(frame)-4) + self.assertEqual(await self.read(frame),value) + + async def test_truncated_frames_fail(self): + for frame in [b'\x00\x00',struct.pack('>I',20)+b'{}']: + with self.assertRaises(asyncio.IncompleteReadError): await self.read(frame) + + async def test_oversize_rejected_before_payload_read(self): + with self.assertRaises(ProtocolError): await self.read(struct.pack('>I',MAX_FRAME+1)) + with self.assertRaises(ProtocolError): encode({"large":"x"*MAX_FRAME}) + + async def test_duplicate_keys_and_nonfinite_values_rejected(self): + for raw in [b'{"a":1,"a":2}',b'{"x":NaN}',b'{"x":Infinity}',b'\xff',b'{']: + with self.subTest(raw=raw),self.assertRaises(ProtocolError): + await self.read(struct.pack('>I',len(raw))+raw) + + async def test_shared_golden_frames(self): + fixtures=json.loads((ROOT/"tests/runtime-fixtures/envelopes.json").read_text()) + for fixture in fixtures: + with self.subTest(kind=fixture["message"]["kind"]): + self.assertEqual(await self.read(encode(fixture)),fixture) + + +class ValueTests(unittest.TestCase): + def test_decision_shapes_are_explicit(self): + # A mistyped public SDK slot must fail locally, not kill a workbench by + # sending an invalid typed wire variant to the host. + with self.assertRaises(ValueError): + asyncio.run(describe("misspelled.slot")) + self.assertEqual(DeliveryDecision.allow().to_wire(),{"action":"allow"}) + self.assertEqual(AttentionDecision.defer(8).to_wire(),{"action":"defer","seconds":8}) + for decision in [DeliveryDecision("allow","invalid"),DeliveryDecision.hold(""), + AttentionDecision.defer(float("nan")),AttentionDecision.defer(-1)]: + with self.subTest(decision=decision),self.assertRaises(ValueError): decision.to_wire() + + def test_behavior_identity_changes_with_source(self): + with tempfile.TemporaryDirectory() as directory: + root=behavior_copy(Path(directory)/"candidate") + original=fingerprint(root) + self.assertEqual(original,fingerprint(ROOT/"behavior")) + with (root/"klbr_hooks/defaults.py").open("a") as file: file.write("\n# a change\n") + self.assertNotEqual(original,fingerprint(root)) + + def test_behavior_symlinks_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root=behavior_copy(Path(directory)/"candidate") + (root/"outside.py").symlink_to('/etc/passwd') + with self.assertRaises(ValueError): fingerprint(root) + + + def test_behavior_root_symlinks_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory)/"alias" + root.symlink_to(ROOT/"behavior",target_is_directory=True) + with self.assertRaises(ValueError): fingerprint(root) + + def test_oversized_behavior_file_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root=behavior_copy(Path(directory)/"candidate") + with (root/"oversized.py").open("wb") as file: + file.truncate(2_097_153) + with self.assertRaises(ValueError): fingerprint(root) + + def test_shared_behavior_digest(self): + fixture=json.loads((ROOT/"tests/runtime-fixtures/behavior.json").read_text()) + self.assertEqual(fingerprint(ROOT/"behavior"),fixture["sha256"]) + + +class SchemaTests(unittest.TestCase): + """Executes the exact shipped migration, not Rust Store methods. + + Python's linked SQLite is only used for these serial schema tests. The Rust + runtime separately enforces the documented WAL-reset fixed-version floor. + """ + def setUp(self): + self.db=sqlite3.connect(':memory:') + self.db.execute('PRAGMA foreign_keys=ON') + self.db.executescript((ROOT/'klbr-runtime/migrations/001_runtime.sql').read_text()) + self.db.execute("INSERT INTO sessions(id) VALUES('root')") + self.db.execute("INSERT INTO sessions(id) VALUES('child')") + self.db.execute("INSERT INTO releases(id,path) VALUES('revision','/test')") + self.db.commit() + + def tearDown(self): self.db.close() + + def event(self,key=None,session='root',kind='input'): + return self.db.execute("INSERT INTO events(session_id,kind,source_key,payload,created_ms) VALUES(?,?,?,?,0)", + (session,kind,key,'{}')).lastrowid + + def execution(self,identity='cell',session='root'): + event=self.event(session=session,kind='execution.started') + self.db.execute("INSERT INTO executions(id,session_id,generation,hook_revision,state,started_event) VALUES(?,?,'g','revision','running',?)", + (identity,session,event)) + + def test_schema_identity(self): + self.assertEqual(self.db.execute('PRAGMA user_version').fetchone()[0],1) + self.assertEqual(self.db.execute('PRAGMA application_id').fetchone()[0],1263288882) + + def test_originals_cannot_be_updated_or_deleted(self): + identity=self.event() + for sql in ['UPDATE events SET created_ms=1 WHERE seq=?','DELETE FROM events WHERE seq=?']: + with self.assertRaises(sqlite3.IntegrityError): self.db.execute(sql,(identity,)) + + def test_source_identity_is_unique_per_session(self): + self.event('source-1') + with self.assertRaises(sqlite3.IntegrityError): self.event('source-1') + self.event('source-1',session='child') + + def test_burst_has_one_inbox_row_per_input(self): + for i in range(500): + event=self.event(f'input-{i}') + self.db.execute('INSERT INTO inbox(event_id) VALUES(?)',(event,)) + self.assertEqual(self.db.execute("SELECT count(*) FROM inbox WHERE status='pending'").fetchone()[0],500) + + def test_foreign_keys_prevent_orphaned_inbox(self): + with self.assertRaises(sqlite3.IntegrityError): self.db.execute('INSERT INTO inbox(event_id) VALUES(999)') + + def test_unfinished_execution_unique_with_independent_sessions(self): + self.execution() + with self.assertRaises(sqlite3.IntegrityError): self.execution('second') + self.execution('child-cell',session='child') + + def test_yield_does_not_open_a_second_foreground_slot(self): + self.execution() + self.db.execute("UPDATE executions SET state='yielded' WHERE id='cell'") + with self.assertRaises(sqlite3.IntegrityError): self.execution('second') + + def test_confirmed_effect_requires_receipt(self): + self.execution() + with self.assertRaises(sqlite3.IntegrityError): + self.db.execute("INSERT INTO effects(id,execution_id,request_id,text,status) VALUES('e','cell','r','hello','confirmed')") + + def test_publication_transaction_rolls_back_receipt_and_event_together(self): + self.execution();self.db.commit() + before=self.db.execute('SELECT count(*) FROM events').fetchone()[0] + self.db.execute('BEGIN IMMEDIATE') + event=self.event(kind='local.sent') + self.db.execute("INSERT INTO effects(id,execution_id,request_id,text,status,event_id) VALUES('e','cell','r','hello','confirmed',?)",(event,)) + self.db.rollback() + self.assertEqual(self.db.execute('SELECT count(*) FROM effects').fetchone()[0],0) + self.assertEqual(self.db.execute('SELECT count(*) FROM events').fetchone()[0],before) + + def test_dedup_is_by_request_not_message_text(self): + self.execution() + for identity in ['a','b']: + self.db.execute("INSERT INTO effects(id,execution_id,request_id,text,status) VALUES(?,'cell',?,'same','proposed')",(identity,identity)) + with self.assertRaises(sqlite3.IntegrityError): + self.db.execute("INSERT INTO effects(id,execution_id,request_id,text,status) VALUES('c','cell','a','other','proposed')") + self.assertEqual(self.db.execute('SELECT count(*) FROM effects').fetchone()[0],2) + + def test_impossible_ready_with_deadline_is_rejected(self): + with self.assertRaises(sqlite3.IntegrityError): + self.db.execute("UPDATE sessions SET state='ready',wake_at_ms=100 WHERE id='root'") diff --git a/python/klbr-runtime/tests/test_processes.py b/python/klbr-runtime/tests/test_processes.py new file mode 100644 index 0000000..3be5481 --- /dev/null +++ b/python/klbr-runtime/tests/test_processes.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import asyncio +import tempfile +import unittest +from pathlib import Path + +from peer import ROOT, Peer, behavior_copy, output + + +class WorkbenchTests(unittest.IsolatedAsyncioTestCase): + async def test_persistent_namespace_and_last_expression(self): + async with Peer() as peer: + first = await peer.execute("answer = 6 * 7\nanswer") + self.assertEqual(first["status"], "ok") + self.assertEqual(output(first), "42") + self.assertEqual(output(await peer.execute("answer + _")), "84") + + async def test_top_level_await_and_background_computation(self): + async with Peer() as peer: + first = await peer.execute("import asyncio\ntask = asyncio.create_task(asyncio.sleep(0.03, result=7))") + self.assertEqual(first["status"], "ok") + second = await peer.execute("await task") + self.assertEqual(output(second), "7") + + async def test_future_flags_survive_cells(self): + async with Peer() as peer: + self.assertEqual((await peer.execute("from __future__ import annotations\ndef f(x: Unknown): return x"))["status"],"ok") + result = await peer.execute("def g(x: AlsoUnknown): return x\ng.__annotations__") + self.assertEqual(result["status"],"ok") + self.assertIn("AlsoUnknown",output(result)) + + async def test_syntax_and_runtime_errors_do_not_destroy_namespace(self): + async with Peer() as peer: + await peer.execute("saved = 9") + for code in ["if :", "1/0", "raise SystemExit(5)", "raise KeyboardInterrupt()"]: + with self.subTest(code=code): + result = await peer.execute(code) + self.assertEqual(result["status"],"error") + self.assertTrue(result["error"]) + self.assertEqual(output(await peer.execute("saved")),"9") + + async def test_print_and_native_stdout_cannot_send_or_forge_control(self): + async with Peer() as peer: + result = await peer.execute("import os,sys\nprint('scratch-like output')\nprint('error lane',file=sys.stderr)\nos.write(1, b'{\"kind\":\"host_request\"}\\n')\nNone") + self.assertEqual(result["status"],"ok") + self.assertIn("scratch-like output",output(result,"stdout")) + self.assertIn("error lane",output(result,"stderr")) + self.assertEqual(peer.requests,[]) + await asyncio.sleep(0.01) + self.assertIn(b'host_request',peer.raw["stdout"]) + + async def test_output_is_bounded_and_truncation_explicit(self): + async with Peer() as peer: + result = await peer.execute("print('🌙' * 100000)") + self.assertLessEqual(sum(len(x["text"].encode()) for x in result["output"]),65536) + self.assertGreater(result["truncated_bytes"],300000) + self.assertEqual(output(await peer.execute("21*2")),"42") + + async def test_many_alternating_writes_bound_record_count(self): + async with Peer() as peer: + result = await peer.execute("import sys\nfor i in range(10000):\n sys.stdout.write('a'); sys.stderr.write('b')") + self.assertLessEqual(len(result["output"]),256) + self.assertGreater(result["truncated_bytes"],0) + + async def test_explicit_send_roundtrip(self): + async def service(message): + self.assertEqual(message["request"],{"method":"local.send","text":"hello"}) + return {"kind":"ok","value":{"effect_id":"effect-1","status":"confirmed","event_id":7,"reason":None}} + async with Peer() as peer: + result = await peer.execute("from klbr import local\nreceipt = await local.send('hello')\nreceipt.status",service) + self.assertEqual(output(result),"'confirmed'") + self.assertEqual(len(peer.requests),1) + + async def test_host_error_is_inspectable_and_kernel_recovers(self): + async def service(_): + return {"kind":"error","code":"scope_closed","message":"execution expired"} + async with Peer() as peer: + result = await peer.execute("from klbr import local\nawait local.send('hello')",service) + self.assertEqual(result["status"],"error") + self.assertIn("scope_closed",result["error"]) + self.assertEqual(output(await peer.execute("2")),"2") + + async def test_wait_is_terminal_not_a_sleep(self): + async def service(message): + self.assertEqual(message["request"]["method"],"runtime.wait") + return {"kind":"ok","value":{"wake_at_ms":None}} + async with Peer() as peer: + result = await peer.execute("from klbr import runtime\nawait runtime.wait(reason='quiet')\nprint('must not execute')",service) + self.assertEqual(result["status"],"yielded") + self.assertNotIn("must not execute",output(result)) + self.assertEqual(len(peer.requests),1) + + async def test_catching_yield_does_not_restore_effect_scope(self): + async def service(_): + return {"kind":"ok","value":{}} + code = "from klbr import runtime,local\ntry:\n await runtime.wait(reason='quiet')\nexcept BaseException:\n pass\ntry:\n await local.send('not allowed')\nexcept RuntimeError as e:\n print(type(e).__name__)" + async with Peer() as peer: + result = await peer.execute(code,service) + self.assertEqual(result["status"],"yielded") + self.assertIn("ScopeClosed",output(result)) + self.assertEqual(len(peer.requests),1) + + async def test_invalid_wait_values_never_reach_host(self): + async with Peer() as peer: + for value in ["0", "-1", "601", "True", "float('nan')", "float('inf')"]: + with self.subTest(value=value): + result = await peer.execute(f"from klbr import runtime\nawait runtime.wait({value},reason='bad')") + self.assertEqual(result["status"],"error") + self.assertFalse(peer.requests) + + async def test_cooperative_interrupt_preserves_globals(self): + async with Peer() as peer: + operation = await peer.begin("import asyncio\nsaved=42\nawait asyncio.sleep(60)") + await asyncio.sleep(0.03) + await peer.wire.send({"kind":"interrupt","id":operation}) + result = await asyncio.wait_for(peer.finish(operation),1) + self.assertEqual(result["status"],"interrupted") + self.assertEqual(output(await peer.execute("saved")),"42") + + async def test_late_reply_after_interrupt_is_discarded(self): + async with Peer() as peer: + operation = await peer.begin("from klbr import local\nawait local.send('pending')") + request = await asyncio.wait_for(peer.wire.receive(),1) + self.assertEqual(request["kind"],"host_request") + await peer.wire.send({"kind":"interrupt","id":operation}) + self.assertEqual((await peer.finish(operation))["status"],"interrupted") + await peer.wire.send({"kind":"host_reply","id":request["id"],"execution_id":operation, + "reply":{"kind":"ok","value":{"ignored":True}}}) + self.assertEqual(output(await peer.execute("42")),"42") + + async def test_background_effects_expire_with_originating_cell(self): + async with Peer() as peer: + await peer.execute("import asyncio\nfrom klbr import local\nresult='pending'\nasync def later():\n global result\n await asyncio.sleep(.03)\n try:\n await local.send('late')\n except RuntimeError as e:\n result=type(e).__name__\ntask=asyncio.create_task(later())") + result=await peer.execute("await task\nresult") + self.assertEqual(output(result),"'ScopeClosed'") + self.assertFalse(peer.requests) + + async def test_background_output_is_not_reattributed_to_next_cell(self): + async with Peer() as peer: + await peer.execute("import asyncio\nasync def later():\n await asyncio.sleep(.02)\n print('from old cell')\ntask=asyncio.create_task(later())") + result=await peer.execute("await task\nprint('new cell')") + self.assertEqual(output(result),"new cell\n") + await asyncio.sleep(.02) + self.assertIn(b'background origin=',peer.raw["stdout"]) + self.assertIn(b'from old cell',peer.raw["stdout"]) + + async def test_stale_generation_is_connection_fatal(self): + from klbr_runtime.wire import encode + async with Peer() as peer: + peer.wire.writer.write(encode({"version":1,"session_id":peer.session,"generation":"old-generation", + "message":{"kind":"execute","id":"old","code":"42"}})) + await peer.wire.writer.drain() + status=await asyncio.wait_for(peer.process.wait(),2) + self.assertNotEqual(status,0) + + async def test_native_exit_does_not_claim_completion(self): + async with Peer() as peer: + await peer.begin("import os; os._exit(19)") + with self.assertRaises(asyncio.IncompleteReadError): + await asyncio.wait_for(peer.wire.receive(),1) + self.assertEqual(await peer.process.wait(),19) + + +class PolicyProcessTests(unittest.IsolatedAsyncioTestCase): + async def test_editable_defaults_match_attention_contract(self): + async with Peer("hooks",ROOT/"behavior") as peer: + cases=[("operator",{"action":"wake","priority":"operator"}), + ("discord.dm",{"action":"wake","priority":"directed"}), + ("discord.mention",{"action":"wake","priority":"directed"}), + ("discord.ambient",{"action":"defer","seconds":8})] + for source,expected in cases: + result=await peer.invoke("attention.plan",{"event_id":1,"source":source,"received_ms":0}) + self.assertEqual(result,{"kind":"hook","value":expected}) + + async def test_send_waits_for_independent_hook_without_circular_wait(self): + published=[] + async with Peer("hooks",ROOT/"behavior") as hooks, Peer() as workbench: + async def service(message): + request=message["request"] + decision=await hooks.invoke("delivery.review",{"effect_id":"effect-1","destination":"operator","text":request["text"]}) + self.assertEqual(decision["value"],{"action":"allow"}) + published.append(request["text"]) + return {"kind":"ok","value":{"effect_id":"effect-1","status":"confirmed","event_id":1,"reason":None}} + result=await workbench.execute("from klbr import local\n(await local.send('hello')).status",service) + self.assertEqual(output(result),"'confirmed'") + self.assertEqual(published,["hello"]) + + async def test_hook_worker_available_while_workbench_cpu_blocked(self): + async with Peer("hooks",ROOT/"behavior") as hooks, Peer() as workbench: + await workbench.begin("while True: pass") + result=await hooks.invoke("delivery.review",{"effect_id":"e","destination":"operator","text":"hello"},timeout=1) + self.assertEqual(result["value"],{"action":"allow"}) + await workbench.close() + + async def test_wrong_hook_return_is_failure_not_approval(self): + with tempfile.TemporaryDirectory() as directory: + behavior=behavior_copy(Path(directory)/"candidate","def delivery(request, context):\n return True") + async with Peer("hooks",behavior) as hooks: + result=await hooks.invoke("delivery.review",{"effect_id":"e","destination":"operator","text":"hello"}) + self.assertEqual(result["kind"],"failed") + self.assertIn("DeliveryDecision",result["error"]) + + async def test_hook_cannot_use_workbench_effect_sdk(self): + source="async def delivery(request, context):\n from klbr import local\n return await local.send('not a proposal')" + with tempfile.TemporaryDirectory() as directory: + behavior=behavior_copy(Path(directory)/"candidate",source) + async with Peer("hooks",behavior) as hooks: + result=await hooks.invoke("delivery.review",{"effect_id":"e","destination":"operator","text":"hello"}) + self.assertEqual(result["kind"],"failed") + self.assertIn("ScopeClosed",result["error"]) + self.assertFalse(hooks.requests) + + async def test_replacing_hook_worker_preserves_workbench_heap(self): + with tempfile.TemporaryDirectory() as directory: + candidate=behavior_copy(Path(directory)/"candidate","def delivery(request, context):\n return DeliveryDecision.hold('new rule')") + async with Peer() as workbench: + await workbench.execute("kept = object()\nidentity = id(kept)") + async with Peer("hooks",ROOT/"behavior") as old: + self.assertEqual((await old.invoke("delivery.review",{"effect_id":"e","destination":"operator","text":"hello"}))["value"]["action"],"allow") + async with Peer("hooks",candidate) as new: + self.assertEqual((await new.invoke("delivery.review",{"effect_id":"e","destination":"operator","text":"hello"}))["value"]["action"],"hold") + self.assertEqual(output(await workbench.execute("id(kept) == identity")),"True") + + async def test_bad_candidate_does_not_affect_existing_worker(self): + with tempfile.TemporaryDirectory() as directory: + candidate=behavior_copy(Path(directory)/"candidate","this is a syntax error !!!") + async with Peer("hooks",ROOT/"behavior") as good: + with self.assertRaises(RuntimeError): + await Peer("hooks",candidate).start() + self.assertEqual((await good.invoke("delivery.review",{"effect_id":"e","destination":"operator","text":"hello"}))["value"]["action"],"allow") + + async def test_blocked_policy_does_not_block_workbench_and_can_be_killed(self): + with tempfile.TemporaryDirectory() as directory: + candidate=behavior_copy(Path(directory)/"candidate","def delivery(request, context):\n while True: pass") + async with Peer("hooks",candidate) as hooks, Peer() as workbench: + with self.assertRaises(asyncio.TimeoutError): + await hooks.invoke("delivery.review",{"effect_id":"e","destination":"operator","text":"hello"},timeout=.05) + self.assertEqual(output(await workbench.execute("2+2")),"4") + await hooks.close() + self.assertIsNotNone(hooks.process.returncode) diff --git a/tests/runtime-fixtures/behavior.json b/tests/runtime-fixtures/behavior.json new file mode 100644 index 0000000..d7cae55 --- /dev/null +++ b/tests/runtime-fixtures/behavior.json @@ -0,0 +1,3 @@ +{ + "sha256": "d6dbde5c5f2dc8e362c43a003be609d8e67b378ba61715a449c4f0c404bc5a2e" +} diff --git a/tests/runtime-fixtures/envelopes.json b/tests/runtime-fixtures/envelopes.json new file mode 100644 index 0000000..3a9ac30 --- /dev/null +++ b/tests/runtime-fixtures/envelopes.json @@ -0,0 +1,126 @@ +[ + { + "version": 1, + "session_id": "test-session", + "generation": "test-generation", + "message": { + "kind": "hello", + "token": "test-token", + "role": "workbench", + "revision": null, + "slots": [] + } + }, + { + "version": 1, + "session_id": "test-session", + "generation": "test-generation", + "message": { + "kind": "execute", + "id": "cell-1", + "code": "42" + } + }, + { + "version": 1, + "session_id": "test-session", + "generation": "test-generation", + "message": { + "kind": "invoke", + "id": "hook-1", + "slot": "delivery.review", + "input": { + "effect_id": "effect-1", + "destination": "operator", + "text": "hi" + } + } + }, + { + "version": 1, + "session_id": "test-session", + "generation": "test-generation", + "message": { + "kind": "host_request", + "id": "req-1", + "execution_id": "cell-1", + "request": { + "method": "local.send", + "text": "hello" + } + } + }, + { + "version": 1, + "session_id": "test-session", + "generation": "test-generation", + "message": { + "kind": "host_reply", + "id": "req-1", + "execution_id": "cell-1", + "reply": { + "kind": "ok", + "value": { + "effect_id": "e", + "status": "confirmed", + "event_id": 1, + "reason": null + } + } + } + }, + { + "version": 1, + "session_id": "test-session", + "generation": "test-generation", + "message": { + "kind": "completed", + "id": "cell-1", + "outcome": { + "kind": "cell", + "status": "ok", + "output": [ + { + "stream": "result", + "text": "42" + } + ], + "truncated_bytes": 0, + "error": null + } + } + }, + { + "version": 1, + "session_id": "test-session", + "generation": "test-generation", + "message": { + "kind": "completed", + "id": "hook-1", + "outcome": { + "kind": "hook", + "value": { + "action": "hold", + "reason": "test" + } + } + } + }, + { + "version": 1, + "session_id": "test-session", + "generation": "test-generation", + "message": { + "kind": "interrupt", + "id": "cell-1" + } + }, + { + "version": 1, + "session_id": "test-session", + "generation": "test-generation", + "message": { + "kind": "shutdown" + } + } +] diff --git a/tools/check_runtime.py b/tools/check_runtime.py new file mode 100644 index 0000000..df8a26f --- /dev/null +++ b/tools/check_runtime.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Run starter checks without a shell or package installation. + +Default: real Python subprocess + shared schema/fixture tests. +--rust: also run the authored Rust tests. Missing Cargo is an ERROR, not a skip. +--format: explicitly allow rustfmt to edit only the new Rust crate. +""" +from __future__ import annotations + +import argparse +import datetime +import json +import os +from pathlib import Path +import platform +import shutil +import sqlite3 +import subprocess +import sys +import time +import tomllib + +ROOT = Path(__file__).resolve().parents[1] + + +def static_contracts() -> dict: + """Only manifest consistency, not dependency resolution or Rust compilation.""" + workspace = tomllib.loads((ROOT / "Cargo.toml").read_text()) + lock = tomllib.loads((ROOT / "Cargo.lock").read_text()) + assert "klbr-runtime" in workspace["workspace"]["members"] + packages = lock["package"] + entry = next(p for p in packages if p["name"] == "klbr-runtime") + for dependency in entry["dependencies"]: + parts = dependency.split() + matches = [p for p in packages if p["name"] == parts[0] and + (len(parts) == 1 or p["version"] == parts[1])] + assert len(matches) == 1, f"ambiguous/missing lock entry: {dependency}" + crate = tomllib.loads((ROOT / "klbr-runtime/Cargo.toml").read_text()) + assert set(crate["dependencies"]) == {d.split()[0] for d in entry["dependencies"]} + tomllib.loads((ROOT / "python/klbr-runtime/pyproject.toml").read_text()) + return {"status": "passed", "meaning": "manifest/lock-reference consistency only; Cargo resolver not run"} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rust", action="store_true", help="run Cargo tests and lint checks") + parser.add_argument("--format", action="store_true", help="run rustfmt in write mode; requires --rust") + parser.add_argument("--portable-linker", action="store_true", help="override the existing Linux clang/wild config with cc") + parser.add_argument("--report", type=Path, help="write machine-readable evidence and separate step logs") + args = parser.parse_args() + if sys.version_info < (3, 11): + parser.error("Python >=3.11 is required") + if args.format and not args.rust: + parser.error("--format requires --rust") + if os.name != "posix": + parser.error("this first slice requires Unix-domain sockets (Linux/macOS)") + + environment = os.environ.copy() + environment["PYTHONPATH"] = str(ROOT / "python/klbr-runtime/src") + environment["PYTHONDONTWRITEBYTECODE"] = "1" + environment["KLBR_PYTHON"] = sys.executable + if args.portable_linker: + # CARGO_ENCODED_RUSTFLAGS takes precedence over .cargo/config.toml. + # Empty intentionally clears --ld-path=wild, independently of Nushell. + environment["CARGO_ENCODED_RUSTFLAGS"] = "" + environment["CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER"] = "cc" + + report = { + "created_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "python": sys.version, + "platform": platform.platform(), + "python_sqlite": sqlite3.sqlite_version, + "python_sqlite_scope": "serial in-memory migration tests only; not runtime WAL qualification", + "cargo_available": shutil.which("cargo") is not None, + "rustc_available": shutil.which("rustc") is not None, + "rust_checks_requested": args.rust, + "steps": [], + } + try: + report["manifests"] = static_contracts() + except (AssertionError, ValueError, StopIteration, KeyError) as error: + report["manifests"] = {"status": "failed", "error": str(error)} + + def run(name: str, command: list[str]) -> None: + print(f"\n== {name} ==", flush=True) + started = time.monotonic() + try: + completed = subprocess.run(command, cwd=ROOT, env=environment, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + errors="replace", check=False) + code, output = completed.returncode, completed.stdout + except OSError as error: + code, output = 127, str(error) + print(output, end="" if output.endswith("\n") else "\n", flush=True) + step = {"name": name, "argv": command, "exit_code": code, + "elapsed_seconds": round(time.monotonic() - started, 3)} + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + log = args.report.with_name(f"{args.report.stem}-{name}.log") + log.write_text(output) + step["log"] = log.name + report["steps"].append(step) + + run("python", [sys.executable, "-B", "-m", "unittest", "discover", "-s", + "python/klbr-runtime/tests", "-v"]) + if args.rust: + if not report["cargo_available"] or not report["rustc_available"]: + report["steps"].append({"name": "rust", "exit_code": 127, + "reason": "Cargo/rustc unavailable; no Rust tests or integration checks ran"}) + else: + run("rust-version", ["rustc", "--version", "--verbose"]) + run("rustfmt", ["cargo", "fmt", "-p", "klbr-runtime"] + ([] if args.format else ["--", "--check"])) + run("rust-tests", ["cargo", "test", "--locked", "-p", "klbr-runtime", "--all-targets"]) + # Warnings are visible; denying all Clippy style lints before the + # first toolchain pass would hide more useful compilation evidence. + run("clippy", ["cargo", "clippy", "--locked", "-p", "klbr-runtime", "--all-targets"]) + else: + report["rust_status"] = "not run; pass --rust on a machine with Cargo/rustc" + passed = report["manifests"]["status"] == "passed" and all(s["exit_code"] == 0 for s in report["steps"]) + report["requested_checks_passed"] = passed + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + "\n") + print(f"\nEvidence: {args.report}") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) -- 2.51.2