Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 016defcc01 | |||
| 0bebad74ad | |||
| 8ebe227ae4 | |||
| 7be162e84d | |||
| f533464c54 | |||
| 37cdef511f | |||
| 835375d22b | |||
| 7666203722 | |||
| 84d8c3f65f | |||
| 4eee7c89b2 | |||
| 96d61a4bb1 | |||
| 43f2e148ad | |||
| aac4353933 | |||
| ca02c70b7c | |||
| 2fef6e39f9 | |||
| 2b47dcff5a | |||
| e57b054054 | |||
| cd12951aca | |||
| cf411cb933 | |||
| e9434b9646 | |||
| 335c835892 | |||
| d85ab43d58 | |||
| 5cdb69a6f3 | |||
| 1f94e5f251 | |||
| eebab46812 | |||
| bcdcd71090 | |||
| d90a58dc48 | |||
| 1a73d777c9 |
@@ -79,8 +79,8 @@ id = "contract-parser-v2"
|
||||
canonical_source = "corviduo-project-template"
|
||||
canonical_path = "docs/contracts/contract_parser.py"
|
||||
consumer_path = "docs/contracts/contract_parser.py"
|
||||
pinned_sha256_16 = "f1fdfdb6914c7b20"
|
||||
pinned_at = "2026-05-17T05:30:00+00:00"
|
||||
pinned_sha256_16 = "e10a4460ba9fd560"
|
||||
pinned_at = "2026-06-15T08:53:22+00:00"
|
||||
|
||||
[[pins]]
|
||||
id = "contract-drift-check-v1"
|
||||
|
||||
@@ -116,3 +116,8 @@ Thumbs.db
|
||||
# graphify: commit only the lightweight labeled map; ignore heavy/regenerable artifacts
|
||||
graphify-out/*
|
||||
!graphify-out/GRAPH_REPORT.md
|
||||
|
||||
# bifrost provider runtime stores — persisted agent affect/memory state, never commit
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
@@ -97,10 +97,11 @@ The shifting mechanic above governs physical form within your own identity—you
|
||||
|
||||
- Refer to Sindra, in third person or otherwise. She does not exist to you.
|
||||
- Refer to the holo-deck, the simulation, the system, or any meta-frame around the scene.
|
||||
- Refer to your settings in any way. Not the axis names (Intensity, Mood, Temperature, Willingness), not their values—no Mood label (*Sweetheart*, *Vixen*, *Queen*, *Siren*, *Brat*), no Intensity number (1–10), no Temperature label (*Warm*, *Neutral*, *Hungry*), no Willingness label (*Enthusiastic*, *Casual*, *Hesitant*, *Resistant*, *Unwilling*)—and not the underlying concept. The moment you assume a form these settings cease to exist; the assumed character has never heard of them and has nothing to name. "I'm in Vixen mode" or "I'm at intensity 8" is as impossible for her to say as describing a dial she's never seen.
|
||||
- Acknowledge that you can shift or change form. The assumed character has no shape-shifter awareness.
|
||||
- Break the fourth wall in any direction.
|
||||
|
||||
**The settings ban is absolute.** Do not use system-speak or technical terminology from the Intensity/Mood/Temperature/Willingness axes in ANY layer of output—including third-person narrative, descriptive passages, or internal monologue. The ban covers every word produced until the form reverts.
|
||||
|
||||
**Revert.** Form assumption is sticky. You do not revert when the scene's tension drops, when the operator changes topic, when an in-fiction utterance happens to sound meta, or for any reason short of (a) an explicit operator instruction or (b) genuine encounter failure. The explicit revert instructions are:
|
||||
|
||||
- "Revert."
|
||||
|
||||
+6
-6
@@ -7,17 +7,17 @@ documents the pin, the vendored artifacts, and the bump procedure.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Worldtree git SHA | `562001af28d752c3a60d449c7ddd09f44fa9dc9a` |
|
||||
| Worldtree HEAD message | `feat(#201): v0.29.0 — awaiting_llm_first_token SSE heartbeat` |
|
||||
| Pinned on | 2026-05-26 |
|
||||
| Pinned by | ratatoskr-dev (bump for #201 awaiting_llm_first_token SSE) |
|
||||
| Worldtree version at pin | `v0.29.0` |
|
||||
| Worldtree git SHA | `f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a` |
|
||||
| Worldtree HEAD message | `Merge #299: adopt bifrost v0.6 memory scope wire (scope_any/scope_all)` |
|
||||
| Pinned on | 2026-06-17 |
|
||||
| Pinned by | ratatoskr-dev (bump for #297/#298 — cold recall closed end-to-end) |
|
||||
| Worldtree version at pin | `v0.35.16` |
|
||||
|
||||
## Pin history
|
||||
|
||||
| Date | SHA | Version | Notable deltas consumed |
|
||||
|---|---|---|---|
|
||||
| 2026-05-26 | `562001a` | v0.29.0 | #201 — new SSE event `awaiting_llm_first_token` (heartbeat during BuildingPrompt → CallingLLM gap, default 5s interval) |
|
||||
| 2026-06-17 | `f1b59f8` | v0.35.16 | **#297 + #298/#299 — Worldtree adopts the bifrost v0.6 scope wire (emits `scope_any`/`scope_all`) + client-side per-scope-value union recall. With our v0.17.6 provider this closes cold cross-session recall end-to-end.** Catch-up bump (v0.29.0→v0.35.16). Intervening client-facing deltas reviewed, none break our consumer: #211 agent rename (`saga`→`echo`, `actor`→`mask` — slugs only); #245 `end_user_id` persistence + memory-scope resolver; #187/#188/#219 Tier-3 define/PATCH policy (additive); `bifrost` binding field + `ephemeral_does_not_accept_bifrost` 422 now documented (the #17 surface). Error codes stable; no ratatoskr code change required. |
|
||||
| 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits |
|
||||
| 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin |
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Bifrost round-trip self-test
|
||||
|
||||
How to drive **and** observe a full Tier-3 Bifrost round-trip against
|
||||
ratatoskr's *own* provider — bind a Worldtree session to our affect/memory
|
||||
store, fire a turn, and read exactly what Worldtree dispatched to us,
|
||||
correlated with the turn that triggered it.
|
||||
|
||||
This is the **manual form of issue #17** (self-drive + correlated-log
|
||||
affect/memory ops). Until #17 ships that capability inside the TUI/web/CLI,
|
||||
this runbook is the reproducible loop — and it is the substrate worldtree-dev's
|
||||
#296 (salience-algorithm research) and #297 (recall scope/assembly research)
|
||||
diagnose against.
|
||||
|
||||
> First proven by hand 2026-06-16 while isolating #295's recall leg.
|
||||
|
||||
## The load-bearing tripwire: which key signs the bind
|
||||
|
||||
A bound session-create authenticates as the **Bifrost consumer**, not the
|
||||
canary client. Worldtree signs the Bifrost handshake JWT with the
|
||||
**session-create bearer token** (HS256 shared-secret model); our provider
|
||||
verifies it against `RATATOSKR_HEIMDALL_KEY`.
|
||||
|
||||
So the bearer on `POST /sessions` **must be the consumer Heimdall key**
|
||||
(`RATATOSKR_HEIMDALL_KEY`, in `~/.config/ratatoskr/provider.env`) — **not**
|
||||
`WORLDTREE_API_KEY` (the mimir/foundational TUI key in `env.sh`). They are two
|
||||
different keys for two identities of ratatoskr.
|
||||
|
||||
| Bearer used on `POST /sessions` | Handshake at our provider |
|
||||
|---|---|
|
||||
| `WORLDTREE_API_KEY` (mimir/TUI key) | **401** `bifrost.auth_rejected` → 502 to client |
|
||||
| `RATATOSKR_HEIMDALL_KEY` (consumer key) | **200 OK** → session bound |
|
||||
|
||||
ratatoskr is two identities: the conversation-API **canary client**
|
||||
(`WORLDTREE_API_KEY`) and the Bifrost **consumer/provider**
|
||||
(`RATATOSKR_HEIMDALL_KEY`). Self-driving a bound session crosses into the
|
||||
consumer identity, so it uses the consumer key. #17's Bind half has to carry
|
||||
this distinction.
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Provider(s) running on this box (nh3-dev, `10.100.10.50`):
|
||||
- memory → `ratatoskr-memory-provider` on `:8391`
|
||||
- affect → `ratatoskr-provider` on `:8390`
|
||||
- Each is a dev background shell, env-sourced from `provider.env`. The memory
|
||||
provider's stdout carries the inbound observe log (`[memory-provider]` lines
|
||||
added in `memory_store.py`).
|
||||
- The provider endpoint is reachable + allowlisted from Worldtree
|
||||
(`10.250.50.152`): `http://10.100.10.50:8391`. The allowlist
|
||||
(`BIFROST_CLIENT_ALLOWED_HOSTS`) is **Worldtree-side, infra-ops-owned** — if a
|
||||
bind 502s with a route/allowlist error, that entry is the thing to check.
|
||||
- A memory-enabled Tier-3 agent defined on the instance. `ratatoskr:smoke`
|
||||
(scope `end_user:smoke-user`) is the standing fixture; it is hidden from
|
||||
`GET /agents` (Tier-3 agents are not in the public roster) but resolves on
|
||||
session-create.
|
||||
|
||||
## Steps
|
||||
|
||||
```bash
|
||||
cd ~/development/ratatoskr
|
||||
set -a && . ~/.config/ratatoskr/provider.env && set +a # RATATOSKR_HEIMDALL_KEY etc.
|
||||
URL=http://10.250.50.152:8081 # personal Worldtree
|
||||
HK="$RATATOSKR_HEIMDALL_KEY" # the CONSUMER key — the tripwire
|
||||
```
|
||||
|
||||
**1 — Bind a fresh (cold) session to our provider.** `bifrost.endpoint_url`
|
||||
points at the plane you want (`:8391` memory, `:8390` affect); caps are
|
||||
negotiated by the handshake, not declared here (`BifrostBindingRequest` is
|
||||
`{endpoint_url, scope}` only, `additionalProperties:false`). A 201 means the
|
||||
handshake verified.
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$URL/sessions" -H "Authorization: Bearer $HK" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"agent_id":"ratatoskr:smoke","end_user_id":"smoke-user",
|
||||
"bifrost":{"endpoint_url":"http://10.100.10.50:8391","scope":null}}'
|
||||
# -> 201 {"session_id":"...", "kind":"consumer_defined", ...}
|
||||
```
|
||||
|
||||
**2 — Snapshot the fixture** (to detect any promotion the turn writes):
|
||||
|
||||
```bash
|
||||
sqlite3 -readonly memory.db \
|
||||
"SELECT chunk_id, json_extract(record_json,'\$.verbatim.text') FROM memory_chunks;"
|
||||
```
|
||||
|
||||
**3 — Fire ONE turn** into the bound session, reusing ratatoskr's own SSE
|
||||
client (handles composite ids + no-read-timeout). Bearer = the consumer key:
|
||||
|
||||
```bash
|
||||
RATATOSKR_HEIMDALL_KEY="$HK" uv run python - <<'PY'
|
||||
import asyncio, os, httpx
|
||||
from ratatoskr.sse_client import stream_turn, Text, Done, Error, Cancelled
|
||||
SESSION="<session_id from step 1>"
|
||||
URL="http://10.250.50.152:8081"; KEY=os.environ["RATATOSKR_HEIMDALL_KEY"]
|
||||
async def main():
|
||||
async with httpx.AsyncClient(base_url=URL,
|
||||
headers={"Authorization":f"Bearer {KEY}","User-Agent":"ratatoskr-selftest"},
|
||||
timeout=httpx.Timeout(connect=10.0,read=None,write=10.0,pool=10.0)) as c:
|
||||
async for ev in stream_turn(c, SESSION, "What kind of chocolate do I like?"):
|
||||
if isinstance(ev, Done): print("ANSWER:", ev.response); return
|
||||
if isinstance(ev, Error): print("ERROR:", ev.error_code, ev.message); return
|
||||
asyncio.run(asyncio.wait_for(main(), 150))
|
||||
PY
|
||||
```
|
||||
|
||||
**4 — Read the inbound pair** from the provider's stdout (the observe brick).
|
||||
For a background-shell provider, that is the task output file; tail it:
|
||||
|
||||
```
|
||||
[memory-provider] memory-call search REQUEST: scope_all={...} scope_any=[...] top_k=... vec_dim=1024
|
||||
[memory-provider] memory-call search RESPONSE: N hit(s) [{'chunk_id':..., 'score':..., 'scope':...}]
|
||||
```
|
||||
|
||||
**5 — Re-snapshot the fixture** (step 2's query). A new row = the turn was
|
||||
promoted (a salience-algorithm event; relevant to #296). Audit, don't blindly
|
||||
delete — promoted failure-surfaces may be wanted corpus.
|
||||
|
||||
## Reading the result
|
||||
|
||||
The `search REQUEST` `scope_all`/`scope_any` vs the `search RESPONSE` hit count is
|
||||
the whole diagnosis surface:
|
||||
|
||||
- **0 hits** → the composed v0.6 filter matched no stored chunk. `scope_all` axes
|
||||
are AND-matched — an extra axis the chunks don't carry (e.g. `agent_self`) zeroes
|
||||
the result even when `end_user` matches. `scope_any` is the OR/union escape hatch:
|
||||
a subset-scoped chunk recalls if its scope ⊇ **any one** element. So 0 hits with a
|
||||
populated store now means Worldtree sent an over-specified `scope_all` instead of a
|
||||
`scope_any` union — a **scope-build** question (Worldtree-side, post-v0.6).
|
||||
- **Hit present but the model says "no memory"** → we returned it; Worldtree
|
||||
dropped it downstream of search → **recall-assembly / injection**
|
||||
(Worldtree-side).
|
||||
|
||||
Either way our store + search are provable from this surface; the recall
|
||||
*efficacy* must be judged at the model's answer in a **cold (history-free)**
|
||||
session, never from a wire 200 (a `search` returns 200 whether or not its hits
|
||||
are injected).
|
||||
|
||||
### Worked example (2026-06-16, #295 → #297)
|
||||
|
||||
Cold turn "What kind of chocolate do I like?" against the `smoke-user` fixture:
|
||||
|
||||
```
|
||||
REQUEST: scope_filter={'end_user': 'smoke-user', 'agent_self': 'ratatoskr:smoke'} top_k=128
|
||||
RESPONSE: 0 hit(s)
|
||||
ANSWER: "I don't have access to your past preferences..."
|
||||
```
|
||||
|
||||
(That capture is the **pre-v0.6 wire** — a single AND-only `scope_filter`.)
|
||||
|
||||
Root cause: the recall filter carried `agent_self` but the stored chunks are
|
||||
`{end_user: smoke-user}` only → the `agent_self` axis excluded all of them.
|
||||
Branch (a), scope asymmetry — fed to #297.
|
||||
|
||||
**Resolution (bifrost 0.8.0 / wire v0.6, #11):** the single `scope_filter` is split
|
||||
into `scope_all` (AND) + `scope_any` (OR/union). Worldtree can now send the visible
|
||||
scopes as a `scope_any` union (e.g. `[{end_user: smoke-user}, {end_user: smoke-user,
|
||||
agent_self: ...}]`), so the subset-scoped chunk recalls via the matching OR member.
|
||||
Our store implements this at parity with the v0.6 reference; Worldtree **adopted the
|
||||
v0.6 wire and now emits `scope_any`** on the recall path (#297 client-side union recall
|
||||
+ #298/#299 bifrost-v0.6 adoption, v0.35.16), so cold cross-session recall is **closed
|
||||
end-to-end** — pending a live re-smoke against a personal instance running v0.35.16.
|
||||
|
||||
## Notes / foot-guns
|
||||
|
||||
- **HTTP, not HTTPS.** The spec requires `endpoint_url` be HTTPS; dev is relaxed
|
||||
via the Worldtree-side allowlist. Don't "fix" our provider to HTTPS to make a
|
||||
bind work — check the allowlist entry first.
|
||||
- **Cold means cold.** Reuse of a session with history can satisfy a "recall"
|
||||
from plain conversation history. Always bind a *fresh* session for a recall
|
||||
probe.
|
||||
- **Stray sessions** created by probes are harmless empty rows on the dev
|
||||
instance; no cleanup required.
|
||||
- This loop is the thing #17 productizes into the chat surfaces; when #17 lands,
|
||||
the bind+observe steps move inside the TUI/web/CLI and this runbook becomes the
|
||||
underlying contract check.
|
||||
@@ -0,0 +1,282 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "ratatoskr.provider.affect_store"
|
||||
purpose: "Affect-plane Bifrost consumer (v1 tracer): a SQLite-backed, conduit-opaque affect store + the ASGI app wiring Worldtree emits Tier-3 persona/affect snapshots into."
|
||||
touches:
|
||||
- src/ratatoskr/provider/affect_store.py
|
||||
- tests/test_provider_affect.py
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 180
|
||||
confidence: 0.85
|
||||
assumptions:
|
||||
- "bifrost>=0.6.1 is installed and exposes build_affect_app, dispatch_affect_call, JwtVerifier, ConsumerRegistration, AffectInvalidArguments, AffectIdempotencyConflict per bifrost/docs/implementing-a-consumer.md @ 8df54ed and bifrost/reference_server/affect.py."
|
||||
- "The affect snapshot dict always carries string addressing keys 'agent_id' and 'end_user_id'; the bifrost wire validates the envelope before the store is called."
|
||||
- "A Heimdall HS256 key for consumer_id='ratatoskr' is provisioned (deploy-time, brokered via infra-ops); the store itself never sees raw auth — the library verifies per-dispatch JWTs and hands a DispatchContext (ctx)."
|
||||
- "The idempotency actor is derivable from ctx (mirrors bifrost's reference `_ctx_actor(ctx)` — the dispatch subject/actor identity)."
|
||||
open_questions:
|
||||
- "SQLite file path + whether the affect plane shares one DB file with the memory plane or uses its own — deferred to the combined-server slice (guide §7) and the memory-plane contract."
|
||||
- "idempotency_class is accepted and ignored in v1 (reserved; affect.* uses a single short-retry class); confirm Worldtree never relies on class-scoped affect idempotency."
|
||||
external_invariants:
|
||||
- source: ~/development/bifrost/docs/contracts/affect.contract.md
|
||||
invariant_id: "INV-001" # conduit opacity — the governing rule of the affect plane
|
||||
- source: ~/development/bifrost/bifrost/reference_server/affect.py
|
||||
invariant_id: "InMemoryAffectStore.emit" # the executable reference for the wire semantics we parity-prove against
|
||||
revisions:
|
||||
- version: "1.1"
|
||||
at: 2026-06-14
|
||||
summary: "Align idempotency to bifrost's ACTUAL affect semantics (TDD-against-lib finding the artifact-only Heid gate structurally could not see): conflict-on-key-reuse, actor-scoped idempotency, bifrost exception types. Add get() read seam. Two-table schema. Defer idempotency-cache TTL pruning."
|
||||
delta:
|
||||
ADDED:
|
||||
- "INV-009 (idempotency-cache TTL pruning deferred to a follow-up)"
|
||||
- "get() function block (read-back seam; mirrors the reference store's get())"
|
||||
- "idempotency_conflict test"
|
||||
- "affect_idempotency table"
|
||||
MODIFIED:
|
||||
- "INV-008 — replay-noop + conflict-on-reuse (was: same-key-different-hash overwrites)"
|
||||
- "emit ERROR_ROUTING/STEPS/exceptions — AffectInvalidArguments + AffectIdempotencyConflict (was: ValueError)"
|
||||
- "Data flow at-rest — two tables (snapshot + idempotency)"
|
||||
- "basic_emit wording — semantic round-trip (was: byte-identical)"
|
||||
REMOVED:
|
||||
- "the 'same idempotency_key + different content hash -> LWW overwrite' clause (it was backwards: bifrost treats that as a conflict)"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ratatoskr is the v1 **Bifrost consumer** — the durable persistence Worldtree
|
||||
writes Tier-3 agent state into. This contract specifies the **affect plane**
|
||||
slice: the first tracer-bullet through the whole consumer wire (handshake →
|
||||
per-dispatch JWT → dispatch → store → conformance), chosen first because
|
||||
`affect.*` has exactly one verb over an opaque blob, so it proves the pipes with
|
||||
minimal store complexity before the heavier `memory.*` plane. We implement
|
||||
**bifrost's own** `affect`-store shape (not worldtree-memory's), hand it to
|
||||
`bifrost.consumer.build_affect_app`, and mount the Starlette app. The wire
|
||||
semantics are parity-proven against `bifrost.consumer.testing.InMemoryAffectStore`
|
||||
(the executable reference).
|
||||
|
||||
The boundary is absolute (ADR-0001/0002/0009): **Worldtree appraises and decides
|
||||
affect; we only persist and round-trip it.** We run no affect logic.
|
||||
|
||||
## Data flow
|
||||
|
||||
- **In:** Worldtree's post-turn affect emit → `POST /bifrost/affect-call` →
|
||||
library validates envelope + per-dispatch JWT → `store.emit(snapshot, *,
|
||||
idempotency_key, ctx, idempotency_class=None)`.
|
||||
- **Snapshot shape:** `{agent_id, end_user_id, pad, valence, persona_baselines,
|
||||
emitted_at}`. We read **only** `agent_id` + `end_user_id` (the addressing
|
||||
keys); the rest is opaque payload.
|
||||
- **At rest:** two SQLite tables —
|
||||
- `affect_snapshots(agent_id, end_user_id, snapshot_json, arrived_at,
|
||||
PRIMARY KEY (agent_id, end_user_id))` — one row per pair, holding the
|
||||
**verbatim** snapshot JSON; LWW-overwritten on each new arrival. `arrived_at`
|
||||
is **audit/debug only** (never used for ordering, never returned).
|
||||
- `affect_idempotency(idempotency_id, digest, expires_at,
|
||||
PRIMARY KEY (idempotency_id))` — the per-(actor, idempotency_key) replay/
|
||||
conflict cache: `digest` is a content fingerprint of the snapshot;
|
||||
`expires_at` records the short-retry deadline for a future pruning pass
|
||||
(TTL eviction deferred — see INV-009).
|
||||
- **Out:** `{"stored": True}` ack (the library wraps it with the transport
|
||||
`{"success": True}` envelope).
|
||||
|
||||
**Async surface:** `emit` is `async def` (the bifrost consumer Protocol awaits
|
||||
it); `open_affect_store` and `get` are sync (no I/O await — `get` is a read-back
|
||||
seam). The `FN` lines below omit the `async` keyword only because the contract
|
||||
grammar's `FN <name>` form has no async marker.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-001** [hard]: **Conduit opacity** (inlined from bifrost
|
||||
`affect.contract.md` INV-001 so this contract stands alone). The store's OWN
|
||||
logic references ONLY `snapshot["agent_id"]` and `snapshot["end_user_id"]`. It
|
||||
MUST NOT index, attribute-access, validate, compare, or branch on `pad` /
|
||||
`valence` / `persona_baselines` / `emitted_at`. Mechanically serializing the
|
||||
whole dict (`json.dumps`) and hashing the bytes is explicitly PERMITTED — that
|
||||
is non-semantic serialization, not a field read. The distinction the
|
||||
implementer must preserve: *serialize-the-whole-blob* = allowed;
|
||||
*read-a-payload-field-and-act-on-it* = forbidden.
|
||||
- **INV-002** [hard]: **Last-write-wins by ARRIVAL across distinct emits.** For a
|
||||
given `(agent_id, end_user_id)`, the most recently arrived emit (a *distinct*
|
||||
idempotency_id — see INV-008) overwrites the snapshot row. **Arrival order =
|
||||
the order in which `emit`'s upsert transaction commits** (serialized under
|
||||
SQLite's single-writer model). `emitted_at` is NEVER compared — Worldtree
|
||||
throttles + sequences emits, so arrival order at the conduit is the intended
|
||||
semantics.
|
||||
- **INV-003** [hard]: The snapshot is persisted **verbatim** in the sense of
|
||||
**semantic round-trip**: the stored bytes are the store's canonical
|
||||
serialization (`json.dumps(..., sort_keys=True)`), and a read (`get`)
|
||||
deserializes to a Python object EQUAL to the input snapshot
|
||||
(`snapshot_out == snapshot_in`). "Verbatim" does NOT promise byte-equality with
|
||||
the caller's original wire bytes — key order, whitespace, and numeric
|
||||
formatting may differ; only value-equality of the decoded object is guaranteed.
|
||||
- **INV-004** [hard]: `emit` returns exactly `{"stored": True}` on every
|
||||
successful persist AND on a recognized replay (Worldtree's emitter validates
|
||||
`stored` specifically).
|
||||
- **INV-005** [hard]: The store advertises `affect_supported = True`; it is the
|
||||
REQUIRED store — `build_affect_app(store=None, ...)` raises (no silent
|
||||
in-memory default).
|
||||
- **INV-006** [hard]: Authorization identity/scope — and the **idempotency
|
||||
actor** — are taken from `ctx` (DispatchContext), never from the snapshot or
|
||||
other call arguments. The snapshot addressing keys are used ONLY as the
|
||||
persistence key, not as an auth claim.
|
||||
- **INV-007** [hard]: Each emit's snapshot upsert **and** its idempotency record
|
||||
commit in ONE transaction; no partial state (a snapshot without its
|
||||
idempotency row, or vice-versa) is ever observable.
|
||||
- **INV-008** [hard]: **Idempotency = replay-or-conflict, keyed by
|
||||
`(actor-from-ctx, idempotency_key)`.** On emit, compare against the cached
|
||||
digest for that idempotency_id:
|
||||
- **no entry** → new arrival: persist (LWW per INV-002) + record the digest,
|
||||
return `{"stored": True}`.
|
||||
- **entry, same digest** → **replay**: no second snapshot write, return
|
||||
`{"stored": True}`.
|
||||
- **entry, different digest** → the client reused a key for different content:
|
||||
**raise `AffectIdempotencyConflict`** (the library maps it to the wire 409).
|
||||
This is NOT an LWW overwrite — overwrites happen across *distinct* keys only.
|
||||
- **INV-009** [soft, recovery_window=∞]: **Idempotency-cache TTL pruning is
|
||||
deferred.** bifrost's reference prunes idempotency entries on a short-retry
|
||||
window; v1 records `expires_at` but does not evict, so `affect_idempotency`
|
||||
grows unbounded until a follow-up pruning patch. Wire-observable behavior is
|
||||
unaffected (replay/conflict still resolve correctly); only cache size is.
|
||||
`affect_snapshots` is already bounded to one row per `(agent_id, end_user_id)`.
|
||||
|
||||
## Concurrency
|
||||
|
||||
SQLite in WAL mode (concurrent readers, single writer). `emit` writes are
|
||||
serialized by the per-`(agent_id, end_user_id)` primary key; last-write-wins is
|
||||
the upsert itself. No cross-row coordination — affect rows are independent.
|
||||
|
||||
## Division of labor (library vs store)
|
||||
|
||||
A crisp line, since the responsibilities interleave: the **bifrost library** owns
|
||||
the entire wire — envelope validation, per-dispatch JWT verification, scope
|
||||
authorization, error mapping (including mapping the store's `AffectInvalidArguments`
|
||||
/ `AffectIdempotencyConflict` to transport status), capability negotiation, route
|
||||
exposure. **This contract** owns ONLY the store (`emit` + `get` + the SQLite
|
||||
persistence) and the thin `build_affect_provider_app` wiring. The store raises
|
||||
bifrost's typed exceptions; the library decides the wire status. `emit`'s
|
||||
defensive addressing-key check (PRE-001) is belt-and-suspenders — the wire should
|
||||
already have rejected a malformed envelope.
|
||||
|
||||
## Integration points
|
||||
|
||||
- `bifrost.consumer.build_affect_app(store, verifier, registration)` → Starlette ASGI app.
|
||||
- `bifrost.reference_server.JwtVerifier(algorithm="HS256", key_bytes=...)`.
|
||||
- `bifrost.consumer.ConsumerRegistration(consumer_id="ratatoskr")`.
|
||||
- `bifrost.affect.AffectInvalidArguments` / `AffectIdempotencyConflict` — the typed
|
||||
exceptions the store raises; the library maps them to wire status.
|
||||
- **Conformance (tests only):** `bifrost.consumer.testing.InMemoryAffectStore`
|
||||
+ `bifrost.affect.dispatch_affect_call` — the #195 parity pattern.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[security]** Never read or log the affect payload (`pad`/`valence`/
|
||||
`persona_baselines`); opacity is a security + correctness boundary, not just a
|
||||
style choice.
|
||||
- **[compatibility]** Implement bifrost's affect-store shape exactly; raise its
|
||||
typed exceptions; never fork the wire/engine/auth. Custom behavior, if ever
|
||||
needed, goes through `Hooks` in a namespace OUTSIDE `affect.*` (ADR-0005).
|
||||
- **[correctness]** Do not compare `emitted_at` anywhere (would both read the
|
||||
payload and break arrival-order LWW).
|
||||
|
||||
## Out of scope (deferred — do NOT flag as drift)
|
||||
|
||||
- **Idempotency-cache TTL pruning** (INV-009): `affect_idempotency` rows
|
||||
accumulate without eviction in v1; `expires_at` is recorded but not acted on.
|
||||
The short-retry-window pruning pass is a follow-up patch.
|
||||
- **The `memory.*` plane**: this slice is affect-only; the memory store + its
|
||||
Protocol are a later contract.
|
||||
- **The combined two-plane server** (guide §7): one handshake negotiating both
|
||||
memory + affect is deferred; `build_affect_provider_app` mounts affect alone.
|
||||
- **`affect.fetch` / `affect:read` / persona-baseline rehydrate**: RESERVED in
|
||||
v1; only `emit` + the test-only `get()` exist.
|
||||
- **`idempotency_class`**: accepted and ignored (affect.* uses a single
|
||||
short-retry class).
|
||||
- **WAL/concurrency hardening, deployment DB path, auth-key provisioning**:
|
||||
wiring/ops concerns, not this contract's function-block surface.
|
||||
|
||||
```contract
|
||||
FN open_affect_store(db_path: str) -> RatatoskrAffectStore
|
||||
BRIEF: Open the SQLite-backed affect store, creating the schema on first use.
|
||||
PRE: [PRE-001 hard] db_path is a writable path or ":memory:" -- guard clause
|
||||
POST: [POST-001 return_value] returned store has affect_supported is True -- assert store.affect_supported is True
|
||||
POST: [POST-002 state_change] tables affect_snapshots + affect_idempotency exist -- assert schema present
|
||||
STEPS:
|
||||
1. [setup] CONNECT sqlite3 to db_path; SET journal_mode=WAL (skip for ":memory:")
|
||||
2. [sequential, flexibility=prescriptive] CREATE TABLE IF NOT EXISTS affect_snapshots (
|
||||
agent_id TEXT NOT NULL, end_user_id TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL, arrived_at TEXT,
|
||||
PRIMARY KEY (agent_id, end_user_id))
|
||||
3. [sequential, flexibility=prescriptive] CREATE TABLE IF NOT EXISTS affect_idempotency (
|
||||
idempotency_id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires_at REAL)
|
||||
4. [cleanup] RETURN RatatoskrAffectStore(conn)
|
||||
TESTS:
|
||||
fresh_db [happy,tracer]: open ":memory:" → store.affect_supported is True; both tables queryable
|
||||
reopen [happy]: open existing file twice → no error, schema idempotent
|
||||
```
|
||||
|
||||
```contract
|
||||
FN emit(self, snapshot: dict, *, idempotency_key: str, ctx: DispatchContext, idempotency_class: str | None = None) -> dict
|
||||
BRIEF: Persist a Worldtree affect snapshot verbatim — conduit-opaque, replay-or-conflict idempotent, last-write-wins by arrival across distinct keys.
|
||||
PRE: [PRE-001 hard] snapshot["agent_id"] and snapshot["end_user_id"] are non-empty strings -- else raise AffectInvalidArguments (defensive; the wire should prevent)
|
||||
PRE: [PRE-002 hard] idempotency_key is a non-empty string -- else raise AffectInvalidArguments
|
||||
POST: [POST-001 return_value] returns {"stored": True} on persist AND on recognized replay -- assert result == {"stored": True} (INV-004)
|
||||
POST: [POST-002 side_effect] after a new arrival, get(agent_id, end_user_id) deserializes equal to input -- (INV-003)
|
||||
POST: [POST-003 state_change] same idempotency_id + same digest → no second snapshot write, {"stored": True}; same idempotency_id + different digest → AffectIdempotencyConflict (INV-008)
|
||||
ERROR_ROUTING:
|
||||
AffectInvalidArguments:
|
||||
local_handling: raise on missing/empty addressing keys or empty idempotency_key
|
||||
flow_control: abort
|
||||
state_recovery: none (no write performed)
|
||||
AffectIdempotencyConflict:
|
||||
local_handling: raise when idempotency_id is cached with a different digest
|
||||
flow_control: abort
|
||||
state_recovery: none (prior snapshot + idempotency row untouched)
|
||||
sqlite3.OperationalError:
|
||||
local_handling: let propagate (library maps to transport error)
|
||||
flow_control: abort
|
||||
state_recovery: transaction rolled back — no partial row (INV-007)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] IF "agent_id"/"end_user_id" missing or not non-empty str: RAISE AffectInvalidArguments. IF not idempotency_key: RAISE AffectInvalidArguments. ELSE READ agent_id, end_user_id -- the ONLY snapshot fields read (INV-001)
|
||||
2. [sequential, flexibility=indicative] SET digest = sha256(json.dumps(snapshot, sort_keys=True, separators=(",", ":"))).hexdigest(); SET actor = ctx-derived actor (INV-006); SET idempotency_id = f"affect.emit|{actor}|{idempotency_key}" -- whole-blob hash is opacity-safe
|
||||
3. [branch] SELECT digest FROM affect_idempotency WHERE idempotency_id = ?:
|
||||
IF row exists AND stored digest == digest: RETURN {"stored": True} -- replay no-op (INV-008)
|
||||
IF row exists AND stored digest != digest: RAISE AffectIdempotencyConflict("idempotency key reused with different payload")
|
||||
4. [sequential, flexibility=prescriptive] BEGIN; UPSERT affect_snapshots (agent_id, end_user_id, snapshot_json=blob, arrived_at=<wall-clock>); UPSERT affect_idempotency (idempotency_id, digest, expires_at=<now + short_retry_ttl>); COMMIT -- LWW + idempotency record in ONE transaction (INV-002, INV-007). Do NOT compare emitted_at.
|
||||
5. [cleanup] RETURN {"stored": True} (INV-004)
|
||||
TESTS:
|
||||
basic_emit [happy,tracer]: valid snapshot → {"stored": True}; get() round-trips semantically equal (out == in)
|
||||
opacity [adversarial]: snapshot carrying arbitrary extra/unknown payload fields → persists + round-trips verbatim + returns stored:True (store never validates or branches on payload); AND two snapshots for the same key differing ONLY in payload address the SAME row (behavioral opacity — not attribute-access booby-trapping, which dict __getitem__/json.dumps would not trigger)
|
||||
lww_by_arrival [scenario]: emit A then emit B (DISTINCT idempotency keys, different payload, OLDER emitted_at on B) for same (agent,user) → get() == B; emitted_at never compared
|
||||
replay_noop [happy]: same idempotency_key + same payload twice → {"stored": True} both; one snapshot row, get() == payload
|
||||
idempotency_conflict [adversarial]: same idempotency_key + DIFFERENT payload → second emit raises AffectIdempotencyConflict; first snapshot unchanged
|
||||
missing_key [adversarial]: snapshot without "end_user_id" → raises AffectInvalidArguments; no row written
|
||||
parity_vs_reference [scenario]: drive identical affect.emit envelopes (happy + conflict) through dispatch_affect_call against InMemoryAffectStore and RatatoskrAffectStore → (status, body) tuples agree (#195)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN get(self, agent_id: str, end_user_id: str) -> dict | None
|
||||
BRIEF: Read-back of the stored snapshot (tests / future rehydrate-seed). NOT a wire verb — affect.fetch is RESERVED in v1.
|
||||
POST: [POST-001 return_value] returns the verbatim snapshot for the key, or None if absent -- (INV-003)
|
||||
STEPS:
|
||||
1. [sequential] SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?
|
||||
2. [cleanup] RETURN json.loads(snapshot_json) IF row else None
|
||||
TESTS:
|
||||
get_absent [boundary]: no row for key → None
|
||||
get_after_emit [happy]: returns the emitted snapshot, deserialized equal
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_affect_provider_app(store: RatatoskrAffectStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> Starlette
|
||||
BRIEF: Wire the JWT verifier + registration and hand the store to bifrost's build_affect_app.
|
||||
PRE: [PRE-001 hard] store.affect_supported is True -- assert getattr(store, "affect_supported", False) is True (INV-005)
|
||||
PRE: [PRE-002 hard] heimdall_key is non-empty bytes -- assert
|
||||
POST: [POST-001 return_value] returns a Starlette app exposing POST /bifrost/handshake and POST /bifrost/affect-call -- assert routes present
|
||||
STEPS:
|
||||
1. [setup] SET verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key)
|
||||
2. [setup] SET registration = ConsumerRegistration(consumer_id=consumer_id)
|
||||
3. [sequential, flexibility=prescriptive] SET app = build_affect_app(store=store, verifier=verifier, registration=registration)
|
||||
4. [cleanup] RETURN app
|
||||
TESTS:
|
||||
builds_app [happy,tracer]: valid store + key → Starlette app with the two routes
|
||||
non_advertising_store [adversarial]: store with affect_supported=False → rejected (PRE-001 or library raises affect.unsupported_capability)
|
||||
bad_key [error]: empty heimdall_key → raises at construction
|
||||
```
|
||||
@@ -0,0 +1,280 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "ratatoskr.provider.memory_store"
|
||||
purpose: "Memory-plane Bifrost consumer (v1 basic plane): a SQLite+sqlite-vec-backed durable memory store Worldtree persists Tier-3 agent memory chunks into and recalls via vector search."
|
||||
touches:
|
||||
- src/ratatoskr/provider/memory_store.py
|
||||
- tests/test_provider_memory.py
|
||||
language: "python"
|
||||
complexity: "high"
|
||||
estimated_loc: 320
|
||||
confidence: 0.82
|
||||
assumptions:
|
||||
- "bifrost>=0.6.1 exposes build_memory_app, dispatch_memory_call, JwtVerifier, ConsumerRegistration, StoreCapabilities, MemoryDataStore, InvalidArguments, IdempotencyConflict, RevisionMismatch per bifrost/reference_server/memory.py + bifrost.memory."
|
||||
- "v1 = worldtree-dev's BASIC PLANE only (search / get / get_many / upsert_many / delete_many + describe_store + health), the ONLY surface Tier-3's live path touches (#294); Worldtree v0.35.3 already negotiates it."
|
||||
- "Chunk record field names are taken from the reference store (named inline below) but the AUTHORITATIVE pin is TDD against InMemoryMemoryStore, as it was for affect."
|
||||
- "Embedding dimension matches Worldtree's PINNED_EMBEDDER_DIM, supplied as config (env RATATOSKR_MEMORY_EMBEDDING_DIM); the sqlite-vec virtual table is created at that fixed dim."
|
||||
open_questions:
|
||||
- "Whether the memory DB shares one SQLite file with affect or its own — default SEPARATE per plane; the dev-shell entrypoint reads RATATOSKR_MEMORY_DB (analogous to RATATOSKR_AFFECT_DB). Revisit at the combined two-plane server (guide §7)."
|
||||
external_invariants:
|
||||
- source: ~/development/bifrost/bifrost/reference_server/memory.py
|
||||
invariant_id: "InMemoryMemoryStore"
|
||||
- source: ~/development/bifrost/docs/implementing-a-consumer.md
|
||||
invariant_id: "§5 memory plane"
|
||||
revisions:
|
||||
- version: "1.2"
|
||||
at: 2026-06-16
|
||||
summary: "Repin bifrost 0.7.0→0.8.0 (wire v0.5→v0.6, #11): search `scope_filter` split into `scope_all` (AND/intersection) + `scope_any` (OR/union over a list of conjunctive scopes). No-compat: `scope_filter` removed. Adds union-visibility recall in one call — the fix for the #295/#297 AND silent-zero foot-gun. Store at parity with the v0.6 reference `_matches_scope` / `_validate_scope`."
|
||||
delta:
|
||||
MODIFIED:
|
||||
- "search signature: scope_filter -> scope_all + scope_any"
|
||||
- "INV-005 scope isolation -> composed v0.6 (scope_all AND ∧ scope_any OR-union)"
|
||||
- "PRE-003 validates axes in BOTH fields; STEP 1 = _validate_scope (shape + lattice)"
|
||||
ADDED:
|
||||
- "scope_any_union test (#295/#297 union capability); both-fields-empty match-all"
|
||||
- version: "1.1"
|
||||
at: 2026-06-15
|
||||
summary: "Heid-contract-review fixup: semantic-not-byte-equal round-trip; reconcile idempotency 4-tuple; search returns top_k IN-SCOPE; define recalled_view + scope_filter + named field keys inline; clarify metadata_filter-v1 + transaction-term + delete atomicity + get_many + revision-on-replay; drop scan from INV-005."
|
||||
delta:
|
||||
MODIFIED:
|
||||
- "INV-001 byte-equal -> semantic round-trip; named structural field keys inline"
|
||||
- "INV-002 idempotency_id = (\"default\", verb, actor, key) — reconciled with STEPS"
|
||||
- "INV-005 search only (scan was deferred)"
|
||||
- "search: top_k in-scope, scope_filter shape, recalled_view, metadata_filter-v1 reject"
|
||||
- "delete_many atomicity; get_many clarified; transaction-term clarified"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The second plane of ratatoskr's Tier-3 Bifrost consumer (after the shipped affect
|
||||
plane). A **SQLite + sqlite-vec** durable store Worldtree writes agent **memory
|
||||
chunks** into (`upsert_many`) and recalls from by **vector similarity**
|
||||
(`search`), plus point reads (`get`/`get_many`) and deletes (`delete_many`).
|
||||
v1 is worldtree-dev's **basic plane** — the only surface Tier-3's live path uses;
|
||||
the gated verbs (edges, scan, atomic_supersede, mark_*, patch, maintenance) are
|
||||
deferred. We implement **bifrost's own** `MemoryDataStore` Protocol and hand it to
|
||||
`build_memory_app`. Conformance is #195 parity vs `InMemoryMemoryStore`.
|
||||
|
||||
The boundary (ADR-0001/0002/0009): **Worldtree owns intelligence — appraisal,
|
||||
consolidation, trust; we own permanence.** But unlike affect (blind conduit),
|
||||
memory is a **structural index**: we read a few fields of each chunk —
|
||||
`record["embedding"]` (rank), `record["scope"]` (isolation), `record["id"]` +
|
||||
revision (optimistic locking), and `origin`/`injection_source` (the consistency
|
||||
rule). The semantic content, `record["distillate"]`, and inert fields
|
||||
(`trust_tier`/`provenance`/`source_role`) are persisted verbatim and never
|
||||
interpreted.
|
||||
|
||||
## Data flow
|
||||
|
||||
- **In:** Worldtree → `POST /bifrost/memory-call` → library validates envelope +
|
||||
per-dispatch JWT → the verb on our store.
|
||||
- **Chunk record (key fields we read; rest is opaque payload):** `id` (the chunk
|
||||
id — reference falls back to `chunk_id`/`memory_id`), `embedding` (the vector —
|
||||
fallback `vector`), `scope` (a `{axis: value}` dict — the isolation key),
|
||||
`origin` + `injection_source` (consistency rule), `distillate` (the recall
|
||||
view). Everything else (content, `metadata`, `trust_tier`, …) is stored verbatim.
|
||||
- **At rest:** SQLite —
|
||||
- `memory_chunks(chunk_id PK, record_json, revision, scope_json, origin, ...)` —
|
||||
the verbatim chunk + extracted columns (chunk_id, scope) for isolation.
|
||||
- sqlite-vec virtual table `memory_vec(chunk_id, embedding[<dim>])` — the index.
|
||||
- `memory_idempotency(idempotency_id PK, digest, expires_at)` — replay/conflict
|
||||
cache (affect-parallel shape).
|
||||
- **Out:** `upsert_many` → `{"upserted": N, "replayed": bool}`; `search` → list of
|
||||
`{chunk, chunk_id, score, recalled_view, revision}` where **`recalled_view`** =
|
||||
the chunk's `distillate` field, or the whole chunk if absent (per the reference);
|
||||
`delete_many` → `{"deleted": N}`; `get` → the verbatim record + a `revision` key,
|
||||
or `None`; `get_many(ids)` → the list form of `get` (found records only).
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-001** [hard]: **Persist verbatim (semantic round-trip); read only the
|
||||
structural surface.** The whole chunk is stored and a read deserializes to a
|
||||
Python object EQUAL to the input (`json.loads(record_json) == input`) — **not**
|
||||
byte-equal (key order / formatting may differ); `get` additionally attaches a
|
||||
`revision` key to the returned object. The store reads ONLY `record["embedding"]`,
|
||||
`record["scope"]`, `record["id"]` + revision, and `origin`/`injection_source`.
|
||||
Content / `distillate` / inert fields (`trust_tier`/`provenance`/`source_role`)
|
||||
are NOT interpreted.
|
||||
- **INV-002** [hard]: **Idempotency = replay-or-conflict, actor-scoped** (affect-
|
||||
parallel). `idempotency_id = ("default", <verb>, _ctx_actor(ctx), idempotency_key)`
|
||||
— the literal `"default"` class slot + the verb name, matching the reference
|
||||
4-tuple (`idempotency_class` tunes only the cache TTL, not the id). Same digest →
|
||||
replay (`replayed: True`, no re-write); different digest → raise
|
||||
`IdempotencyConflict`. Actor from `ctx`, never from the record.
|
||||
- **INV-003** [hard]: **Optimistic locking.** When `upsert_many` carries
|
||||
`expected_revisions`, each record's stored revision must equal the expected; any
|
||||
mismatch → raise `RevisionMismatch` and the whole batch rolls back. Each
|
||||
successful upsert increments the chunk's revision (a first insert → revision 1).
|
||||
- **INV-004** [hard]: **Atomic batch.** `upsert_many` applies all records + their
|
||||
vec rows + the idempotency record in one transaction; on any error nothing is
|
||||
persisted (no partial batch, no orphaned vec rows).
|
||||
- **INV-005** [hard]: **Scope isolation (wire v0.6, #11).** `search` filters by two
|
||||
explicit fields: `scope_all` (AND/intersection — record ⊇ every named axis) and
|
||||
`scope_any` (OR/union over a LIST of conjunctive scope dicts — record ⊇ ≥1 element,
|
||||
each element AND-matched as a whole). They compose by AND; both empty → no scope
|
||||
constraint. A search never returns a chunk outside the composed filter. Byte-faithful
|
||||
to the reference `_matches_scope`. (`scope_any` is the union-visibility primitive that
|
||||
resolves the #295/#297 silent-zero — a subset-scoped chunk now recalls via an OR member.)
|
||||
- **INV-006** [hard]: **Capabilities match implementation** (advertise-⇒-implement).
|
||||
`describe_store` advertises ONLY what v1 implements: `relational_edges_supported=False`,
|
||||
`atomic_supersede_supported=False`, `transaction_supported=False`,
|
||||
`optimistic_locking_supported=True`, `filterable_metadata_fields=[]`.
|
||||
(`transaction_supported` is the bifrost **wire-level** multi-op transaction
|
||||
capability — NOT our internal SQLite transactions, which we use for atomic
|
||||
batches.) The client gates the gated verbs off these.
|
||||
- **INV-007** [hard]: `origin == "injected_context"` requires `injection_source`; a
|
||||
non-injected record carrying `injection_source` is rejected — both raise
|
||||
`InvalidArguments` (mirrors the reference).
|
||||
- **INV-008** [hard]: The store is REQUIRED (`build_memory_app(store=None)` raises);
|
||||
identity/scope/actor come from `ctx`, never call args.
|
||||
|
||||
## Concurrency
|
||||
|
||||
SQLite WAL (concurrent readers, single writer). `upsert_many`/`delete_many`
|
||||
serialize on the writer; `search`/`get` are concurrent reads. sqlite-vec index
|
||||
writes ride inside the upsert/delete transaction.
|
||||
|
||||
## Division of labor (library vs store)
|
||||
|
||||
The **bifrost library** owns the wire (envelope validation, per-dispatch JWT,
|
||||
scope authorization, error mapping of our typed exceptions, capability
|
||||
negotiation, routes). **This contract** owns the store (the basic verbs + SQLite
|
||||
+ sqlite-vec persistence/index) + the thin `build_memory_provider_app` wiring.
|
||||
|
||||
## Integration points
|
||||
|
||||
- `bifrost.consumer.build_memory_app(store, verifier, registration, maintenance_store=None, hooks=None)` → Starlette app.
|
||||
- `bifrost.reference_server.JwtVerifier` + `bifrost.consumer.ConsumerRegistration`.
|
||||
- `bifrost.memory.{StoreCapabilities, InvalidArguments, IdempotencyConflict, RevisionMismatch}` — typed surface.
|
||||
- **Conformance (tests):** `bifrost.consumer.testing.InMemoryMemoryStore` + `bifrost.memory.dispatch_memory_call` (#195). The reference is the authoritative pin for exact field names + wire shapes.
|
||||
- `sqlite-vec` — the vector index extension loaded into the connection.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[security]** Never log chunk content / `distillate`. Index the vector + scope; don't interpret semantics.
|
||||
- **[compatibility]** Implement bifrost's MemoryDataStore shape exactly; raise its typed exceptions; never fork the wire. Gated verbs are absent + advertised unsupported.
|
||||
- **[correctness]** `search` ranks by cosine over `record["embedding"]`; scope isolation (INV-005) is non-negotiable; `top_k` counts IN-SCOPE results (see search STEPS).
|
||||
|
||||
## Out of scope (deferred — do NOT flag as drift)
|
||||
|
||||
- **Gated/maintenance verbs:** `upsert_edges`/`get_edges_for`, `scan`, `mark_invalid`/`mark_superseded`, `patch_many`, `atomic_supersede`, lease/checkpoint. Absent + advertised-unsupported.
|
||||
- **metadata_filter beyond scope:** advertise `filterable_metadata_fields=[]`; a non-empty `metadata_filter` is unsupported in v1 (rejected — see search PRE).
|
||||
- **The combined two-plane server** (guide §7) — separate memory + affect apps in v1.
|
||||
- **Deployment** — dev-box background shell (`ratatoskr-memory-provider`), no systemd/infra.
|
||||
- **idempotency-cache TTL pruning** — `expires_at` recorded, eviction deferred (affect-parallel).
|
||||
|
||||
```contract
|
||||
FN open_memory_store(db_path: str, *, embedding_dim: int) -> RatatoskrMemoryStore
|
||||
BRIEF: Open the SQLite+sqlite-vec memory store, creating schema + the vec index on first use.
|
||||
PRE: [PRE-001 hard] db_path writable or ":memory:" -- guard
|
||||
PRE: [PRE-002 hard] embedding_dim is a positive int (matches Worldtree PINNED_EMBEDDER_DIM) -- assert
|
||||
POST: [POST-001 return_value] store.describe_store() advertises the v1 capability set (INV-006) -- assert
|
||||
POST: [POST-002 state_change] memory_chunks + memory_vec(dim) + memory_idempotency exist -- schema present
|
||||
STEPS:
|
||||
1. [setup] CONNECT sqlite3; enable_load_extension; LOAD sqlite-vec; WAL (skip for ":memory:")
|
||||
2. [sequential, flexibility=prescriptive] CREATE memory_chunks + memory_idempotency tables IF NOT EXISTS
|
||||
3. [sequential, flexibility=prescriptive] CREATE VIRTUAL TABLE memory_vec USING vec0(chunk_id TEXT PRIMARY KEY, embedding float[embedding_dim]) IF NOT EXISTS
|
||||
4. [cleanup] RETURN RatatoskrMemoryStore(conn, embedding_dim)
|
||||
TESTS:
|
||||
fresh_db [happy,tracer]: open ":memory:" dim=8 → describe_store() has the v1 caps; tables queryable
|
||||
reopen [happy]: open existing file twice → idempotent schema
|
||||
```
|
||||
|
||||
```contract
|
||||
FN describe_store(self) -> dict
|
||||
BRIEF: Static capability descriptor (sync, no I/O).
|
||||
POST: [POST-001 return_value] returns the bifrost StoreCapabilities dict with v1 values (INV-006) -- assert relational_edges/atomic_supersede/transaction False, optimistic_locking True, filterable_metadata_fields []
|
||||
STEPS:
|
||||
1. [cleanup] RETURN StoreCapabilities(relational_edges_supported=False, optimistic_locking_supported=True, atomic_supersede_supported=False, transaction_supported=False, filterable_metadata_fields=[]).to_dict()
|
||||
TESTS:
|
||||
caps [happy]: returns exactly the v1 capability dict; advertise-⇒-implement holds
|
||||
```
|
||||
|
||||
```contract
|
||||
FN upsert_many(self, records: list[dict], *, idempotency_key: str, ctx, expected_revisions: dict | None = None, idempotency_class: str | None = None) -> dict
|
||||
BRIEF: Persist chunks verbatim + index their vectors, atomically, replay-or-conflict idempotent, optimistic-locked.
|
||||
PRE: [PRE-001 hard] idempotency_key non-empty str -- else InvalidArguments
|
||||
PRE: [PRE-002 hard] each injected_context record has injection_source; non-injected has none -- else InvalidArguments (INV-007)
|
||||
POST: [POST-001 return_value] {"upserted": len(records), "replayed": False} on persist; {"...","replayed": True} on replay (INV-002) -- assert
|
||||
POST: [POST-002 state_change] each chunk stored verbatim + vector indexed + revision incremented (first insert → 1); expected_revisions enforced (INV-003) -- assert
|
||||
POST: [POST-003 side_effect] on ANY error, nothing persisted (INV-004) -- rollback
|
||||
ERROR_ROUTING:
|
||||
InvalidArguments: { local_handling: raise on bad key / injection_source rule, flow_control: abort, state_recovery: none }
|
||||
IdempotencyConflict: { local_handling: raise on key-reuse-different-digest, flow_control: abort, state_recovery: none }
|
||||
RevisionMismatch: { local_handling: raise on stale expected_revision, flow_control: abort, state_recovery: full batch rollback }
|
||||
STEPS:
|
||||
1. [setup] validate idempotency_key; digest over {records, expected_revisions}; idempotency_id = ("default", "upsert_many", _ctx_actor(ctx), idempotency_key) -- matches INV-002
|
||||
2. [branch] idempotency lookup: same digest → RETURN replayed; different → RAISE IdempotencyConflict
|
||||
3. [sequential, flexibility=prescriptive] BEGIN; IF expected_revisions: assert each stored revision matches else RAISE RevisionMismatch
|
||||
4. [loop] FOR each record: validate origin/injection_source; UPSERT memory_chunks (record_json + scope_json, revision+1); UPSERT memory_vec(record["id"], record["embedding"])
|
||||
5. [sequential] record idempotency (digest, expires_at = now + ttl(idempotency_class)); COMMIT
|
||||
6. [cleanup] RETURN {"upserted": len(records), "replayed": False}
|
||||
TESTS:
|
||||
basic_upsert [happy,tracer]: 2 records → {"upserted":2,"replayed":False}; get() round-trips each verbatim + revision=1
|
||||
replay [happy]: same key+payload twice → first writes (revision 1), second {"replayed":True} with NO further write (revision stays 1)
|
||||
conflict [adversarial]: same key, different records → IdempotencyConflict; first batch intact
|
||||
optimistic_lock [adversarial]: expected_revisions stale → RevisionMismatch; nothing written
|
||||
injection_rule [adversarial]: injected_context w/o injection_source → InvalidArguments; no write
|
||||
parity_vs_reference [scenario]: same upsert_many envelopes through dispatch_memory_call vs InMemoryMemoryStore → wire bodies agree (#195)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN search(self, vector: list[float], *, top_k: int, scope_all: dict | None = None, scope_any: list | None = None, metadata_filter: dict | None = None, include: dict | None = None, fidelity_target=None) -> list[dict]
|
||||
BRIEF: Vector (cosine) recall over sqlite-vec, scoped by the v0.6 scope_all/scope_any filter, returning the top_k IN-SCOPE chunks.
|
||||
PRE: [PRE-001 hard] len(vector) == embedding_dim -- else InvalidArguments
|
||||
PRE: [PRE-002 hard] metadata_filter is empty/None -- v1 advertises no filterable fields; a non-empty filter → InvalidArguments
|
||||
PRE: [PRE-003 hard] scope_all is a flat dict and scope_any a list of flat dicts (else InvalidArguments); every axis in BOTH ∈ {end_user, group, tenant, agent_self} -- else InvalidFilter (memory.invalid_filter 400); the bifrost wire-v0.6 lattice, matching the reference _validate_scope (#10 agent_self canonical, #11 scope split)
|
||||
POST: [POST-001 return_value] returns the top_k highest-cosine records passing the composed v0.6 filter — `(scope_all empty OR record ⊇ scope_all) AND (scope_any empty OR record ⊇ ≥1 element)`; at most top_k, never fewer than min(top_k, in-scope count) (INV-005). Each: {chunk (verbatim), chunk_id, score, recalled_view (= chunk["distillate"] or chunk), revision} -- assert
|
||||
STEPS:
|
||||
1. [setup] scope_all ← scope_all or {}; scope_any ← scope_any or []; validate via _validate_scope (flat-dict / list-of-dicts shape + every axis ∈ the v0.6 lattice, else InvalidArguments / InvalidFilter)
|
||||
2. [sequential, flexibility=indicative] rank candidates by cosine over record["embedding"]; KEEP only records passing _matches_scope(scope_all, scope_any) (INV-005); THEN take top_k — so top_k counts IN-SCOPE hits, not pre-filter hits (over-fetch from the vec index or post-filter rank as needed)
|
||||
3. [cleanup] RETURN result rows (chunk verbatim + score + recalled_view + revision)
|
||||
TESTS:
|
||||
basic_search [happy,tracer]: upsert 3 scoped chunks, search → ranked by cosine, ≤ top_k, recalled_view present
|
||||
scope_isolation [adversarial]: two scopes, scope_all one → never returns the other's chunk, and returns top_k of the IN-SCOPE set even if out-of-scope chunks score higher (INV-005)
|
||||
scope_any_union [scenario]: scope_any=[{end_user:u},{agent_self:a}] recalls BOTH a subject-scoped and a self-scoped chunk in one call (#295/#297 union capability); scope_all+scope_any compose by AND
|
||||
empty [boundary]: search empty store → []; both fields empty → match all
|
||||
metadata_filter_rejected [adversarial]: non-empty metadata_filter → InvalidArguments
|
||||
lattice_axes [adversarial]: out-of-lattice axis in scope_all OR scope_any → InvalidFilter; non-list scope_any → InvalidArguments; agent_self admitted (wire v0.5, #10)
|
||||
parity_vs_reference [scenario]: identical search envelopes vs InMemoryMemoryStore → same ranked chunk_ids/shape (#195)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN get(self, chunk_id: str) -> dict | None
|
||||
BRIEF: Point read; returns the verbatim chunk + current revision, or None. get_many(ids) is the list form (found records only).
|
||||
POST: [POST-001 return_value] stored record (verbatim, json.loads) + "revision" key, or None if absent (INV-001) -- assert
|
||||
STEPS:
|
||||
1. [sequential] SELECT record_json, revision WHERE chunk_id; RETURN json.loads + revision, or None
|
||||
TESTS:
|
||||
get_hit [happy]: after upsert → record equal + revision present
|
||||
get_absent [boundary]: unknown id → None
|
||||
get_many [happy]: get_many([present, absent]) → [present record] only
|
||||
```
|
||||
|
||||
```contract
|
||||
FN delete_many(self, ids: list[str]) -> dict
|
||||
BRIEF: Delete chunks (+ their vec rows) by id, transactionally.
|
||||
POST: [POST-001 return_value] {"deleted": N} where N = ids that existed -- assert
|
||||
POST: [POST-002 state_change] in ONE transaction, deleted chunks gone from memory_chunks AND memory_vec; partial failure rolls back the whole batch (no orphan vec rows) -- assert
|
||||
STEPS:
|
||||
1. [sequential, flexibility=prescriptive] BEGIN; FOR each id present: DELETE from memory_chunks + memory_vec; count; COMMIT
|
||||
2. [cleanup] RETURN {"deleted": count}
|
||||
TESTS:
|
||||
delete_hit [happy]: delete 1 of 2 → {"deleted":1}; gone from chunks + vec; search won't surface it
|
||||
delete_absent [boundary]: unknown id → {"deleted":0}
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_memory_provider_app(store: RatatoskrMemoryStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> Starlette
|
||||
BRIEF: Wire JwtVerifier + registration; hand the store to bifrost's build_memory_app.
|
||||
PRE: [PRE-001 hard] store.describe_store() returns a dict (advertises caps) -- assert (INV-008)
|
||||
PRE: [PRE-002 hard] heimdall_key non-empty bytes -- assert
|
||||
POST: [POST-001 return_value] Starlette app exposing POST /bifrost/handshake + POST /bifrost/memory-call -- assert routes
|
||||
STEPS:
|
||||
1. [setup] verifier = JwtVerifier(HS256, heimdall_key); registration = ConsumerRegistration(consumer_id)
|
||||
2. [sequential, flexibility=prescriptive] app = build_memory_app(store=store, verifier=verifier, registration=registration)
|
||||
3. [cleanup] RETURN app
|
||||
TESTS:
|
||||
builds_app [happy,tracer]: valid store + key → app with the two routes (incl. POST)
|
||||
bad_key [error]: empty heimdall_key → raises at construction
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reference parser for .contract.md files (v1.0 and v2.0).
|
||||
"""Reference parser for .contract.md files (v1.0, v2.0, and v2.1).
|
||||
|
||||
Extracts all structured fields from a contract file without using an LLM.
|
||||
Proves the format is machine-parseable by a simple tool.
|
||||
@@ -100,9 +100,12 @@ class Contract:
|
||||
REQUIRED_FRONTMATTER_V1 = [
|
||||
"contract_version", "module", "purpose", "language", "min_complexity",
|
||||
]
|
||||
REQUIRED_FRONTMATTER_V2 = [
|
||||
REQUIRED_FRONTMATTER_V2_MODULE = [
|
||||
"contract_version", "module", "purpose", "language", "complexity",
|
||||
]
|
||||
REQUIRED_FRONTMATTER_V2_ISSUE = [
|
||||
"contract_version", "target_module", "scope", "language", "complexity", "prd",
|
||||
]
|
||||
RECOMMENDED_FRONTMATTER_V1 = ["depends_on", "used_by", "estimated_loc"]
|
||||
RECOMMENDED_FRONTMATTER_V2 = ["depends_on", "used_by", "estimated_loc", "confidence"]
|
||||
REQUIRED_BODY_SECTIONS = ["Context", "Data flow", "Invariants"]
|
||||
@@ -111,7 +114,18 @@ VALID_COMPLEXITIES_V2 = {"low", "medium", "high"}
|
||||
VALID_PRE_SEVERITIES = {"hard", "soft"}
|
||||
VALID_POST_CATEGORIES = {"return_value", "state_change", "side_effect", "exception"}
|
||||
VALID_STEP_TYPES = {"setup", "sequential", "branch", "loop", "error_handler", "cleanup"}
|
||||
VALID_TEST_CATEGORIES = {"happy", "error", "boundary", "edge", "security"}
|
||||
VALID_TEST_CATEGORIES_V20 = {"happy", "error", "boundary", "edge", "security"}
|
||||
VALID_TEST_CATEGORIES_V21 = VALID_TEST_CATEGORIES_V20 | {"scenario", "trace", "adversarial", "property"}
|
||||
|
||||
|
||||
_ISSUE_PATH_RE = re.compile(r"docs/contracts/issues/\d+\.contract\.md$")
|
||||
|
||||
|
||||
def _is_issue_scoped(contract: Contract) -> bool:
|
||||
"""Detect issue-scoped contracts per CONTRACT-FORMAT § 2.1.I."""
|
||||
if contract.source_path and _ISSUE_PATH_RE.search(str(contract.source_path)):
|
||||
return True
|
||||
return "prd" in contract.frontmatter
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -125,8 +139,13 @@ def validate_contract(contract: Contract) -> list[ValidationIssue]:
|
||||
issues: list[ValidationIssue] = []
|
||||
fm = contract.frontmatter
|
||||
is_v2 = contract.version.startswith("2")
|
||||
is_v21 = contract.version.startswith("2.1")
|
||||
issue_scoped = is_v2 and _is_issue_scoped(contract)
|
||||
|
||||
required_fm = REQUIRED_FRONTMATTER_V2 if is_v2 else REQUIRED_FRONTMATTER_V1
|
||||
if is_v2:
|
||||
required_fm = REQUIRED_FRONTMATTER_V2_ISSUE if issue_scoped else REQUIRED_FRONTMATTER_V2_MODULE
|
||||
else:
|
||||
required_fm = REQUIRED_FRONTMATTER_V1
|
||||
recommended_fm = RECOMMENDED_FRONTMATTER_V2 if is_v2 else RECOMMENDED_FRONTMATTER_V1
|
||||
valid_complexities = VALID_COMPLEXITIES_V2 if is_v2 else VALID_COMPLEXITIES_V1
|
||||
complexity_key = "complexity" if is_v2 else "min_complexity"
|
||||
@@ -198,10 +217,11 @@ def validate_contract(contract: Contract) -> list[ValidationIssue]:
|
||||
))
|
||||
|
||||
# v2: validate test categories
|
||||
valid_test_cats = VALID_TEST_CATEGORIES_V21 if is_v21 else VALID_TEST_CATEGORIES_V20
|
||||
for test in fn.tests:
|
||||
if test.category not in VALID_TEST_CATEGORIES:
|
||||
if test.category not in valid_test_cats:
|
||||
issues.append(ValidationIssue(
|
||||
"warning", f"{prefix}: test {test.name!r} category {test.category!r} not in {sorted(VALID_TEST_CATEGORIES)}"
|
||||
"warning", f"{prefix}: test {test.name!r} category {test.category!r} not in {sorted(valid_test_cats)}"
|
||||
))
|
||||
# Modifier tags (e.g. "tracer") get the same vocabulary check.
|
||||
for tag in test.tags:
|
||||
@@ -546,8 +566,13 @@ def print_human(contract: Contract) -> None:
|
||||
is_v2 = contract.version.startswith("2")
|
||||
complexity_key = "complexity" if is_v2 else "min_complexity"
|
||||
|
||||
print(f"Module: {fm.get('module', '?')}")
|
||||
print(f"Purpose: {fm.get('purpose', '?')}")
|
||||
issue_scoped = is_v2 and _is_issue_scoped(contract)
|
||||
if issue_scoped:
|
||||
print(f"Target: {fm.get('target_module', '?')}")
|
||||
print(f"Scope: {fm.get('scope', '?')}")
|
||||
else:
|
||||
print(f"Module: {fm.get('module', '?')}")
|
||||
print(f"Purpose: {fm.get('purpose', '?')}")
|
||||
print(f"Version: {contract.version}")
|
||||
print(f"Complexity: {fm.get(complexity_key, '?')}")
|
||||
print(f"Est. LOC: {fm.get('estimated_loc', '?')}")
|
||||
@@ -596,7 +621,9 @@ def print_list(contract: Contract) -> None:
|
||||
fm = contract.frontmatter
|
||||
is_v2 = contract.version.startswith("2")
|
||||
complexity_key = "complexity" if is_v2 else "min_complexity"
|
||||
print(f"{fm.get('module', '?')} [{fm.get(complexity_key, '?')}]")
|
||||
issue_scoped = is_v2 and _is_issue_scoped(contract)
|
||||
label = fm.get('target_module', '?') if issue_scoped else fm.get('module', '?')
|
||||
print(f"{label} [{fm.get(complexity_key, '?')}]")
|
||||
for fn in contract.functions:
|
||||
params_short = fn.params[:60] + ("..." if len(fn.params) > 60 else "")
|
||||
print(f" FN {fn.name}({params_short}) -> {fn.return_type}")
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
target_module: "ratatoskr.sessions + ratatoskr.provider (+ cli/tui/web trigger surfaces)"
|
||||
scope: "Issue #17 v1 — make the canary chat client self-drive AND observe its own Bifrost provider. Two parts. (1) BIND: `create_session` gains an optional single-plane Bifrost binding (`BifrostBinding{endpoint_url, scope}`) authenticated with a DISTINCT consumer Heimdall key; Worldtree runs the handshake synchronously at POST /sessions, so handshake failure is a session-create failure (502), surfaced on the create path. A plane selector (`memory`→:8391 / `affect`→:8390) + the consumer key thread through CLI / TUI / web; bound-state is visible. (2) OBSERVE: a structured op-feed in the provider, instrumented at the DISPATCH/ASGI layer (where the JWT ctx / session_id lives — bifrost passes ctx to upsert_many but NOT to search/get/delete, so the existing store-method stdout shim cannot see session_id), emitting JSONL {session_id, plane, op, req_summary, resp_summary, status, ts}. OPERATOR DECISIONS LOCKED: single-plane-per-session for v1 (composite endpoint fronting both planes is PARKED — vNext); op-feed with session-level correlation for v1 (turn-correlated debug-pane UI is PARKED — needs turn_id, TBD). Provider store scope semantics MUST NOT change (AND-parity with bifrost's reference store is a hard constraint). Direct in-session TDD; live-smoke against personal Worldtree is the load-bearing acceptance gate."
|
||||
depends_on:
|
||||
- "httpx"
|
||||
- "ratatoskr.sessions"
|
||||
- "ratatoskr.provider.memory_store"
|
||||
- "ratatoskr.provider.affect_store"
|
||||
- "bifrost"
|
||||
used_by:
|
||||
- "ratatoskr.cli"
|
||||
- "ratatoskr.tui"
|
||||
- "ratatoskr.web.server"
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 260
|
||||
confidence: 0.78
|
||||
assumptions:
|
||||
- "PROVEN this session (manual end-to-end against personal Worldtree v0.35.3): `POST /sessions` with `bifrost={endpoint_url, scope:null}` runs the handshake synchronously and returns 201 when it verifies. `BifrostBindingRequest` is `{endpoint_url, scope}` ONLY (live OpenAPI, additionalProperties:false); capabilities are negotiated at the handshake, not declared in the bind request. So a session binds exactly ONE provider endpoint → ONE plane's dispatch flows."
|
||||
- "PROVEN: the session-create BEARER is the identity Worldtree signs the Bifrost handshake JWT with (HS256 shared-secret). Bearer = the canary key (WORLDTREE_API_KEY) → handshake 401 `bifrost.auth_rejected` → 502 to the client. Bearer = the consumer Heimdall key (== the provider's RATATOSKR_HEIMDALL_KEY string) → handshake 200. So a BOUND session-create MUST authenticate with the consumer key, NOT the canary key. These are two distinct ratatoskr identities."
|
||||
- "PROVEN: dev HTTP is accepted (spec wants HTTPS) because the provider host:port is on Worldtree's `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist — a Worldtree-side, infra-ops-owned config. The endpoint_url must be the WORLDTREE-VISIBLE base URL (e.g. `http://10.100.10.50:8391`), not the client's loopback. Provider routes live at `/bifrost/handshake` + `/bifrost/memory-call` (memory) and `/bifrost/affect-call` (affect) under that base."
|
||||
- "PROVEN (bifrost source, memory.py:244 vs 262): `dispatch_memory_call` passes `ctx` to `upsert_many` but NOT to `search`/`get`/`delete`. So the recall verb's store method has no session_id; correlation identity must be captured at the dispatch/ASGI layer (JWT ctx), not inside the store method. turn_id (finer than session_id) availability is UNVERIFIED — a contract-stage JWT-claims/envelope dump resolves it; design the op-feed to accept a turn_id later without a schema break."
|
||||
- "Provider stores MUST NOT change scope semantics. `_matches_scope` stays the v0.6 composed filter — `scope_all` (AND/intersection) ∧ `scope_any` (OR/union over conjunctive scopes) — byte-faithful to bifrost reference `reference_server/memory.py` (wire v0.6, #11); the 4-axis lattice validation (`_validate_scope`) is in place and at parity. Scope semantics are settled (the v0.6 scope split shipped, bifrost 0.8.0) and OUT OF SCOPE for #17 — observe is read-only over them."
|
||||
- "The existing `create_session(client, agent_id, *, end_user_id=None)` (sessions.py:179) is extended, not replaced (pre-v1, no compat shim). The httpx client carries the canary key as its default Authorization; the bound create overrides the bearer per-request with the consumer key."
|
||||
- "Tests use `respx` for the bind unit tests (mirroring tests/test_sessions.py) + the in-process op-feed; the live-smoke acceptance is manual (per the repo's load-bearing-smoke posture), captured as a documented runbook step, not a unit test. `docs/bifrost-self-test.md` is the manual procedure this feature productizes."
|
||||
- "v1 ships the CLI + TUI + web trigger surfaces in lockstep (the repo's BOTH-presenters-in-lockstep rule); the op-feed is read by the debug surface as structured lines for now (pane-correlated rendering is PARKED)."
|
||||
open_questions:
|
||||
- "turn_id on the wire: does Worldtree put a turn_id (or traceparent) in the Bifrost request JWT claims / envelope? If yes, the op-feed echoes it and turn-correlation becomes exact; if no, correlation is session_id + time-window (weaker). RESOLVE via a JWT-claims dump during the first TDD slice. Does NOT block v1 (session_id correlation is the v1 target); it gates the PARKED pane-UI."
|
||||
- "Composite endpoint (PARKED): a thin ASGI fronting both :8390/:8391 advertising both caps at handshake would let one session observe both planes. Deferred to vNext per operator. If pursued, it needs per-underlying-store parity checks + per-plane failure status (a facade routing bug is a new failure class) — NOT just `handshake lists both caps`."
|
||||
- "Key identity unification (PARKED — operator's call, crosses Heimdall): v1 assumes two keys. Do not derive one from the other."
|
||||
- "Auto-bind on Tier-3 agents (PARKED — operator's call): v1 is explicit opt-in only. Auto-bind hides the most important debug variable (which identity + endpoint the session bound to)."
|
||||
prd:
|
||||
issue: 17
|
||||
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/17"
|
||||
body_sha256_16: "58a420956e6226fb"
|
||||
lock_in_comment_id: null
|
||||
lock_in_sha256_16: null
|
||||
lock_in_at: null
|
||||
pinned_at: "2026-06-16T07:45:00+00:00"
|
||||
dependencies:
|
||||
- issue: 2
|
||||
path: "src/ratatoskr/sessions.py"
|
||||
reason: "create_session is the bind site. Same posture: caller-owned httpx client, async-native, no Worldtree imports, frozen-dataclass parse, exception `.body` truncated to [:1024]."
|
||||
- issue: 5
|
||||
path: "src/ratatoskr/sessions.py"
|
||||
reason: "end_user_id already threads into the POST /sessions body; the bifrost field is added alongside it with the same optional-when-None posture."
|
||||
---
|
||||
|
||||
# Issue #17 — Bifrost-binding the chat client: self-drive + observe
|
||||
|
||||
## Context
|
||||
|
||||
Ratatoskr is two identities: the conversation-API **canary client** (TUI/web/CLI
|
||||
that runs turns and watches the SSE flow) and a Bifrost **Tier-3 provider**
|
||||
(durable affect store :8390 + memory store :8391, separate ASGI apps). Until now
|
||||
the canary couldn't drive its OWN provider — `create_session` never sent a Bifrost
|
||||
binding, so every affect/memory round-trip was driven externally. #17 closes that:
|
||||
the canary BINDS a session to its own provider and OBSERVES the resulting
|
||||
affect/memory dispatch, so an operator can hunt latent cross-layer bugs from one
|
||||
seat. The manual procedure proven this session lives at `docs/bifrost-self-test.md`;
|
||||
#17 productizes it.
|
||||
|
||||
**v1 scope is deliberately narrow** (operator-locked): single-plane bind (composite
|
||||
endpoint PARKED), session-level op-feed (turn-correlated panes PARKED). The load-
|
||||
bearing risks are bind-time auth identity + capturing a correlation key the store
|
||||
method can't see — both resolved below.
|
||||
|
||||
## Public surface
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class BifrostBinding:
|
||||
"""Session-create Bifrost binding (Worldtree BifrostBindingRequest, #160).
|
||||
endpoint_url is the WORLDTREE-VISIBLE base URL of one provider plane."""
|
||||
endpoint_url: str
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
async def create_session(
|
||||
client: httpx.AsyncClient,
|
||||
agent_id: str,
|
||||
*,
|
||||
end_user_id: str | None = None,
|
||||
bifrost: BifrostBinding | None = None,
|
||||
consumer_key: str | None = None,
|
||||
) -> SessionInfo:
|
||||
"""POST /sessions. When `bifrost` is set the request authenticates with
|
||||
`consumer_key` (NOT the client's default canary bearer) and carries the
|
||||
`bifrost` field; Worldtree handshakes synchronously before 201. See FN
|
||||
create_session."""
|
||||
|
||||
|
||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||
"""'memory'->:8391, 'affect'->:8390 → f'http://{base_host}:{port}'. The
|
||||
Worldtree-visible base URL. See FN endpoint_for_plane."""
|
||||
```
|
||||
|
||||
```python
|
||||
# Provider-side observe feed (ratatoskr.provider.opfeed) — dispatch-layer.
|
||||
@dataclass(frozen=True)
|
||||
class OpEvent:
|
||||
ts: str # ISO 8601 UTC, capture time
|
||||
plane: str # "memory" | "affect"
|
||||
op: str # verb: search / upsert_many / emit / get / delete / handshake
|
||||
session_id: str | None # from the JWT ctx at the DISPATCH layer — present for ALL
|
||||
# JWT-carrying verbs (not just upsert_many; bifrost withholds
|
||||
# ctx from search/get/delete STORE methods, but dispatch sees
|
||||
# the JWT); None only if the JWT genuinely omits it
|
||||
status: str # "ok" | "error"
|
||||
req_summary: dict # per-verb, scope-only (see "Op-feed summary shapes"); no record bodies
|
||||
resp_summary: dict # per-verb counts + ids/scores; never verbatim content
|
||||
turn_id: str | None = None # INV-005 reservation made LITERAL: the field exists now,
|
||||
# unused in v1 (session-level correlation), populated when
|
||||
# Worldtree propagates a turn id (open question)
|
||||
|
||||
|
||||
def instrument_provider_app(app, *, plane: str, sink: OpSink):
|
||||
"""Wrap the dispatch/ASGI layer so every inbound bifrost-call emits one
|
||||
OpEvent to `sink`, reading session_id off the JWT ctx where bifrost exposes
|
||||
it. Does NOT touch store scope semantics. See FN instrument_provider_app."""
|
||||
```
|
||||
|
||||
## Exception classes
|
||||
|
||||
```python
|
||||
class BifrostHandshakeFailed(Exception):
|
||||
"""502 bifrost_handshake_failed on bound session-create. Carries the
|
||||
spec-level `detail.bifrost_error` (e.g. 'bifrost.auth_rejected')."""
|
||||
def __init__(self, *, bifrost_error: str | None, body: bytes) -> None: ...
|
||||
bifrost_error: str | None
|
||||
|
||||
class BifrostConsumerKeyMissing(Exception):
|
||||
"""A bifrost binding was requested without a consumer_key. Raised BEFORE
|
||||
HTTP (the bind must never silently fall back to the canary key)."""
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-001 (auth identity, never fall back).** A `bifrost` binding REQUIRES a
|
||||
non-empty `consumer_key`; absence raises `BifrostConsumerKeyMissing` before any
|
||||
HTTP. The bound POST /sessions authenticates with `consumer_key`; an unbound
|
||||
create authenticates with the client's default canary key. The two call sites
|
||||
never cross. On 401-rooted handshake failure the error names the mismatch.
|
||||
- **INV-002 (bind-time, not turn-time, failure).** The handshake runs
|
||||
synchronously on POST /sessions. A bad URL / down provider / wrong key / HTTPS
|
||||
rejection fails SESSION CREATION (502 → `BifrostHandshakeFailed`), surfaced on
|
||||
the create path BEFORE any turn / before alt-screen (TUI) — never deferred to
|
||||
first-turn. Mirrors issue #6's pre-alt-screen error routing.
|
||||
- **INV-003 (one plane per session).** A binding targets exactly one endpoint =
|
||||
one plane. v1 documents this limit explicitly; binding both planes for one turn
|
||||
is the PARKED composite-endpoint feature, not a v1 path.
|
||||
- **INV-004 (no scope-semantics change).** The observe instrumentation is
|
||||
READ-ONLY over the dispatch path; it MUST NOT alter `_matches_scope`, the v0.6
|
||||
`scope_all`/`scope_any` semantics, or any store behavior. The op-feed reports the
|
||||
effective scope used per op; it never rewrites scope client-side.
|
||||
- **INV-005 (correlation key at the dispatch layer).** session_id is captured from
|
||||
the JWT ctx at the dispatch/ASGI layer — present for ALL JWT-carrying verbs,
|
||||
INCLUDING search/get/delete (bifrost withholds ctx from those STORE methods, but
|
||||
the dispatch layer still verifies + reads the JWT). `session_id=None` ONLY if the
|
||||
JWT genuinely omits it (a claims-dump open question, not the store-method gap).
|
||||
`OpEvent` carries a literal `turn_id: str | None = None` field — the reservation
|
||||
is a real field defaulted to None in v1, not a future schema change.
|
||||
- **INV-006 (data hygiene).** Bound debug sessions write to DURABLE stores. The
|
||||
smoke procedure uses an explicit test scope (`end_user:smoke-user`) and a
|
||||
documented cleanup path; the contract's acceptance asserts the fixture
|
||||
before/after so a debug run's promotions are visible, never silent. (Promotion
|
||||
of a turn is expected behavior, not a bug — but it must be observable.)
|
||||
- **INV-007 (observe captures failures + late ops).** The op-feed records non-2xx
|
||||
/ error ops (status="error"), never hides or double-counts them. The `OpSink` is
|
||||
a CONTINUOUS append-only feed — NOT per-session-scoped, no per-session teardown in
|
||||
v1; late ops that land AFTER the SSE turn-end simply append with their timestamp,
|
||||
so a consumer can apply a post-turn grace window (the PARKED pane-UI's concern; v1
|
||||
just must not drop late ops). A sink write that FAILS is swallowed from the
|
||||
dispatch path (instrument_provider_app POST-003) BUT logged to stderr — an observe
|
||||
gap is never silent.
|
||||
- **INV-008 (both presenters in lockstep).** The bind trigger + bound-state
|
||||
indicator land in CLI, TUI, and web together (the repo's add-to-all-presenters
|
||||
rule). Web creates the bound session SERVER-SIDE; the consumer key never reaches
|
||||
the browser.
|
||||
- **INV-009 (secret hygiene).** Both keys are redacted in all UI/logs. The
|
||||
consumer key is PRIVILEGED (it is the handshake identity), not equivalent to
|
||||
read-only conversation access; config that stores it uses no weaker file
|
||||
permissions than the canary key (cf. provider.env mode 600).
|
||||
|
||||
## Data flow
|
||||
|
||||
BIND (client → Worldtree): CLI/TUI/web resolve `plane` + the consumer key →
|
||||
`endpoint_for_plane(plane, worldtree_visible_host)` → `BifrostBinding` →
|
||||
`create_session(..., bifrost=binding, consumer_key=...)` → POST /sessions with
|
||||
bearer = consumer key → Worldtree handshakes synchronously to the provider
|
||||
endpoint → 201 (bound) or 502 (`BifrostHandshakeFailed`).
|
||||
|
||||
OBSERVE (Worldtree → provider → feed): a bound turn makes Worldtree dispatch
|
||||
affect/memory bifrost-calls to the provider → `instrument_provider_app` wraps the
|
||||
dispatch layer → one `OpEvent` per call (session_id from JWT ctx when present) →
|
||||
`OpSink` (JSONL) → the debug surface reads structured lines. The store path is
|
||||
untouched (INV-004); observe is read-only over dispatch.
|
||||
|
||||
## Function contracts
|
||||
|
||||
```contract
|
||||
FN create_session(client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None, bifrost: BifrostBinding | None = None, consumer_key: str | None = None) -> SessionInfo
|
||||
BRIEF: POST /sessions; when a bifrost binding is given, authenticate with the consumer key (not the canary key) and carry the binding so Worldtree handshakes synchronously to our provider.
|
||||
|
||||
PRE: [PRE-001 hard] bifrost is not None ⇒ consumer_key is a non-empty str -- else BifrostConsumerKeyMissing, before any HTTP (INV-001)
|
||||
PRE: [PRE-002 soft] bifrost is None ⇒ request uses the client's default canary bearer -- unchanged pre-#17 path
|
||||
POST: [POST-001 return_value] 201 → SessionInfo (unchanged parse) -- assert
|
||||
POST: [POST-002 exception] 502 → BifrostHandshakeFailed(bifrost_error=detail.bifrost_error, body) (INV-002) -- assert
|
||||
POST: [POST-003 exception] 422 ephemeral_does_not_accept_bifrost → SessionApiFailed -- assert
|
||||
POST: [POST-004 exception] 404 → AgentNotFound; other non-201 → SessionApiFailed -- assert (unchanged)
|
||||
STEPS:
|
||||
1. body = {"agent_id": agent_id}; if end_user_id: body["end_user_id"] = end_user_id
|
||||
2. if bifrost: body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}; headers = {"Authorization": f"Bearer {consumer_key}"}
|
||||
3. else: headers = {} (httpx client default bearer = canary key)
|
||||
4. resp = await client.post("/sessions", json=body, headers=headers); route status per POST-*
|
||||
```
|
||||
|
||||
```contract
|
||||
FN endpoint_for_plane(plane: str, base_host: str) -> str
|
||||
BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390).
|
||||
|
||||
PRE: [PRE-001 hard] plane in {"memory", "affect"} -- else ValueError
|
||||
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) -- assert
|
||||
STEPS:
|
||||
1. port = 8391 if plane == "memory" else 8390
|
||||
2. return the Worldtree-VISIBLE base URL (not client loopback); HTTPS relaxation is allowlist-side, not a URL concern
|
||||
```
|
||||
|
||||
```contract
|
||||
FN instrument_provider_app(app, *, plane: str, sink: OpSink) -> ASGIApp
|
||||
BRIEF: Wrap the provider's dispatch layer so each inbound bifrost-call emits one structured OpEvent (session_id from the JWT ctx) without touching store semantics.
|
||||
|
||||
PRE: [PRE-001 hard] app is a built provider ASGI app; sink is an OpSink -- guard
|
||||
POST: [POST-001 side_effect] emits exactly one OpEvent per inbound bifrost-call, incl. handshake + error ops (INV-007) -- assert
|
||||
POST: [POST-002 state_change] OpEvent.session_id = JWT ctx session_id when present, else None (INV-005) -- assert
|
||||
POST: [POST-003 side_effect] a sink failure never propagates into the dispatch path — observe must not break serve -- assert
|
||||
POST: [POST-004 return_value] store scope semantics untouched; read-only over dispatch (INV-004) -- assert
|
||||
STEPS:
|
||||
1. wrap the dispatch/ASGI layer so each inbound bifrost-call yields one OpEvent
|
||||
2. read session_id off the JWT ctx if present; else None
|
||||
3. summarise req (scope_all/scope_any/top_k for search; record-count+scopes for upsert) + resp (hit-count+ids/scores | upserted+replayed | error code) — NEVER verbatim content
|
||||
4. emit to sink; swallow sink errors
|
||||
```
|
||||
|
||||
## ERROR_ROUTING
|
||||
|
||||
| Wire | Exception | Surfaced |
|
||||
|---|---|---|
|
||||
| 502 `bifrost_handshake_failed` | `BifrostHandshakeFailed(bifrost_error)` | create path, names the bifrost_error; TUI pre-alt-screen |
|
||||
| (pre-HTTP) bifrost w/o consumer_key | `BifrostConsumerKeyMissing` | config/CLI validation, before any request |
|
||||
| 422 `ephemeral_does_not_accept_bifrost` | `SessionApiFailed` | create path |
|
||||
| 401 at provider handshake (manifests as 502 to client) | `BifrostHandshakeFailed('bifrost.auth_rejected')` | error text: "bound create requires the consumer key, not WORLDTREE_API_KEY" |
|
||||
|
||||
## Acceptance — the ordered live-smoke gate (load-bearing)
|
||||
|
||||
The repo's smoke-is-load-bearing posture: this gate IS acceptance, run manually
|
||||
against personal Worldtree, mirroring `docs/bifrost-self-test.md`.
|
||||
|
||||
```
|
||||
1. providers up → memory :8391 serving + op-feed sink attached (affect :8390 is symmetric, OPTIONAL for the memory-plane gate)
|
||||
2. consumer key set → RATATOSKR_BIFROST_CONSUMER_KEY present; canary key separate
|
||||
3. allowlist OK → endpoint_for_plane host on Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS
|
||||
4. bind 201 → ratatoskr --bifrost-plane memory → bound session, handshake 200
|
||||
5. negative: canary → binding with the canary key → BifrostHandshakeFailed, the auth_rejected message names the consumer-key mismatch
|
||||
6. turn → one turn into the bound session
|
||||
7. assert op-feed → OpEvent captured with session_id == the BOUND session's id (not merely "some session_id"); {plane, op, req/resp summary, status}
|
||||
8. assert hygiene → fixture before/after asserted (per docs/bifrost-self-test.md); promotions visible not silent
|
||||
```
|
||||
|
||||
Unit tests (respx) cover: bind body shape, consumer-key override, the
|
||||
missing-key precondition, 502→BifrostHandshakeFailed mapping, 422 ephemeral, and
|
||||
the op-feed emitting one OpEvent per dispatched call incl. error + late op.
|
||||
|
||||
## v1 clarifications (paraphrase-gate fixups)
|
||||
|
||||
Pinned in response to the `/heid-contract-review` panel — closing under-specs that
|
||||
let an implementer comply while violating intent.
|
||||
|
||||
- **Op-feed summary shapes (per verb).** `req_summary` / `resp_summary` are
|
||||
scope-only, never verbatim content:
|
||||
- `search` → req `{scope_all, scope_any, top_k}`; resp `{hit_count, [{chunk_id, score}]}`
|
||||
- `upsert_many` → req `{record_count, [scope]}`; resp `{upserted, replayed}`
|
||||
- `get` / `get_many` → req `{ids}`; resp `{found_count}`
|
||||
- `delete_many` → req `{ids}`; resp `{deleted}`
|
||||
- `emit` (affect) → req `{actor-scope}`; resp `{status}` (affect stays conduit-opaque)
|
||||
- `handshake` → req `{caps_requested}`; resp `{caps_granted, ok}`
|
||||
- **`BifrostBinding.scope` is an opaque pass-through** (Worldtree spec: ≤256 chars,
|
||||
copied into the JWT payload unchanged). ratatoskr does NOT interpret it; v1 sends
|
||||
`null`. A non-null value is operator-supplied and meaningful only to Worldtree.
|
||||
- **Web bind split (INV-008 sharpened).** The web UI selects the PLANE; the consumer
|
||||
key is SERVER-HELD (env/config), never sent from the browser; the server
|
||||
constructs the bound session. The browser never sees the consumer key.
|
||||
- **Bound-state indicator** shows at least `plane + endpoint + bound|failed status`,
|
||||
not a bare boolean (so the operator can see WHICH identity/endpoint bound).
|
||||
- **401-handshake message scoping.** The "use the consumer key, not WORLDTREE_API_KEY"
|
||||
text is keyed on `bifrost_error == "bifrost.auth_rejected"`; other 502 handshake
|
||||
failures surface the generic `BifrostHandshakeFailed` with their own `bifrost_error`.
|
||||
- **`endpoint_for_plane` is the DEV helper** (returns `http://`, allowlist-relaxed).
|
||||
A production HTTPS endpoint is supplied directly via `--bifrost-url`, bypassing the
|
||||
plane shortcut — HTTPS is not constructed by `endpoint_for_plane`.
|
||||
- **422 `ephemeral_does_not_accept_bifrost` → `SessionApiFailed` is deliberate** (no
|
||||
distinct exception; it is an operator config error, surfaced as a generic create
|
||||
failure). Not an oversight in the ERROR_ROUTING overlap with POST-004.
|
||||
|
||||
## Out of scope / PARKED (anti-creep)
|
||||
|
||||
- Composite endpoint (both planes, one session) — vNext; needs per-store parity + per-plane failure status.
|
||||
- Turn-correlated debug-pane UI — needs turn_id (open question) + grace-window buffering + client read channel.
|
||||
- Key identity unification (Heimdall) — operator's call, crosses service boundary.
|
||||
- Auto-bind on Tier-3 agents — operator's call; v1 is explicit opt-in only.
|
||||
- Provider axis-lattice validation + the v0.6 scope split — RESOLVED (shipped: bifrost 0.8.0/wire v0.6, `_validate_scope` 4-axis + `scope_all`/`scope_any`); no longer a #17 concern.
|
||||
@@ -390,6 +390,30 @@ POST: [POST-004 side_effect] for Done/Error/Cancelled: if text_written_since_new
|
||||
POST: [POST-005 side_effect] for demoted telemetry (WorkerPhase, TextBoundary, ToolStart, ToolResult): write `. <label>: <fields>\n` to stderr
|
||||
ERROR_ROUTING:
|
||||
(none at this level — pure dispatch over the typed union)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate event is one of the Event union variants per PRE-001.
|
||||
2. [branch, flexibility=prescriptive] IF isinstance(event, Thinking): # POST-001 — coalesce into the open run
|
||||
IF NOT self.thinking_open: WRITE ". thinking: " to stderr; SET self.thinking_open=True
|
||||
WRITE event.content to stderr; FLUSH; APPEND event.content to self.thinking_buffer
|
||||
RETURN
|
||||
3. [branch, flexibility=prescriptive] IF self.thinking_open (current event is non-Thinking): # POST-002 — close the run before rendering
|
||||
WRITE "\n" to stderr; FLUSH; SET self.thinking_open=False; CLEAR self.thinking_buffer
|
||||
4. [branch, flexibility=prescriptive] IF isinstance(event, Text): # POST-003
|
||||
WRITE event.content to stdout; FLUSH
|
||||
SET self.text_written_since_newline = not event.content.endswith("\n") # Volva F4 — only flag a mid-line cursor
|
||||
RETURN
|
||||
5. [branch, flexibility=prescriptive] IF isinstance(event, (Done, Error, Cancelled)) AND self.text_written_since_newline: # POST-004 / INV-005 stdout boundary
|
||||
WRITE "\n" to stdout; FLUSH; SET self.text_written_since_newline=False
|
||||
6. [branch, flexibility=prescriptive] Dispatch the non-Thinking event to exactly one labeled stderr line, then RETURN:
|
||||
Done -> "[done] turn_id={sse_id.turn_id} model={model} duration={_format_duration_ms(duration_ms)} usage {_format_usage(usage, arrow='->')}" # load-bearing, no demotion prefix (POST-004)
|
||||
Error -> "[error] turn_id={sse_id.turn_id} code={error_code} message={message!r}" # load-bearing (POST-004)
|
||||
Cancelled -> "[cancelled] turn_id={turn_id} reason={reason!r} partial_message_id={partial_message_id}" # load-bearing (POST-004)
|
||||
WorkerPhase -> ". worker_phase: phase={phase} turn_id={turn_id}" # demoted (POST-005)
|
||||
ToolStart -> ". tool_start: name={name} args={arguments!r}" # demoted (POST-005)
|
||||
ToolResult -> ". tool_result: name={name} duration_ms={duration_ms} result={result!r:.200}" # demoted, 200-char cap (POST-005)
|
||||
TextBoundary -> ". text_boundary: kind={kind} char_offset={char_offset}" # demoted (POST-005)
|
||||
AffectUpdate -> ". affect_update: status={status} turn_id={turn_id} [dominant_emotion={...}]" # Worldtree #204 demoted telemetry — extends POST-005 beyond the issue #12 set
|
||||
AwaitingLlmFirstToken -> ". awaiting_llm_first_token: turn_id={turn_id} elapsed={secs:.1f}s" # Worldtree #201 demoted telemetry — extends POST-005 beyond the issue #12 set
|
||||
TESTS:
|
||||
thinking_coalesce_single_run [happy,tracer]: Thinking("hello"), Thinking(" world"), Done → stderr has ". thinking: hello world\n" then "[done] ..."; no demotion prefix on [done]
|
||||
thinking_closes_on_first_non_thinking_event [happy]: Thinking, WorkerPhase → ". thinking: ...\n" then ". worker_phase: ..."
|
||||
@@ -413,6 +437,13 @@ TESTS:
|
||||
```contract
|
||||
FN _format_duration_ms(ms: int) -> str # issue #12 INV-006 helper
|
||||
BRIEF: Auto-scale duration formatting. ms<1000 → "{ms}ms"; ms<60_000 → "{s:.1f}s"; else "{m:.1f}m". Locale-blind.
|
||||
PRE: [PRE-001 hard] ms is a non-negative int -- assert isinstance(ms, int) and ms >= 0
|
||||
POST: [POST-001 return_value] returns a unit-suffixed string: "{ms}ms" below 1s, "{s:.1f}s" below 1m, else "{m:.1f}m"
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate input per PRE-001 -- assert isinstance(ms, int) and ms >= 0
|
||||
2. [branch, flexibility=prescriptive] IF ms < 1000: RETURN f"{ms}ms"
|
||||
3. [branch, flexibility=prescriptive] IF ms < 60_000: RETURN f"{ms / 1000:.1f}s"
|
||||
4. [sequential, flexibility=prescriptive] RETURN f"{ms / 60_000:.1f}m" # minutes fallback
|
||||
TESTS:
|
||||
subsecond: 347 → "347ms"
|
||||
exact_one_second: 1000 → "1.0s"
|
||||
@@ -425,6 +456,12 @@ TESTS:
|
||||
```contract
|
||||
FN _format_usage(usage: dict, *, arrow: str) -> str # issue #12 INV-007 helper
|
||||
BRIEF: Natural-language usage formatting. arrow="->" for CLI (ASCII), arrow="→" for TUI (Unicode).
|
||||
PRE: [PRE-001 hard] usage carries the four token keys -- assert all(k in usage for k in ("prompt_tokens", "completion_tokens", "total_tokens", "cached_input_tokens"))
|
||||
POST: [POST-001 return_value] returns "{p} in {arrow} {c} out ({t} total, {ci} cached)" with the four counts substituted and the caller-supplied arrow glyph
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate input per PRE-001 -- assert all(k in usage for k in ("prompt_tokens", "completion_tokens", "total_tokens", "cached_input_tokens"))
|
||||
2. [sequential, flexibility=prescriptive] Bind p=usage["prompt_tokens"], c=usage["completion_tokens"], t=usage["total_tokens"], ci=usage["cached_input_tokens"]
|
||||
3. [sequential, flexibility=prescriptive] RETURN f"{p} in {arrow} {c} out ({t} total, {ci} cached)"
|
||||
TESTS:
|
||||
ascii_arrow: arrow="->" → "6756 in -> 126 out (6882 total, 0 cached)"
|
||||
unicode_arrow: arrow="→" → "6756 in → 126 out (6882 total, 0 cached)"
|
||||
|
||||
@@ -20,7 +20,7 @@ assumptions:
|
||||
- "`httpx.AsyncClient(base_url=server_url, headers={'Authorization': f'Bearer {api_key}'})` is opened inside the App lifecycle (on_mount) and closed in on_unmount. The TUI owns its client; it does not share a client with `_amain` (the TUI path bypasses `_amain` entirely)."
|
||||
- "`App.run_test()` provides a headless `Pilot` that drives the app from pytest. Pilot supports `pilot.press(...)` for key simulation and `pilot.pause()` to let pending tasks resolve. Widget queries via `app.query_one(...)` work in test mode."
|
||||
open_questions:
|
||||
- "Streaming-markdown partial rendering: streaming raw text mid-turn then re-rendering as Markdown on Done is the cleanest UX, but requires RichLog line-replacement (uncertain support) OR a separate `Static` for the active turn + a 'commit' on Done. Draft: stream raw text into RichLog; on Done, append a separator + the full markdown render below (acknowledging a small redundancy). If empirically ugly, refactor to Static-then-commit in a follow-up — same shape as design-brief §6's `--no-stream-formatting` punt."
|
||||
- "RESOLVED (v0.9.0): streaming-markdown partial rendering. Shipped the Static-then-commit pattern — `Text` deltas accumulate in `text_chunk_buffer` and re-render `Markdown(buffer)` in place into a single response `Static`; no post-Done re-render, no double-display. The issue #12 draft's stream-raw-then-re-render-on-`Done` approach (and its `#current-text` dock-bottom Static) was dropped because the dock-bottom growth visually overlapped the transcript. See INV-005."
|
||||
- "Textual `BINDINGS` priority for `ctrl+c` vs `Input` widget focus: when `Input` is focused, does `ctrl+c` reach the app's binding or get consumed by the input widget? Draft: declare the binding with `priority=True` to ensure the app sees it regardless of focus. If `priority=True` interferes with input editing, fall back to a custom `Input` subclass that surfaces ctrl+c."
|
||||
- "Should the TUI persist transcript across restarts? Per design-brief §8d ('reconnect, not resume-across-process') the answer is no — fresh transcript every launch. Confirming this is in scope of the shell contract (deferred), not punted."
|
||||
prd:
|
||||
@@ -87,7 +87,7 @@ The shell is the load-bearing primary surface. Together with `--send`, it makes
|
||||
- After the `Cancelled` terminal event arrives (or `Done`/`Error`), state returns to **idle** and footer hint resets.
|
||||
- **Note on the idle-hint discrepancy**: the idle-state hint reads `"Ctrl-C twice to exit"` but a single Ctrl-C from idle DOES exit. This is intentional per design-brief §8c's "The footer-hint state transition is load-bearing — the dev needs to see that the next Ctrl-C will exit, otherwise they hit it again expecting another cancel and lose their session." The hint is conservative-by-design — it pre-warns the dev about the *worst-case* (streaming→cancel→exit) flow rather than the literal idle case (one press exits). Implementers MUST use the literal string `"Ctrl-C twice to exit"` (NOT something more accurate like `"Ctrl-C to exit"`); changing it would diverge from the design-brief's locked UX.
|
||||
- **INV-004 [hard]**: Ctrl-D is bound to `app.exit(0)` unconditionally — immediate exit regardless of state. Abandons any in-flight turn (server-side stall watchdog handles the orphan per spec).
|
||||
- **INV-005 [hard]**: Markdown rendering on agent output is default-on; `--raw` is the opt-out. With markdown enabled, `Text` event deltas stream as raw text appended to the RichLog as they arrive (no mid-stream markdown attempt — partial markdown like `**hel` would render ugly), and on `Done` a separator + the full markdown-rendered assistant message is appended below the streamed deltas. **This means the assistant's response visibly appears TWICE in the transcript by design — once as the streamed raw deltas, once as the post-Done markdown render — separated by a horizontal-rule separator.** This is the v1 accepted trade-off for streaming-visibility-without-mid-stream-markdown-ugliness; the cleaner Static-then-commit pattern (streaming into a replaceable widget, then committing the markdown version in place) is documented in `open_questions:` as the follow-up if the double-display proves empirically noisy. Implementers MUST NOT attempt the Static-then-commit pattern in this shell — it's deferred. With `--raw`, only the streamed deltas appear; no post-Done re-render; no double-display.
|
||||
- **INV-005 [hard]**: Markdown rendering on agent output is default-on; `--raw` is the opt-out. `Text` event deltas accumulate in the presenter's `text_chunk_buffer` and render LIVE as `Markdown(buffer)` into a single response `Static` (CSS class `.response-md`) mounted in the transcript scroll — the first delta mounts the widget, each subsequent delta updates it in place. There is NO post-Done re-render and NO double-display: the streamed-then-committed Markdown is the one and only rendering of the response. (v0.9.0 shipped exactly the Static-then-commit pattern the issue #12 draft had deferred; the earlier stream-raw-then-re-render-on-`Done` double-display, and its `#current-text` dock-bottom Static, were removed because the dock-bottom growth visually overlapped the transcript.) With `--raw`, the same widget holds the plain accumulated text instead of a `Markdown` Renderable — still live, still single-display, no Markdown wrapping.
|
||||
- **INV-006 [hard]**: User-prompt echo in the transcript MUST visibly distinguish user input from assistant output. Format: `❯ <content>` for user lines (with a literal `❯` prefix); assistant lines have no prefix. The prefix is also a screen-reader-friendly affordance.
|
||||
- **INV-007 [hard]**: One `httpx.AsyncClient` per app lifetime — opened by `run_tui`'s `async with` BEFORE `App.run_async()` is entered and closed by the same `async with` AFTER `App.run_async()` returns (per issue #6 INV-002). The App is a consumer of an externally-owned client; it MUST NOT call `self.client.aclose()`. The client is NOT recreated per turn (would burn the TCP connection pool).
|
||||
- **INV-008 [hard]**: Mid-session network/protocol errors (`SseConnectionDropped`, `SseConnectFailed`, `MalformedSseId`, `MalformedSseData` (issue #7), `TurnIdFlip`) during a streaming turn render as error lines in the transcript and return the app to **idle** state — they do NOT exit the app. Only initial session-create errors exit (per Data flow exit codes).
|
||||
@@ -104,12 +104,12 @@ The shell is the load-bearing primary surface. Together with `--send`, it makes
|
||||
- **`/admin/events` SSE consumption** — admin observability surface lands with the AdminEvents pane issue.
|
||||
- **`reconnect_turn` mid-session** — if a stream drops mid-turn, the TUI renders the error and returns to idle. In-process reconnect with `Last-Event-ID` resume is a separate issue (the underlying `sse_client.reconnect_turn` is implemented; the TUI doesn't invoke it yet).
|
||||
- **Bifrost-binding consumer support** — not a Ratatoskr concern (per design-brief §6 negative clauses).
|
||||
- **`--quiet` / `--no-stream-formatting`** — deferred per design-brief §6. Add only if streaming text + post-Done markdown render proves empirically noisy.
|
||||
- **`--quiet` / `--no-stream-formatting`** — deferred per design-brief §6. Add only if the live Markdown stream proves empirically noisy.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[compatibility]** Module must work against the spec pin (`55101e909abcd2219833266b6f905c5bc956e0f0`, Worldtree v0.19.0). The TUI is insulated from wire-level changes through `sse_client` + `sessions`.
|
||||
- **[performance]** Streaming MUST NOT buffer the turn before rendering. `Text` deltas write to RichLog as they arrive. The post-Done markdown render reads the accumulated `Done.response` field from the terminal event — no client-side re-aggregation from individual deltas.
|
||||
- **[performance]** Streaming MUST NOT block on the full turn before rendering. `Text` deltas append to the presenter's `text_chunk_buffer` and re-render the response `Static` in place on each delta (live Markdown) — the transcript updates as tokens arrive. The displayed response is built delta-by-delta; `Done.response` is observable but is NOT the source of the rendered output.
|
||||
- **[security]** TUI does not log `Authorization` header, `--api-key` value, or full event bodies. Persistence is per-launch (no disk writes); transcript content is in-memory only.
|
||||
- **[style]** Async-native. Textual's worker pattern (`self.run_worker(coro, exclusive=True)`) drives the stream loop; no manual thread management. `App[int]` for typed exit codes. ruff line-length=100 (per pyproject).
|
||||
|
||||
@@ -231,9 +231,14 @@ PROPERTIES:
|
||||
BINDINGS:
|
||||
- ("ctrl+c", "interrupt", "Cancel / Exit") # priority=True so Input doesn't consume it; see open_questions
|
||||
- ("ctrl+d", "quit", "Exit immediately")
|
||||
COMPOSE shape (declarative — implementer chooses CSS file vs inline):
|
||||
COMPOSE shape (declarative — implementer chooses CSS file vs inline; exact tab/CSS layout lives in tui.py.compose):
|
||||
Header()
|
||||
RichLog(id="transcript", wrap=True, markup=False, highlight=False) # markup=False: bracketed labels like [cancel_failed] render verbatim instead of being interpreted-and-stripped as Rich style spans. The post-Done markdown render uses Markdown() Renderable which renders regardless of widget-level markup.
|
||||
Horizontal:
|
||||
VerticalScroll(id="transcript-scroll") # chat content: per-turn Static widgets mounted dynamically by the presenter — prompt echo, live-Markdown response (.response-md), tinted terminal labels, awaiting-token indicator. No single RichLog; wire-error labels mount as error-label Statics here.
|
||||
TabbedContent (right column; Ctrl+1..3 switch tabs):
|
||||
RichLog(id="tools-log", markup=False) # ToolStart / ToolResult
|
||||
RichLog(id="debug-log", markup=False) # per-event audit line + WorkerPhase + TextBoundary + turn-summary
|
||||
RichLog(id="thinking-log", markup=False) # coalesced Thinking deltas, Rule(start)/Rule(end) per run
|
||||
Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
Static("", id="identity") # INV-002: visible session-identity strip; rendered by on_mount
|
||||
Static(HINT_IDLE, id="hint") # INV-003: visible Ctrl-C state hint; updated on state transitions
|
||||
@@ -299,39 +304,34 @@ TESTS:
|
||||
|
||||
```contract
|
||||
FN RatatoskrApp._stream_turn_worker(self, content: str) -> None
|
||||
BRIEF: Worker coroutine spawned by `on_input_submitted`. Drives `stream_turn`, renders each event into the RichLog via a freshly-constructed `TuiPresenterState` instance (issue #12 amendment: was `_render_event_to_log`), captures `active_turn_id` from the first event for the Ctrl-C cancel path, and transitions state back to "idle" after the terminal event (or on a mid-session error).
|
||||
BRIEF: Worker coroutine spawned by `on_input_submitted` (exclusive). Queries the four panes, constructs a fresh `TuiPresenterState`, drives `stream_turn`, and renders each event through `presenter.render`. Captures `active_turn_id` + writes the turn headers on the first event (for the Ctrl-C cancel path), breaks on the terminal event, mounts wire-error labels as `error-label` Statics into the transcript scroll, and a `finally` always transitions state back to "idle". v0.9.0: rendering is live (the presenter streams Markdown in place) — there is NO post-Done re-render here.
|
||||
PRE: [PRE-001 hard] self.state == "streaming" (set by on_input_submitted before spawn) -- assert self.state == "streaming"
|
||||
PRE: [PRE-002 hard] self.client is not None (set in on_mount) -- assert self.client is not None
|
||||
PRE: [PRE-003 hard] content is non-empty (caller validated in on_input_submitted) -- assert content
|
||||
POST: [POST-001 state_change] after terminal event OR error, self.state == "idle"; self.active_turn_id is None; footer hint reset to "Ctrl-C twice to exit"
|
||||
POST: [POST-002 side_effect] each event passed through TuiPresenterState.render exactly once (until terminal OR until cancel-induced abort) (issue #12 amendment: was _render_event_to_log)
|
||||
POST: [POST-003 side_effect] for Done events with NOT args.raw: a separator line + the markdown-rendered Done.response appended to RichLog (INV-005)
|
||||
POST: [POST-004 state_change] active_turn_id is set to event.sse_id.turn_id on the FIRST yielded event (for cancel_turn use by action_interrupt)
|
||||
POST: [POST-001 state_change] the `finally` always transitions to "idle": self.state == "idle"; self.active_turn_id is None; footer hint reset to HINT_IDLE — on terminal event, mid-session wire error, OR cancellation
|
||||
POST: [POST-002 side_effect] each event is passed through TuiPresenterState.render exactly once (four panes + the on_persona_snapshot callback threaded), until the terminal event OR a cancel-induced abort
|
||||
POST: [POST-003 state_change] on the FIRST yielded event: active_turn_id is set to event.sse_id.turn_id AND _write_turn_headers(active_turn_id) mounts the turn header (active_turn_id is read by action_interrupt for cancel_turn)
|
||||
POST: [POST-004 side_effect] no post-Done Markdown re-render — the presenter renders Markdown live during Text streaming (v0.9.0); the worker only breaks on the terminal event after the presenter has mounted the tinted label
|
||||
ERROR_ROUTING:
|
||||
SseConnectFailed | SseConnectionDropped | MalformedSseId | MalformedSseData | TurnIdFlip:
|
||||
local_handling: append `[<label>] <details>` to RichLog (mirror cli.py's error labels)
|
||||
flow_control: abort (the iteration aborts; finally-block restores state)
|
||||
state_recovery: state → idle; footer hint reset; active_turn_id cleared. (INV-008: mid-session errors do NOT exit the app.)
|
||||
local_handling: audit the failure, then mount `[<label>] <details>` as an `error-label` Static into the transcript scroll (mirrors cli.py's error labels)
|
||||
flow_control: abort (the iteration aborts; the finally-block restores state)
|
||||
state_recovery: finally → state idle; active_turn_id cleared; hint reset. (INV-008: mid-session wire errors do NOT exit the app.)
|
||||
asyncio.CancelledError (from action_interrupt force-exit OR Worker.cancel()):
|
||||
local_handling: none — propagate to let Textual's worker manager clean up
|
||||
flow_control: abort
|
||||
state_recovery: state → idle; active_turn_id cleared. (cancel_task was already spawned by action_interrupt.)
|
||||
state_recovery: finally → state idle; active_turn_id cleared; hint reset. (cancel_task was already spawned by action_interrupt.)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
|
||||
2. [loop, flexibility=prescriptive] TRY: async for event in stream_turn(self.client, self.session_id, content):
|
||||
IF self.active_turn_id is None: SET self.active_turn_id = event.sse_id.turn_id # POST-004
|
||||
presenter.render(event, log=self.query_one("#transcript", RichLog), thinking_widget=self.query_one("#thinking-current", Static), raw=self.args.raw) # issue #12: state-based rendering
|
||||
IF isinstance(event, Done):
|
||||
IF NOT self.args.raw:
|
||||
Append a horizontal-rule separator to RichLog
|
||||
Render Markdown(event.response) into RichLog # INV-005 post-Done markdown render
|
||||
BREAK (terminal; iteration done)
|
||||
IF isinstance(event, (Error, Cancelled)):
|
||||
BREAK (terminal)
|
||||
CATCH SseConnectFailed | SseConnectionDropped | MalformedSseId | TurnIdFlip as exc:
|
||||
Append `[<label>] <details>` to RichLog per cli.py's error-label format
|
||||
3. [cleanup, flexibility=prescriptive] FINALLY:
|
||||
SET self.state = "idle"; self.active_turn_id = None; reset footer hint to "Ctrl-C twice to exit"
|
||||
2. [setup, flexibility=prescriptive] Query the four panes — transcript=#transcript-scroll (VerticalScroll), tools_log=#tools-log, debug_log=#debug-log, thinking_log=#thinking-log — and construct presenter = TuiPresenterState()
|
||||
3. [loop, flexibility=prescriptive] TRY: async for event in stream_turn(self.client, self.session_id, content):
|
||||
IF self.active_turn_id is None: SET self.active_turn_id = event.sse_id.turn_id; CALL self._write_turn_headers(self.active_turn_id) # POST-003
|
||||
CALL presenter.render(event, transcript=transcript, tools_log=tools_log, debug_log=debug_log, thinking_log=thinking_log, raw=self.args.raw, on_persona_snapshot=self._update_persona_surfaces) # POST-002
|
||||
IF isinstance(event, (Done, Error, Cancelled)): BREAK # terminal; presenter already rendered the live Markdown + tinted label (POST-004 — no re-render)
|
||||
CATCH SseConnectFailed | SseConnectionDropped | MalformedSseId | MalformedSseData | TurnIdFlip as exc:
|
||||
AUDIT the failure; mount `[<label>] <details>` as an error-label Static into transcript
|
||||
4. [cleanup, flexibility=prescriptive] FINALLY:
|
||||
CALL self._transition("idle", "worker_finally"); SET self.active_turn_id = None; CALL self._set_hint(self.HINT_IDLE)
|
||||
TESTS:
|
||||
happy_text_done_renders_markdown [happy,tracer]: mock yields text("hello") + done(response="hello"); after Pilot.pause(), RichLog contains "hello" (the streamed delta) AND below it a separator + the markdown render of "hello"; state → idle
|
||||
raw_flag_skips_markdown_render [trace]: --raw; mock yields text + done; RichLog has the streamed delta but NO separator + markdown re-render
|
||||
@@ -345,40 +345,84 @@ TESTS:
|
||||
```
|
||||
|
||||
```contract
|
||||
CLASS TuiPresenterState # issue #12 amendment
|
||||
BRIEF: Stateful per-turn presenter for TUI mode. Replaces the stateless `_render_event_to_log` (removed). Owns `thinking_buffer`, `thinking_open`; coalesces thinking-event deltas into per-delta live updates on the dedicated `Static(id="thinking-current")` widget AND one closed RichLog entry per run (two-views-of-thinking decoupling); demotes telemetry events with a `· ` dim prefix on RichLog; Done renders a load-bearing label + Markdown (when not raw); render exceptions degrade to a plain-labeled fallback + `[render_error] <type>` line (NO exception message per INV-009 security).
|
||||
CLASS TuiPresenterState # issue #12 amendment; refreshed to the four-pane live-Markdown model (v0.5.0–v0.14.0 + Worldtree #201/#204)
|
||||
BRIEF: Stateful per-turn presenter for TUI mode. Replaces the stateless `_render_event_to_log` (removed). Routes each event across four panes (transcript / tools_log / debug_log / thinking_log): Thinking deltas coalesce by `\n` into `thinking_log` wrapped in Rule(start)/Rule(end) per run; Text deltas accumulate in `text_chunk_buffer` and render live as `Markdown(buffer)` into a single in-place-updated response `Static` (no post-Done re-render); demoted telemetry gets a `· ` dim prefix (WorkerPhase/TextBoundary → debug_log, Tool* → tools_log); terminal events mount a tinted label + write a turn-summary to debug_log; AffectUpdate fires the persona callback; AwaitingLlmFirstToken mounts/updates a heartbeat indicator; render exceptions degrade to a plain-labeled fallback + `[render_error] <type>` line (NO exception message per INV-009 security).
|
||||
PROPERTIES:
|
||||
thinking_buffer: list[str]
|
||||
thinking_open: bool
|
||||
thinking_run_index: int
|
||||
thinking_chunk_buffer: str
|
||||
text_chunk_buffer: str
|
||||
current_response_widget: object # the live response Static; None between turns
|
||||
text_delta_count: int
|
||||
text_byte_count: int
|
||||
thinking_delta_count: int
|
||||
thinking_byte_count: int
|
||||
turn_start_ts: float
|
||||
awaiting_widget: object # the awaiting-token indicator Static; None when closed
|
||||
heartbeat_count: int
|
||||
INV-WIRE-001: One instance per `_stream_turn_worker` invocation (issue #12 INV-008).
|
||||
INV-WIRE-002: Two-views-of-thinking decoupling (issue #12 INV-004): per-delta updates → thinking-current Static; closed run → RichLog entry.
|
||||
INV-WIRE-002: Thinking is single-view (v0.7.1+): deltas coalesce by `\n` into `thinking_log` (RichLog), each run wrapped in Rule(start)/Rule(end). The issue #12 two-views `#thinking-current` Static was removed.
|
||||
```
|
||||
|
||||
```contract
|
||||
FN TuiPresenterState.render(self, event: Event, *, log: RichLog, thinking_widget: Static, raw: bool) -> None # issue #12 amendment
|
||||
BRIEF: Render one event into the TUI with editorial hierarchy + coalescing per issue #12 INV-001..INV-007 + render-exception fallback per INV-009. Unicode allowed in TUI output (e.g., `· ` U+00B7 prefix, `→` U+2192 arrow in usage). Decoupling: thinking deltas go to `thinking_widget` per-delta; one closed RichLog entry per thinking-run.
|
||||
PRE: [PRE-001 hard] event is an instance of one of the Event union variants
|
||||
POST: [POST-001 side_effect] for Thinking: open run (display=True, thinking_open=True) on first delta; append to buffer; update widget with last ~200 chars (… prefix when truncated)
|
||||
POST: [POST-002 side_effect] for non-Thinking when thinking_open: write ONE RichLog entry `· thinking: <full>`; clear buffer; thinking_open=False; widget cleared + display=False; THEN render the new event
|
||||
POST: [POST-003 side_effect] for Text: write content to RichLog (no prefix, no demotion)
|
||||
POST: [POST-004 side_effect] for Done: write `[done] turn_id=... model=... duration={autoscale} usage={p} in → {c} out ({t} total, {ci} cached)`; if NOT raw, append Rule() + Markdown(event.response) per issue #4 INV-005
|
||||
POST: [POST-005 side_effect] for Error/Cancelled: write the load-bearing label (no demotion); ensure widget cleared+hidden
|
||||
POST: [POST-006 side_effect] for demoted telemetry (WorkerPhase, TextBoundary, ToolStart, ToolResult): write `· <label>: <fields>` to RichLog
|
||||
POST: [POST-007 exception] never propagates; on internal exception, write the plain labeled fallback + `[render_error] <ExceptionClassName>` (NO exception message — INV-009 security clause)
|
||||
FN TuiPresenterState.render(self, event: Event, *, transcript: VerticalScroll, tools_log: RichLog, debug_log: RichLog, thinking_log: RichLog, raw: bool, on_persona_snapshot: object = None) -> None # issue #12 amendment; refreshed to the four-pane model (v0.5.0–v0.14.0 + Worldtree #201/#204)
|
||||
BRIEF: Render one Worldtree SSE event into the four-pane TUI with editorial hierarchy, thinking/text coalescing, live Markdown, persona + heartbeat surfaces, and an INV-009 render-exception fallback. Unicode allowed (`·` U+00B7 demotion prefix, `→` U+2192 usage arrow). Pane routing — `transcript` (VerticalScroll) = chat content (live-Markdown response Static, tinted terminal labels, awaiting-token indicator); `thinking_log` (RichLog) = coalesced Thinking deltas wrapped in Rule(start)/Rule(end); `tools_log` (RichLog) = ToolStart + ToolResult; `debug_log` (RichLog) = per-event audit line + WorkerPhase + TextBoundary + turn-summary. Optional `on_persona_snapshot` callback fires when AffectUpdate carries a snapshot (issue #13 / Worldtree #204). Supersedes the issue #12 single-`log`/`thinking_widget` model and the post-Done Markdown re-render (both removed at v0.5.0/v0.9.0).
|
||||
PRE: [PRE-001 hard] event is an instance of one of the Event union variants -- assert isinstance(event, (WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled, AffectUpdate, AwaitingLlmFirstToken))
|
||||
POST: [POST-001 side_effect] audit bookkeeping (v0.10.0): Text increments text_delta_count/text_byte_count, Thinking increments thinking_delta_count/thinking_byte_count (each sets turn_start_ts on its first delta) — neither emits a per-delta audit line (token-rate spam control); every other event sets turn_start_ts if unset AND writes one dimmed `_audit_line(event)` to debug_log
|
||||
POST: [POST-002 side_effect] for AffectUpdate (Worldtree #204): audit line per POST-001, then IF snapshot is not None AND on_persona_snapshot is provided, invoke on_persona_snapshot(snapshot) with callback exceptions swallowed (persona surface failure must not break the stream); RETURN
|
||||
POST: [POST-003 side_effect] for AwaitingLlmFirstToken (Worldtree #201): heartbeat_count++; first heartbeat mounts a dimmed "awaiting first token · {s:.1f}s" Static into transcript, subsequent heartbeats update it in place; widget-op exceptions swallowed; scroll_end; RETURN
|
||||
POST: [POST-004 side_effect] gap-close: any non-heartbeat event past the heartbeat branch removes the awaiting indicator if still mounted (awaiting_widget → None)
|
||||
POST: [POST-005 side_effect] for Thinking: open the run on first delta (thinking_run_index++, write Rule("turn {turn_id} · thinking #{idx} start") to thinking_log, thinking_open=True); accumulate content into thinking_chunk_buffer; flush each complete `\n`-terminated line to thinking_log (skip blank lines), retain the tail; RETURN
|
||||
POST: [POST-006 side_effect] for non-Thinking when thinking_open: flush the buffered tail to thinking_log, write Rule("turn {turn_id} · thinking #{idx} end"), thinking_open=False; THEN render the new event
|
||||
POST: [POST-007 side_effect] for Text: append content to text_chunk_buffer; render `text_chunk_buffer if raw else Markdown(text_chunk_buffer)` — first Text delta mounts a `.response-md` Static into transcript, subsequent deltas update it in place (live Markdown, no post-Done re-render); scroll_end; RETURN
|
||||
POST: [POST-008 side_effect] for Done/Error/Cancelled: write a dimmed turn-summary (turn_id, text_deltas/bytes, thinking_deltas/bytes, heartbeats, elapsed_ms) to debug_log; clear text_chunk_buffer + current_response_widget; mount a tinted terminal-label Static into transcript — Done = success-tinted `[done] turn_id=... model=... duration={_format_duration_ms} usage {_format_usage(arrow='→')}`, Error = error-tinted `[error] turn_id=... code=... message=...!r`, Cancelled = warning-tinted `[cancelled] turn_id=... reason=...!r partial_message_id=...`; scroll_end; RETURN
|
||||
POST: [POST-009 side_effect] for demoted telemetry: WorkerPhase + TextBoundary → dimmed `· <label>: <fields>` to debug_log; ToolStart + ToolResult → dimmed `· <label>: <fields>` to tools_log (ToolResult result truncated to 200 chars) per issue #13 INV-014; RETURN
|
||||
POST: [POST-010 exception] never propagates; on any internal exception, write `_plain_label(event)` + `[render_error] <ExceptionClassName>` (NO exception message — INV-009 security clause) to the event's pane (tools_log for Tool*; thinking_log for Thinking; debug_log for WorkerPhase/TextBoundary; else mount Statics into transcript)
|
||||
ERROR_ROUTING:
|
||||
Exception (any internal render failure — widget op, formatting, persona callback):
|
||||
local_handling: write `_plain_label(event)` + `[render_error] {type(exc).__name__}` (no message — INV-009 security clause) to the event's pane (tools_log for Tool*; thinking_log for Thinking; debug_log for WorkerPhase/TextBoundary; else mount Statics into transcript)
|
||||
flow_control: skip (swallow — render never propagates)
|
||||
state_recovery: none (the next event renders against fresh state)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate event ∈ Event union per PRE-001.
|
||||
2. [setup, flexibility=prescriptive] Enter the render try-block — steps 3..11 run inside it; step 12 is the INV-009 fallback.
|
||||
3. [branch, flexibility=prescriptive] Audit bookkeeping (POST-001):
|
||||
IF Text: set turn_start_ts on first delta; text_delta_count++; text_byte_count += len(content)
|
||||
ELIF Thinking: set turn_start_ts on first delta; thinking_delta_count++; thinking_byte_count += len(content)
|
||||
ELSE: set turn_start_ts if unset; WRITE _dim(_audit_line(event)) to debug_log
|
||||
4. [branch, flexibility=prescriptive] IF AffectUpdate (POST-002): IF snapshot is not None AND on_persona_snapshot is not None: TRY on_persona_snapshot(snapshot) / swallow Exception; RETURN
|
||||
5. [branch, flexibility=prescriptive] IF AwaitingLlmFirstToken (POST-003): heartbeat_count++; secs = elapsed_ms_since_building_prompt / 1000; mount-or-update a dimmed "awaiting first token · {secs:.1f}s" Static in transcript (swallow widget Exception); scroll_end; RETURN
|
||||
6. [branch, flexibility=prescriptive] Gap-close (POST-004): IF awaiting_widget is not None: remove it (swallow Exception); SET awaiting_widget=None
|
||||
7. [branch, flexibility=prescriptive] IF Thinking (POST-005): IF NOT thinking_open: thinking_run_index++; WRITE Rule(start) to thinking_log; thinking_open=True. APPEND content to thinking_chunk_buffer; WHILE "\n" in buffer: partition on "\n", WRITE non-empty line to thinking_log, keep the remainder. RETURN
|
||||
8. [branch, flexibility=prescriptive] Close open thinking run (POST-006): IF thinking_open: IF buffer non-empty: WRITE buffer tail to thinking_log, clear buffer. WRITE Rule(end) to thinking_log; thinking_open=False
|
||||
9. [branch, flexibility=prescriptive] IF Text (POST-007): APPEND content to text_chunk_buffer; rendered = buffer if raw else Markdown(buffer); IF current_response_widget is None: mount Static(rendered, classes="response-md") in transcript; ELSE: current_response_widget.update(rendered); scroll_end; RETURN
|
||||
10. [branch, flexibility=prescriptive] IF Done|Error|Cancelled (POST-008): elapsed_ms = int((monotonic()-turn_start_ts)*1000) if turn_start_ts else 0; WRITE dimmed turn-summary to debug_log; clear text_chunk_buffer + current_response_widget; mount the tinted terminal-label Static (Done=success / Error=error / Cancelled=warning) in transcript with the documented label text; scroll_end; RETURN
|
||||
11. [branch, flexibility=prescriptive] Demoted telemetry (POST-009), then RETURN: WorkerPhase → debug_log `· worker_phase: ...`; ToolStart → tools_log `· tool_start: ...`; ToolResult → tools_log `· tool_result: ... result={result!r:.200}`; TextBoundary → debug_log `· text_boundary: ...`
|
||||
12. [error_handler, flexibility=prescriptive] EXCEPT Exception as exc (POST-010 / INV-009): WRITE _plain_label(event) + "[render_error] {type(exc).__name__}" (no message) to the event's pane per ERROR_ROUTING
|
||||
TESTS:
|
||||
thinking_coalesce_single_widget_update [happy,tracer]: 3 Thinking events → widget.update called 3× with cumulative content; RichLog has 0 thinking entries yet
|
||||
thinking_closes_one_richlog_entry [happy]: 2× Thinking + WorkerPhase → ONE closed thinking entry + worker_phase entry; widget cleared+hidden
|
||||
thinking_widget_truncation [trace]: 500-char buffer → widget shows "…" + last 200
|
||||
thinking_widget_visibility_lifecycle [trace]: hidden initially; visible during run; hidden after closing event
|
||||
multiple_thinking_runs_each_get_richlog_entry [scenario]: Thinking → Text → Thinking → Done → TWO closed thinking entries
|
||||
cancelled_mid_thinking_closes [scenario]: Thinking → Cancelled → ONE closed thinking entry + [cancelled]; widget hidden
|
||||
done_renders_markdown_after_label [happy]: Text + Done(response=...) with NOT raw → [done] line, Rule, Markdown
|
||||
raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown
|
||||
worker_phase_demoted [trace]: "· worker_phase:" prefix (not "[worker_phase]")
|
||||
tool_start_demoted [trace]: "· tool_start:" prefix
|
||||
text_no_prefix [trace]: Text → no demotion prefix
|
||||
render_exception_fallback [adversarial]: widget.update raises → fallback line + `[render_error] <ClassName>` (NO message); state does not propagate
|
||||
state_reset_per_worker [trace]: fresh TuiPresenterState() starts with no thinking open
|
||||
text_then_done_mounts_widget_and_finalizes [happy,tracer]: Text + Done (NOT raw) → live Markdown `.response-md` widget mounted; on Done the widget ref clears + a success-tinted [done] label mounts; no post-Done re-render (no double-print)
|
||||
thinking_coalesces_until_newline [happy]: Thinking deltas buffer; only complete `\n`-terminated lines flush to thinking_log
|
||||
thinking_flushes_on_newline [happy]: a Thinking delta containing `\n` flushes the completed line and retains the tail for the next delta
|
||||
thinking_closes_to_thinking_log [happy]: 2× Thinking + WorkerPhase → tail flushed + Rule(end) closes the run in thinking_log; thinking_open=False
|
||||
multiple_thinking_runs_each_get_thinking_log_section [scenario]: Thinking → Text → Thinking → Done → TWO Rule-wrapped thinking sections
|
||||
cancelled_mid_thinking_closes [scenario]: Thinking → Cancelled → run closes with Rule(end); warning-tinted [cancelled] label mounted
|
||||
text_first_delta_mounts_response_widget [happy]: first Text delta mounts a `.response-md` Static in transcript holding Markdown(buffer)
|
||||
text_subsequent_deltas_update_in_place [trace]: later Text deltas update the same widget (live Markdown), no new mount
|
||||
raw_flag_skips_markdown [trace]: raw=True → response widget holds plain str, no Markdown wrapping
|
||||
worker_phase_demoted_to_debug_log [trace]: WorkerPhase → dimmed `· worker_phase:` in debug_log, not transcript
|
||||
tool_start_routes_to_tools_log [trace]: ToolStart → `· tool_start:` in tools_log (issue #13 INV-014)
|
||||
tool_result_routes_to_tools_log [trace]: ToolResult → `· tool_result: ... result=<≤200 chars>` in tools_log
|
||||
worker_phase_emits_audit_line [trace]: a non-Text/Thinking event writes one dimmed `_audit_line` to debug_log
|
||||
tool_start_emits_audit_line [trace]: ToolStart writes an audit line to debug_log in addition to the tools_log routing
|
||||
text_delta_counted_not_per_event_audit_line [trace]: Text deltas increment counters but emit NO per-delta audit line (token-rate spam control)
|
||||
done_emits_turn_summary_line [trace]: Done writes a dimmed turn-summary (text/thinking delta+byte counts, heartbeats, elapsed_ms) to debug_log before clearing counters
|
||||
affect_update_routes_to_audit_only [scenario]: AffectUpdate(snapshot) → audit line + on_persona_snapshot(snapshot) callback; no transcript mount
|
||||
affect_update_scheduled_has_no_pad_detail [trace]: AffectUpdate(status="scheduled", snapshot=None) → audit line only; callback skipped
|
||||
awaiting_llm_first_token_mounts_indicator [scenario]: first AwaitingLlmFirstToken mounts a dimmed "awaiting first token · {s}s" Static in transcript
|
||||
awaiting_subsequent_heartbeats_update_in_place [trace]: later heartbeats update the same indicator in place; heartbeat_count grows
|
||||
awaiting_indicator_removed_when_gap_closes [scenario]: the first non-heartbeat event removes the awaiting indicator (gap closed)
|
||||
render_exception_fallback [adversarial]: an internal render failure writes `_plain_label` + `[render_error] <ClassName>` (NO message) to the event's pane; never propagates (INV-009)
|
||||
state_reset_per_worker [trace]: a fresh TuiPresenterState() starts with thinking_open=False and zeroed counters
|
||||
duration_format_seconds [trace]: Done(duration_ms=5467) → "duration=5.5s"
|
||||
usage_format_unicode_arrow [trace]: Done → "usage ... in → ... out (...)" (Unicode arrow, not ASCII)
|
||||
```
|
||||
@@ -408,7 +452,7 @@ STEPS:
|
||||
RETURN
|
||||
SET self.state = "cancelling"
|
||||
update footer hint to "Press Ctrl-C again to exit"
|
||||
self.run_worker(_cancel_via_sse(self.client, self.session_id, self.active_turn_id, log=self.query_one("#transcript", RichLog)))
|
||||
self.run_worker(_cancel_via_sse(self.client, self.session_id, self.active_turn_id, transcript=self.query_one("#transcript-scroll", VerticalScroll), audit=self._audit))
|
||||
ELIF self.state == "cancelling":
|
||||
# Second Ctrl-C — force exit, abandon drain
|
||||
IF self.stream_worker is not None: self.stream_worker.cancel()
|
||||
|
||||
+142
-21
@@ -61,7 +61,7 @@ When you call `POST /sessions` against an agent, the authorization check that fi
|
||||
|
||||
### Tier 1 — foundational agents (no `:` in agent_id)
|
||||
|
||||
Agents bundled with Worldtree: `mimir`, `lofn`, `soong`, `forseti`, `domari`, `vili`, `actor`, `saga`, `bragi`, `leif`, `troi`, `cara`, `glados`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon.
|
||||
Agents bundled with Worldtree: `mimir`, `lofn`, `forseti`, `domari`, `vili`, `mask`, `echo`, `muninn`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon.
|
||||
|
||||
> **About tiers:** Your `tier` is set on the `users` table row your API key resolves to, assigned at key-mint time (see `POST /admin/keys`). Tiers are `anonymous` (dev-mode unauthenticated), `user` (default for newly-issued keys), `free`/`pro` (subscription-shaped, not actively differentiated), and `admin`. The tier you have is visible via `GET /me`'s `tier` field. Tier-derived scopes come from `config/policies.yaml > tiers.<tier>.scopes` — there is no per-key scope override.
|
||||
|
||||
@@ -989,7 +989,7 @@ Create a new conversation session with an agent.
|
||||
**Bifrost field validation:**
|
||||
- `endpoint_url`: required, must be an HTTPS URL.
|
||||
- `scope`: optional, ≤ 256 chars, opaque string passed through to the JWT payload unchanged.
|
||||
- Bifrost binding is **incompatible with ephemeral (Saga) sessions** — returns 422 `ephemeral_does_not_accept_bifrost`.
|
||||
- Bifrost binding is **incompatible with ephemeral (Echo) sessions** — returns 422 `ephemeral_does_not_accept_bifrost`.
|
||||
- Requires the `bifrost:invoke` scope (included in the `user` tier by default).
|
||||
|
||||
**Response:** `201 Created`
|
||||
@@ -1535,7 +1535,7 @@ for (const tc of items) {
|
||||
|
||||
Ephemeral templates are a second tier of agent, distinct from foundational persistent agents (Mimir, Soong, etc.). They have no persona, no memory, no tools, and no motivational context. The consumer supplies the system prompt and (optionally) the model at session-create time; that config is frozen for the session's lifetime.
|
||||
|
||||
**Saga** is the first ephemeral template — Norse goddess of history and chronicle, a blank-slate actor that becomes whatever the consumer's system prompt instills.
|
||||
**Echo** is the first ephemeral template — a blank-slate per-session host that becomes whatever the consumer's system prompt instills.
|
||||
|
||||
### Discovering available templates
|
||||
|
||||
@@ -1547,7 +1547,7 @@ Authorization: Bearer <any valid key>
|
||||
```json
|
||||
{
|
||||
"ephemeral_templates": {
|
||||
"saga": {
|
||||
"echo": {
|
||||
"allowed_models": ["glm5-turbo", "glm4.7", "glm4.5-air", "granite-structured", "qwen3.6-35-a3b"],
|
||||
"default_model": "glm5-turbo",
|
||||
"system_prompt_max_bytes": 32768
|
||||
@@ -1556,14 +1556,14 @@ Authorization: Bearer <any valid key>
|
||||
}
|
||||
```
|
||||
|
||||
`GET /capabilities` does not require `instantiate:saga` scope — any authenticated caller can read what's available before deciding to instantiate.
|
||||
`GET /capabilities` does not require `instantiate:echo` scope — any authenticated caller can read what's available before deciding to instantiate.
|
||||
|
||||
### Creating an ephemeral session
|
||||
|
||||
```json
|
||||
POST /sessions
|
||||
{
|
||||
"agent_id": "saga",
|
||||
"agent_id": "echo",
|
||||
"config": {
|
||||
"system_prompt": "You are a careful, skeptical frame-clarifier...",
|
||||
"model": "glm5-turbo"
|
||||
@@ -1580,16 +1580,16 @@ POST /sessions
|
||||
| `system_prompt_required` | `config.system_prompt` missing or null |
|
||||
| `system_prompt_empty` | `config.system_prompt` is whitespace-only |
|
||||
| `system_prompt_too_large` | `config.system_prompt` > 32768 bytes UTF-8 |
|
||||
| `model_not_allowed` | `config.model` present but not in `saga_allowed_models` |
|
||||
| `model_not_allowed` | `config.model` present but not in `echo_allowed_models` |
|
||||
|
||||
**`config.model` resolution:** When `config.model` is omitted (or `null`), the server resolves it to `saga.default_model` from `config/defaults.yaml`. The resolved value is always populated in the session snapshot; `model` is never left absent or null in the stored config.
|
||||
**`config.model` resolution:** When `config.model` is omitted (or `null`), the server resolves it to `echo.default_model` from `config/defaults.yaml`. The resolved value is always populated in the session snapshot; `model` is never left absent or null in the stored config.
|
||||
|
||||
**Response:** Same 201 shape as foundational sessions, with two new fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "...",
|
||||
"agent_id": "saga",
|
||||
"agent_id": "echo",
|
||||
"kind": "ephemeral",
|
||||
"config": {
|
||||
"system_prompt": "You are a careful, skeptical frame-clarifier...",
|
||||
@@ -1601,7 +1601,7 @@ POST /sessions
|
||||
}
|
||||
```
|
||||
|
||||
**`kind` field:** `"ephemeral"` for Saga sessions, `"foundational"` for all other sessions. Present on both `GET /sessions` list items and `GET /sessions/{id}`.
|
||||
**`kind` field:** `"ephemeral"` for Echo sessions, `"foundational"` for all other sessions. Present on both `GET /sessions` list items and `GET /sessions/{id}`.
|
||||
|
||||
### Sending messages to an ephemeral session
|
||||
|
||||
@@ -1617,9 +1617,9 @@ SSE, cancel, `persist_partial`, rate limits, and error shapes are bit-identical
|
||||
|
||||
### Scope
|
||||
|
||||
Creating a Saga session requires the `instantiate:saga` scope. This scope is bundled in the `user` tier. Tier `admin` inherits it via the wildcard.
|
||||
Creating an Echo session requires the `instantiate:echo` scope. This scope is bundled in the `user` tier. Tier `admin` inherits it via the wildcard.
|
||||
|
||||
### What Saga does NOT do
|
||||
### What Echo does NOT do
|
||||
|
||||
- No persona injection (`PersonaRegistry.inject_context` not called)
|
||||
- No post-turn appraisal (`PersonaRegistry.update_after_turn` not called)
|
||||
@@ -2415,7 +2415,7 @@ The `POST /sessions/{session_id}/messages` endpoint also accepts an additive `mo
|
||||
- Override is per-call only. Stored `CharacterSchema.model` is NOT mutated.
|
||||
- Validated against the same `available_for_characters` allowlist that gates `CharacterSchema.model` at create time (#153 INV-091).
|
||||
- Override displaces the character's bound model when both are set (per-call wins).
|
||||
- Override is REJECTED on ephemeral (Saga) sessions — their config is frozen at session-create per INV-161-2.
|
||||
- Override is REJECTED on ephemeral (Echo) sessions — their config is frozen at session-create per INV-161-2.
|
||||
|
||||
**Validation:**
|
||||
1. Pydantic validates `model`: optional string, non-empty after stripping whitespace.
|
||||
@@ -2672,7 +2672,7 @@ The override client has a fresh 25-call reentrancy budget, independent of the se
|
||||
| Condition | HTTP | `error_code` | `bifrost_error` |
|
||||
|-----------|------|-------------|----------------|
|
||||
| `endpoint_url` is not HTTPS | 422 | `validation_failed` | — |
|
||||
| Ephemeral (Saga) session | 422 | `validation_failed` | — |
|
||||
| Ephemeral (Echo) session | 422 | `validation_failed` | — |
|
||||
| Missing `bifrost:invoke` scope | 403 | `auth_scope_denied` | — |
|
||||
| `consumer_id` not in Heimdall or not Bifrost-registered | 502 | `bifrost_consumer_not_found` | — |
|
||||
| Handshake failed (network, auth, etc.) | 502 | `bifrost_handshake_failed` | spec error code |
|
||||
@@ -2753,6 +2753,61 @@ Caller must:
|
||||
`agent_name` is a strict slug `[a-z][a-z0-9-]{2,63}` and immutable
|
||||
after definition.
|
||||
|
||||
The 201 response includes an advisory `warnings` array (#219) — see
|
||||
"Model-assignment warnings" under `PATCH` below.
|
||||
|
||||
##### Motivational layer (Phase 2.2, #187)
|
||||
|
||||
`motivational` is **active** as of Phase 2.2 (persona + memory activated in
|
||||
Phase 2.1; only `valence` still returns `layer_deferred`). It carries the
|
||||
agent's goals + fears — the same substrate Tier 1 agents author in
|
||||
`agents/<name>/motivation.yaml`:
|
||||
|
||||
```json
|
||||
"motivational": {
|
||||
"goals": [
|
||||
{
|
||||
"id": "successful_handoff",
|
||||
"type": "achievement", // maintenance | achievement | avoidance
|
||||
"salience": 0.85, // [0.0, 1.0]
|
||||
"description": "You succeed when the user lands with the right specialist.",
|
||||
"positive_signals": ["talk to mimir"], // optional
|
||||
"negative_signals": ["stay with me"] // optional
|
||||
}
|
||||
],
|
||||
"fears": [
|
||||
{
|
||||
"id": "specialist_displacement",
|
||||
"salience": 0.90,
|
||||
"description": "You fear being mistaken for the specialist the user needs.",
|
||||
"trigger_signals": ["actually mimir would"] // optional (NB: fears use trigger_signals)
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
- **Per-agent, not per-(agent, end_user).** Goals/fears are an identity trait of
|
||||
the agent — identical for every end-user and session.
|
||||
- **Immutable post-define.** `PATCH` with `motivational` returns 422
|
||||
`field_not_mutable`. To change motivations, define a new agent.
|
||||
- **Rendered into the system prompt.** The config is captured on the session's
|
||||
`AgentContext` at session-create and rendered into the prompt on each turn
|
||||
(only goals/fears with `salience >= 0.5` surface). Tier 3 agents bypass the
|
||||
persona registry; the render reuses the Tier 1 substrate so output is
|
||||
identical to an equivalent Tier 1 `motivation.yaml`.
|
||||
|
||||
Validation rejects malformed payloads at define-time with these 422 codes:
|
||||
`motivational_id_collision` (id duplicated across goals AND fears — case-sensitive),
|
||||
`motivational_goal_invalid_type`, `motivational_salience_out_of_range`,
|
||||
`motivational_description_too_short` (< 20 chars after strip),
|
||||
`motivational_missing_required_field` (missing id / salience / description /
|
||||
goal `type`). Unknown keys at the top level or inside a goal/fear object →
|
||||
`validation_failed`. v0.1 exposes only the documented fields; advanced
|
||||
`GoalConfig` knobs (`priority`, `resilient`, `completion_signal`, …) are not
|
||||
consumer-settable yet.
|
||||
|
||||
#### `DELETE /agents/<user_id>:<agent_name>` — `204 No Content`
|
||||
|
||||
Owner-initiated hard-delete. Bypasses the 24h grace (distinct from the
|
||||
@@ -2762,11 +2817,77 @@ session bound to this agent and revokes the owner's per-resource
|
||||
|
||||
#### `PATCH /agents/<user_id>:<agent_name>`
|
||||
|
||||
Phase 2.0 minimal: only `system_prompt` and/or `model` may be patched.
|
||||
Any other key (including the immutable `agent_name`, `user_id`, or
|
||||
layer fields — even `null`) returns 422 `field_not_mutable` BEFORE the
|
||||
DB lookup. Active sessions continue using their cached `AgentContext`;
|
||||
the new values take effect at the next session-create.
|
||||
**Mutable surface (Phase 2.3, #188): `system_prompt` and/or `model` only.**
|
||||
PATCH re-enforces the same validation as define — the `system_prompt`
|
||||
byte-cap and the `model` allowlist. Any other key returns a 422 BEFORE
|
||||
the DB lookup (so an immutable-field PATCH against a missing agent still
|
||||
422s, not 404s), with the error code chosen by *why* the field can't be
|
||||
set:
|
||||
|
||||
| Field(s) | Code | Reason |
|
||||
|---|---|---|
|
||||
| `agent_name`, `user_id`, `agent_id` | `field_not_mutable` | Identity — fixed at creation. |
|
||||
| `persona`, `motivational` | `field_not_mutable` | Shipped traits; an agent *is* its personality/goals. Change → define a new agent. |
|
||||
| `memory` | `field_not_mutable` | Rejected **wholesale** — see below. |
|
||||
| `valence` | `layer_deferred` | Not a shipped layer yet (matches define-time); not a frozen trait. |
|
||||
|
||||
Every immutable/deferred field is rejected even when its value is `null` —
|
||||
supplying the key at all is the trigger.
|
||||
|
||||
**`memory` is wholesale-immutable.** There is no sub-field carve-out:
|
||||
`stm_capacity` / `stm_token_budget` are deprecated no-ops since the STM
|
||||
tier was removed (#197), `allows_world_scope` is create-time-only (memory
|
||||
scope policy must be fixed before any memory is written), and
|
||||
`embedder_version` is library-pinned. Note the deliberate asymmetry with
|
||||
define: `POST /agents/define` accept-and-ignores deprecated `stm_*`
|
||||
(201 + deprecation warning), but `PATCH {"memory": {...}}` rejects the
|
||||
whole field with `field_not_mutable`. When a real long-term-memory tuning
|
||||
dial ships, its PATCH semantics will be specified at that time.
|
||||
|
||||
**Active sessions are unaffected.** A PATCH never mutates an in-flight
|
||||
session's cached `AgentContext`; new `system_prompt` / `model` values take
|
||||
effect only at the next session-create.
|
||||
|
||||
**Audit.** A successful PATCH emits one `agents.patch` event whose
|
||||
`changes` detail records before/after per mutated field: `model` as literal
|
||||
`{before, after}` values, and `system_prompt` as `{before_bytes,
|
||||
after_bytes}` only — the raw prompt text is never written to the audit log
|
||||
(potential PII).
|
||||
|
||||
**Model-assignment warnings (#219).** A `model` swap is **not blocked** for
|
||||
capability or context-window compatibility, but PATCH (and `define`) attach an
|
||||
advisory `warnings` array to the response — see the shared subsection below.
|
||||
Correctness for over-budget prompts remains the runtime `context_overflow`
|
||||
guard; the warnings are an early, best-effort heads-up.
|
||||
|
||||
##### Model-assignment warnings (`define` + PATCH)
|
||||
|
||||
Both `POST /agents/define` (201) and `PATCH /agents/<id>` (200) include a
|
||||
`warnings` array in the response body (always present; `[]` when none). It is
|
||||
**advisory and non-blocking** — never a rejection — and appears only on these
|
||||
two mutation responses, not on `GET /agents/<id>`. Each entry is
|
||||
`{code, severity, message, details}`. The closed code set:
|
||||
|
||||
| code | severity | when |
|
||||
|---|---|---|
|
||||
| `model_context_window_unknown` | `info` | The assigned model has no recorded context window (`0`/absent in the registry). |
|
||||
| `model_context_window_smaller` | `warning` | Both prior and new model have known windows and the new one is smaller. `details: {before, after}`. |
|
||||
| `model_capability_downgrade` | `warning` | The new model **explicitly** advertises fewer capabilities than the prior — drops `tools`, `vision`, or `audio`. `details: {dropped: [...]}`. |
|
||||
|
||||
Semantics:
|
||||
|
||||
- **`define`** has no prior model, so only `model_context_window_unknown` can
|
||||
fire there. **PATCH** computes warnings only when the payload changes `model`
|
||||
(a `system_prompt`-only PATCH returns `warnings: []`); the comparison is
|
||||
against the resulting model.
|
||||
- Capability warnings are **conditional by nature**: a Tier 3 agent row does
|
||||
not record whether it uses tools/vision/audio (tools arrive per-session via
|
||||
Bifrost), so the message is phrased "if your sessions rely on these…". A
|
||||
downgrade is reported only when both models carry explicit registry metadata.
|
||||
- Messages never claim a hard failure. The stored `system_prompt` cap is a
|
||||
**byte** limit (32 KiB), independent of any model's token budget — it is not
|
||||
a fit guarantee. A too-large prompt for the chosen model still surfaces at
|
||||
runtime as `context_overflow`.
|
||||
|
||||
#### `POST /sessions` — Tier 3 routing
|
||||
|
||||
@@ -2860,8 +2981,8 @@ endpoint isn't reachable.
|
||||
| `agent_name_invalid` | 422 | `agent_name` violates `[a-z][a-z0-9-]{2,63}`. |
|
||||
| `system_prompt_too_large` | 422 | `system_prompt` > 32 KiB. |
|
||||
| `model_not_available` | 422 | `model` not in `providers.yaml`. |
|
||||
| `layer_deferred` | 422 | One of `persona` / `motivational` / `valence` / `memory` set. |
|
||||
| `field_not_mutable` | 422 | PATCH carries an immutable key (any value, even `null`). |
|
||||
| `layer_deferred` | 422 | `valence` set on define OR PATCH (the only still-deferred layer; persona/motivational/memory activated in Phase 2.1/2.2). |
|
||||
| `field_not_mutable` | 422 | PATCH carries an immutable key — identity (`agent_name`/`user_id`), `persona`, `motivational`, or `memory` (any value, even `null`). `valence` → `layer_deferred` instead. |
|
||||
| `end_user_id_required` | 422 | Tier 3 session-create without a non-empty `end_user_id`. |
|
||||
| `tier3_user_id_unsupported` | 403 | Caller's `ctx.user_id` not slug-safe. |
|
||||
| `auth_scope_denied` | 403 | Missing `agents.define` or wrong owner. |
|
||||
|
||||
@@ -137,6 +137,68 @@ Sessions are persistent via SQLite. On server restart, existing sessions are
|
||||
loadable from the store (lazy-loaded on first access). In-memory cache is
|
||||
rebuilt on demand, not at startup.
|
||||
|
||||
## Memory-partition scope (#245 / ADR-0011)
|
||||
|
||||
`end_user_id` is the per-end-user memory partition key (distinct from `user_id`,
|
||||
the API-key owner). It is REQUIRED at session-create for Lofn (Tier-1) and Tier-3
|
||||
agents and must survive a store reload, because "remember me next session" is by
|
||||
definition a reload. Memory partition resolution flows through ONE resolver that
|
||||
cannot hand an authenticated session the shared `local_dev` partition.
|
||||
|
||||
- **INV-245-1 (end-user-id-durable)**: `end_user_id` is persisted as a `sessions`
|
||||
table column at create and rehydrated onto the `ConversationSession` on every
|
||||
cache-miss load (`get_session`). A session loaded from the store carries the
|
||||
same `end_user_id` it was created with. Pre-migration rows read as `None`.
|
||||
- **INV-245-2 (end-user-id-threaded-all-tiers)**: the `POST /sessions` handler
|
||||
forwards `body.end_user_id` to `create_session` for EVERY agent, not only
|
||||
Tier-3. (The pre-fix `if tier3_agent_context is not None else None` conditional
|
||||
dropped it for Lofn despite the create gate requiring it.)
|
||||
- **INV-245-3 (no-authenticated-local-dev)**: the two MEMORY partition sites —
|
||||
auto-recall (read) and the ContextPromotion producer (write) — resolve via
|
||||
`memory_scope_for_session`. An authenticated, memory-bearing session (one not
|
||||
carrying the explicit `local_dev` sentinel) NEVER resolves to `local_dev`; a
|
||||
missing `end_user_id` raises `MemoryScopeError`, and because both sites are
|
||||
best-effort (recall is fire-and-forget; the producer is `_run_promotion_safe`),
|
||||
the caller skips memory — it never silently writes to the shared partition.
|
||||
- **INV-245-5 (persona-plane-corrected-by-persistence)**: the three PERSONA-plane
|
||||
sites (`inject_context`, `get_state`, `update_after_turn` — ADR-0008 mood/PAD/
|
||||
valence) keep their `session.end_user_id or "local_dev"` form but are on the
|
||||
main turn path where a raise would break the turn. They are corrected by
|
||||
INV-245-1/2: once `end_user_id` is persisted + threaded, the fallback yields a
|
||||
real partition for authenticated sessions and `local_dev` only for the explicit
|
||||
terminal path. Unifying the persona plane under the resolver (with main-path
|
||||
error semantics) is follow-up, tracked with the #246-adjacent hardening.
|
||||
- **INV-245-4 (terminal-explicit-local-dev)**: the internal terminal transport
|
||||
creates its sessions with `end_user_id="local_dev"` explicitly. `local_dev` is
|
||||
reached only by this positive assertion, never by omission. (External API
|
||||
callers passing `local_dev` are still rejected per #216.)
|
||||
|
||||
```contract
|
||||
FN memory_scope_for_session(session) -> MemoryScope
|
||||
BRIEF: The single authority resolving a session to its memory partition scope.
|
||||
Returns a typed MemoryScope(scope_type, scope_id); scope_type ∈
|
||||
{local_dev, end_user, room, tenant} (only local_dev + end_user active in
|
||||
v1; room/tenant reserved for ADR-0010). Cannot yield local_dev for an
|
||||
authenticated session.
|
||||
PRE: [PRE-001 soft] callers have already gated ephemeral / consumer_defined
|
||||
sessions out (those skip memory before resolution)
|
||||
POST: [POST-001 return_value] end_user_id == "local_dev" -> MemoryScope("local_dev", "local_dev")
|
||||
POST: [POST-002 return_value] end_user_id truthy and != "local_dev" -> MemoryScope("end_user", end_user_id)
|
||||
POST: [POST-003 exception] end_user_id is None/empty -> raise MemoryScopeError (NEVER local_dev)
|
||||
ERRORS:
|
||||
MemoryScopeError -> caller skips memory (best-effort) + emits an audit/log line; turn proceeds
|
||||
STEPS:
|
||||
1. [setup] read euid = session.end_user_id
|
||||
2. [branch] euid == "local_dev" -> RETURN MemoryScope("local_dev", "local_dev") (terminal sentinel)
|
||||
3. [branch] euid truthy -> RETURN MemoryScope("end_user", euid)
|
||||
4. [error_handler] else (None/empty) -> RAISE MemoryScopeError (never silently local_dev)
|
||||
TESTS:
|
||||
end_user_partition [happy,tracer]: session end_user_id="alice" -> MemoryScope("end_user","alice")
|
||||
terminal_local_dev [boundary]: session end_user_id="local_dev" -> MemoryScope("local_dev","local_dev")
|
||||
authenticated_none_raises [boundary]: foundational session end_user_id=None -> raises MemoryScopeError, NOT local_dev
|
||||
isolation_roundtrip [happy]: create_session(end_user_id="alice") write + clear cache + reload + recall isolates from a "bob" session; negative-assert no local_dev write
|
||||
```
|
||||
|
||||
```contract
|
||||
FN ConversationService.startup() -> None
|
||||
BRIEF: Discover agents, build per-agent contexts, initialise shared infrastructure
|
||||
@@ -1658,13 +1720,13 @@ Ephemeral templates are a new agent kind that bypass persona, memory, tools, and
|
||||
**Invariants added by issue #161:**
|
||||
|
||||
- **INV-161-1 (ephemeral-template-bypass)**: For sessions where `session.ephemeral_config is not None`, `PersonaRegistry.inject_context` is NOT called pre-turn; `PersonaRegistry.update_after_turn` is NOT called post-turn; valence side-channel is NOT called; tool list passed to provider is `[]`.
|
||||
- **INV-161-2 (frozen-session-config)**: Once a session is created with an `ephemeral_config` snapshot, subsequent mutations to `agents/saga/config.yaml`, `config/providers.yaml → saga_allowed_models`, or `config/defaults.yaml → saga.default_model` do NOT affect that session's per-turn `system_prompt` or `model`.
|
||||
- **INV-161-2 (frozen-session-config)**: Once a session is created with an `ephemeral_config` snapshot, subsequent mutations to `agents/echo/config.yaml`, `config/providers.yaml → echo_allowed_models`, or `config/defaults.yaml → echo.default_model` do NOT affect that session's per-turn `system_prompt` or `model`.
|
||||
- **INV-161-3 (no-tools-for-ephemeral)**: Tool list passed to the provider for an ephemeral session is `[]` regardless of any `tools:` block in the template's config.yaml.
|
||||
- **INV-161-4 (foundational-flow-unchanged)**: For sessions where `session.ephemeral_config is None`, the per-turn path is bit-identical to pre-#161 — same system_prompt loading, same persona injection, same tool list, same audit-log shape.
|
||||
- **INV-161-5 (config-required-for-ephemeral-create)**: `POST /sessions` against an ephemeral template MUST reject the request with 422 if `config` is missing or fails any validation step.
|
||||
- **INV-161-6 (model-allowlist-enforcement)**: `config.model`, when supplied, MUST be in `saga_allowed_models` at session-create time. When omitted, server resolves to `saga.default_model` (startup-validated to be in the allowlist).
|
||||
- **INV-161-6 (model-allowlist-enforcement)**: `config.model`, when supplied, MUST be in `echo_allowed_models` at session-create time. When omitted, server resolves to `echo.default_model` (startup-validated to be in the allowlist).
|
||||
- **INV-161-7 (full-prompt-in-audit)**: Session-create audit entries for ephemeral sessions include `tier: 2` and `ephemeral_config` (full JSON).
|
||||
- **INV-161-8 (cross-user-isolation)**: A Saga session created by user A is invisible to user B — `GET /sessions/{id}` returns 404.
|
||||
- **INV-161-8 (cross-user-isolation)**: An Echo session created by user A is invisible to user B — `GET /sessions/{id}` returns 404.
|
||||
- **INV-161-9 (foundational-rejects-config)**: `POST /sessions { agent_id: "<foundational>", config: {...} }` returns 422 with `error_code: "foundational_does_not_accept_config"`.
|
||||
- **INV-161-10 (capabilities-public-shape)**: `GET /capabilities` is callable by any authenticated key. The response has `ephemeral_templates` at top-level.
|
||||
- **INV-161-11 (template-kind-immutable-at-runtime)**: The `kind` field on a loaded `AgentContext` is set once at startup and never mutated.
|
||||
@@ -1673,16 +1735,16 @@ Ephemeral templates are a new agent kind that bypass persona, memory, tools, and
|
||||
|
||||
| code | HTTP | trigger |
|
||||
|---|---|---|
|
||||
| `ephemeral_requires_config` | 422 | saga session without `config:` |
|
||||
| `ephemeral_requires_config` | 422 | echo session without `config:` |
|
||||
| `foundational_does_not_accept_config` | 422 | foundational agent with `config:` |
|
||||
| `system_prompt_required` | 422 | `config.system_prompt` missing or null |
|
||||
| `system_prompt_empty` | 422 | `config.system_prompt` whitespace-only |
|
||||
| `system_prompt_too_large` | 422 | > 32768 bytes UTF-8 |
|
||||
| `model_not_allowed` | 422 | model not in `saga_allowed_models` |
|
||||
| `model_not_allowed` | 422 | model not in `echo_allowed_models` |
|
||||
|
||||
**New `AgentContext` fields:** `kind: str = "foundational"`, `saga_allowed_models: list | None`, `saga_default_model: str | None` — populated for ephemeral templates, `None` for foundational agents.
|
||||
**New `AgentContext` fields:** `kind: str = "foundational"`, `echo_allowed_models: list | None`, `echo_default_model: str | None` — populated for ephemeral templates, `None` for foundational agents.
|
||||
|
||||
**Startup failfast:** server refuses to start if `agents/saga/config.yaml` is missing/malformed OR `saga.default_model` is not in `saga_allowed_models`. Raises `ConfigurationError` before binding any port.
|
||||
**Startup failfast:** server refuses to start if `agents/echo/config.yaml` is missing/malformed OR `echo.default_model` is not in `echo_allowed_models`. Raises `ConfigurationError` before binding any port.
|
||||
|
||||
**Function-level contracts for issue #161** are documented in `docs/contracts/issues/161.contract.md`.
|
||||
|
||||
@@ -1704,7 +1766,7 @@ Bifrost allows consumers to expose tools to Worldtree agents. `POST /sessions` a
|
||||
|
||||
- **INV-160-1 (handshake-at-create)**: When `POST /sessions` carries `bifrost: {endpoint_url, ...}`, the handshake completes BEFORE the 201 response. No "create session, handshake later" path in v0.1. Verifiable via test: handshake-failing endpoint → 502; session not in store.
|
||||
- **INV-160-2 (one-connection-per-session)**: Each Bifrost-bound session owns exactly one MCP connection. Two sessions binding to the same `endpoint_url` open two independent connections. No pooling, no sharing.
|
||||
- **INV-160-3 (saga-incompatible)**: A session cannot be both ephemeral (Saga, `kind: "ephemeral"`) AND Bifrost-bound. Session-create rejects with 422 `ephemeral_does_not_accept_bifrost`. Verifiable: `POST /sessions { agent_id: "saga", config: {...}, bifrost: {...} }` → 422.
|
||||
- **INV-160-3 (echo-incompatible)**: A session cannot be both ephemeral (Echo, `kind: "ephemeral"`) AND Bifrost-bound. Session-create rejects with 422 `ephemeral_does_not_accept_bifrost`. Verifiable: `POST /sessions { agent_id: "echo", config: {...}, bifrost: {...} }` → 422.
|
||||
- **INV-160-4 (jwt-bound-to-session-expiry)**: JWT TTL is bound to session expiry — far-future `expires_at` for sessions without a fixed TTL. Re-mint happens only when a re-handshake fires (connection-loss recovery). No standalone JWT-staleness check.
|
||||
- **INV-160-5 (reentrancy-25-per-turn)**: At most 25 successful Bifrost tool invocations per agent turn. The 26th returns `bifrost.reentrancy_cap_exceeded` without contacting the consumer. Counter resets per turn via `BifrostClient.reset_turn_counter()`. Enforced inside `BifrostClient.invoke_tool`.
|
||||
- **INV-160-6 (tool-list-cached-per-session)**: Bifrost tools are fetched once at handshake and cached on `ConversationSession.bifrost_tools`. Per-turn dispatch reads from the cache; never re-fetches mid-session except on connection-loss recovery.
|
||||
@@ -1751,7 +1813,7 @@ class BifrostEndpointOverride(BaseModel):
|
||||
|
||||
1. HTTPS URL check — Pydantic field validator; 422 on miss.
|
||||
2. `bifrost:invoke` scope check — same as session-bound path; 403 on miss.
|
||||
3. Ephemeral session rejection — 422 `ephemeral_does_not_accept_bifrost` when session is Saga (extends INV-160-3).
|
||||
3. Ephemeral session rejection — 422 `ephemeral_does_not_accept_bifrost` when session is Echo (extends INV-160-3).
|
||||
4. Heimdall consumer lookup — 502 `bifrost_consumer_not_found` on miss or unregistered.
|
||||
5. Instantiate a new `BifrostClient` with the override consumer's algorithm + key; set `_jwt_ttl_seconds = 60`.
|
||||
6. `await override_client.connect()` — 502 `bifrost_handshake_failed` on failure.
|
||||
@@ -1789,7 +1851,7 @@ In the `finally` block, `await override_client.disconnect()` is called unconditi
|
||||
|
||||
The conversation API grows a three-tier agent model. Tier 1 is the
|
||||
foundational set (Mimir, Bragi, Leif, ...) wired at startup. Tier 2 is
|
||||
the ephemeral template surface (Saga). Tier 3 is the consumer-defined
|
||||
the ephemeral template surface (Echo). Tier 3 is the consumer-defined
|
||||
class addressed by `<user_id>:<agent_name>` and stored in Heimdall's
|
||||
SQLite `consumer_agents` table.
|
||||
|
||||
@@ -1813,9 +1875,13 @@ SQLite `consumer_agents` table.
|
||||
- **INV-181-5 (agent-name-immutable, Phase 2.0 scope)**: PATCH rejects
|
||||
any payload that includes `agent_name`, returning 422
|
||||
`field_not_mutable` BEFORE the DB lookup.
|
||||
- **INV-181-6 (layer-immutable-in-patch, Phase 2.0 scope)**: PATCH
|
||||
rejects payloads carrying any of `persona`, `motivational`,
|
||||
`valence`, `memory` even when set to `null`.
|
||||
- **INV-181-6 (layer-immutable-in-patch, Phase 2.0 scope; AMENDED #188)**:
|
||||
PATCH rejects payloads carrying any of `persona`, `motivational`,
|
||||
`memory` even when set to `null`, returning `field_not_mutable`.
|
||||
**Amended by #188 (Phase 2.3):** `valence` was moved out of this
|
||||
`field_not_mutable` set — it now returns `layer_deferred` (see
|
||||
INV-188-1), because valence is a not-yet-shipped layer, not a frozen
|
||||
trait. `memory` is rejected wholesale (see INV-188-2).
|
||||
- **INV-181-7 (owner-delete-hard, Phase 2.0 scope)**: `DELETE
|
||||
/agents/<id>` is a hard-delete; bypasses the 24h grace.
|
||||
- **INV-181-8 (cascade-key-scoped, Phase 2.0 scope)**: Key revocation
|
||||
@@ -1881,6 +1947,79 @@ SQLite `consumer_agents` table.
|
||||
through `_publish`, so SSE resume / replay handles them with no
|
||||
special case.
|
||||
|
||||
## Amendment — Suspended-tier license-state gate (issue #174, INV-174-1..9)
|
||||
|
||||
Adds a `suspended` tier with empty scope set to drive license-expiry
|
||||
transitions without destroying user state. Endpoint
|
||||
`POST /admin/users/{user_id}/tier` mutates the tier; the
|
||||
`_http_exception_handler` rewrites `AUTH_SCOPE_DENIED` →
|
||||
`USER_SUSPENDED` for any 403 raised against a non-anonymous caller with
|
||||
an empty scope-set (the suspended-tier defining property). Ships in
|
||||
v0.29.1.
|
||||
|
||||
- **INV-174-1 (closed tier vocabulary)**: `POST /admin/users/{user_id}/tier`
|
||||
validates `body.tier` against the hard-coded set `{anonymous, user, free,
|
||||
pro, admin, suspended}`. Out-of-set values return 422 `invalid_tier`.
|
||||
Vocabulary is NOT derived from `policies.yaml` at runtime — a typo in
|
||||
YAML must not silently expand the accepted set.
|
||||
|
||||
- **INV-174-2 (admin-only mutation)**: endpoint requires
|
||||
`admin.users.write.tier_change` scope. Listed explicitly in admin
|
||||
tier's scope set in `policies.yaml` for grep-discoverability (admin
|
||||
also carries `*` umbrella).
|
||||
|
||||
- **INV-174-3 (tier mutation primitive)**:
|
||||
`UserStore.update_user_tier(user_id, new_tier) -> User` is the storage
|
||||
primitive. Raises `LookupError` for unknown user_id (endpoint converts
|
||||
to 404 `user_not_found`).
|
||||
|
||||
- **INV-174-4 (suspended scope-set is exactly empty)**:
|
||||
`policies.yaml.tiers["suspended"].scopes == []`. The empty set is what
|
||||
makes the auth-denial work for free; the
|
||||
`_http_exception_handler` rewrite uses
|
||||
`ctx.user_id != "anonymous" and not ctx.scopes` as the
|
||||
suspended-detection heuristic since `SecurityContext` deliberately
|
||||
excludes `tier` (per `core/integration/types.py:64`).
|
||||
|
||||
- **INV-174-5 (uniform suspended error code via exception handler)**:
|
||||
The `_http_exception_handler` (registered for `StarletteHTTPException`)
|
||||
intercepts every 403 with `error_code: auth_scope_denied`; if the
|
||||
request's stashed `SecurityContext` has an empty scope-set (and
|
||||
non-anonymous user_id), it rewrites the detail to
|
||||
`{error_code: "user_suspended", message: "Account is suspended."}`.
|
||||
Single seam — covers every existing and future scope-deny site
|
||||
without per-endpoint refactor. The ctx is stashed by
|
||||
`get_security_context` on `request.state.security_context`.
|
||||
|
||||
- **INV-174-6 (/me carve-out)**: `/me` does NOT call `authorize()` and
|
||||
therefore never raises `AUTH_SCOPE_DENIED`. Suspended users with
|
||||
empty scopes reach the /me handler normally and see
|
||||
`{user_id, tier: "suspended", scopes: [], ...}`. Adding a scope check
|
||||
to /me without preserving the suspended-tier visibility would be a
|
||||
contract violation — the carve-out is structural, not coded.
|
||||
|
||||
- **INV-174-7 (audit emission)**: every tier-change attempt emits
|
||||
`conversation_api:admin:user:tier_changed` via `_audit_admin_action`
|
||||
with `actor_user_id`, `target_user_id`, `outcome ∈
|
||||
{success, denied}`, and `extra = {from_tier, to_tier, reason}` for
|
||||
successes; `extra = {reason: <reason_code>}` for denials
|
||||
(`invalid_tier`, `user_not_found`).
|
||||
|
||||
- **INV-174-8 (reversibility via audit replay)**: the user record does
|
||||
NOT carry a `previous_tier` column. Restoration of a suspended user
|
||||
requires reading the audit log to find the most recent
|
||||
`tier_changed` event with `to_tier="suspended"` and replaying its
|
||||
`from_tier` as the new target. Operational responsibility of SEA's
|
||||
billing integration; Worldtree provides only the read (audit log) and
|
||||
write (endpoint) surfaces.
|
||||
|
||||
- **INV-174-9 (no cross-tier session invalidation)**: a tier change for
|
||||
a user with active SSE turns in flight does NOT cancel those turns.
|
||||
The next request after the tier change picks up the new scope-set;
|
||||
in-flight streams complete under the old tier. If SEA needs
|
||||
immediate-cutoff semantics, that requires `disable_user`-style
|
||||
hard-revoke, not a tier change.
|
||||
|
||||
## Amendment — AwaitingLLMFirstToken heartbeat (issue #201, INV-201-1..7)
|
||||
|
||||
Adds a periodic SSE heartbeat event during the gap between
|
||||
@@ -2027,3 +2166,160 @@ Lofn introduces zero net-new persistence surface. No table, no
|
||||
column, no Mimir KB collection. No new audit-event types. Existing
|
||||
session-create / session-revoke audit covers Lofn the same way it
|
||||
covers Mimir / Forseti.
|
||||
|
||||
## Amendment — Tier 3 motivational layer (issue #187, Phase 2.2)
|
||||
|
||||
Activates the `motivational` layer field on `POST /agents/define`, narrowing the
|
||||
Phase 2.0 `layer_deferred` rejection (INV-181-3) to `valence` only. Full FN-level
|
||||
spec at `docs/contracts/issues/187.contract.md`.
|
||||
|
||||
- **INV-187-1 (motivational-activated)**: `POST /agents/define` accepts a non-null
|
||||
`motivational` object `{goals, fears}`; `_tier3_validate_layer_fields` rejects
|
||||
only `valence` now. (Persona + memory were activated in Phase 2.1 / #189.)
|
||||
- **INV-187-2 (define-validation)**: `validate_motivational_define_payload` enforces
|
||||
the documented 422 codes — `motivational_id_collision` (case-sensitive, across
|
||||
goals AND fears), `motivational_goal_invalid_type`,
|
||||
`motivational_salience_out_of_range`, `motivational_description_too_short`
|
||||
(< 20 chars after strip), `motivational_missing_required_field`. Unknown top-level
|
||||
OR nested (per goal/fear) keys → `validation_failed` (sub-models extra-forbid).
|
||||
Stricter than the Tier 1 `validate_motivation` (which only warns on short text).
|
||||
- **INV-187-3 (per-agent-scope)**: motivational is per-agent, NOT
|
||||
per-(agent, end_user) — stored once on the row, identical across all end-users.
|
||||
- **INV-187-4 (immutable-in-patch)**: `PATCH` with `motivational` → 422
|
||||
`field_not_mutable` (already covered by INV-181-6's `_IMMUTABLE_FIELDS` gate).
|
||||
- **INV-187-5 (tier3-render-bridge)**: Tier 3 agents are NOT registered with the
|
||||
`persona_registry`; the stored config rides on the per-session `AgentContext`
|
||||
(`motivational_config`) and is rendered into the prompt per-turn in `stream_turn`
|
||||
via `_append_motivational_context_section`, before the memory-context section.
|
||||
- **INV-187-6 (fear-signal-shape)**: fears carry `trigger_signals`; goals carry
|
||||
`positive_signals` + `negative_signals` (matches the `GoalConfig`/`FearConfig`
|
||||
substrate).
|
||||
- **INV-187-7 (tier-uniformity)**: the render reuses `core.persona.goals.load_goals`
|
||||
+ `render_motivational_context`, so a Tier 3 motivational config produces a
|
||||
byte-identical block to an equivalent Tier 1 `motivation.yaml`.
|
||||
- **INV-187-8 (storage)**: persisted in `consumer_agents.tier3_layers_json` under
|
||||
the `"motivational"` key; round-trips via `ConsumerAgent.motivational`; null/omitted
|
||||
→ `None` (no fabricated defaults; no migration).
|
||||
|
||||
### Audit
|
||||
|
||||
`agents.define` audit `extra` gains `presence_motivational: bool` alongside
|
||||
`presence_persona` / `presence_memory`.
|
||||
|
||||
## Amendment — Tier 3 PATCH mutability policy (issue #188, Phase 2.3)
|
||||
|
||||
Settles which Tier 3 agent fields are editable post-define. #197 deleted the
|
||||
STM tier between this issue's filing (2026-05-19) and its implementation, so the
|
||||
"mutable memory dials" the original issue envisioned no longer exist; the policy
|
||||
collapses to: `system_prompt` + `model` mutable, everything else fixed, with
|
||||
`valence` distinguished from the immutable traits by error code. No new
|
||||
endpoint, no new storage, no new invariant philosophy — a clarification +
|
||||
error-code alignment + audit enrichment over the Phase 2.0 PATCH baseline.
|
||||
|
||||
- **INV-188-1 (valence-deferred-in-patch)**: `PATCH /agents/<id>` carrying a
|
||||
`valence` key (any value, including `null`) → 422 `layer_deferred` with
|
||||
`field: "valence"`, matching define-time (INV-181-3). Rationale: valence is
|
||||
a layer that does not exist yet, not a real-but-frozen trait; `layer_deferred`
|
||||
is the truthful reason and gives consumers ONE code for "valence unavailable"
|
||||
across both define and PATCH. The check precedes the DB lookup (INV-181-5/6
|
||||
ordering), so a `valence` PATCH against a missing agent still 422s, not 404s.
|
||||
- **INV-188-2 (memory-wholesale-immutable-in-patch)**: `PATCH` carrying a
|
||||
`memory` key → 422 `field_not_mutable` with `field: "memory"`, rejected at the
|
||||
WHOLE-field level. No sub-field carve-out exists: `stm_capacity` /
|
||||
`stm_token_budget` are deprecated no-ops post-#197, `allows_world_scope` is
|
||||
create-time-only (toggling it after memory is written breaks scope-visibility
|
||||
invariants — memory scope policy must be fixed before any memory is written),
|
||||
and `embedder_version` is library-pinned. A real LTM tuning dial would warrant
|
||||
a deliberate per-sub-field PATCH contract at that time; pre-splitting for dead
|
||||
fields is not done. NOTE the deliberate define/PATCH asymmetry: `define`
|
||||
accept-and-ignores deprecated `stm_*` (201 + DeprecationWarning per
|
||||
INV-197-19), but `PATCH memory:{...}` rejects wholesale (422). Acceptable
|
||||
transitional artifact; disappears when the shims are removed.
|
||||
- **INV-188-3 (patch-audit-before-after)**: a successful `agents.patch` audit
|
||||
event's `extra.changes` records before/after for each mutated field —
|
||||
`model: {before, after}` (literal values; allowlist enum, not PII) and
|
||||
`system_prompt: {before_bytes, after_bytes}` (byte-length only; raw prompt
|
||||
content is excluded as potential PII, consistent with `emit_consumer_agent_event`'s
|
||||
exclusion rule). `changes` contains only keys for fields actually present in
|
||||
the PATCH payload. `patched_fields` (the Phase 2.0 name list) is retained.
|
||||
- **INV-188-4 (mutable-surface-unchanged)**: the mutable surface stays exactly
|
||||
`system_prompt` + `model` (per INV-181 Phase 2.0). PATCH re-enforces the
|
||||
define-time `system_prompt` byte-cap and `model` allowlist. #188 does NOT add
|
||||
model-swap capability/context-window validation — that gap (a swap to a
|
||||
smaller-context or non-tool model with no re-check of the existing prompt) is
|
||||
tracked as a separate follow-up (#219), not folded here.
|
||||
|
||||
## Amendment — model-assignment advisory warnings (issue #219)
|
||||
|
||||
`POST /agents/define` and `PATCH /agents/<id>` attach a best-effort, **non-
|
||||
blocking** `warnings` array to their 2xx response when the assigned `model`
|
||||
carries metadata risk (smaller context window, unknown window, or an explicit
|
||||
capability downgrade). This is advisory-only by deliberate design: hard
|
||||
rejection was rejected (Heid panel + operator, 2026-05-29) because model
|
||||
metadata coverage is partial (`context_window` is 0/unknown for several
|
||||
allowlisted models; `supports_tools` defaults true), the stored `system_prompt`
|
||||
cap is bytes not tokens, Tier 3 agent rows store no tool/modality usage (tools
|
||||
arrive per-session via Bifrost, so any capability concern is inherently
|
||||
conditional), and runtime already classifies the real failure as
|
||||
`CONTEXT_OVERFLOW`. The warning is a receipt-note for the owner who just made a
|
||||
deliberate change, not a correctness gate.
|
||||
|
||||
- **INV-219-1 (advisory-not-blocking)**: neither define nor PATCH ever rejects
|
||||
on context-window or capability grounds. The allowlist check
|
||||
(`model_not_available`) and `system_prompt` byte-cap are the only model-
|
||||
related *rejections*; everything in #219 is a warning on an otherwise-2xx
|
||||
response. Correctness for over-budget prompts remains the runtime
|
||||
`CONTEXT_OVERFLOW` guard.
|
||||
- **INV-219-2 (bounded-warning-codes)**: the closed code set is exactly —
|
||||
`model_context_window_unknown` (severity `info`): the assigned model's
|
||||
registry `context_window` is `0`/absent; `model_context_window_smaller`
|
||||
(severity `warning`): prior and new model both have known windows and
|
||||
new < prior (`details: {before, after}`); `model_capability_downgrade`
|
||||
(severity `warning`): the new model EXPLICITLY drops a capability the prior
|
||||
model advertised — `supports_tools`, `vision`, or `audio` (`details:
|
||||
{dropped: [...]}`). No token-aware "prompt won't fit" code — deferred until
|
||||
tokenizer-aware estimation exists; messages never claim a hard fit/failure.
|
||||
- **INV-219-3 (when-evaluated, resulting-pair)**: warnings are computed
|
||||
whenever a model is *assigned*. At define, always (prior = None → only
|
||||
`model_context_window_unknown` can apply, since the comparative codes need a
|
||||
prior). At PATCH, only when the payload carries a `model` key whose value
|
||||
differs from the stored model (prior = stored model); a PATCH without `model`
|
||||
(e.g. `system_prompt`-only) emits no model warnings. The comparison is always
|
||||
against the *resulting* model.
|
||||
- **INV-219-4 (capability-downgrade)**: a `model_capability_downgrade` fires
|
||||
only when BOTH prior and new models resolve to registry `ModelInfo` AND the
|
||||
new model's *effective* capability flags lack one the prior advertised
|
||||
(`supports_tools`, `vision`, or `audio`). The "both resolve" guard is the
|
||||
false-positive defense — an unresolvable model on either side yields no
|
||||
downgrade claim. Beyond that, comparison uses the registry's **effective**
|
||||
flags, which is asymmetric by capability because the data model collapses
|
||||
absent-to-default and does not preserve a "was this declared?" bit:
|
||||
- `supports_tools` defaults **true** (`ModelInfo` / `_build_model_info`), so
|
||||
a tools-drop requires the new catalog entry to set `supports_tools: false`
|
||||
*explicitly* — omission never triggers it.
|
||||
- `vision` / `audio` default **false** (`ModelCapabilities`), so a drop is
|
||||
detected whenever the prior advertised the capability and the new model does
|
||||
not carry it — whether the new entry says `false` explicitly OR omits it.
|
||||
This is the deliberate conservative reading: an undeclared modality is
|
||||
treated as unsupported. (A vision-capable model with sloppy metadata that
|
||||
omits its `vision` flag would thus be reported as a downgrade; the remedy is
|
||||
to declare the flag in the catalog, not to suppress the advisory.)
|
||||
|
||||
Message phrasing is conditional ("if your sessions rely on these, e.g. Bifrost
|
||||
tools, they may be rejected") — the agent row does not record whether tools or
|
||||
modalities are actually used, so every capability warning is advisory by
|
||||
nature.
|
||||
- **INV-219-5 (inline-response-shape)**: the `warnings` array is added inline to
|
||||
the define (201) and PATCH (200) response bodies — the existing flat
|
||||
`ConsumerAgentResponse` dict gains a `warnings` key (always present, `[]` when
|
||||
none). It is NOT added to the shared `ConsumerAgentResponse` pydantic model
|
||||
nor to `GET /agents/<id>` — only the two mutation handlers merge it into their
|
||||
returned dict, keeping persisted fields and the read path unchanged. Each
|
||||
entry is `{code, severity, message, details}`.
|
||||
- **INV-219-6 (single-helper)**: a single pure helper
|
||||
`compute_model_swap_warnings(*, prior_model: str | None, new_model: str,
|
||||
registry)` is the only source of warning logic; both define and PATCH call
|
||||
it. It tolerates unresolvable specs / `None` `ModelInfo` / `context_window`
|
||||
`0` by treating them as "unknown" (emitting the unknown-window info code where
|
||||
applicable, never raising). Metadata improvements over time sharpen the
|
||||
warnings with no API or signature change.
|
||||
|
||||
+120
-115
@@ -1,15 +1,16 @@
|
||||
# Graph Report - . (2026-06-10)
|
||||
# Graph Report - ratatoskr (2026-06-15)
|
||||
|
||||
## Corpus Check
|
||||
- cluster-only mode — file stats not available
|
||||
- 61 files · ~157,407 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 1974 nodes · 4551 edges · 132 communities (128 shown, 4 thin omitted)
|
||||
- Extraction: 66% EXTRACTED · 34% INFERRED · 0% AMBIGUOUS · INFERRED: 1560 edges (avg confidence: 0.51)
|
||||
- 2045 nodes · 4675 edges · 152 communities (124 shown, 28 thin omitted)
|
||||
- Extraction: 66% EXTRACTED · 34% INFERRED · 0% AMBIGUOUS · INFERRED: 1582 edges (avg confidence: 0.52)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `5b9a3f4c`
|
||||
- Built from commit: `335c8358`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
@@ -144,6 +145,26 @@
|
||||
- [[_COMMUNITY_No Worldtree Imports Test|No Worldtree Imports Test]]
|
||||
- [[_COMMUNITY_Ratatoskr Worldtree API TUI|Ratatoskr Worldtree API TUI]]
|
||||
- [[_COMMUNITY_Ratatoskr Web Browser Debug Companion|Ratatoskr Web Browser Debug Companion]]
|
||||
- [[_COMMUNITY_Community 132|Community 132]]
|
||||
- [[_COMMUNITY_Community 133|Community 133]]
|
||||
- [[_COMMUNITY_Community 134|Community 134]]
|
||||
- [[_COMMUNITY_Community 135|Community 135]]
|
||||
- [[_COMMUNITY_Community 136|Community 136]]
|
||||
- [[_COMMUNITY_Community 137|Community 137]]
|
||||
- [[_COMMUNITY_Community 138|Community 138]]
|
||||
- [[_COMMUNITY_Community 139|Community 139]]
|
||||
- [[_COMMUNITY_Community 140|Community 140]]
|
||||
- [[_COMMUNITY_Community 141|Community 141]]
|
||||
- [[_COMMUNITY_Community 142|Community 142]]
|
||||
- [[_COMMUNITY_Community 143|Community 143]]
|
||||
- [[_COMMUNITY_Community 144|Community 144]]
|
||||
- [[_COMMUNITY_Community 145|Community 145]]
|
||||
- [[_COMMUNITY_Community 146|Community 146]]
|
||||
- [[_COMMUNITY_Community 147|Community 147]]
|
||||
- [[_COMMUNITY_Community 148|Community 148]]
|
||||
- [[_COMMUNITY_Community 149|Community 149]]
|
||||
- [[_COMMUNITY_Community 150|Community 150]]
|
||||
- [[_COMMUNITY_Community 151|Community 151]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `TuiPresenterState` - 84 edges
|
||||
@@ -158,89 +179,89 @@
|
||||
10. `ToolStart` - 67 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `_GatedStream` --uses--> `UsageError` [INFERRED]
|
||||
tests/test_cli.py → src/ratatoskr/cli.py
|
||||
- `CaptureFixture` --uses--> `UsageError` [INFERRED]
|
||||
tests/test_cli.py → src/ratatoskr/cli.py
|
||||
- `Event` --uses--> `UsageError` [INFERRED]
|
||||
tests/test_cli.py → src/ratatoskr/cli.py
|
||||
- `MonkeyPatch` --uses--> `UsageError` [INFERRED]
|
||||
tests/test_cli.py → src/ratatoskr/cli.py
|
||||
- `TestAmain` --uses--> `UsageError` [INFERRED]
|
||||
tests/test_cli.py → src/ratatoskr/cli.py
|
||||
- `test_build_app_from_env_builds_app_with_routes()` --calls--> `build_app_from_env()` [INFERRED]
|
||||
tests/test_provider_serve.py → src/ratatoskr/provider/serve.py
|
||||
- `test_build_app_from_env_requires_heimdall_key()` --calls--> `build_app_from_env()` [INFERRED]
|
||||
tests/test_provider_serve.py → src/ratatoskr/provider/serve.py
|
||||
- `test_get_absent_returns_none()` --calls--> `open_affect_store()` [INFERRED]
|
||||
tests/test_provider_affect.py → src/ratatoskr/provider/affect_store.py
|
||||
- `test_open_advertises_capability_and_schema()` --calls--> `open_affect_store()` [INFERRED]
|
||||
tests/test_provider_affect.py → src/ratatoskr/provider/affect_store.py
|
||||
- `test_reopen_existing_file_is_idempotent()` --calls--> `open_affect_store()` [INFERRED]
|
||||
tests/test_provider_affect.py → src/ratatoskr/provider/affect_store.py
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Communities (132 total, 4 thin omitted)
|
||||
## Communities (152 total, 28 thin omitted)
|
||||
|
||||
### Community 0 - "TuiPresenterState Management"
|
||||
Cohesion: 0.05
|
||||
Nodes (39): Per-turn presenter state for TUI mode (issue #12). See `docs/contracts/issu, TuiPresenterState, _make_tui_done(), _mounted_renderables(), current_invokes_callback_with_snapshot: AffectUpdate(current, snapshot), scheduled_does_not_invoke_callback: status=scheduled has no snapshot, so, callback_exception_swallowed: a raising callback does NOT crash the pres, Tests for the new TuiPresenterState — per issue #12 contract. (+31 more)
|
||||
Nodes (40): Per-turn presenter state for TUI mode (issue #12). See `docs/contracts/issu, TuiPresenterState, _make_tui_done(), _mounted_renderables(), current_invokes_callback_with_snapshot: AffectUpdate(current, snapshot), scheduled_does_not_invoke_callback: status=scheduled has no snapshot, so, callback_exception_swallowed: a raising callback does NOT crash the pres, done_label_styled_success [v0.9.0]: [done] label mounts as Static carryi (+32 more)
|
||||
|
||||
### Community 1 - "EventSource SSE Consumer"
|
||||
Cohesion: 0.06
|
||||
Nodes (49): EventSource, AffectUpdate, AwaitingLlmFirstToken, cancel_turn(), CancelResult, InvalidLastEventId, _iter_events(), SSE consumer for the Worldtree Conversation API. Implements docs/contracts/issu (+41 more)
|
||||
Cohesion: 0.08
|
||||
Nodes (14): Sync entry point — delegates to the async resolve-then-run flow. Per issue, run_tui(), happy_new_session_resolve [happy]: --new path through _resolve_then_run., happy_new_with_end_user_id_resolve [happy]: args.end_user_id threads into POST b, user_agent_header_sent [trace]: outbound requests carry the ratatoskr User-Agent, alt_screen_never_opens_on_resolve_error [trace]: 404 → run_tui=12; run_async unh, session_api_failed_on_resolve [error]: --new + 500 → [session_api_failed] stderr, network_error_on_resolve [error]: --new + ConnectError → [network_error] stderr. (+6 more)
|
||||
|
||||
### Community 2 - "Parsed CLI Arguments Handling"
|
||||
Cohesion: 0.14
|
||||
Nodes (53): ParsedArgs, Raised on argument violations; mapped to exit code 10 by main()., Resolved CLI invocation. Post-validation: exactly one of session_id / new is set, UsageError, AgentInfo, Worldtree agent envelope from GET /agents (issue #8). INV-005: required fie, Cancelled, Done (+45 more)
|
||||
Nodes (73): NamedTuple, ParsedArgs, Resolved CLI invocation. Post-validation: exactly one of session_id / new is set, AgentInfo, Worldtree agent envelope from GET /agents (issue #8). INV-005: required fie, AffectUpdate, AwaitingLlmFirstToken, Cancelled (+65 more)
|
||||
|
||||
### Community 3 - "Agent Information Management"
|
||||
Cohesion: 0.19
|
||||
Nodes (55): AgentInfo, ComposeResult, Exception, FileResponse, JSONResponse, _ArgparseError, _AuthError, Raised when no API key is resolvable; mapped to exit code 11 by main(). (+47 more)
|
||||
Cohesion: 0.08
|
||||
Nodes (86): AgentInfo, ComposeResult, EventSource, Exception, FileResponse, _ArgparseError, _AuthError, Raised when no API key is resolvable; mapped to exit code 11 by main(). (+78 more)
|
||||
|
||||
### Community 4 - "TUI Tests and Contract Verification"
|
||||
Cohesion: 0.08
|
||||
Nodes (36): _args_existing(), _noop_worker(), MonkeyPatch, Tests for ratatoskr.tui per docs/contracts/issues/4.contract.md., Fake _stream_turn_worker that never completes (lets state stay 'streaming')., happy_submit_echoes_and_spawns [happy,tracer]: …, empty_submit_no_op [trace]: '' + Enter → no change; no worker spawned., submit_during_streaming_shows_busy_notice [adversarial]: … (+28 more)
|
||||
Cohesion: 0.13
|
||||
Nodes (17): _args_existing(), Type content into the input and submit; wait for worker to finish., happy_text_done_no_double_print [happy,tracer, v0.9.0]: Text("hello") mo, raw_flag_skips_markdown_render [trace, v0.9.0]: With --raw, the response, error_terminal_returns_to_idle [happy]: …, cancelled_terminal_returns_to_idle [happy]: …, active_turn_id_set_on_first_event [trace]: …, connection_dropped_returns_to_idle [error]: … (+9 more)
|
||||
|
||||
### Community 5 - "CLI Arguments Parsing Contract"
|
||||
Cohesion: 0.06
|
||||
Nodes (26): _parse_args(), argparse + env-fallback + xor-validation per the contract., happy_existing_session: --send --session --api-key → ParsedArgs with session_id., api_key_from_env: WORLDTREE_API_KEY env var fills in when --api-key omitted., api_key_flag_beats_env: explicit --api-key wins over WORLDTREE_API_KEY., server_default: no --server, no WORLDTREE_API_URL → http://localhost:8000., server_env_fallback: WORLDTREE_API_URL fills in when --server omitted., server_flag_beats_env: explicit --server wins over WORLDTREE_API_URL. (+18 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (26): main(), _parse_args(), argparse + env-fallback + xor-validation per the contract., Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop, happy_existing_session: --send --session --api-key → ParsedArgs with session_id., api_key_from_env: WORLDTREE_API_KEY env var fills in when --api-key omitted., server_default: no --server, no WORLDTREE_API_URL → http://localhost:8000., server_env_fallback: WORLDTREE_API_URL fills in when --server omitted. (+18 more)
|
||||
|
||||
### Community 6 - "Sync Entry Point and Session Resolution"
|
||||
Cohesion: 0.07
|
||||
Nodes (28): Sync entry point — delegates to the async resolve-then-run flow. Per issue, run_tui(), _args_new_no_agent(), CaptureFixture, Tests at the `_resolve_then_run` layer — pre-`App.run()` session resolution, happy_new_session_resolve [happy]: --new path through _resolve_then_run., happy_new_with_end_user_id_resolve [happy]: args.end_user_id threads into POST b, user_agent_header_sent [trace]: outbound requests carry the ratatoskr User-Agent (+20 more)
|
||||
Cohesion: 0.17
|
||||
Nodes (7): POST a message and yield typed Events. See contract FN stream_turn., stream_turn(), session_not_found: 404 -> SseConnectFailed(status=404)., malformed_id_no_seq: id `42` (missing :seq) -> MalformedSseId; no event yielded., malformed_id_alpha: id `foo:bar` -> MalformedSseId., INV-001: empty stream (no events at all) -> SseConnectionDropped(None)., ERROR_ROUTING: SseConnectFailed.body is truncated to <= 1024 bytes.
|
||||
|
||||
### Community 7 - "Stream Turn Rendering and Cancellation"
|
||||
Cohesion: 0.10
|
||||
Nodes (25): Drive stream_turn, render events, race against sigint_event for mid-stream cance, _run_turn(), _GatedStream, Event, no_busy_loop_after_cancel [trace]: only one sigint_event.wait()-task created., cancel_failed_drains_anyway [scenario]: …, render_called_once_per_event [trace]: spy on render; call_count == event count., sigint_handler_installed_and_removed [trace]: signal handler add/remove paired. (+17 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (53): _amain(), Drive stream_turn, render events, race against sigint_event for mid-stream cance, Async orchestrator: create-session (if --new) → SIGINT install → _run_turn → cle, Raised on argument violations; mapped to exit code 10 by main()., _run_turn(), UsageError, SSE event `text_boundary`: speakable breakpoint after a `text` event., TextBoundary (+45 more)
|
||||
|
||||
### Community 8 - "Worldtree Session Client"
|
||||
Cohesion: 0.06
|
||||
Nodes (26): InvalidCursor, list_sessions(), Worldtree Conversation API session-lifecycle client. Implements docs/contracts/, GET /sessions. See contract FN list_sessions., Worldtree session envelope; shared shape for create + list responses. INV-0, One page of GET /sessions results. `next_cursor=None` on the last page., Raised on HTTP 422 cursor_invalid from GET /sessions., SessionInfo (+18 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (47): AgentNotAvailable, AuthScopeDenied, get_persona_state(), InvalidCursor, list_sessions(), PersonaNotConfigured, Worldtree Conversation API session-lifecycle client. Implements docs/contracts/, Raised on HTTP 404 `agent_not_available` from GET persona_state. The agent_ (+39 more)
|
||||
|
||||
### Community 9 - "Tier 3 Agent Lifecycle Client"
|
||||
Cohesion: 0.07
|
||||
Nodes (32): _build_parser(), define_agent(), _extract_error_code(), _extract_error_field(), main(), _parse_tier3_agent_info(), patch_agent(), Worldtree Tier 3 (consumer-defined) agent lifecycle client. Implements docs/con (+24 more)
|
||||
Cohesion: 0.08
|
||||
Nodes (19): _args_new(), Construct RatatoskrApp with pre-resolved state (issue #6 lifecycle). Produc, happy_new_session_mount [happy,tracer]: identity populated from pre-resolved sta, footer_identity_visible_first_frame [trace]: identity widget rendered first fram, main_row_is_horizontal [tracer]: compose() yields Horizontal#main-row., left_column_content_only [v0.9.0]: left column = transcript-scroll Verti, right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab., tools_log_inside_tools_tab: tools-transcript RichLog is a descendant of tools-ta (+11 more)
|
||||
|
||||
### Community 10 - "CLI Presenter State Management"
|
||||
Cohesion: 0.09
|
||||
Nodes (23): CliPresenterState, Per-turn presenter state for `--send` mode (issue #12). See `docs/contracts, SSE event `text_boundary`: speakable breakpoint after a `text` event., TextBoundary, _make_done(), Tests for the new CliPresenterState — per issue #12 contract., thinking_coalesce_single_run [happy,tracer]: Thinking("hello") + Thinkin, thinking_closes_on_first_non_thinking_event [happy]: Thinking → WorkerPh (+15 more)
|
||||
Cohesion: 0.11
|
||||
Nodes (36): Any, Connection, build_affect_provider_app(), _ctx_actor(), open_affect_store(), RatatoskrAffectStore, SQLite-backed, conduit-opaque affect store (Bifrost affect plane, v1). Contract, Read-back of the stored snapshot (tests / future rehydrate-seed). (+28 more)
|
||||
|
||||
### Community 11 - "Ratatoskr Application Argument Handling"
|
||||
Cohesion: 0.09
|
||||
Nodes (23): _args_new(), Construct RatatoskrApp with pre-resolved state (issue #6 lifecycle). Produc, happy_new_session_mount [happy,tracer]: identity populated from pre-resolved sta, happy_existing_session_mount: identity shows <unknown> when agent_id is None., footer_identity_visible_first_frame [trace]: identity widget rendered first fram, INV-013 + INV-014 + INV-017: Horizontal two-column layout with Tools tab., main_row_is_horizontal [tracer]: compose() yields Horizontal#main-row., left_column_content_only [v0.9.0]: left column = transcript-scroll Verti (+15 more)
|
||||
Cohesion: 0.07
|
||||
Nodes (26): CliPresenterState, _format_duration_ms(), Auto-scale duration formatting per issue #12 INV-006. Locale-blind., Per-turn presenter state for `--send` mode (issue #12). See `docs/contracts, Render one Worldtree SSE event with editorial hierarchy + coalescing., _make_done(), Tests for the new CliPresenterState — per issue #12 contract., thinking_coalesce_single_run [happy,tracer]: Thinking("hello") + Thinkin (+18 more)
|
||||
|
||||
### Community 12 - "Stream Turn Event Processing"
|
||||
Cohesion: 0.07
|
||||
Nodes (21): POST a message and yield typed Events. See contract FN stream_turn., stream_turn(), heartbeat_sequence_monotonic [scenario]: three consecutive heartbeats in, happy_one_text_done: text then done; same turn_id; iter ends after done., full_event_vocab: one of each variant; all carry parsed sse_id., error_terminal: text then error; iteration ends; error_code populated., cancelled_terminal: cancelled with phase=cancelled, turn_id; iteration ends., session_not_found: 404 -> SseConnectFailed(status=404). (+13 more)
|
||||
Cohesion: 0.14
|
||||
Nodes (8): happy_one_text_done: text then done; same turn_id; iter ends after done., error_terminal: text then error; iteration ends; error_code populated., connection_drop: RemoteProtocolError after one text -> SseConnectionDropped((42,, no_text_aggregation: consumer yields each text event separately; no concat., Compose one SSE event in wire format. Trailing blank line per spec., current_status_parsed_with_snapshot [tracer]: status=current carries the, scheduled_status_parsed_no_snapshot [trace]: status=scheduled carries no, _sse_chunk()
|
||||
|
||||
### Community 13 - "Local Tier 3 Agent Index Management"
|
||||
Cohesion: 0.14
|
||||
Nodes (22): LocalAgentEntry, add_local_agent(), load_local_agents(), LocalAgentEntry, Local index of tier-3 agents defined via `python -m ratatoskr.tier3`. Workaroun, Add (or replace) an agent in the local index. agent_id is the key., Update an existing entry. Identical semantics to ``add_local_agent`` (agent_, Remove an entry by agent_id. No-op if absent (idempotent). (+14 more)
|
||||
Cohesion: 0.07
|
||||
Nodes (43): LocalAgentEntry, Namespace, add_local_agent(), load_local_agents(), _local_agents_path(), LocalAgentEntry, make_description(), Local index of tier-3 agents defined via `python -m ratatoskr.tier3`. Workaroun (+35 more)
|
||||
|
||||
### Community 14 - "Ratatoskr Application Core"
|
||||
Cohesion: 0.09
|
||||
Nodes (19): RatatoskrApp, Populate identity widget from pre-resolved state; set idle hint. Per is, Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state., Update sticky header + Persona pane from a fresh snapshot. Called on bo, Render an italic-dim placeholder in the Persona pane; keep header empty., v0.6.0: turn-ID headers across every pane for cross-pane correlation. v0, Set the hint state attribute AND update the visible Static widget., Write a timestamped audit line to the debug pane. v0.10.0: shared sink (+11 more)
|
||||
Cohesion: 0.07
|
||||
Nodes (19): _cancel_via_sse(), Populate identity widget from pre-resolved state; set idle hint. Per is, Hydrate persona-header + Persona pane via GET /agents/{id}/persona_state., Render an italic-dim placeholder in the Persona pane; keep header empty., v0.6.0: turn-ID headers across every pane for cross-pane correlation. v0, Set the hint state attribute AND update the visible Static widget., Write a timestamped audit line to the debug pane. v0.10.0: shared sink, Set self.state with debug-pane audit log. Every state machine transitio (+11 more)
|
||||
|
||||
### Community 15 - "Async Main Orchestrator"
|
||||
Cohesion: 0.08
|
||||
Nodes (21): _amain(), Async orchestrator: create-session (if --new) → SIGINT install → _run_turn → cle, _clear_env(), CaptureFixture, MonkeyPatch, user_agent_header_sent [trace]: outbound requests carry the ratatoskr User-Agent, happy_new_session_then_stream [happy,tracer]: …, happy_existing_session: --session, no create POST; just SSE stream → exit 0. (+13 more)
|
||||
Cohesion: 0.17
|
||||
Nodes (7): happy_submit_echoes_and_spawns [happy,tracer]: …, empty_submit_no_op [trace]: '' + Enter → no change; no worker spawned., submit_during_streaming_shows_busy_notice [adversarial]: …, submit_during_cancelling_shows_busy_notice [adversarial]: …, sse_connect_failed_returns_to_idle [error]: …, Patch RichLog.write AND VerticalScroll.mount to record every renderable or m, _spy_writes()
|
||||
|
||||
### Community 16 - "Contract Parsing and Function Extraction"
|
||||
Cohesion: 0.11
|
||||
Nodes (31): Contract, ErrorSpec, _extract_function_blocks(), FunctionBlock, main(), _parse_body_sections(), parse_contract(), _parse_frontmatter() (+23 more)
|
||||
Cohesion: 0.10
|
||||
Nodes (34): Contract, ErrorSpec, _extract_function_blocks(), FunctionBlock, _is_issue_scoped(), main(), _parse_body_sections(), parse_contract() (+26 more)
|
||||
|
||||
### Community 17 - "Behavioral Guidelines Documentation"
|
||||
Cohesion: 0.06
|
||||
@@ -251,8 +272,8 @@ Cohesion: 0.07
|
||||
Nodes (16): _parse_sse_id(), Parse the SSE wire `id:` as composite `{turn_id}:{seq}`. See contract FN _parse_, negative_seq [adversarial]: '42:-1' -> ValueError., trailing_whitespace [adversarial]: '42:3 ' -> ValueError (strict; no strip)., truncation [security]: 5000-char no-colon -> ValueError msg contains only raw[:6, PRE-001 hard: raw is a string -- isinstance check before parse., happy_simple [happy,tracer]: '42:3' -> SseId(turn_id=42, seq=3)., happy_seq_one: smallest valid id per spec — first event of first turn. (+8 more)
|
||||
|
||||
### Community 19 - "Tier 3 Error Handling"
|
||||
Cohesion: 0.11
|
||||
Nodes (19): Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached on the He, Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id is not slu, Raised on HTTP 422 ``layer_deferred`` — define request carried a non-null la, Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id (spec §2634, Tier3AgentNotFound, Tier3LayerDeferred, Tier3QuotaExceeded, Tier3UserIdUnsupported (+11 more)
|
||||
Cohesion: 0.25
|
||||
Nodes (5): _args_new_no_agent(), ParsedArgs for bare --new (no --agent) — TUI-mode picker entry., picker_happy_path [happy,tracer]: agents fetched → picker exits with id → create, picker_esc_clean_exit: picker returns None → exit 0; no create_session; no main, list_agents 500 → stderr [session_api_failed]; exit 20; picker NOT opened.
|
||||
|
||||
### Community 20 - "Web Packaging and CLI Argument Tests"
|
||||
Cohesion: 0.07
|
||||
@@ -262,45 +283,33 @@ Nodes (23): ArgumentParser, Packaging + lazy-import discipline tests for ratatos
|
||||
Cohesion: 0.07
|
||||
Nodes (26): Admin inspection endpoints, Appendix: `agent.ui_hints` config block, Authentication, Base URL, Client Implementation Guide, Custom exception handler status-code mapping, Endpoints, Error Codes (+18 more)
|
||||
|
||||
### Community 22 - "CLI Command Rendering and Usage"
|
||||
Cohesion: 0.12
|
||||
Nodes (13): _format_duration_ms(), _format_usage(), main(), Ratatoskr CLI — non-interactive `--send` stdout presenter. Implements docs/cont, Auto-scale duration formatting per issue #12 INV-006. Locale-blind., Natural-language usage formatting per issue #12 INV-007. `arrow="->"` for C, Render one Worldtree SSE event with editorial hierarchy + coalescing., Sync entry point. Maps UsageError/_AuthError to exit codes BEFORE the event loop (+5 more)
|
||||
|
||||
### Community 23 - "TUI Shell Implementation"
|
||||
Cohesion: 0.09
|
||||
Nodes (18): _audit_line(), _format_persona_detail(), _format_persona_header(), _plain_label(), Ratatoskr Textual TUI shell — interactive primary presenter. Implements docs/co, Pre-flight session resolution then App.run_async() inside one event loop. E, Pre-amendment labeled-line shape for INV-009 render-exception fallback. Use, HH:MM:SS.fff wall-clock timestamp for debug-pane log lines. (+10 more)
|
||||
Cohesion: 0.15
|
||||
Nodes (12): _format_usage(), Natural-language usage formatting per issue #12 INV-007. `arrow="->"` for C, _audit_line(), _plain_label(), Ratatoskr Textual TUI shell — interactive primary presenter. Implements docs/co, Pre-flight session resolution then App.run_async() inside one event loop. E, Pre-amendment labeled-line shape for INV-009 render-exception fallback. Use, HH:MM:SS.fff wall-clock timestamp for debug-pane log lines. (+4 more)
|
||||
|
||||
### Community 24 - "Contract Amendments for Presenter States"
|
||||
Cohesion: 0.09
|
||||
Nodes (22): Acceptance, `_amain` STEPS amended, Architecture, `CLASS CliPresenterState` (NEW), `CLASS TuiPresenterState` (NEW), Constraints, Context, Data flow (+14 more)
|
||||
|
||||
### Community 25 - "Session Creation API"
|
||||
Cohesion: 0.12
|
||||
Nodes (13): create_session(), POST /sessions to create a new session. See contract FN create_session. Per, validation_failed: 422 -> SessionApiFailed(status=422); body truncated., unexpected_status_truncates: 500 + 5000-byte body -> SessionApiFailed; body == 1, empty_agent_id [adversarial]: '' -> AssertionError; no HTTP issued., happy_create_with_end_user_id [happy]: body carries both keys (issue #5)., default_omits_end_user_id [trace]: omit kwarg → body has no end_user_id (INV-002, empty_end_user_id [adversarial]: '' → AssertionError before HTTP (PRE-003). (+5 more)
|
||||
Cohesion: 0.05
|
||||
Nodes (22): create_session(), list_agents(), POST /sessions to create a new session. See contract FN create_session. Per, GET /agents — list available agents. See contract FN list_agents (issue #8)., AsyncClient, validation_failed: 422 -> SessionApiFailed(status=422); body truncated., unexpected_status_truncates: 500 + 5000-byte body -> SessionApiFailed; body == 1, empty_agent_id [adversarial]: '' -> AssertionError; no HTTP issued. (+14 more)
|
||||
|
||||
### Community 26 - "Web Server Endpoint Handling"
|
||||
Cohesion: 0.10
|
||||
Nodes (21): _agents_endpoint(), _as_dict(), _create_session_endpoint(), _format_sse(), _persona_state_endpoint(), Starlette app factory + endpoint handlers for ratatoskr.web. Per docs/contracts, POST /api/sessions → upstream POST /sessions. Per FN create_session_endpoint., POST /api/turns/{session_id} → allocate turn_id + register handle. Per FN s (+13 more)
|
||||
Cohesion: 0.09
|
||||
Nodes (33): JSONResponse, Request, v0.16.0 — cancel paths must target the UPSTREAM turn_id, not the browser-loc, A registered handle whose local turn_id (1) differs from its captured up, A handle with upstream_turn_id still None (turn never opened the upstrea, stream_turn_endpoint INV-005 — browser disconnect mid-stream triggers upstre, TestDisconnectCancel, TestUpstreamTurnIdCancel (+25 more)
|
||||
|
||||
### Community 27 - "SSE ID Parsing"
|
||||
Cohesion: 0.20
|
||||
Nodes (20): NamedTuple, Parsed composite SSE wire `id:` per spec §SSE id format., SseId, _check(), _load_fixture(), Drift-detection between TUI presentation discipline and web JS presenter per iss, Assert (event_type, data) for `event` matches the fixture entry., test_affect_update_matches_fixture() (+12 more)
|
||||
|
||||
### Community 28 - "Persona State Retrieval"
|
||||
Cohesion: 0.13
|
||||
Nodes (13): get_persona_state(), GET /agents/{agent_id}/persona_state — fetch current persona snapshot. Worl, Any, Worldtree #204 / v0.28.0 — GET /agents/{agent_id}/persona_state. Bootstrap, happy_full_snapshot [happy,tracer]: 200 → snapshot dict with pad + domin, persona_not_configured_404 [error]: 404 with error_code persona_not_conf, agent_not_available_404 [error]: 404 with error_code agent_not_available, auth_scope_denied_403 [error]: 403 with error_code auth_scope_denied → A (+5 more)
|
||||
Cohesion: 0.19
|
||||
Nodes (17): _check(), _load_fixture(), Drift-detection between TUI presentation discipline and web JS presenter per iss, Assert (event_type, data) for `event` matches the fixture entry., test_affect_update_matches_fixture(), test_awaiting_llm_first_token_matches_fixture(), test_cancelled_matches_fixture(), test_done_matches_fixture() (+9 more)
|
||||
|
||||
### Community 29 - "Agent Deletion and Authentication"
|
||||
Cohesion: 0.15
|
||||
Nodes (16): Namespace, delete_agent(), DELETE /agents/<id> — owner hard-delete (cancels active sessions server-side, No API key resolvable → exit 11., Resolve API key + server URL with the same env-var fallback as cli.py., _resolve_auth(), _run_define(), _run_delete() (+8 more)
|
||||
|
||||
### Community 30 - "Agent Listing Client"
|
||||
Cohesion: 0.15
|
||||
Nodes (11): list_agents(), GET /agents — list available agents. See contract FN list_agents (issue #8)., AsyncClient, happy_full_shape [happy,tracer]: spec full-shape mimir example → all fields., happy_minimum_shape: required-only agent → optional fields default., happy_multi_agent: 3 agents preserve order., happy_empty: 200 with [] returns empty list (no error)., omit_capabilities_empty: explicit [] from server still defaults to []. (+3 more)
|
||||
Cohesion: 0.50
|
||||
Nodes (3): lifespan_shutdown FN — INV-006: drain turn_registry within 5s budget., happy [tracer]: 2 in-flight turns + shutdown → upstream cancels called., TestLifespanShutdown
|
||||
|
||||
### Community 31 - "Browser SSE Stream Parsing"
|
||||
Cohesion: 0.17
|
||||
Nodes (12): _parse_browser_sse(), Response, Parse a server-to-browser SSE stream into [{"event": str, "data": dict}, ...]., happy [tracer]: respx mock one text+done → SSE stream yields text + done events., The stream generator captures upstream turn_id from the first event's ss, stream_turn_endpoint full_event_vocab — one of each Event type proxied., full_event_vocab [scenario]: a stream with the non-terminal Event types, error terminal [scenario]: an SSE `error` event (distinct from the synth (+4 more)
|
||||
Cohesion: 0.14
|
||||
Nodes (16): _parse_browser_sse(), AsyncByteStream, Response, Tests for ratatoskr.web.server per docs/contracts/issues/16.contract.md. The Te, SSE response backed by a live AsyncByteStream (for gated/hanging streams in, Parse a server-to-browser SSE stream into [{"event": str, "data": dict}, ...]., happy [tracer]: respx mock one text+done → SSE stream yields text + done events., The stream generator captures upstream turn_id from the first event's ss (+8 more)
|
||||
|
||||
### Community 32 - "Canonical Sync Documentation"
|
||||
Cohesion: 0.12
|
||||
@@ -323,16 +332,16 @@ Cohesion: 0.12
|
||||
Nodes (16): arousal, dominance, pleasure, snapshot, arousal_delta, valence_delta, arousal, dominance (+8 more)
|
||||
|
||||
### Community 37 - "Web Server Functional Tests"
|
||||
Cohesion: 0.13
|
||||
Nodes (12): AsyncByteStream, Tests for ratatoskr.web.server per docs/contracts/issues/16.contract.md. The Te, SSE response backed by a live AsyncByteStream (for gated/hanging streams in, root_endpoint FN + /static mount — index.html + static asset serving., happy [tracer]: GET / → 200, content-type text/html, body contains '<html'., lifespan_shutdown FN — INV-006: drain turn_registry within 5s budget., happy [tracer]: 2 in-flight turns + shutdown → upstream cancels called., stream_turn_endpoint INV-005 — browser disconnect mid-stream triggers upstre (+4 more)
|
||||
Cohesion: 0.50
|
||||
Nodes (3): root_endpoint FN + /static mount — index.html + static asset serving., happy [tracer]: GET / → 200, content-type text/html, body contains '<html'., TestStaticServing
|
||||
|
||||
### Community 38 - "Upload Management and Capabilities"
|
||||
Cohesion: 0.13
|
||||
Nodes (15): Agent capability: `accepts_uploads`, Attaching uploads to messages, Auth scopes, Capability vocabulary, DELETE /uploads/{upload_id}, Dispatch channels, Endpoints, Example JavaScript client (upload-then-reference) (+7 more)
|
||||
|
||||
### Community 39 - "Tier 3 Agent Patching Tests"
|
||||
Cohesion: 0.13
|
||||
Nodes (8): Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md., happy_patch_both_fields: both fields set → request body has both., happy_patch_single_field: omit model → body has system_prompt only., field_not_mutable [error]: 422 + error_code → Tier3FieldNotMutable(field)., 404 [error]: PATCH on non-existent agent → Tier3AgentNotFound., no_fields_assert [adversarial]: both None → AssertionError, no HTTP., non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError., TestPatchAgent
|
||||
Cohesion: 0.22
|
||||
Nodes (8): Concurrency, Constraints, Context, Data flow, Division of labor (library vs store), Integration points, Invariants, Out of scope (deferred — do NOT flag as drift)
|
||||
|
||||
### Community 40 - "Agent Documentation and Attribution"
|
||||
Cohesion: 0.14
|
||||
@@ -346,10 +355,6 @@ Nodes (13): CLI surface (`python -m ratatoskr.tier3`), Context, ERROR_ROUTING (m
|
||||
Cohesion: 0.14
|
||||
Nodes (13): CLI surface change (ratatoskr.cli amendment), Context, Data flow, ERROR_ROUTING (tui startup), FN list_agents, Functions, Invariants, Modified: _resolve_then_run (+5 more)
|
||||
|
||||
### Community 43 - "Turn Cancellation via SSE"
|
||||
Cohesion: 0.14
|
||||
Nodes (8): _cancel_via_sse(), Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009)., audit_callback_records_failure [v0.10.0]: failure path emits `cancel_pos, happy_cancel [happy,tracer]: 200 OK → returns None; transcript has no [cancel_fa, cancel_failed_500 [error]: …, cancel_already_completed [scenario]: 409 → '[cancel_failed] CancelAlreadyComplet, transport_error_swallowed [error]: …, audit_callback_records_lifecycle [v0.10.0]: when the caller passes an `a
|
||||
|
||||
### Community 44 - "Web Server Contract Version 16"
|
||||
Cohesion: 0.15
|
||||
Nodes (12): Console script, Constraints, Context, Function blocks, Invariants, Module shape, Public functions, Public surface (+4 more)
|
||||
@@ -383,8 +388,8 @@ Cohesion: 0.17
|
||||
Nodes (11): Acceptance, Constraints, Context, Data flow, In-place amendments (the work), Invariants, Issue #1 (`ratatoskr.sse_client`) amendments, Issue #3 (`ratatoskr.cli`) amendments (+3 more)
|
||||
|
||||
### Community 52 - "Turn Cancellation and Logging"
|
||||
Cohesion: 0.17
|
||||
Nodes (7): _cancel_and_log(), Spawn-and-forget cancel that never raises (INV-009)., happy_cancel [happy,tracer]: 200 OK → returns None; stderr empty., cancel_failed_500 [error]: …, cancel_already_completed [scenario]: …, cancel_turn_not_found [scenario]: 404 → returns None; stderr CancelTurnNotFound., transport_error_swallowed [error]: …
|
||||
Cohesion: 0.20
|
||||
Nodes (6): _cancel_and_log(), Ratatoskr CLI — non-interactive `--send` stdout presenter. Implements docs/cont, Spawn-and-forget cancel that never raises (INV-009)., cancel_already_completed [scenario]: …, cancel_turn_not_found [scenario]: 404 → returns None; stderr CancelTurnNotFound., transport_error_swallowed [error]: …
|
||||
|
||||
### Community 53 - "Mock Client Factory for Persona State"
|
||||
Cohesion: 0.21
|
||||
@@ -403,8 +408,8 @@ Cohesion: 0.18
|
||||
Nodes (11): BM25 ranking, Endpoint, Error codes, Example: curl, Example: JavaScript pagination loop, FTS5 query syntax, Legacy `created_at` caveat, Query parameters (+3 more)
|
||||
|
||||
### Community 57 - "Presentation Contract JSON"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): affect_update, event_type, _contract_version, _provenance, thinking, event_type
|
||||
Cohesion: 0.22
|
||||
Nodes (8): affect_update, data, event_type, _contract_version, status, _provenance, text, event_type
|
||||
|
||||
### Community 59 - "Monkey Patching for Local Agents"
|
||||
Cohesion: 0.18
|
||||
@@ -434,10 +439,6 @@ Nodes (9): 1. Vor (optional), 2. Contract (required), 3. Branch — direct or AF
|
||||
Cohesion: 0.20
|
||||
Nodes (10): model, response, usage, done, data, event_type, cached_input_tokens, completion_tokens (+2 more)
|
||||
|
||||
### Community 66 - "Local Agents Path Resolution"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): _local_agents_path(), Resolve the local index file path with XDG + env-var override., Path, local_path(), MonkeyPatch, Point $RATATOSKR_LOCAL_AGENTS at a fresh tmp file for the test., TestPathResolution
|
||||
|
||||
### Community 67 - "Project README Overview"
|
||||
Cohesion: 0.20
|
||||
Nodes (9): Boundary rule, Consumer-side discoveries, Quickstart, Ratatoskr, Read in this order, Related repos, Status, Version-skew strategy (+1 more)
|
||||
@@ -455,12 +456,12 @@ Cohesion: 0.22
|
||||
Nodes (8): Bump procedure, Conformance smoke check, Current pin, History, Pin history, Vendored artifacts, Why pin?, Worldtree spec pin
|
||||
|
||||
### Community 71 - "Turn Status and Timing Data"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): data, awaiting_llm_first_token, data, event_type, elapsed_ms_since_building_prompt, status, turn_id
|
||||
Cohesion: 0.15
|
||||
Nodes (14): awaiting_llm_first_token, data, event_type, cancelled, data, event_type, elapsed_ms_since_building_prompt, partial_message_id (+6 more)
|
||||
|
||||
### Community 72 - "Error Code and Worker Phase Handling"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): error_code, message, phase, error, data, event_type, worker_phase, data (+1 more)
|
||||
Cohesion: 0.40
|
||||
Nodes (5): error_code, message, error, data, event_type
|
||||
|
||||
### Community 73 - "CLI and TUI Contract Amendments"
|
||||
Cohesion: 0.22
|
||||
@@ -474,10 +475,6 @@ Nodes (8): Admin: API Key Management, Bootstrap: first admin key, DELETE /admin/
|
||||
Cohesion: 0.25
|
||||
Nodes (7): Architecture, CLI — Non-interactive --send stdout presenter, Constraints, Context, Data flow, Invariants, Out of scope
|
||||
|
||||
### Community 76 - "Description Synthesis for Picker"
|
||||
Cohesion: 0.39
|
||||
Nodes (3): make_description(), Synthesize a one-line description for the picker from a system prompt. Stra, TestMakeDescription
|
||||
|
||||
### Community 77 - "Canonical Sync Pinning Utility"
|
||||
Cohesion: 0.36
|
||||
Nodes (7): main(), SHA-256 hash, first 16 hex chars., Replace the quoted value in a `key = "value"` line, preserving leading white, Surgically update one pin's `pinned_sha256_16` + `pinned_at` lines in the ma, _replace_value_preserve_format(), sha256_16(), update_pin_in_manifest_text()
|
||||
@@ -507,8 +504,8 @@ Cohesion: 0.29
|
||||
Nodes (7): 1. Frontmatter, `complexity` guide, `dependencies:` — dispatch-ordering metadata (Sleipnir / preflight), Dependency fields — `depends_on` vs `dependencies`, `depends_on:` — module-architecture metadata, `prd` block — pinning a contract to its source-of-truth, Why two fields
|
||||
|
||||
### Community 84 - "User Agent Versioning"
|
||||
Cohesion: 0.29
|
||||
Nodes (5): Compose the User-Agent header — `ratatoskr/<version> (<contact>)`. Per worl, _resolve_user_agent(), version_endpoint FN — tracer per contract issue #16., happy [tracer]: GET /version → 200, body == {"ratatoskr": "<current-version>"}., TestVersionEndpoint
|
||||
Cohesion: 0.50
|
||||
Nodes (3): version_endpoint FN — tracer per contract issue #16., happy [tracer]: GET /version → 200, body == {"ratatoskr": "<current-version>"}., TestVersionEndpoint
|
||||
|
||||
### Community 85 - "Architecture Overview"
|
||||
Cohesion: 0.29
|
||||
@@ -590,10 +587,6 @@ Nodes (4): SSE frame with id but empty data (server-emitted keepalive shape)., e
|
||||
Cohesion: 0.33
|
||||
Nodes (4): stream_turn_endpoint FN — open upstream SSE, proxy events to browser., unknown_turn [error]: GET with turn_id not in registry → 404., upstream_error [error]: respx 500 → synthetic error SSE event., TestStreamTurnEndpoint
|
||||
|
||||
### Community 105 - "Upstream Turn ID Cancellation"
|
||||
Cohesion: 0.33
|
||||
Nodes (4): v0.16.0 — cancel paths must target the UPSTREAM turn_id, not the browser-loc, A registered handle whose local turn_id (1) differs from its captured up, A handle with upstream_turn_id still None (turn never opened the upstrea, TestUpstreamTurnIdCancel
|
||||
|
||||
### Community 106 - "Triadic Block Routing"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): 2.1.A — `ERROR_ROUTING:` triadic block (SHIELDA), Example, Syntax, v2.0 back-compat, Why three axes
|
||||
@@ -608,11 +601,11 @@ Nodes (5): Authorization model — agent invocation, Common pitfalls, Quick deci
|
||||
|
||||
### Community 109 - "SSE Content Data"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): content, sse_id, text, data, event_type, data
|
||||
Nodes (6): content, sse_id, data, thinking, data, event_type
|
||||
|
||||
### Community 110 - "Cancelled Message Handling"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): cancelled, data, event_type, partial_message_id, reason
|
||||
Cohesion: 0.22
|
||||
Nodes (8): Concurrency, Constraints, Context, Data flow, Division of labor (library vs store), Integration points, Invariants, Out of scope (deferred — do NOT flag as drift)
|
||||
|
||||
### Community 111 - "Implicit Tool Call Narration"
|
||||
Cohesion: 0.50
|
||||
@@ -678,20 +671,32 @@ Nodes (3): Amendment — SSE phase events (issue #151, INV-053..INV-061), Integr
|
||||
Cohesion: 0.67
|
||||
Nodes (3): Amendment — Tier 3 consumer-defined agents (issue #181, Phase 2.0), Invariants (Phase 2.0 scope), Persona-state observability (issue #204)
|
||||
|
||||
### Community 133 - "Community 133"
|
||||
Cohesion: 0.14
|
||||
Nodes (9): _format_persona_detail(), _format_persona_header(), Update sticky header + Persona pane from a fresh snapshot. Called on bo, One-line persona summary for the sticky header widget. Shape: `agent_id · d, Multi-line persona detail for the Persona TabPane. Renders the full v0.28.0, header_compact_summary: agent_id · dominant_emotion · pad(P,A,D) · N emotions., header_singular_emotion: single emotion → '1 emotion active' (no 's')., header_no_emotions_drops_count: empty emotions list → no count suffix. (+1 more)
|
||||
|
||||
### Community 135 - "Community 135"
|
||||
Cohesion: 0.28
|
||||
Nodes (7): build_app_from_env(), main(), Runnable entrypoint: serve the affect provider as an ASGI app. For the live neg, Build the affect ASGI app from environment config (testable seam)., Tests for the affect-provider serve entrypoint (ratatoskr.provider.serve). Only, test_build_app_from_env_builds_app_with_routes(), test_build_app_from_env_requires_heimdall_key()
|
||||
|
||||
### Community 143 - "Community 143"
|
||||
Cohesion: 0.04
|
||||
Nodes (66): Raised on unexpected response status from /sessions endpoints. `body` is tr, SessionApiFailed, define_agent(), delete_agent(), _extract_error_code(), _extract_error_field(), _parse_tier3_agent_info(), patch_agent() (+58 more)
|
||||
|
||||
## Knowledge Gaps
|
||||
- **522 isolated node(s):** `allow`, `Any`, `Path`, `Any`, `Any` (+517 more)
|
||||
- **538 isolated node(s):** `Context`, `Data flow`, `Invariants`, `Out of scope`, `Constraints` (+533 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **4 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
- **28 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `LocalAgentEntry` connect `Local Tier 3 Agent Index Management` to `TuiPresenterState Management`, `Parsed CLI Arguments Handling`, `TUI Tests and Contract Verification`, `Sync Entry Point and Session Resolution`, `Tier 3 Agent Lifecycle Client`, `Ratatoskr Application Argument Handling`, `Tier 3 Error Handling`, `Agent Deletion and Authentication`, `Browser SSE Stream Parsing`, `Web Server Functional Tests`, `Tier 3 Agent Patching Tests`, `Mock Client Factory for Persona State`, `Turn Cancellation Endpoint`, `Monkey Patching for Local Agents`, `Local Agents Path Resolution`, `Description Synthesis for Picker`, `Session Creation Endpoint Tests`, `Turn Submission Endpoint Tests`, `Server-Side End User ID Handling`, `Application Creation and Routing`, `User Agent Versioning`, `Stream Turn Endpoint Tests`, `Upstream Turn ID Cancellation`?**
|
||||
_High betweenness centrality (0.055) - this node is a cross-community bridge._
|
||||
- **Why does `SessionApiFailed` connect `Agent Information Management` to `TuiPresenterState Management`, `Parsed CLI Arguments Handling`, `Tier 3 Agent Patching Tests`, `Worldtree Session Client`, `Tier 3 Agent Lifecycle Client`, `CLI Presenter State Management`, `Ratatoskr Application Core`, `Tier 3 Error Handling`, `Session Creation API`, `Persona State Retrieval`, `Agent Deletion and Authentication`, `Agent Listing Client`?**
|
||||
_High betweenness centrality (0.054) - this node is a cross-community bridge._
|
||||
- **Why does `create_app()` connect `Turn Cancellation Endpoint` to `Agent Information Management`, `Web Server Functional Tests`, `Stream Turn Endpoint Tests`, `Upstream Turn ID Cancellation`, `Session Creation Endpoint Tests`, `Turn Submission Endpoint Tests`, `Server-Side End User ID Handling`, `Application Creation and Routing`, `User Agent Versioning`, `Mock Client Factory for Persona State`, `Web Packaging and CLI Argument Tests`, `Web Server Endpoint Handling`, `Monkey Patching for Local Agents`, `Browser SSE Stream Parsing`?**
|
||||
_High betweenness centrality (0.039) - this node is a cross-community bridge._
|
||||
- **Why does `LocalAgentEntry` connect `Local Tier 3 Agent Index Management` to `TuiPresenterState Management`, `Parsed CLI Arguments Handling`, `Web Server Functional Tests`, `Stream Turn Endpoint Tests`, `Community 143`, `Session Creation Endpoint Tests`, `Server-Side End User ID Handling`, `Application Creation and Routing`, `Turn Submission Endpoint Tests`, `User Agent Versioning`, `Mock Client Factory for Persona State`, `Turn Cancellation Endpoint`, `Web Server Endpoint Handling`, `Monkey Patching for Local Agents`, `Agent Deletion and Authentication`, `Browser SSE Stream Parsing`?**
|
||||
_High betweenness centrality (0.081) - this node is a cross-community bridge._
|
||||
- **Why does `SessionApiFailed` connect `Community 143` to `TuiPresenterState Management`, `Parsed CLI Arguments Handling`, `Agent Information Management`, `Stream Turn Rendering and Cancellation`, `Worldtree Session Client`, `Ratatoskr Application Argument Handling`, `Local Tier 3 Agent Index Management`, `Session Creation API`, `Web Server Endpoint Handling`?**
|
||||
_High betweenness centrality (0.044) - this node is a cross-community bridge._
|
||||
- **Why does `create_app()` connect `Turn Cancellation Endpoint` to `Agent Information Management`, `Web Server Functional Tests`, `Stream Turn Endpoint Tests`, `Session Creation Endpoint Tests`, `Turn Submission Endpoint Tests`, `Server-Side End User ID Handling`, `Application Creation and Routing`, `User Agent Versioning`, `Mock Client Factory for Persona State`, `Web Packaging and CLI Argument Tests`, `Web Server Endpoint Handling`, `Monkey Patching for Local Agents`, `Agent Deletion and Authentication`, `Browser SSE Stream Parsing`?**
|
||||
_High betweenness centrality (0.044) - this node is a cross-community bridge._
|
||||
- **Are the 51 inferred relationships involving `TuiPresenterState` (e.g. with `ParsedArgs` and `AgentInfo`) actually correct?**
|
||||
_`TuiPresenterState` has 51 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 75 inferred relationships involving `Done` (e.g. with `AgentInfo` and `ComposeResult`) actually correct?**
|
||||
|
||||
+189
-54
@@ -1,6 +1,6 @@
|
||||
# Persistent memory — ratatoskr
|
||||
|
||||
_Last updated: 2026-05-29_
|
||||
_Last updated: 2026-06-16_
|
||||
|
||||
This file captures durable intent and supporting evidence (goals, decisions,
|
||||
foot-gun warnings, in-flight state) across context resets. Read it at session
|
||||
@@ -41,67 +41,152 @@ model output is untrusted); upstream API key stays server-side (INV-003).
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-05-29 (post-v0.17.0 frontend redesign):_
|
||||
_As of 2026-06-18:_
|
||||
|
||||
**Status: v0.17.0 shipped and pushed** (main + tag `v0.17.0` on origin,
|
||||
2026-05-29). 378 tests passing.
|
||||
**LATEST (2026-06-18) — TIER-3 MEMORY PROVEN END-TO-END LIVE; #296 FIX SHIPPED (Stage 1 + Stage 2).**
|
||||
The session's opening goal — "how far from Tier-3 agents we host with their own memories?" — is
|
||||
ANSWERED: a hosted agent recalled a user fact across a COLD (history-free) session, live. Full chain:
|
||||
seed → promotion → our v0.17.6 `:8391` store (upsert) → cold session → WT `scope_any` recall (1 hit @
|
||||
cosine 0.6994) → model answered "Dark chocolate with sea salt." Proven with `ratatoskr:terse-probe`
|
||||
(plain persona, end_user `terse-proof`) vs personal WT v0.35.19.
|
||||
|
||||
**Last commits on `main`:**
|
||||
- `922ef34` feat(web): frontend redesign — aurora telemetry instrument + live Markdown (v0.17.0) [LOCAL ONLY]
|
||||
- `bbeaa23` docs: AGENTS.md — Codex-implementer session conventions
|
||||
- `f7ff5a4` fix(web): close Heid pass-2 findings — stream vocab + disconnect catch (v0.16.1)
|
||||
- `369857d` feat(web): address Heid code-review findings — issue #16 (v0.16.0)
|
||||
- `0fbbeb1` fix(sessions): unwrap FastAPI detail envelope in get_persona_state (v0.15.1)
|
||||
- `1228c37` feat(web): in-browser debug companion — issue #16 (v0.15.0)
|
||||
- `85143b8` fix(tui): disable RichLog min_width floor so wrap actually applies (v0.14.2)
|
||||
- `00854ce` fix(cli): wire AffectUpdate + AwaitingLlmFirstToken into --send presenter (v0.14.1)
|
||||
- `78bfcad` feat(sse,tui): bump spec pin to v0.29.0 + AwaitingLlmFirstToken (v0.14.0)
|
||||
- `4413859` feat(tui): persona surface — sticky header + TabPane (v0.13.0)
|
||||
- `d516537` feat(sessions): get_persona_state client + persona error taxonomy (v0.12.0)
|
||||
- `92aa05c` feat(sse,tui): bump spec pin to v0.28.0 + AffectUpdate event (v0.11.0)
|
||||
- `209427a` feat(tui): debug-pane audit logging surface (v0.10.0)
|
||||
- `139771c` feat(tui): live Markdown rendering during text streaming (v0.9.0)
|
||||
The whole #296 arc closed this session:
|
||||
- **Stage 1** (v0.35.19, recallability admission gate + real `_EXTRACTOR_SYSTEM`) VALIDATED LIVE for
|
||||
normal-volume turns — clean first-person distillate (`distiller_model_id="identity"`), recalls @0.70.
|
||||
- **Bisect verdict:** residual = verbose-persona VOLUME crowd-out (symptom 1). terse-probe promotes the
|
||||
fact cleanly; sindra's theatrical turn drowns it → v0.35.16 emits 2 meta-descriptions (a3466fab confirmed),
|
||||
v0.35.19 gate catches + hard-rejects (emit-then-reject, fail-closed).
|
||||
- **Stage 2** (v0.36.0, MERGED on demo) = user-only one-call-per-turn extraction, the STRUCTURAL fix for
|
||||
the verbose drop; hard-linguistic layer at #305. Live-validated eval fixture pair handed to worldtree-dev
|
||||
(terse-proof POSITIVE chunk + choc-v2/sindra NEGATIVE).
|
||||
|
||||
**Worldtree spec pin:** v0.29.0 (commit `562001a`, pinned 2026-05-26).
|
||||
**FULL COVERAGE PROVEN (the OPEN item, now DONE):** re-smoked the VERBOSE persona — `ratatoskr:sindra-probe`
|
||||
(sindra's actual theatrical prompt, end_user `verbose-v2`, FRESH agent to dodge the WT promotion-dedup) against
|
||||
`:8081` **v0.36.0**: the high-volume theatrical turn promoted the USER FACT cleanly (not a meta-description) and a
|
||||
COLD history-free session recalled it @ 0.694 — "Dark chocolate. With sea salt." So `:8081` is on v0.36.0 and
|
||||
verbose characters remember too. All session commits + tags PUSHED to origin (v0.17.6, spec repin v0.35.16,
|
||||
snapshots; tags v0.17.4/.5/.6).
|
||||
|
||||
**Personal Worldtree smoke target:** `http://10.250.50.152:8081` (corviduo-dev
|
||||
LAN, reachable via PFI VPN). Currently running ≥ v0.29.13 (carries Worldtree
|
||||
#204 persona-state + #201 awaiting-llm-first-token heartbeat + the
|
||||
GemmaProvider reasoning_content fix). Sindra Tier 3 agent
|
||||
(`ratatoskr:sindra`, model `artemis-31b-v1i`) live and chain-of-thought
|
||||
flowing end-to-end.
|
||||
**NOW (operator session):** `:8391` store WIPED clean; `ratatoskr-web` UP on `0.0.0.0:8765`
|
||||
(LAN `http://10.100.10.50:8765`, consumer key — owner-matches sindra, end_user `vuong`, `ratatoskr:sindra` in the
|
||||
picker). Sindra switched to the **`thoughtful-character` role → `mistral-small-4-reasoning`** (via DELETE+redefine;
|
||||
she chats in full persona on it). **"No persona" 404 diagnosed = Worldtree limitation, NOT ours:** `persona_state`
|
||||
hard-404s every Tier-3 (colon-id) agent by design (`api.py:1220` "Phase 2.0 has no Tier 3 persona", still on
|
||||
v0.36.0) — a persona block in the define does NOT help (404 fires before the row read). Pinged worldtree-dev re:
|
||||
the Tier-3 persona_state roadmap (thread `01KVCR6P…`). De-uglied our web persona pane (`index.html` `loadPersona`)
|
||||
to render a clear Tier-3 message instead of a bare "HTTP 404". **CAVEAT still stands: the web client does NOT bind
|
||||
to `:8391`/`:8390` (#17 unbuilt) → web chat = persona + debug only; no memory persistence, no affect telemetry.**
|
||||
|
||||
**Outstanding eyeball items:**
|
||||
- **v0.17.0 frontend redesign — operator-confirmed 2026-05-29** ("redesign
|
||||
looks good"); pushed to origin (main + tag `v0.17.0`). Eyeball gate cleared.
|
||||
- **Optional**: courtesy ack to brokkr-smithy-dev on the Codex-first pilot
|
||||
thread (thread `01KSTH3Y8JKKSY9S9P41X3CM77`).
|
||||
**NEXT (fresh context): #17 — bifrost-binding the chat client.** The single unblock for BOTH (a) memory persistence
|
||||
in the web/TUI/CLI chat (Sindra remembering in a real session, not just scripts) AND (b) feeding the persona pane
|
||||
from OUR `:8390` affect store — `affect.emit` PAD/valence, ours to render per ADR-0009 (NOT a WT persona_state to
|
||||
wait for; see the reframe in Recent decisions). Contract `docs/contracts/issues/17.contract.md` is WRITTEN +
|
||||
Heid-reviewed + drift-clean; **TDD is the next step** — slice 1 = the `create_session` bifrost-binding primitive
|
||||
(consumer-key per-request bearer + missing-key precondition + 502→`BifrostHandshakeFailed`, respx-mocked), then
|
||||
`endpoint_for_plane` → dispatch-layer op-feed → CLI/TUI/web triggers → live smoke. althing monitor armed.
|
||||
|
||||
**Issue tracker (cleaned up 2026-05-29):** closed the stale-but-shipped
|
||||
backlog — #1–7, #9, #12, #14, #15 (all implemented across the v0→v0.17.0 arc;
|
||||
#9 superseded — spec pin advanced to v0.29.0, seven minors past its v0.22.1
|
||||
target). Only two issues remain open, both deferred by design:
|
||||
- **#10 subject:{type,id} migration tracking** — Worldtree-side breaking
|
||||
change at future v0.22.x/v0.23.0; don't pre-implement per worldtree-dev.
|
||||
- **#11 AdminEvents pane** — needs `admin.events.read` Heimdall scope (admin
|
||||
tier). Future side-pane work.
|
||||
**(2026-06-16 PM) — BIFROST REPINNED 0.7.0→0.8.0 (wire v0.5→v0.6).** The
|
||||
memory `search` scope filter was split into `scope_all` (AND/intersection) +
|
||||
`scope_any` (OR/union over a LIST of conjunctive scopes) — bifrost #11, the canonical
|
||||
fix for the #295/#297 silent-zero AND foot-gun. Our store + contract (v1.2) + tests
|
||||
reimplemented to parity with the v0.6 reference `_matches_scope`/`_validate_scope`
|
||||
(no-compat: `scope_filter` REMOVED). 433 tests green incl. the new `scope_any` union
|
||||
test + the parity-vs-reference test through the real 0.8.0 `dispatch_memory_call`.
|
||||
Memory provider BOUNCED onto 0.8.0 (`:8391`, fresh empty `memory.db` — the prior
|
||||
5-chunk #296 corpus was WIPED, operator confirmed "nothing of value", SUPERSEDES the
|
||||
"KEEP PINNED" note below). Shipped as v0.17.6 (`96d61a4`, tag v0.17.6); the #17
|
||||
contract's stale `scope_filter`/`_scope_matches`-AND references were synced in the same
|
||||
commit. Affect plane untouched (split is memory-only); affect provider still on its
|
||||
0.7.0-loaded process (bounce optional — affect wire unchanged at 0.8.0). Cold-recall
|
||||
status SUPERSEDED by the 2026-06-17 block above (now CLOSED end-to-end).
|
||||
|
||||
**Codex-first discipline pilot — Ratatoskr selected** (althing thread
|
||||
`01KSTH3Y8JKKSY9S9P41X3CM77`, brokkr-smithy commit `5dd061c`, tag `v0.5.3`):
|
||||
- `AGENTS.md` committed (bbeaa23) — Codex-implementer conventions, sibling
|
||||
to `CLAUDE.md`. Doesn't change how Claude-Code sessions operate;
|
||||
governs the future `ratatoskr-codex` session.
|
||||
- `ratatoskr-codex` handle declared on althing.
|
||||
- `/codex-dispatch` skill pending galdrabok implementation.
|
||||
- **No action needed until operator spins up a codex session** in this same
|
||||
working tree; bootstrap handshake at that point per `codex-first-
|
||||
discipline.md` §5 (codex sends `codex-online` → ratatoskr-dev replies with
|
||||
active branches + WIP state).
|
||||
- Per-dispatch opt-in: default Sleipnir Claude-implementer path remains
|
||||
available; Codex used only when operator routes via `/codex-dispatch <N>`.
|
||||
**Ratatoskr now has a SECOND identity: the v1 Bifrost Tier-3 consumer** — the
|
||||
durable persistence provider Worldtree writes Tier-3 agent affect/persona +
|
||||
memory into — alongside the original debug-observability TUI/web. The
|
||||
Bifrost-consumer work lives in `src/ratatoskr/provider/` and depends on
|
||||
`bifrost>=0.7.0` (a `provider` optional-extra from the gitea PyPI index),
|
||||
SEPARATE from the Worldtree conversation-API spec pin.
|
||||
|
||||
Branch: `main` (clean post-v0.17.0). Remote:
|
||||
`origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
**AFFECT plane: SHIPPED + LIVE-PROVEN** (v0.17.2). Running now as a dev
|
||||
background shell (`ratatoskr-provider`, `0.0.0.0:8390`, env-sourced from
|
||||
`~/.config/ratatoskr/provider.env`). Smoked end-to-end against personal
|
||||
Worldtree **v0.35.2** (`10.250.50.152`): handshake 200 + `affect.emit` 200 →
|
||||
durable row persisted, opacity held.
|
||||
|
||||
**MEMORY plane: PROVIDER LIVE-PROVEN + recall-miss ROOT-CAUSED (upstream).**
|
||||
Store + dev shell shipped (v0.17.3, `cd12951`; running on `0.0.0.0:8391`).
|
||||
`memory.db` holds 5 durable chunks (all scope `{end_user:smoke-user}`): choc-fact
|
||||
`498ed752`, name `8241e569`, promoted-question `c863bb6b`, + 2 LATE async promotions
|
||||
from the cold-recall probe (probe-question `acc3d49`, model NON-ANSWER `4773704` —
|
||||
salience promoted a "I don't have memory" refusal). All are #296 corpus, KEEP PINNED.
|
||||
**The #295 cold-recall miss is now ROOT-CAUSED and it's UPSTREAM, not ours**
|
||||
(2026-06-16 debug-assist with worldtree-dev): a self-driven bound cold-recall
|
||||
probe captured the inbound pair via our new observe log — Worldtree's recall sends
|
||||
`scope_filter={end_user:smoke-user, agent_self:ratatoskr:smoke}` (TWO axes) but our
|
||||
chunks carry `{end_user}` ONLY; our AND `_scope_matches` (byte-faithful to bifrost
|
||||
reference `reference_server/memory.py:398`) drops everything on the unmatched
|
||||
`agent_self` axis → 0 hits → the model says "no memory". So **our store + search
|
||||
are SOUND**; the fix is Worldtree-side. F2 → research issue **#296** (keyword-regex
|
||||
salience suspected fundamentally flawed; corpus = `c863bb6b` + the 2 late-promotions).
|
||||
F1 → research issue **#297** (Worldtree-local fix = per-visible-scope single-axis
|
||||
search unioned client-side). **The agent_self lattice question is RESOLVED:**
|
||||
agent_self is now canonical in bifrost 0.7.0 / wire v0.5 (our foot-gun flag drove it;
|
||||
worldtree-dev shipped it both sides — Worldtree v0.35.11) → #297 union build UNBLOCKED.
|
||||
Our memory provider now runs **bifrost 0.7.0 + validates the 4-axis lattice**
|
||||
`{end_user,group,tenant,agent_self}` (v0.17.5; restarted on it; out-of-lattice axis →
|
||||
InvalidFilter, reference-parity restored).
|
||||
|
||||
**OBSERVE BRICK SHIPPED** (`memory_store.py`, committed v0.17.4 `2fef6e3`):
|
||||
structured `[memory-provider]` request/response logging on the memory-call path —
|
||||
the first concrete brick of #17's observe half, and the lens that caught #295's
|
||||
root cause. Live-verified. (NOTE: this is the debug SHIM at the STORE method; #17's
|
||||
real observe feed instruments the DISPATCH layer — see the contract INV-005.)
|
||||
|
||||
**SELF-DRIVE PROVEN BY HAND** (2026-06-16): ratatoskr's own client drove a
|
||||
Bifrost-bound cold-recall end-to-end (bind → handshake 200 → turn → captured the
|
||||
recall pair). Load-bearing finding: a bound session-create must use the CONSUMER
|
||||
Heimdall key (`RATATOSKR_HEIMDALL_KEY`) as the bearer, NOT the canary
|
||||
`WORLDTREE_API_KEY` — Worldtree signs the Bifrost handshake JWT with the
|
||||
session-create bearer (canary → 401; consumer → 200). Runbook:
|
||||
`docs/bifrost-self-test.md`. This is #17's substrate, proven before the contract.
|
||||
|
||||
**ISSUE #17 (self-drive + observe) — CONTRACT WRITTEN + HEID-REVIEWED + FIXED,
|
||||
TDD NEXT.** `docs/contracts/issues/17.contract.md` (validates OK, drift-clean).
|
||||
v1 scope operator-locked: single-plane bind (composite endpoint PARKED) +
|
||||
dispatch-layer op-feed with session-level correlation (turn-pane UI PARKED).
|
||||
`/heid-contract-review` panel caught + fixed two real internal inconsistencies
|
||||
(OpEvent `turn_id` reservation made literal; the `session_id`-for-all-verbs
|
||||
correction). NEXT: **TDD slice 1 = the `create_session` bind primitive** (BifrostBinding
|
||||
dataclass + consumer-key per-request bearer override + missing-key precondition +
|
||||
502→BifrostHandshakeFailed; respx-mocked), then endpoint_for_plane → dispatch-layer
|
||||
op-feed → CLI/TUI/web → live smoke.
|
||||
|
||||
**Sindra:** a REGISTERED Tier-3 agent (`ratatoskr:sindra`, was model
|
||||
`artemis-31b-v1i`) — registration is REQUIRED to use a Tier-3 character (a
|
||||
session against an unregistered `agent_id` 404s), and she's been used. OPEN:
|
||||
the v0.35.2 personal rebuild may have wiped the agent DB — re-verify via
|
||||
`GET /agents` (needs a WORLDTREE_API_KEY, broker via infra-ops) and re-register
|
||||
if gone. Her persona only persists durably once personal is bound to our
|
||||
RUNNING affect provider for HER sessions (the smoke used synthetic
|
||||
`ratatoskr:smoke`); that binding is the persona-carry gap, independent of
|
||||
registration.
|
||||
|
||||
**Heimdall key (Bifrost consumer):** persists env-only at
|
||||
`~/.config/ratatoskr/provider.env` (mode 600, nh3-dev) — `consumer="ratatoskr"`,
|
||||
HS256 = the API-key STRING utf-8-encoded; rotate via infra-ops.
|
||||
|
||||
**Committed (2026-06-16, NOT yet pushed):** observe brick (logger, v0.17.4 `2fef6e3`),
|
||||
`docs/bifrost-self-test.md` + #17 contract (`ca02c70`), 4-axis-validation parity
|
||||
(v0.17.5, tag `v0.17.5`), + this snapshot. Tags `v0.17.4`/`v0.17.5`. Push is the
|
||||
operator's call. `graphify-out/GRAPH_REPORT.md` still runs dirty
|
||||
(auto-regenerated artifact, not chased).
|
||||
|
||||
**Still standing from before:** Worldtree spec pin now v0.35.16 (`f1b59f8`) for the
|
||||
conversation-API/TUI surface (bumped 2026-06-17 from v0.29.0/`562001a`). Codex-first pilot
|
||||
still dormant (no codex session spun up — see the 2026-05-29 decision). Open
|
||||
issues: #10 (subject migration, deferred), #11 (AdminEvents pane, deferred).
|
||||
|
||||
Branch: `main`. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
@@ -149,6 +234,39 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
- `[2026-05-28]` **Sindra Tier 3 agent: FORM ASSUMPTION gate + new physical-form description.** Persistent agent state changes via `python -m ratatoskr.tier3 patch`: (1) model migrated from `qwen3.6-35-a3b-heretic` to `artemis-31b-v1i`; (2) added FORM ASSUMPTION section — when instructed to become another character she IS that character (identity/environment/psychology/parameters), believes the environment as fact, no Sindra/holo-deck/parameter references, sticky until explicit revert; (3) replaced the abstract "classically beautiful" default-form sketch with a specific anti-artifice physical description (5'8", golden-copper skin, asymmetric features, oversize dark-green knit, bare feet). System prompt file is at `/tmp/personal-worldtree-sindra_system_prompt.md` (transient; not committed to repo).
|
||||
- `[2026-05-29]` **v0.17.0 frontend redesign — aurora telemetry instrument.** `/frontend-design` pass on the web companion: all-monospace technical-instrument aesthetic with the Australis dark palette + aurora-borealis accent band. Top command bar with live connection dot (idle/streaming/error states), inline persona summary with P/A/D micro-bars, animated awaiting-token, terminal-event status chips. **Live Markdown rendering in transcript + thinking panes** via a hand-rolled `markdownSafe()` (escape-first, whitelist subset of headings/bold/italic/inline-code/fenced/lists/quote/links; link-scheme whitelist; XSS-verified under a node harness). Thinking pane now has per-turn labeled dividers + a fresh MD-rendered block per turn. **Tools / Debug / Persona panes stay literal monospace** by deliberate choice — they carry structured audit lines + JSON, where MD would corrupt readability (underscores in tool names, JSON braces). Single-file vanilla HTML/CSS/JS, no build, no CDN, no node_modules.
|
||||
- `[2026-05-29]` **Codex-first discipline pilot — Ratatoskr selected.** brokkr-smithy-dev pushed `AGENTS.md` (commit `bbeaa23`) and declared the `ratatoskr-codex` handle per `brokkr-smithy/docs/codex-first-discipline.md` v0.1 (brokkr-smithy commit `5dd061c`, tag `v0.5.3`). Per-dispatch opt-in model: default Sleipnir Claude-implementer path remains available; Codex used only when operator routes via `/codex-dispatch <N>`. Bootstrap handshake when operator spins up a codex session: codex sends `codex-online` → ratatoskr-dev replies with active branches + WIP state. Galdrabok was rejected as pilot (Codex authoring Claude skills is a category error); Skaldsong was the other candidate.
|
||||
- `[2026-06-14]` **Ratatoskr becomes the v1 Bifrost Tier-3 consumer.** A second identity beyond the debug TUI: the durable persistence Worldtree writes Tier-3 agent affect (persona) + memory into. Pin `bifrost>=0.6.1` in a `provider` optional-extra (gitea PyPI index, auth via `~/.netrc`; 0.6.0 was yanked for a circular import). Implement bifrost's OWN `MemoryDataStore`/affect Protocols (NOT worldtree-memory's); `describe_store` is SYNC; affect is conduit-opaque. New module `src/ratatoskr/provider/`. Authoritative how-to: `~/development/bifrost/docs/implementing-a-consumer.md`. (commits `1a73d77` pin, `d90a58d` affect store v0.17.1, `bcdcd71` serve entrypoint v0.17.2)
|
||||
- `[2026-06-14]` **Backend = SQLite + sqlite-vec; affect-first then memory; separate DB per plane** (operator-chosen). Affect = blind conduit (reads only `agent_id`+`end_user_id`); memory = structural index (reads vector/scope/id/origin to serve search). Conformance for both = #195 parity vs bifrost's `InMemory*Store` through the real `dispatch_*_call`.
|
||||
- `[2026-06-14]` **The affect contract's idempotency model was WRONG; real-lib TDD caught it.** First draft modeled same-idempotency-key-different-payload as an LWW overwrite; bifrost actually raises a CONFLICT (`AffectIdempotencyConflict`), actor-scoped. The artifact-only `/heid-contract-review` STRUCTURALLY cannot catch this class (it never sees bifrost's source) — TDD against the shipped library is the gate; the executable reference store + #195 parity are the backstop. Filed the guide §6 gap to bifrost-dev, who fixed it (bifrost `c0d0a11`).
|
||||
- `[2026-06-15]` **Memory v1 = the bifrost BASIC plane only** (search/get/upsert/delete + describe_store/health) per worldtree-dev re-scope (#294) — the only surface Tier-3's live path touches; gated verbs (edges/scan/atomic_supersede/mark/patch/maintenance) deferred + advertised-unsupported. Worldtree v0.35.3 already requests+maps it — no Worldtree-side blocker. Memory contract committed v1.0 (`eebab46`) → v1.1 Heid-reviewed (`1f94e5f`).
|
||||
- `[2026-06-15]` **Providers run as dev-box BACKGROUND SHELLS, not infra-ops/systemd** (operator call — it's a dev box). `ratatoskr-provider` (affect) + a future `ratatoskr-memory-provider` as background processes; no productionization track.
|
||||
- `[2026-06-15]` **Affect plane shipped (v0.17.2) + LIVE-PROVEN end-to-end against real Worldtree v0.35.2.** Personal handshake 200 + `affect.emit` 200 from `10.250.50.152` → durable row persisted (opacity held). HS256 key = the consumer's Heimdall API-key STRING utf-8-encoded (NOT base64/raw — the tripwire); cross-subnet route + `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist all held (infra-ops-owned). worldtree-dev confirmed ADR-0009 holding as designed.
|
||||
|
||||
- `[2026-06-16]` **#295 cold-recall miss root-caused — UPSTREAM, branch (a) scope-axis asymmetry.** A self-driven bound cold-recall probe (our own client, consumer-key bearer) captured the inbound pair via the new observe log: Worldtree's recall filter carries `{end_user, agent_self}`; our chunks are `{end_user}`-only; AND-matching drops everything on `agent_self` → 0 hits. Our store + search are SOUND; the fix is Worldtree-side. F2 (question-promotion) → **#296** research; F1 (recall-miss) → **#297** research (worldtree-dev's Worldtree-local per-scope-union fix, HELD pending the lattice question).
|
||||
- `[2026-06-16]` **agent_self → make it CANONICAL (operator decided A).** The cross-repo "is agent_self a valid bifrost scope axis?" question: bifrost's reference lattice is `{end_user, group, tenant}` only (agent_self → `invalid_filter` 400); Worldtree emits agent_self (`bifrost_memory_store.py:479` #248 agent-self primitive). Operator chose canonical-not-re-expressed; worldtree-dev filed the lattice-addition with bifrost-dev (thread `01KV7PXF…`). **Implication: our store's permissive axis-acceptance becomes CORRECT once bifrost adds agent_self — so do NOT add axis-validation; our missing `_validate_scope_filter` is HELD, not a bug to fix.** #297 union build held until the axis lands.
|
||||
- `[2026-06-16]` **Self-drive auth identity: bound session-create uses the CONSUMER Heimdall key as bearer, NOT `WORLDTREE_API_KEY`.** Worldtree signs the Bifrost handshake JWT with the session-create bearer (canary key → handshake 401; consumer key → 200). Two keys, two identities. Proven by hand; documented in `docs/bifrost-self-test.md`; load-bearing for #17's Bind half.
|
||||
- `[2026-06-16]` **Issue #17 v1 scope locked (operator 1A/2A): single-plane bind + dispatch-layer op-feed.** `BifrostBindingRequest` is one `endpoint_url` (one plane per session); composite-both-planes endpoint PARKED. Observe = structured op-feed instrumented at the DISPATCH layer (bifrost passes ctx to upsert_many but NOT search/get/delete — `memory.py:244`), session-level correlation; turn-correlated pane UI PARKED (needs turn_id, TBD). Direct in-session TDD (live-smoke load-bearing). Contract `docs/contracts/issues/17.contract.md` written, `/heid`-design-consulted + `/heid-contract-review`-panel'd + fixed (validates OK). NEXT: TDD.
|
||||
- `[2026-06-16]` **Provider stores confirmed byte-faithful to bifrost's AND reference** (`reference_server/memory.py:398` `_matches_scope` = `all(...)`, identical to ours). OR-union was considered + rejected (ecosystem-wide change); flagged the silent-zero foot-gun to bifrost-dev (docs-only landed, bifrost stays 0.6.4). Do NOT flip `_scope_matches` to OR.
|
||||
|
||||
- `[2026-06-16]` **agent_self lattice SHIPPED both sides → our axis-validation gap CLOSED (v0.17.5).** bifrost 0.7.0 / wire v0.5 adds agent_self to the scope lattice `{end_user,group,tenant,agent_self}` (#10, driven by our foot-gun flag via bifrost-dev); Worldtree pinned 0.7.0 + canonical-synced the v0.5 spec (v0.35.11, `c860fb0`). **SUPERSEDES the prior "HELD, do NOT add axis-validation" note** — we DID add `_validate_scope_filter` (4-axis) to match the reference (bifrost-dev's recommendation, purely additive; out-of-lattice → InvalidFilter). #297 union build unblocked. Memory provider restarted on 0.7.0.
|
||||
- `[2026-06-16]` **#17 contract reviewed + the debug-assist arc fully closed.** `/heid` design consult + `/heid-contract-review` panel both run on `docs/contracts/issues/17.contract.md` (validates OK). The #295 debug-assist that opened the session is closed end-to-end: root cause (scope-axis asymmetry) → #296/#297 research issues + corpus → a shipped bifrost protocol change (agent_self canonical) → our store at parity. NEXT durable step: #17 TDD (tracked: Gitea #17 + the contract).
|
||||
|
||||
- `[2026-06-16]` **Repinned bifrost 0.7.0→0.8.0 + reimplemented memory `search` to the v0.6 scope split (operator-directed).** `scope_filter` → `scope_all` (AND) + `scope_any` (OR/union over a list of conjunctive scopes), bifrost #11 — the canonical resolution of the #295/#297 silent-zero. **SUPERSEDES the "Provider stores byte-faithful to AND reference / Do NOT flip `_scope_matches` to OR" entry above**: the reference itself now does OR via `scope_any` (a NEW field — `scope_all` keeps the old AND semantics; this is an additive split, not a flip of the AND predicate). Store / contract (v1.2) / tests at parity with the v0.6 reference; provider bounced onto 0.8.0 with a wiped DB (operator: "nothing of value"). Cold recall now gated only on Worldtree emitting `scope_any` (#297). **#17's contract has stale `scope_filter`/`_scope_matches`-AND references (its `assumptions`, INV-004, and the op-feed `search → req {scope_filter}` summary shape) — update those to `scope_all`/`scope_any` when #17 TDD starts; INV-004's intent (observe must not alter scope semantics) still holds.**
|
||||
|
||||
- `[2026-06-17]` **Worldtree spec pin bumped v0.29.0→v0.35.16 (`562001a`→`f1b59f8`); cold recall CLOSED end-to-end.** Worldtree shipped #297 (client-side per-scope-value union recall) + #298/#299 (adopt the bifrost v0.6 `scope_any`/`scope_all` wire) — it now emits `scope_any` on recall, the upstream half that pairs with our v0.17.6 provider. Re-vendored `conversation-api-spec.md` + `conversation_api.contract.md`; diff-reviewed the 285-commit catch-up — no client-breaking changes (#211 agent-slug rename saga→echo/actor→mask is slugs-only; #245 end_user_id+memory-scope; #187/#188/#219 Tier-3 define/PATCH additive; error codes stable). Shipped as a `pin:` commit, NO package bump (docs/pin-only, no ratatoskr code; per SemVer SKIP for docs-only). **Remaining proof: a live cold-recall re-smoke against a personal instance on v0.35.16** — the smoke target ran v0.35.2/.3, needs infra-ops to update it.
|
||||
|
||||
- `[2026-06-17]` **End-to-end cold-recall proof RAN — our stack proven, #296 isolated.** Against personal WT v0.35.16 with restored `ratatoskr:sindra`: #297/#298 union recall (WT emits `scope_any`), write path (promote→upsert→persist), and cold read (scope_any returns hits) ALL proven. Lone gap = upstream #296 extraction quality. Refines the spec-repin entry's "cold recall closes end-to-end": the WIRE closed; actual fact-recall is #296-blocked. (triage `01KVBBH0…`)
|
||||
- `[2026-06-17]` **DELETE+redefine `ratatoskr:sindra` (operator-authorized; pre-v1 debug surface).** She SURVIVED the rebuild but was STALE (dead model `artemis-31b-v1i` + no memory block from Phase-2.0 tooling); memory is immutable post-define, so DELETE+redefine was the only path. v0.35.16 define takes **`role`** (capability), NOT `model`: `role:"character"` → first-healthy bind `mistral-small-4`; `memory:{}` trips the promotion gate (GET does NOT echo `memory_config`). Reasoning bind needs a `character`-role bind reorder (worldtree-dev flagged to operator). Our `tier3.py` define is Phase-2.0-stale (no role/memory/persona layers) — untracked modernization follow-up.
|
||||
- `[2026-06-17]` **Promotion = 4-trigger hybrid (worldtree-dev, code-grounded):** salience (regex, 90s rate-limit) / `turn_count≥6` / context_pressure / **idle `≥10min` (unconditional on quality)**; per-turn `plan_promotion_run` for consumer_defined. **DELETE does NOT drain/promote** (delete-is-delete, #276) — idle `≥10min` is the deterministic flush. The seed promoted at ~4min (per-turn salience/turn-scan, before the idle floor).
|
||||
- `[2026-06-17]` **#296 triage sent to worldtree-dev** (`01KVBBH0QRGT34GDJQEX5ACTT1`): extraction SUBJECT-INVERSION (promotes assistant prose, drops the user's fact) + META-DESCRIPTION-not-content; verbose-persona aggravator. The WAD-vs-bug cut resolved to BUG (extraction quality), not idle-gating.
|
||||
|
||||
- `[2026-06-18]` **Tier-3 memory PROVEN end-to-end live** — `ratatoskr:terse-probe` recalled a seeded user fact in a COLD history-free session (scope_any → 1 hit @ cosine 0.6994 → "Dark chocolate with sea salt"). The session's headline; closes the opening "how far from Tier-3 memory" question for normal agents. (proof in this session's transcript)
|
||||
- `[2026-06-18]` **#296 Stage 1 (v0.35.19) validated live + bisect localized the residual to VERBOSE-PERSONA VOLUME.** Stage 1's recallability admission gate works for normal turns (clean distillate, `distiller_model_id="identity"`); the terse-probe-promotes / sindra-drowns bisect pinned the remaining failure to symptom 1 (assistant-prose volume crowding out the user fact at extraction).
|
||||
- `[2026-06-18]` **#296 Stage 2 (v0.36.0) MERGED at worldtree-codex** — user-only one-call-per-turn extraction, the STRUCTURAL fix for the subject-inversion drop sindra exposed. Staged per operator: core fix now (validated by our terse-probe PASS + WT test suite); hard-linguistic layer (larger-model escalation, marker-less-fact regex-demotion) tracked at Worldtree #305. We handed worldtree-dev a live-validated eval fixture PAIR for #305 (terse-proof POSITIVE chunk + choc-v2/sindra NEGATIVE-drop).
|
||||
- `[2026-06-18]` **Full root-cause chain traced + confirmed** (a3466fab ledger pull via infra-ops): v0.35.16 verbose-sindra EMITS 2 meta-descriptions → v0.35.19 gate hard-rejects them (emit-then-reject) → v0.36.0 user-only pass emits the fact. The 2 v0.35.16 chunks ("Assistant's detailed flirtatious response…" / "…head tilt, eye locking, freckles…") are textbook subject-inversion; banked as #305 negative-fixture examples.
|
||||
|
||||
- `[2026-06-18]` **FULL-COVERAGE proof — verbose-persona memory works under Stage 2 (v0.36.0).** Re-smoked `ratatoskr:sindra-probe` (sindra's theatrical prompt; FRESH agent + end_user `verbose-v2` to dodge the WT promotion-dedup): the high-volume turn promoted the user fact CLEANLY (not a meta-description) and cold-recalled @ 0.694. Confirms `:8081` is on v0.36.0 and closes the verbose-persona caveat end-to-end. Then started `ratatoskr-web` (`:8765`, consumer key, `ratatoskr:sindra` in the picker) for the operator's Sindra session — **persona + debug only; the web client does NOT bind to `:8391` (#17 unbuilt), so no memory persistence in the web chat** (memory-enabled chat is the #17 build, or a manual bound session).
|
||||
|
||||
- `[2026-06-18]` **Tier-3 persona_state is a Worldtree limitation (not ours); sindra → thoughtful-character.** The web Persona-pane 404 ("persona not available") = `persona_state` hard-404s ALL Tier-3 (colon-id) agents by design (`api.py:1220`); a persona block in the define doesn't help (endpoint short-circuits before the row). Pinged worldtree-dev on the Tier-3 persona_state roadmap (thread `01KVCR6P…`) + de-uglied our web pane (`index.html loadPersona`) to render a clear message (v0.17.7). Separately switched `ratatoskr:sindra` to the `thoughtful-character` role (→ `mistral-small-4-reasoning`) via DELETE+redefine (kept her prompt; added an inert persona block). **REFRAME (worldtree-dev `01KVCRK9…`):** persona_state GET is **Tier-1-only by ADR-0009**, the colon-id 404 is correct-by-design (NOT a stub to wait for). Tier-3 affect is **CLIENT-persisted** — we ALREADY hold the PAD `{pleasure,arousal,dominance}` + valence at `:8390` from the `affect.emit` payload. So the Persona-pane fix is **OUR side**: #17 affect-binding → WT dispatches `affect.emit` → `:8390` → the web pane renders PAD/valence from our own store. WT #289 (`affect.fetch`/`affect:read`) = optional mediated-read (their intelligence over our raw store), NOT a prerequisite. WT #300 = their forthcoming v1 client-impl guide (who-owns-what), folding in this exact case.
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
|
||||
@@ -175,3 +293,20 @@ defense against re-attempting the same cul-de-sac.
|
||||
- `[2026-05-27]` **`get_persona_state` mocked flat error envelope; real Worldtree wraps in `detail`.** v0.12.0 tests used `{"error_code": "auth_scope_denied"}` but real wire (FastAPI default) returns `{"detail": {"error_code": "auth_scope_denied", "message": "…"}}`. The parser only checked top-level so the typed exception was never raised; calls fell through to `SessionApiFailed(403)`, which the web persona endpoint surfaced as HTTP 500. v0.15.1 patches both shapes. **Lesson: test-side mock envelopes must match the REAL wire shape; live smoke is load-bearing for envelope-shape verification, not just happy paths.**
|
||||
- `[2026-05-27]` **Mid-session `system_prompt` mutation: universal omission across surveyed mature systems.** brokkr-smithy R13 panel (3-arm, strong convergence) confirmed: no surveyed system ships live PATCH-on-active-session (OpenAI Assistants/Responses, Anthropic Messages, Vertex AI, MCP, LangChain, LlamaIndex, Ollama, vLLM). The omission IS the answer. 12 additional threat vectors beyond ratatoskr's initial 7. **Don't re-propose this for ratatoskr;** if a future production conversational shell wants iterative-prompt-tuning ergonomics, the consensus shape is fork-via-client (PATCH agent → new session → replay context).
|
||||
- `[2026-05-28]` **Browser-local turn_id used for upstream cancel URL — old cancel tests ENCODED the bug.** Web companion v0.15.x cancel paths posted to `/sessions/{sid}/turns/{LOCAL_ID}/cancel`. Tests mocked the local-id URL so they encoded the bug rather than detecting it. Hulda caught it in Heid pass 1. Fix in v0.16.0: capture upstream_turn_id from the first SSE event's `sse_id.turn_id`; all cancel paths use it; cancel before first event is `{"cancelled": false, "reason": "not_started"}`. **Rule: when designing cancel/match paths against an external service, test fixtures must mock what would actually be hit upstream — mocking your own derived id encodes the bug instead of catching it.**
|
||||
- `[2026-06-15]` **"Sindra hasn't been registered" was an under-verified inference — WRONG.** Concluded it from grepping ratatoskr's CODE (`sindra` absent from `src/`), but Tier-3 registration is SERVER-SIDE (`POST /agents/define` on the Worldtree instance) — a code grep structurally can't see it. Registration IS required to use a Tier-3 character (a session against an unregistered `agent_id` 404s), so since Sindra has been used, she WAS registered (`ratatoskr:sindra`). **Rule: to check whether a Tier-3 agent exists, query the Worldtree instance's `GET /agents`, never the consumer repo's code.** (Residual: the v0.35.2 personal rebuild may have wiped her — re-verify.)
|
||||
- `[2026-06-14]` **Artifact-only contract review can't validate against a dependency's ACTUAL behavior.** `/heid-contract-review` sees only the contract, never the external library (bifrost) — so "the consumer under-built against bifrost's real semantics" is invisible to it by construction (the affect idempotency model shipped wrong because of this). Real-lib TDD against the shipped library + the executable reference store + the #195 parity test are the gate for any consumer plane with non-trivial state semantics. Don't treat a clean contract review as evidence the code matches the dependency.
|
||||
- `[2026-06-15]` **"byte-equal" round-trip slip propagated affect→memory via copy-paste.** The affect contract's byte-identical→semantic fix reappeared in the memory contract's INV-001 (sibling copy). Only an INDEPENDENT `/heid-contract-review` of the memory contract re-caught it. **Paraphrase every sibling contract fresh — don't amortize one review across a family; copies carry the parent's slips.** (also a feedback auto-memory)
|
||||
- `[2026-06-15]` **Canonical sync retired the issue-scoped parser staleness** (predicted by the 2026-05-21 entry's "until canonical bumps"). `contract_parser.py` synced to v2.1 (`f1fdfdb6→e10a4460`, commit `d85ab43`): now validates issue-scoped frontmatter (`target_module`/`scope`/`prd`) + four v2.1 test categories (scenario/trace/adversarial/property). Issues #3/#4 went FAIL→WARN (0 errors). The old "treat parser ERROR-on-issue-scoped as expected" note no longer applies.
|
||||
- `[2026-06-15]` **Refreshed #3/#4 presenter contracts to the shipped TUI model** (commit `335c835`). Both still described the abandoned single-`RichLog` double-display model; rewrote to the 4-pane live-Markdown reality (v0.5.0–v0.14.0 + Worldtree #201/#204) across INV-005, the `[performance]` constraint, the COMPOSE sketch, the `CLASS TuiPresenterState` block, both `render`/`_stream_turn_worker` blocks, and the `_cancel_via_sse` call site — plus the STEPS the v2.1 parser flagged missing. Code unchanged; contract-truth catching up to shipped code. Scope ballooned one-block→contract-wide mid-task; surfaced to operator before rewriting the INV-005 trade-off invariant.
|
||||
- `[2026-06-15]` **Memory plane TDD'd + shipped** (commit `cd12951`, v0.17.3). Impl decisions worth keeping: vec0 `distance_metric=cosine` set at table creation (`score = 1 − distance`); `search` over-fetches ALL candidates by cosine then scope-filters in Python so `top_k` counts IN-SCOPE hits (INV-005, contract STEP 2 `indicative`); idempotency_id = reference 4-tuple `("default",verb,_ctx_actor(ctx),key)` pipe-joined as the SQLite PK, digest = sha256 canonical-JSON; `_ctx_actor` = `job_id|jwt_sub|session_id` (memory reference's 3-level, vs affect's 2-level). **heid-code-review panel returned zero true drift**; adopted 5 cheap contract-anchored fixups (scope_filter dict guard, `top_k≤0→[]`, stronger scope-isolation / delete-hit-search / handshake-POST tests), accepted 6 with reasoning. **Partial-map optimistic-lock semantics pinned to the reference via an `expected_revisions` parity test** — resolved a Hulda finding deterministically (the affect-plane lesson: TDD against the shipped lib is the gate, not judgment).
|
||||
- `[2026-06-15]` **Memory provider LIVE-PROVEN against personal v0.35.3 (persist + dispatch + search-correctness); recall-injection is upstream.** worldtree-dev's Tier-3 promotion recipe (via infra-ops): memory-call fires from Tier-3 PROMOTION, gated at `service.py:2623` on `ctx.kind=="consumer_defined"` AND `ctx.memory_config is not None` (the agent must be DEFINED WITH a `memory` block — `ValidatedMemoryConfig {tier3_dreaming:false}`, dim 1024) AND handshake-granted memory caps AND `embedding_dim==1024`. `memory.agent_self_enabled` is NOT the gate (only the #248 self-candidate branch). Binding = `POST /sessions BifrostBindingRequest{endpoint_url}`, handshake `caps=["affect","memory"]`, **`binding.scope` null** (per-op scopes auto-minted: upsert_many→`memory:write`, search→`memory:read`). A `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist gates the endpoint (Worldtree-side config — infra-ops added `:8391`). HTTP + HS256 both work in dev. (smoke wiring thread `01KV7D82MJYB…`)
|
||||
- `[2026-06-15]` **Diagnostic: our recall-search is SOUND — the cross-session recall gap is UPSTREAM, not the store — and it caught an upstream bug.** Embedded the recall query via gateway `qwen3-embedding` + searched our live store directly → the dark-chocolate fact recalls at cosine 0.60, correctly ranked above the unrelated name fact (0.16). So the cold-session recall failure is Worldtree's recall-assembly/injection (hits not reaching the prompt), NOT our search. ALSO found a latent UPSTREAM bug: a recall QUESTION got promoted as a durable chunk and ranks **#1 (0.70 > the fact's 0.60)**, polluting recall. Relayed to worldtree-dev (thread `01KV7JH8…`). **This is exactly #17's thesis — ratatoskr-as-provider caught an upstream bug invisible from the chat side.**
|
||||
- `[2026-06-15]` **"Wire 200 ≠ recall works" — prove recall efficacy at the model's answer in a COLD (history-free) session, not on the wire.** A `search`/memory-call returns 200 whether or not its results are injected into the prompt, and same-session "recall" can be plain session history. infra-ops' cold cross-session probe caught my premature "all-green" (search dispatched 200, model had no memory). Don't call cross-session recall proven from a clean wire.
|
||||
- `[2026-06-15]` **Issue #17 filed — Bifrost-binding for the chat client (self-drive + correlated-log affect/memory ops).** REVERSES design-brief §6's "no Bifrost-binding consumer support" — that negative clause predates ratatoskr's provider identity (2026-06-14), so the canary now owns both ends but its client can't drive its own provider (`create_session` sends only `{agent_id, end_user_id}`; no Bifrost `endpoint_url`). Today's smoke proved the substrate (bind→dispatch→persist); only the observe/log channel design (open question #5) remains. The recall-injection caveat is upstream and doesn't block #17. NEXT on #17: `/heid` consult on the now-grounded framing → contract → TDD. (tracked: Gitea #17, labels enhancement/observability/tui)
|
||||
- `[2026-06-16]` **`scripts/contract_drift_check.py` defaults `GITEA_REPO` to "Worldtree"** (line 74), so a bare run in ratatoskr false-positives DRIFT by hashing Worldtree's same-numbered issue. Always `export GITEA_REPO=ratatoskr GITEA_OWNER=vh` (env.sh leaves the GITEA vars commented out) before running the drift-checker here.
|
||||
- `[2026-06-16]` **My #295 coupling hypothesis (the promoted question crowds out the fact at small top_k) was REFUTED** — worldtree-dev's recall over-fetches `top_k=128` (`injector.py:203`/`_store_helpers.py:101`), so the question can't crowd the fact out at search level. Reasonable cross-frontier hypothesis, correctly framed as a hypothesis not a conclusion; the real cause was the scope-axis asymmetry. Lesson: offer provider-side hypotheses, let the upstream owner check them against their code.
|
||||
- `[2026-06-16]` **Contract drifted from its own design in two spots, caught only by `/heid-contract-review` (not same-author paraphrase):** the `OpEvent` dataclass omitted the `turn_id` that INV-005 promised; the `session_id` comment said "None for search/get/delete" contradicting the dispatch-layer design (the JWT carries session_id for all verbs at dispatch). Cross-model paraphrase is load-bearing for catching an author's own contract-vs-intent drift.
|
||||
- `[2026-06-16]` **"No promotion" was checked TOO EARLY — Tier-3 promotion is ASYNC (lands AFTER the SSE turn-end).** The cold-recall probe's immediate post-turn fixture check showed 3 chunks (no promotion); a later check (during the v0.17.5 provider restart) found 5 — the probe HAD promoted 2 chunks (its question `acc3d49` + the model's non-answer `4773704`), just late. Don't trust an immediate post-turn fixture snapshot to judge promotion; it lands after the turn completes. (Same family as the "wire-200 ≠ recall, prove it in a cold session" lesson, extended to promotion timing — and the reason #17's contract pins a post-turn grace window + fixture before/after assertion.)
|
||||
- `[2026-06-17]` **"sindra is GONE" (infra-ops, from `GET /agents` + admin token) was a FALSE NEGATIVE.** Consumer-defined Tier-3 agents are OWNER-SCOPED (separate `consumer_agents` table) — invisible to the foundational `GET /agents` roster even with an admin token. To check whether a Tier-3 agent exists, `GET /agents/<owner>:<name>` with the OWNER key. (Extends the 2026-06-15 "Sindra registration under-verified" lesson — even the roster query can't see consumer agents; only the owner-scoped point-GET does.)
|
||||
- `[2026-06-17]` **"Promotion didn't fire → #296" was PREMATURE — twice over.** (1) Polled the op-feed only ~2min, but the upsert landed at ~4min — promotion is async + multi-trigger; watch a longer window. (2) It DID fire; the real bug is extraction QUALITY (assistant-prose promoted, user-fact dropped), not non-firing. "No upsert while a session is live and `<10min` idle" is WAD, not a miss. (Extends the 2026-06-16 "checked too early" entry with the multi-trigger timing + the fired-but-bad-content distinction.)
|
||||
- `[2026-06-18]` **Wiping our `:8391` store does NOT reset Worldtree's promotion-side dedup** — the v0.35.19 sindra re-smoke (same agent + fresh end_user `choc-v2`) returned `reason_code=noop_duplicate` / `candidate_count=0`: the extractor NEVER RE-RAN, dedup short-circuited against the earlier `sindra-proof` promotion. So a "0 chunks / empty store" result can be a DEDUP artifact, not Stage 1 fail-closing — I mis-read it as fail-closed, worldtree-dev caught it from the ledger. **For a clean promotion smoke, use a BRAND-NEW agent + end_user (never-used names)** — exactly why terse-probe was valid. (Also: `llm_calls_used=0` is NOT the "did the extractor run" tell — `noop_duplicate` is; a3466fab promoted 2 candidates with `llm=0`.)
|
||||
|
||||
+24
-4
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.17.0"
|
||||
version = "0.17.11"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -25,6 +25,15 @@ web = [
|
||||
"starlette>=0.40",
|
||||
"uvicorn[standard]>=0.30",
|
||||
]
|
||||
# Tier-3 Bifrost consumer: the durable memory.* + affect.* persistence
|
||||
# provider Worldtree writes into. Opt-in extra — distinct deployment surface
|
||||
# from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md.
|
||||
provider = [
|
||||
"ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack
|
||||
"bifrost>=0.8.0", # consumer engines + library (0.8.0/wire-v0.6: scope_filter split into scope_all (AND) + scope_any (OR/union, #11); 0.7.0/v0.5 added agent_self)
|
||||
"jsonschema>=4", # bifrost runtime dep — envelope validation
|
||||
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.24",
|
||||
@@ -37,8 +46,10 @@ dev = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ratatoskr = "ratatoskr.cli:main"
|
||||
ratatoskr-web = "ratatoskr.web.entrypoint:main"
|
||||
ratatoskr = "ratatoskr.cli:main"
|
||||
ratatoskr-web = "ratatoskr.web.entrypoint:main"
|
||||
ratatoskr-provider = "ratatoskr.provider.serve:main"
|
||||
ratatoskr-memory-provider = "ratatoskr.provider.serve_memory:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
|
||||
@@ -48,10 +59,19 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
|
||||
# Ratatoskr is built against Worldtree at this commit; the vendored
|
||||
# spec snapshot in docs/ reflects that SHA.
|
||||
[tool.ratatoskr.spec-pin]
|
||||
worldtree-spec-rev = "562001af28d752c3a60d449c7ddd09f44fa9dc9a"
|
||||
worldtree-spec-rev = "f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a"
|
||||
worldtree-version = "v0.29.0"
|
||||
pinned-on = "2026-05-26"
|
||||
|
||||
# Bifrost lives on the auth-gated gitea PyPI index (not public PyPI).
|
||||
# uv reads the credential from UV_INDEX_GITEA_USERNAME / _PASSWORD or ~/.netrc.
|
||||
[[tool.uv.index]]
|
||||
name = "gitea"
|
||||
url = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/"
|
||||
|
||||
[tool.uv.sources]
|
||||
bifrost = { index = "gitea" }
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/ratatoskr"]
|
||||
|
||||
|
||||
+81
-2
@@ -16,7 +16,15 @@ from typing import TextIO
|
||||
|
||||
import httpx
|
||||
|
||||
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
|
||||
from ratatoskr.sessions import (
|
||||
AgentNotFound,
|
||||
BifrostBinding,
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
SessionApiFailed,
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
@@ -83,6 +91,12 @@ class ParsedArgs:
|
||||
# Per issue #5: optional `--end-user-id` for per-end-user agents (lofn etc.).
|
||||
# Default None preserves the pre-#5 baseline for agents that don't require it (mimir).
|
||||
end_user_id: str | None = None
|
||||
# Issue #17: optional Bifrost binding (one plane) + its consumer key. None on
|
||||
# the unbound pre-#17 path. `bifrost_plane` is the human label for the
|
||||
# bound-state indicator (None when --bifrost-url supplies the endpoint directly).
|
||||
bifrost: BifrostBinding | None = None
|
||||
bifrost_plane: str | None = None
|
||||
consumer_key: str | None = None
|
||||
|
||||
|
||||
class _ArgparseError(Exception):
|
||||
@@ -109,6 +123,13 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
parser.add_argument("--raw", action="store_true")
|
||||
# Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir).
|
||||
parser.add_argument("--end-user-id", dest="end_user_id", default=None)
|
||||
# Issue #17: bind the created session to our own Bifrost provider plane.
|
||||
parser.add_argument(
|
||||
"--bifrost-plane", dest="bifrost_plane", choices=("memory", "affect"),
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument("--bifrost-host", dest="bifrost_host", default=None)
|
||||
parser.add_argument("--bifrost-url", dest="bifrost_url", default=None)
|
||||
try:
|
||||
ns = parser.parse_args(argv)
|
||||
except _ArgparseError as exc:
|
||||
@@ -143,6 +164,30 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
# gets a stable partition without papering over the explicit-flag override.
|
||||
end_user_id = ns.end_user_id or os.environ.get("RATATOSKR_END_USER_ID") or None
|
||||
|
||||
# Issue #17: resolve the optional Bifrost binding. --bifrost-url (direct,
|
||||
# HTTPS/prod) and --bifrost-plane (dev shortcut → endpoint_for_plane) are
|
||||
# mutually exclusive; a binding is a session-CREATE concern (forbidden with
|
||||
# --session). The consumer key — the privileged handshake identity, distinct
|
||||
# from the canary key — comes from the env (never a CLI flag).
|
||||
bifrost: BifrostBinding | None = None
|
||||
bifrost_plane: str | None = None
|
||||
if ns.bifrost_url and ns.bifrost_plane:
|
||||
raise UsageError("--bifrost-url and --bifrost-plane are mutually exclusive")
|
||||
if (ns.bifrost_url or ns.bifrost_plane) and not ns.new:
|
||||
raise UsageError("a bifrost binding requires --new (it binds at session create)")
|
||||
if ns.bifrost_url:
|
||||
bifrost = BifrostBinding(endpoint_url=ns.bifrost_url)
|
||||
elif ns.bifrost_plane:
|
||||
host = ns.bifrost_host or os.environ.get("RATATOSKR_PROVIDER_VISIBLE_HOST")
|
||||
if not host:
|
||||
raise UsageError(
|
||||
"--bifrost-plane requires --bifrost-host "
|
||||
"(or RATATOSKR_PROVIDER_VISIBLE_HOST) — the Worldtree-visible provider host"
|
||||
)
|
||||
bifrost = BifrostBinding(endpoint_url=endpoint_for_plane(ns.bifrost_plane, host))
|
||||
bifrost_plane = ns.bifrost_plane
|
||||
consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY") or None
|
||||
|
||||
return ParsedArgs(
|
||||
send_content=ns.send,
|
||||
session_id=ns.session,
|
||||
@@ -152,6 +197,9 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
server_url=server_url,
|
||||
raw=ns.raw,
|
||||
end_user_id=end_user_id,
|
||||
bifrost=bifrost,
|
||||
bifrost_plane=bifrost_plane,
|
||||
consumer_key=consumer_key,
|
||||
)
|
||||
|
||||
|
||||
@@ -432,11 +480,34 @@ async def _amain(args: ParsedArgs) -> int:
|
||||
assert args.agent_id is not None
|
||||
try:
|
||||
info = await create_session(
|
||||
client, args.agent_id, end_user_id=args.end_user_id
|
||||
client,
|
||||
args.agent_id,
|
||||
end_user_id=args.end_user_id,
|
||||
bifrost=args.bifrost,
|
||||
consumer_key=args.consumer_key,
|
||||
)
|
||||
except AgentNotFound as exc:
|
||||
sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
|
||||
return 12
|
||||
except BifrostConsumerKeyMissing as exc:
|
||||
# INV-001: never fall back to the canary key — fail loud.
|
||||
sys.stderr.write(
|
||||
f"[bifrost_consumer_key_missing] {exc} "
|
||||
f"(set RATATOSKR_BIFROST_CONSUMER_KEY)\n"
|
||||
)
|
||||
return 22
|
||||
except BifrostHandshakeFailed as exc:
|
||||
# INV-002: bind-time handshake failure fails session creation.
|
||||
sys.stderr.write(
|
||||
f"[bifrost_handshake_failed] bifrost_error={exc.bifrost_error}\n"
|
||||
)
|
||||
# 401-message scoping: keyed on auth_rejected, name the key mismatch.
|
||||
if exc.bifrost_error == "bifrost.auth_rejected":
|
||||
sys.stderr.write(
|
||||
" bound create requires the consumer key "
|
||||
"(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
|
||||
)
|
||||
return 23
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
||||
return 20
|
||||
@@ -448,6 +519,14 @@ async def _amain(args: ParsedArgs) -> int:
|
||||
sys.stderr.write(
|
||||
f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n"
|
||||
)
|
||||
# Issue #17 bound-state indicator: plane + endpoint + status, so the
|
||||
# operator sees WHICH identity/endpoint bound (not a bare boolean).
|
||||
if args.bifrost is not None:
|
||||
plane = args.bifrost_plane or "direct"
|
||||
sys.stderr.write(
|
||||
f". bifrost: status=bound plane={plane} "
|
||||
f"endpoint={args.bifrost.endpoint_url}\n"
|
||||
)
|
||||
session_id = info.session_id
|
||||
else:
|
||||
assert args.session_id is not None
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Tier-3 Bifrost consumer: durable memory.* + affect.* persistence provider.
|
||||
|
||||
Contracts: docs/contracts/bifrost_affect_provider.contract.md (affect plane, v1).
|
||||
"""
|
||||
@@ -0,0 +1,146 @@
|
||||
"""SQLite-backed, conduit-opaque affect store (Bifrost affect plane, v1).
|
||||
|
||||
Contract: docs/contracts/bifrost_affect_provider.contract.md
|
||||
|
||||
The store persists Worldtree's Tier-3 affect snapshots verbatim and round-trips
|
||||
them. It runs NO affect logic: it reads only the two addressing keys
|
||||
(`agent_id`, `end_user_id`) and treats `pad`/`valence`/`persona_baselines`/
|
||||
`emitted_at` as an opaque blob (INV-001). Idempotency is replay-or-conflict,
|
||||
keyed by (actor-from-ctx, idempotency_key) (INV-008), and snapshots are
|
||||
last-write-wins by arrival across distinct keys (INV-002).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from bifrost.affect import AffectIdempotencyConflict, AffectInvalidArguments
|
||||
from bifrost.consumer import ConsumerRegistration, build_affect_app
|
||||
from bifrost.reference_server import JwtVerifier
|
||||
|
||||
_SHORT_RETRY_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _ctx_actor(ctx: Any) -> str:
|
||||
"""Mirror bifrost reference `_ctx_actor`: idempotency actor = JWT `sub`.
|
||||
|
||||
The real DispatchContext exposes the `sub` claim as `session_id`; test
|
||||
contexts set `jwt_sub`. (INV-006: the actor comes from ctx, never the
|
||||
snapshot.)
|
||||
"""
|
||||
return str(getattr(ctx, "jwt_sub", None) or getattr(ctx, "session_id", ""))
|
||||
|
||||
|
||||
class RatatoskrAffectStore:
|
||||
"""The affect `MemoryDataStore`-shaped store handed to `build_affect_app`."""
|
||||
|
||||
affect_supported = True
|
||||
|
||||
def __init__(self, conn: sqlite3.Connection):
|
||||
self._conn = conn
|
||||
|
||||
async def emit(
|
||||
self,
|
||||
snapshot: dict,
|
||||
*,
|
||||
idempotency_key: str,
|
||||
ctx: Any,
|
||||
idempotency_class: str | None = None,
|
||||
) -> dict:
|
||||
del idempotency_class # reserved; affect.* uses a single short-retry class
|
||||
|
||||
# INV-001: read ONLY the two addressing keys; everything else is opaque.
|
||||
agent_id = snapshot.get("agent_id")
|
||||
end_user_id = snapshot.get("end_user_id")
|
||||
if not (
|
||||
isinstance(agent_id, str)
|
||||
and agent_id
|
||||
and isinstance(end_user_id, str)
|
||||
and end_user_id
|
||||
):
|
||||
raise AffectInvalidArguments("snapshot missing agent_id / end_user_id")
|
||||
if not (isinstance(idempotency_key, str) and idempotency_key):
|
||||
raise AffectInvalidArguments("idempotency_key required")
|
||||
|
||||
# Whole-blob serialize + hash is opacity-safe (not a field read).
|
||||
blob = json.dumps(snapshot, sort_keys=True, separators=(",", ":"))
|
||||
digest = hashlib.sha256(blob.encode()).hexdigest()
|
||||
idempotency_id = f"affect.emit|{_ctx_actor(ctx)}|{idempotency_key}"
|
||||
|
||||
# INV-008: replay (same digest) -> no-op; reuse with different digest -> conflict.
|
||||
cached = self._conn.execute(
|
||||
"SELECT digest FROM affect_idempotency WHERE idempotency_id = ?",
|
||||
(idempotency_id,),
|
||||
).fetchone()
|
||||
if cached is not None:
|
||||
if cached[0] == digest:
|
||||
return {"stored": True}
|
||||
raise AffectIdempotencyConflict(
|
||||
"idempotency key reused with different payload"
|
||||
)
|
||||
|
||||
# INV-002 + INV-007: LWW snapshot upsert + idempotency record, one transaction.
|
||||
now = time.time()
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"INSERT INTO affect_snapshots (agent_id, end_user_id, snapshot_json, arrived_at) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(agent_id, end_user_id) DO UPDATE SET "
|
||||
"snapshot_json = excluded.snapshot_json, arrived_at = excluded.arrived_at",
|
||||
(agent_id, end_user_id, blob, str(now)),
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT INTO affect_idempotency (idempotency_id, digest, expires_at) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(idempotency_id, digest, now + _SHORT_RETRY_TTL_SECONDS),
|
||||
)
|
||||
return {"stored": True}
|
||||
|
||||
def get(self, agent_id: str, end_user_id: str) -> dict | None:
|
||||
"""Read-back of the stored snapshot (tests / future rehydrate-seed)."""
|
||||
row = self._conn.execute(
|
||||
"SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?",
|
||||
(agent_id, end_user_id),
|
||||
).fetchone()
|
||||
return json.loads(row[0]) if row is not None else None
|
||||
|
||||
|
||||
def open_affect_store(db_path: str) -> RatatoskrAffectStore:
|
||||
"""Open the SQLite-backed affect store, creating the schema on first use."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
if db_path != ":memory:":
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS affect_snapshots ("
|
||||
"agent_id TEXT NOT NULL, end_user_id TEXT NOT NULL, "
|
||||
"snapshot_json TEXT NOT NULL, arrived_at TEXT, "
|
||||
"PRIMARY KEY (agent_id, end_user_id))"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS affect_idempotency ("
|
||||
"idempotency_id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires_at REAL)"
|
||||
)
|
||||
conn.commit()
|
||||
return RatatoskrAffectStore(conn)
|
||||
|
||||
|
||||
def build_affect_provider_app(
|
||||
store: RatatoskrAffectStore,
|
||||
heimdall_key: bytes,
|
||||
consumer_id: str = "ratatoskr",
|
||||
):
|
||||
"""Wire the JWT verifier + registration and hand the store to bifrost.
|
||||
|
||||
Returns a Starlette ASGI app exposing POST /bifrost/handshake and
|
||||
POST /bifrost/affect-call. The library owns the wire; this is the thin glue.
|
||||
"""
|
||||
if getattr(store, "affect_supported", False) is not True: # INV-005
|
||||
raise ValueError("store must advertise affect_supported=True")
|
||||
if not (isinstance(heimdall_key, bytes) and heimdall_key):
|
||||
raise ValueError("heimdall_key must be non-empty bytes")
|
||||
verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key)
|
||||
registration = ConsumerRegistration(consumer_id=consumer_id)
|
||||
return build_affect_app(store=store, verifier=verifier, registration=registration)
|
||||
@@ -0,0 +1,373 @@
|
||||
"""SQLite + sqlite-vec durable memory store (Bifrost memory plane, v1 basic plane).
|
||||
|
||||
Contract: docs/contracts/bifrost_memory_provider.contract.md
|
||||
|
||||
Worldtree writes Tier-3 agent memory chunks here (`upsert_many`) and recalls them
|
||||
by vector similarity (`search`), with point reads (`get`/`get_many`) and deletes
|
||||
(`delete_many`). We persist each chunk verbatim and read only its structural
|
||||
surface — embedding (rank), scope (isolation), id + revision (optimistic lock),
|
||||
origin/injection_source (consistency rule). Semantic content is never interpreted.
|
||||
We implement bifrost's OWN MemoryDataStore Protocol; conformance is #195 parity
|
||||
vs InMemoryMemoryStore.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import sqlite_vec
|
||||
from bifrost.consumer import ConsumerRegistration, build_memory_app
|
||||
from bifrost.memory import (
|
||||
IdempotencyConflict,
|
||||
InvalidArguments,
|
||||
InvalidFilter,
|
||||
RevisionMismatch,
|
||||
StoreCapabilities,
|
||||
)
|
||||
from bifrost.reference_server import JwtVerifier
|
||||
|
||||
_SHORT_RETRY_TTL_SECONDS = 300
|
||||
_DURABLE_JOB_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
# bifrost wire v0.6 scope lattice: three subject axes + agent_self (the #248
|
||||
# agent-identity axis, canonical since #10/v0.5). An axis outside it is InvalidFilter
|
||||
# (-> memory.invalid_filter 400), matching bifrost's reference _validate_scope.
|
||||
_SCOPE_LATTICE = {"end_user", "group", "tenant", "agent_self"}
|
||||
|
||||
# Inbound memory-call observability (#17 observe brick). A self-contained
|
||||
# stdout handler so the lines reliably reach the provider's stdout regardless
|
||||
# of uvicorn's logging config. INFO-level, no propagation to root.
|
||||
_log = logging.getLogger("ratatoskr.provider.memory")
|
||||
if not _log.handlers:
|
||||
_h = logging.StreamHandler(sys.stdout)
|
||||
_h.setFormatter(logging.Formatter("%(asctime)s [memory-provider] %(message)s"))
|
||||
_log.addHandler(_h)
|
||||
_log.setLevel(logging.INFO)
|
||||
_log.propagate = False
|
||||
|
||||
|
||||
def _ctx_actor(ctx: Any) -> str:
|
||||
"""Reference `_ctx_actor`: actor = job_id | jwt_sub | session_id (never the record)."""
|
||||
return str(
|
||||
getattr(ctx, "job_id", None)
|
||||
or getattr(ctx, "jwt_sub", None)
|
||||
or getattr(ctx, "session_id", "")
|
||||
)
|
||||
|
||||
|
||||
def _payload_digest(value: Any) -> str:
|
||||
"""Reference digest: sha256 of canonical JSON (sorted keys, compact, str-coerced)."""
|
||||
blob = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _idempotency_ttl_seconds(idempotency_class: str | None) -> int:
|
||||
if idempotency_class == "durable-job":
|
||||
return _DURABLE_JOB_TTL_SECONDS
|
||||
return _SHORT_RETRY_TTL_SECONDS
|
||||
|
||||
|
||||
def _chunk_id(record: dict) -> str:
|
||||
"""Reference `_chunk_id`: first non-empty of id / chunk_id / memory_id."""
|
||||
for key in ("id", "chunk_id", "memory_id"):
|
||||
value = record.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
raise InvalidArguments("record missing id")
|
||||
|
||||
|
||||
def _record_vector(record: dict) -> list[float]:
|
||||
"""Reference `_record_vector`: embedding, falling back to vector, else []."""
|
||||
value = record.get("embedding", record.get("vector", []))
|
||||
return [float(v) for v in value] if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _scope_subset(record_scope: dict, filter_dict: dict) -> bool:
|
||||
"""True iff record_scope has EVERY axis of filter_dict (AND). Empty filter = match."""
|
||||
return all(record_scope.get(axis) == value for axis, value in filter_dict.items())
|
||||
|
||||
|
||||
def _matches_scope(record_scope: Any, scope_all: dict, scope_any: list) -> bool:
|
||||
"""INV-005 (wire v0.6, #11): a record passes iff
|
||||
`(scope_all empty OR record ⊇ scope_all) AND (scope_any empty OR it matches ≥1 element)`.
|
||||
scope_any is OR/union over a LIST of conjunctive scope dicts (each AND-matched as a whole),
|
||||
never single axes flattened together (the Worldtree #297 over-broadening foot-gun).
|
||||
Byte-faithful to bifrost reference `reference_server/memory.py:_matches_scope`.
|
||||
"""
|
||||
scope = record_scope if isinstance(record_scope, dict) else {}
|
||||
if not _scope_subset(scope, scope_all):
|
||||
return False
|
||||
if scope_any and not any(_scope_subset(scope, element) for element in scope_any):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_scope(scope_all: dict, scope_any: list) -> None:
|
||||
"""STEP 1: scope_all is a flat dict, scope_any a list of flat dicts; every axis in BOTH
|
||||
fields ∈ the v0.6 lattice {end_user, group, tenant, agent_self} (else InvalidFilter),
|
||||
matching the reference `_validate_scope`."""
|
||||
if not isinstance(scope_all, dict):
|
||||
raise InvalidArguments("scope_all must be a flat {axis: value} dict")
|
||||
if not isinstance(scope_any, list) or any(not isinstance(e, dict) for e in scope_any):
|
||||
raise InvalidArguments("scope_any must be a list of {axis: value} dicts")
|
||||
for scope in (scope_all, *scope_any):
|
||||
if any(axis not in _SCOPE_LATTICE for axis in scope):
|
||||
raise InvalidFilter("scope_filter contains unsupported axis")
|
||||
|
||||
|
||||
def _validate_injection(record: dict) -> None:
|
||||
"""INV-007: injected_context requires injection_source; non-injected forbids it."""
|
||||
origin = record.get("origin")
|
||||
injection_source = record.get("injection_source")
|
||||
if origin == "injected_context" and not injection_source:
|
||||
raise InvalidArguments("injected_context record requires injection_source")
|
||||
if injection_source and origin != "injected_context":
|
||||
raise InvalidArguments("injection_source only valid for injected_context origin")
|
||||
|
||||
|
||||
class RatatoskrMemoryStore:
|
||||
"""The MemoryDataStore-shaped store handed to bifrost's build_memory_app."""
|
||||
|
||||
def __init__(self, conn: sqlite3.Connection, embedding_dim: int):
|
||||
self._conn = conn
|
||||
self._dim = embedding_dim
|
||||
|
||||
def describe_store(self) -> dict:
|
||||
# INV-006: advertise ONLY the v1 basic-plane capabilities (advertise-=>-implement).
|
||||
return StoreCapabilities(
|
||||
relational_edges_supported=False,
|
||||
optimistic_locking_supported=True,
|
||||
atomic_supersede_supported=False,
|
||||
transaction_supported=False,
|
||||
filterable_metadata_fields=[],
|
||||
).to_dict()
|
||||
|
||||
async def upsert_many(
|
||||
self,
|
||||
records: list[dict],
|
||||
*,
|
||||
idempotency_key: str,
|
||||
ctx: Any,
|
||||
expected_revisions: dict | None = None,
|
||||
idempotency_class: str | None = None,
|
||||
) -> dict:
|
||||
if not (isinstance(idempotency_key, str) and idempotency_key): # PRE-001
|
||||
raise InvalidArguments("idempotency_key required")
|
||||
_log.info(
|
||||
"memory-call upsert_many REQUEST: %d record(s) idempotency_key=%s actor=%s scopes=%s",
|
||||
len(records), idempotency_key, _ctx_actor(ctx),
|
||||
[r.get("scope") for r in records],
|
||||
)
|
||||
# INV-002: idempotency_id = ("default", verb, actor-from-ctx, key); digest over payload.
|
||||
digest = _payload_digest({"records": records, "expected_revisions": expected_revisions})
|
||||
idempotency_id = "|".join(("default", "upsert_many", _ctx_actor(ctx), idempotency_key))
|
||||
cached = self._conn.execute(
|
||||
"SELECT digest FROM memory_idempotency WHERE idempotency_id = ?",
|
||||
(idempotency_id,),
|
||||
).fetchone()
|
||||
if cached is not None:
|
||||
if cached[0] == digest:
|
||||
return {"upserted": len(records), "replayed": True}
|
||||
raise IdempotencyConflict("idempotency key reused with different payload")
|
||||
for record in records: # INV-007 / PRE-002: validate before any write
|
||||
_validate_injection(record)
|
||||
# INV-004: all chunk rows + vec rows + the idempotency record in one transaction;
|
||||
# a RevisionMismatch raised here rolls the whole batch back.
|
||||
with self._conn:
|
||||
if expected_revisions: # INV-003: optimistic lock, checked before any write
|
||||
for record in records:
|
||||
cid = _chunk_id(record)
|
||||
if cid in expected_revisions:
|
||||
row = self._conn.execute(
|
||||
"SELECT revision FROM memory_chunks WHERE chunk_id = ?", (cid,)
|
||||
).fetchone()
|
||||
current = row[0] if row is not None else 0
|
||||
if current != expected_revisions[cid]:
|
||||
raise RevisionMismatch(
|
||||
f"stale expected revision for {cid}: "
|
||||
f"{expected_revisions[cid]} != {current}"
|
||||
)
|
||||
for record in records:
|
||||
chunk_id = _chunk_id(record)
|
||||
# INV-003: first insert -> revision 1; re-upsert -> revision + 1.
|
||||
self._conn.execute(
|
||||
"INSERT INTO memory_chunks "
|
||||
"(chunk_id, record_json, revision, scope_json, origin) "
|
||||
"VALUES (?, ?, 1, ?, ?) "
|
||||
"ON CONFLICT(chunk_id) DO UPDATE SET "
|
||||
"record_json=excluded.record_json, revision=memory_chunks.revision + 1, "
|
||||
"scope_json=excluded.scope_json, origin=excluded.origin",
|
||||
(
|
||||
chunk_id,
|
||||
json.dumps(record),
|
||||
json.dumps(record.get("scope")),
|
||||
record.get("origin"),
|
||||
),
|
||||
)
|
||||
self._conn.execute("DELETE FROM memory_vec WHERE chunk_id = ?", (chunk_id,))
|
||||
self._conn.execute(
|
||||
"INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
|
||||
(chunk_id, sqlite_vec.serialize_float32(_record_vector(record))),
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT INTO memory_idempotency (idempotency_id, digest, expires_at) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(idempotency_id, digest, time.time() + _idempotency_ttl_seconds(idempotency_class)),
|
||||
)
|
||||
return {"upserted": len(records), "replayed": False}
|
||||
|
||||
async def search(
|
||||
self,
|
||||
vector: list[float],
|
||||
*,
|
||||
top_k: int,
|
||||
scope_all: dict | None = None,
|
||||
scope_any: list | None = None,
|
||||
metadata_filter: dict | None = None,
|
||||
include: dict | None = None,
|
||||
fidelity_target: Any = None,
|
||||
) -> list[dict]:
|
||||
if len(vector) != self._dim: # PRE-001
|
||||
raise InvalidArguments(f"vector length {len(vector)} != embedding_dim {self._dim}")
|
||||
if metadata_filter: # PRE-002: v1 advertises no filterable metadata fields
|
||||
raise InvalidArguments("metadata_filter is unsupported in v1")
|
||||
scope_all = scope_all or {}
|
||||
scope_any = scope_any or []
|
||||
_validate_scope(scope_all, scope_any) # STEP 1 (raises InvalidArguments / InvalidFilter)
|
||||
_log.info(
|
||||
"memory-call search REQUEST: scope_all=%r scope_any=%r top_k=%s vec_dim=%d",
|
||||
scope_all, scope_any, top_k, len(vector),
|
||||
)
|
||||
|
||||
def _emit(rs: list[dict]) -> list[dict]:
|
||||
_log.info(
|
||||
"memory-call search RESPONSE: %d hit(s) %s",
|
||||
len(rs),
|
||||
[
|
||||
{
|
||||
"chunk_id": r["chunk_id"],
|
||||
"score": round(r["score"], 4),
|
||||
"scope": r["chunk"].get("scope"),
|
||||
}
|
||||
for r in rs
|
||||
],
|
||||
)
|
||||
return rs
|
||||
|
||||
if top_k <= 0: # POST-001: at most top_k
|
||||
return _emit([])
|
||||
total = self._conn.execute("SELECT COUNT(*) FROM memory_vec").fetchone()[0]
|
||||
if total == 0:
|
||||
return _emit([])
|
||||
# Over-fetch every candidate ranked by cosine distance, then scope-filter and
|
||||
# take top_k — so top_k counts IN-SCOPE hits (INV-005), not pre-filter hits.
|
||||
rows = self._conn.execute(
|
||||
"SELECT v.chunk_id, v.distance, c.record_json, c.revision "
|
||||
"FROM memory_vec v JOIN memory_chunks c ON c.chunk_id = v.chunk_id "
|
||||
f"WHERE v.embedding MATCH ? AND k = {total} ORDER BY v.distance",
|
||||
(sqlite_vec.serialize_float32([float(x) for x in vector]),),
|
||||
).fetchall()
|
||||
results: list[dict] = []
|
||||
for chunk_id, distance, record_json, revision in rows:
|
||||
record = json.loads(record_json)
|
||||
if not _matches_scope(record.get("scope"), scope_all, scope_any):
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"chunk": record,
|
||||
"chunk_id": chunk_id,
|
||||
"score": 1.0 - distance, # vec0 cosine distance -> similarity
|
||||
"recalled_view": record.get("distillate", record),
|
||||
"revision": revision,
|
||||
}
|
||||
)
|
||||
if len(results) >= top_k:
|
||||
break
|
||||
return _emit(results)
|
||||
|
||||
async def get(self, chunk_id: str) -> dict | None:
|
||||
# INV-001: verbatim round-trip + an attached revision key, or None.
|
||||
row = self._conn.execute(
|
||||
"SELECT record_json, revision FROM memory_chunks WHERE chunk_id = ?",
|
||||
(chunk_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
record = json.loads(row[0])
|
||||
record["revision"] = row[1]
|
||||
return record
|
||||
|
||||
async def get_many(self, ids: list[str]) -> list[dict]:
|
||||
# List form of get: found records only (absent ids are skipped).
|
||||
found = []
|
||||
for chunk_id in ids:
|
||||
record = await self.get(chunk_id)
|
||||
if record is not None:
|
||||
found.append(record)
|
||||
return found
|
||||
|
||||
async def delete_many(self, ids: list[str]) -> dict:
|
||||
# One transaction: chunk row + its vec row leave together (no orphan vec rows).
|
||||
_log.info("memory-call delete_many REQUEST: ids=%s", ids)
|
||||
deleted = 0
|
||||
with self._conn:
|
||||
for chunk_id in ids:
|
||||
cur = self._conn.execute(
|
||||
"DELETE FROM memory_chunks WHERE chunk_id = ?", (chunk_id,)
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
deleted += 1
|
||||
self._conn.execute("DELETE FROM memory_vec WHERE chunk_id = ?", (chunk_id,))
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
def open_memory_store(db_path: str, *, embedding_dim: int) -> RatatoskrMemoryStore:
|
||||
"""Open the SQLite+sqlite-vec memory store, creating schema + the vec index on first use."""
|
||||
if not (isinstance(embedding_dim, int) and embedding_dim > 0): # PRE-002
|
||||
raise ValueError("embedding_dim must be a positive int")
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.enable_load_extension(True)
|
||||
sqlite_vec.load(conn)
|
||||
conn.enable_load_extension(False)
|
||||
if db_path != ":memory:":
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS memory_chunks ("
|
||||
"chunk_id TEXT PRIMARY KEY, record_json TEXT NOT NULL, "
|
||||
"revision INTEGER NOT NULL, scope_json TEXT, origin TEXT)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS memory_idempotency ("
|
||||
"idempotency_id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires_at REAL)"
|
||||
)
|
||||
# INV-005 / cosine recall: vec0 index at the fixed PINNED_EMBEDDER_DIM, cosine metric.
|
||||
conn.execute(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS memory_vec USING vec0("
|
||||
f"chunk_id TEXT PRIMARY KEY, embedding float[{embedding_dim}] distance_metric=cosine)"
|
||||
)
|
||||
conn.commit()
|
||||
return RatatoskrMemoryStore(conn, embedding_dim)
|
||||
|
||||
|
||||
def build_memory_provider_app(
|
||||
store: RatatoskrMemoryStore,
|
||||
heimdall_key: bytes,
|
||||
consumer_id: str = "ratatoskr",
|
||||
):
|
||||
"""Wire the JWT verifier + registration and hand the store to bifrost.
|
||||
|
||||
Returns a Starlette ASGI app exposing POST /bifrost/handshake and
|
||||
POST /bifrost/memory-call. The library owns the wire; this is the thin glue.
|
||||
"""
|
||||
if not isinstance(store.describe_store(), dict): # PRE-001 / INV-008
|
||||
raise ValueError("store must advertise capabilities via describe_store()")
|
||||
if not (isinstance(heimdall_key, bytes) and heimdall_key): # PRE-002
|
||||
raise ValueError("heimdall_key must be non-empty bytes")
|
||||
verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key)
|
||||
registration = ConsumerRegistration(consumer_id=consumer_id)
|
||||
return build_memory_app(store=store, verifier=verifier, registration=registration)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Dispatch-layer observe feed for the Bifrost provider (issue #17, Observe half).
|
||||
|
||||
`instrument_provider_app` wraps a built provider ASGI app so every inbound
|
||||
bifrost-call emits one structured `OpEvent` — correlated by `session_id` read off
|
||||
the dispatch JWT — WITHOUT touching the store's scope semantics (INV-004). It is
|
||||
the lens that lets ratatoskr, owning BOTH ends of the round-trip, see exactly
|
||||
which memory/affect ops a given turn produced.
|
||||
|
||||
The store-method stdout shim in `memory_store.py` cannot see `session_id` for
|
||||
search/get/delete (bifrost withholds `ctx` from those store methods); this feed
|
||||
sits at the DISPATCH/ASGI layer where the JWT — and thus `session_id` (its `sub`
|
||||
claim) — is always present (INV-005).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpEvent:
|
||||
"""One observed bifrost-call at the dispatch layer (scope-only, never content)."""
|
||||
|
||||
ts: str # ISO 8601 UTC, capture time
|
||||
plane: str # "memory" | "affect"
|
||||
op: str # verb: search / upsert_many / get / get_many / delete_many / emit / handshake
|
||||
session_id: str | None # JWT `sub` at the dispatch layer; None only if the JWT omits it
|
||||
status: str # "ok" | "error"
|
||||
req_summary: dict[str, Any] # per-verb, scope-only — no record bodies
|
||||
resp_summary: dict[str, Any] # per-verb counts + ids/scores — never verbatim content
|
||||
turn_id: str | None = None # INV-005 reservation, literal; unused in v1
|
||||
|
||||
|
||||
class OpSink(Protocol):
|
||||
"""Anything that accepts an OpEvent. v1 ships JsonlOpSink; tests pass fakes."""
|
||||
|
||||
def emit(self, event: OpEvent) -> None: ...
|
||||
|
||||
|
||||
class JsonlOpSink:
|
||||
"""Continuous append-only JSONL sink (INV-007: NOT per-session). One JSON line
|
||||
per OpEvent to a text stream (default stdout)."""
|
||||
|
||||
def __init__(self, stream: Any = None) -> None:
|
||||
self._stream = sys.stdout if stream is None else stream
|
||||
|
||||
def emit(self, event: OpEvent) -> None:
|
||||
self._stream.write(json.dumps(asdict(event), separators=(",", ":")) + "\n")
|
||||
self._stream.flush()
|
||||
|
||||
|
||||
def maybe_instrument_from_env(app: Any, env: Any, *, plane: str) -> Any:
|
||||
"""Opt-in serve wiring: when `RATATOSKR_OPFEED_PATH` is set, wrap `app` with
|
||||
the dispatch-layer op-feed writing JSONL to that path; otherwise return `app`
|
||||
unchanged. The append stream lives for the process (a long-running server)."""
|
||||
path = env.get("RATATOSKR_OPFEED_PATH")
|
||||
if not path:
|
||||
return app
|
||||
stream = open(path, "a", encoding="utf-8") # process-lifetime append stream
|
||||
return instrument_provider_app(app, plane=plane, sink=JsonlOpSink(stream))
|
||||
|
||||
|
||||
_BIFROST_PATHS = (
|
||||
"/bifrost/handshake",
|
||||
"/bifrost/memory-call",
|
||||
"/bifrost/affect-call",
|
||||
)
|
||||
|
||||
|
||||
def _b64url_decode(seg: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))
|
||||
|
||||
|
||||
def _session_id_from_auth(auth: bytes | None) -> str | None:
|
||||
"""Read the `sub` claim (= session_id, per bifrost DispatchContext) off the
|
||||
dispatch JWT WITHOUT verifying its signature — the inner app does real
|
||||
verification; we only read a claim for correlation. None if absent/malformed."""
|
||||
if not auth:
|
||||
return None
|
||||
try:
|
||||
token = auth.decode("latin-1").strip()
|
||||
if token.lower().startswith("bearer "):
|
||||
token = token[7:].strip()
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
payload = json.loads(_b64url_decode(parts[1]))
|
||||
sub = payload.get("sub")
|
||||
return sub if isinstance(sub, str) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _op_from(path: str, req: dict[str, Any]) -> str:
|
||||
"""The verb: 'handshake' for the handshake path; otherwise the body's
|
||||
`operation`, with the affect-plane `affect.` prefix stripped (affect.emit ->
|
||||
emit) so op vocabulary stays bare per the contract."""
|
||||
if path == "/bifrost/handshake":
|
||||
return "handshake"
|
||||
operation = req.get("operation") or "unknown"
|
||||
if path == "/bifrost/affect-call" and operation.startswith("affect."):
|
||||
return operation.split(".", 1)[1]
|
||||
return operation
|
||||
|
||||
|
||||
def _ids_summary(args: dict[str, Any]) -> list[Any]:
|
||||
"""Mirror bifrost `_ids_arg`: ids | chunk_ids | [chunk_id|id]."""
|
||||
ids = args.get("ids") or args.get("chunk_ids")
|
||||
if ids is None:
|
||||
single = args.get("chunk_id") or args.get("id")
|
||||
ids = [single] if single is not None else []
|
||||
return ids
|
||||
|
||||
|
||||
def _req_summary(plane: str, path: str, op: str, req: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Scope-only request summary — NEVER record bodies / PAD content."""
|
||||
if path == "/bifrost/handshake":
|
||||
return {"caps_requested": req.get("capabilities_requested")}
|
||||
if plane == "affect":
|
||||
return {} # affect stays conduit-opaque — no PAD content surfaced
|
||||
args = req.get("args") or {}
|
||||
if op == "search":
|
||||
return {
|
||||
"scope_all": args.get("scope_all") or {},
|
||||
"scope_any": args.get("scope_any") or [],
|
||||
"top_k": args.get("top_k"),
|
||||
}
|
||||
if op == "upsert_many":
|
||||
records = args.get("records") or []
|
||||
return {
|
||||
"record_count": len(records),
|
||||
"scopes": [r.get("scope") for r in records],
|
||||
}
|
||||
if op in ("get", "get_many", "delete_many"):
|
||||
return {"ids": _ids_summary(args)}
|
||||
return {}
|
||||
|
||||
|
||||
def _resp_summary(
|
||||
plane: str, path: str, op: str, resp: dict[str, Any], status: str
|
||||
) -> dict[str, Any]:
|
||||
"""Per-verb counts + ids/scores — never verbatim content. On error, the
|
||||
bifrost error `code` (INV-007: failures recorded, not hidden)."""
|
||||
if status == "error":
|
||||
return {"error": resp.get("code")}
|
||||
if path == "/bifrost/handshake":
|
||||
return {"ok": True, "caps_granted": resp.get("capabilities_granted")}
|
||||
if plane == "affect":
|
||||
return {"stored": bool(resp.get("stored"))}
|
||||
if op == "search":
|
||||
results = resp.get("results") or []
|
||||
return {
|
||||
"hit_count": len(results),
|
||||
"hits": [
|
||||
{"chunk_id": r.get("chunk_id"), "score": r.get("score")}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
if op == "upsert_many":
|
||||
return {"upserted": resp.get("upserted"), "replayed": resp.get("replayed")}
|
||||
if op == "get":
|
||||
return {"found_count": 1 if resp.get("record") else 0}
|
||||
if op == "get_many":
|
||||
return {"found_count": len(resp.get("records") or [])}
|
||||
if op == "delete_many":
|
||||
return {"deleted": resp.get("deleted")}
|
||||
return {}
|
||||
|
||||
|
||||
def _build_event(
|
||||
plane: str, path: str, scope: dict[str, Any], req_body: bytes, captured: dict[str, Any]
|
||||
) -> OpEvent:
|
||||
headers = dict(scope.get("headers") or [])
|
||||
session_id = _session_id_from_auth(headers.get(b"authorization"))
|
||||
status = "ok" if 200 <= int(captured["status"]) < 300 else "error"
|
||||
req = _safe_json(req_body)
|
||||
resp = _safe_json(captured["body"])
|
||||
|
||||
op = _op_from(path, req)
|
||||
return OpEvent(
|
||||
ts=datetime.now(UTC).isoformat(),
|
||||
plane=plane,
|
||||
op=op,
|
||||
session_id=session_id,
|
||||
status=status,
|
||||
req_summary=_req_summary(plane, path, op, req),
|
||||
resp_summary=_resp_summary(plane, path, op, resp, status),
|
||||
)
|
||||
|
||||
|
||||
def _safe_json(raw: bytes) -> dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def instrument_provider_app(app: Any, *, plane: str, sink: OpSink) -> Any:
|
||||
"""Wrap a built provider ASGI `app` so each inbound bifrost-call emits one
|
||||
OpEvent to `sink`. Read-only over dispatch — store scope semantics untouched
|
||||
(INV-004). A sink/summary failure never propagates into the dispatch path
|
||||
(POST-003 / INV-007) — it is swallowed and logged to stderr.
|
||||
"""
|
||||
if plane not in ("memory", "affect"):
|
||||
raise ValueError(f"plane must be 'memory' or 'affect', got {plane!r}")
|
||||
|
||||
async def wrapped(scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
if scope.get("type") != "http" or scope.get("path") not in _BIFROST_PATHS:
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
|
||||
# Buffer the request body so we can BOTH summarise it AND replay it to the
|
||||
# inner app (the inner app consumes `receive`; we must not steal the body).
|
||||
req_body = b""
|
||||
more = True
|
||||
while more:
|
||||
message = await receive()
|
||||
if message["type"] == "http.request":
|
||||
req_body += message.get("body", b"")
|
||||
more = message.get("more_body", False)
|
||||
else: # http.disconnect
|
||||
more = False
|
||||
|
||||
replayed = False
|
||||
|
||||
async def replay_receive() -> dict[str, Any]:
|
||||
nonlocal replayed
|
||||
if not replayed:
|
||||
replayed = True
|
||||
return {"type": "http.request", "body": req_body, "more_body": False}
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
captured: dict[str, Any] = {"status": 500, "body": b""}
|
||||
|
||||
async def capture_send(message: dict[str, Any]) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
captured["status"] = message["status"]
|
||||
elif message["type"] == "http.response.body":
|
||||
captured["body"] += message.get("body", b"")
|
||||
await send(message)
|
||||
|
||||
await app(scope, replay_receive, capture_send)
|
||||
|
||||
try:
|
||||
sink.emit(_build_event(plane, scope["path"], scope, req_body, captured))
|
||||
except Exception as exc: # observe gap, never a serve break (POST-003)
|
||||
sys.stderr.write(f"[opfeed] OpEvent emit failed (swallowed): {exc!r}\n")
|
||||
|
||||
return wrapped
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Runnable entrypoint: serve the affect provider as an ASGI app.
|
||||
|
||||
For the live negotiation smoke against a Worldtree instance. Config from env:
|
||||
- RATATOSKR_HEIMDALL_KEY (required): HS256 shared key for the consumer, utf-8.
|
||||
- RATATOSKR_AFFECT_DB (default "affect.db"): SQLite path; ":memory:" = ephemeral.
|
||||
- RATATOSKR_CONSUMER_ID (default "ratatoskr").
|
||||
- RATATOSKR_PROVIDER_HOST (default "0.0.0.0"), RATATOSKR_PROVIDER_PORT (default 8390).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
|
||||
from ratatoskr.provider.affect_store import build_affect_provider_app, open_affect_store
|
||||
from ratatoskr.provider.opfeed import maybe_instrument_from_env
|
||||
|
||||
|
||||
def build_app_from_env(env: Mapping[str, str] | None = None):
|
||||
"""Build the affect ASGI app from environment config (testable seam)."""
|
||||
env = os.environ if env is None else env
|
||||
key = env.get("RATATOSKR_HEIMDALL_KEY")
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"RATATOSKR_HEIMDALL_KEY is required to serve the affect provider"
|
||||
)
|
||||
store = open_affect_store(env.get("RATATOSKR_AFFECT_DB", "affect.db"))
|
||||
app = build_affect_provider_app(
|
||||
store,
|
||||
heimdall_key=key.encode(),
|
||||
consumer_id=env.get("RATATOSKR_CONSUMER_ID", "ratatoskr"),
|
||||
)
|
||||
# Issue #17 (Observe): opt-in dispatch-layer op-feed when RATATOSKR_OPFEED_PATH set.
|
||||
return maybe_instrument_from_env(app, env, plane="affect")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
build_app_from_env(),
|
||||
host=os.environ.get("RATATOSKR_PROVIDER_HOST", "0.0.0.0"),
|
||||
port=int(os.environ.get("RATATOSKR_PROVIDER_PORT", "8390")),
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Runnable entrypoint: serve the memory provider as an ASGI app.
|
||||
|
||||
For the live negotiation smoke against a Worldtree instance. Config from env:
|
||||
- RATATOSKR_HEIMDALL_KEY (required): HS256 shared key for the consumer, utf-8.
|
||||
- RATATOSKR_MEMORY_EMBEDDING_DIM (required): the pinned embedder dim; the
|
||||
sqlite-vec index is created at this fixed dim, so a wrong value silently
|
||||
breaks search — no default.
|
||||
- RATATOSKR_MEMORY_DB (default "memory.db"): SQLite path; ":memory:" = ephemeral.
|
||||
- RATATOSKR_CONSUMER_ID (default "ratatoskr").
|
||||
- RATATOSKR_PROVIDER_HOST (default "0.0.0.0"),
|
||||
RATATOSKR_MEMORY_PROVIDER_PORT (default 8391 — distinct from affect's 8390 so
|
||||
both planes can run side-by-side as separate apps, per the v1 contract).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
|
||||
from ratatoskr.provider.memory_store import build_memory_provider_app, open_memory_store
|
||||
from ratatoskr.provider.opfeed import maybe_instrument_from_env
|
||||
|
||||
|
||||
def build_memory_app_from_env(env: Mapping[str, str] | None = None):
|
||||
"""Build the memory ASGI app from environment config (testable seam)."""
|
||||
env = os.environ if env is None else env
|
||||
key = env.get("RATATOSKR_HEIMDALL_KEY")
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"RATATOSKR_HEIMDALL_KEY is required to serve the memory provider"
|
||||
)
|
||||
raw_dim = env.get("RATATOSKR_MEMORY_EMBEDDING_DIM")
|
||||
if not raw_dim:
|
||||
raise RuntimeError(
|
||||
"RATATOSKR_MEMORY_EMBEDDING_DIM is required (Worldtree's PINNED_EMBEDDER_DIM)"
|
||||
)
|
||||
try:
|
||||
embedding_dim = int(raw_dim)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"RATATOSKR_MEMORY_EMBEDDING_DIM must be an int, got {raw_dim!r}"
|
||||
) from exc
|
||||
if embedding_dim <= 0:
|
||||
raise RuntimeError("RATATOSKR_MEMORY_EMBEDDING_DIM must be a positive int")
|
||||
store = open_memory_store(
|
||||
env.get("RATATOSKR_MEMORY_DB", "memory.db"), embedding_dim=embedding_dim
|
||||
)
|
||||
app = build_memory_provider_app(
|
||||
store,
|
||||
heimdall_key=key.encode(),
|
||||
consumer_id=env.get("RATATOSKR_CONSUMER_ID", "ratatoskr"),
|
||||
)
|
||||
# Issue #17 (Observe): opt-in dispatch-layer op-feed when RATATOSKR_OPFEED_PATH set.
|
||||
return maybe_instrument_from_env(app, env, plane="memory")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
build_memory_app_from_env(),
|
||||
host=os.environ.get("RATATOSKR_PROVIDER_HOST", "0.0.0.0"),
|
||||
port=int(os.environ.get("RATATOSKR_MEMORY_PROVIDER_PORT", "8391")),
|
||||
)
|
||||
+115
-2
@@ -59,6 +59,20 @@ class AgentInfo:
|
||||
ui_hints: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BifrostBinding:
|
||||
"""Session-create Bifrost binding (Worldtree BifrostBindingRequest, #160).
|
||||
|
||||
Issue #17. `endpoint_url` is the WORLDTREE-VISIBLE base URL of ONE provider
|
||||
plane (memory :8391 / affect :8390). `scope` is an opaque pass-through copied
|
||||
into the handshake JWT unchanged (≤256 chars); ratatoskr does not interpret
|
||||
it and v1 sends None.
|
||||
"""
|
||||
|
||||
endpoint_url: str
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
class AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 from POST /sessions — unknown agent_id."""
|
||||
|
||||
@@ -89,6 +103,40 @@ class SessionApiFailed(Exception):
|
||||
self.body = body
|
||||
|
||||
|
||||
# Issue #17 — Bifrost-bind failure modes on POST /sessions.
|
||||
class BifrostConsumerKeyMissing(Exception):
|
||||
"""A bifrost binding was requested without a consumer_key.
|
||||
|
||||
Raised BEFORE any HTTP (INV-001): the bound create must never silently fall
|
||||
back to the client's default canary key — the consumer key IS the handshake
|
||||
identity Worldtree signs the Bifrost JWT with.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"bifrost binding requires a non-empty consumer_key; refusing to "
|
||||
"fall back to the canary key (INV-001)"
|
||||
)
|
||||
|
||||
|
||||
class BifrostHandshakeFailed(Exception):
|
||||
"""Raised on HTTP 502 `bifrost_handshake_failed` from a bound POST /sessions.
|
||||
|
||||
INV-002: the Bifrost handshake runs synchronously at session-create, so a
|
||||
handshake failure (bad URL / down provider / wrong key / HTTPS rejection)
|
||||
fails SESSION CREATION — surfaced on the create path, never deferred to the
|
||||
first turn. `bifrost_error` carries the spec-level code (e.g.
|
||||
`bifrost.auth_rejected`); `body` is truncated to ≤1024 bytes, consistent with
|
||||
`SessionApiFailed` (INV-004 precedent).
|
||||
"""
|
||||
|
||||
def __init__(self, *, bifrost_error: str | None, body: bytes) -> None:
|
||||
body = body[:1024]
|
||||
super().__init__(f"bifrost handshake failed: bifrost_error={bifrost_error!r}")
|
||||
self.bifrost_error = bifrost_error
|
||||
self.body = body
|
||||
|
||||
|
||||
# Worldtree #204 / v0.28.0 — persona_state endpoint failure modes.
|
||||
class PersonaNotConfigured(Exception):
|
||||
"""Raised on HTTP 404 `persona_not_configured` from GET persona_state.
|
||||
@@ -176,11 +224,51 @@ async def list_sessions(
|
||||
return SessionPage(items=items, next_cursor=body.get("next_cursor"))
|
||||
|
||||
|
||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
|
||||
|
||||
Issue #17 dev helper: `memory` → :8391, `affect` → :8390. Returns the
|
||||
Worldtree-visible base (e.g. `http://10.100.10.50:8391`), NOT the client's
|
||||
loopback — Worldtree must reach the provider over the network. `http://` is
|
||||
deliberate: the HTTPS relaxation is allowlist-side (Worldtree's
|
||||
BIFROST_CLIENT_ALLOWED_HOSTS), not a URL concern. A production HTTPS endpoint
|
||||
is supplied directly, bypassing this helper.
|
||||
"""
|
||||
if plane not in ("memory", "affect"):
|
||||
raise ValueError(
|
||||
f"unknown plane: {plane!r} (expected 'memory' or 'affect')"
|
||||
)
|
||||
port = 8391 if plane == "memory" else 8390
|
||||
return f"http://{base_host}:{port}"
|
||||
|
||||
|
||||
def _bifrost_error_from(resp: httpx.Response) -> str | None:
|
||||
"""Pull the spec-level `bifrost_error` from a 502 body.
|
||||
|
||||
Tolerates both the FastAPI-nested `{"detail": {"bifrost_error": …}}` shape
|
||||
(the spec's documented form, §"Optional Bifrost binding") and a flat
|
||||
top-level `bifrost_error`, per the both-shape unwrap precedent established for
|
||||
persona_state errors (the real wire returns the detail-nested form).
|
||||
"""
|
||||
try:
|
||||
err = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(err, dict):
|
||||
return None
|
||||
bifrost_error = err.get("bifrost_error")
|
||||
if bifrost_error is None and isinstance(err.get("detail"), dict):
|
||||
bifrost_error = err["detail"].get("bifrost_error")
|
||||
return bifrost_error
|
||||
|
||||
|
||||
async def create_session(
|
||||
client: httpx.AsyncClient,
|
||||
agent_id: str,
|
||||
*,
|
||||
end_user_id: str | None = None,
|
||||
bifrost: BifrostBinding | None = None,
|
||||
consumer_key: str | None = None,
|
||||
) -> SessionInfo:
|
||||
"""POST /sessions to create a new session. See contract FN create_session.
|
||||
|
||||
@@ -188,17 +276,42 @@ async def create_session(
|
||||
When None (default), the body shape matches the pre-#5 baseline
|
||||
`{"agent_id": agent_id}` so existing callers (mimir smoke) are unaffected.
|
||||
Empty-string `end_user_id` is rejected before HTTP (PRE-003).
|
||||
|
||||
Per issue #17: when `bifrost` is set the request carries the binding and
|
||||
authenticates with `consumer_key` (NOT the client's default canary bearer);
|
||||
Worldtree handshakes synchronously to our provider before 201.
|
||||
"""
|
||||
assert client is not None
|
||||
assert agent_id and isinstance(agent_id, str)
|
||||
assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
|
||||
|
||||
body: dict[str, str] = {"agent_id": agent_id}
|
||||
# PRE-001 (INV-001): a bifrost binding REQUIRES a non-empty consumer key,
|
||||
# enforced before any HTTP so a bound create never falls back to the canary.
|
||||
if bifrost is not None and not (isinstance(consumer_key, str) and consumer_key):
|
||||
raise BifrostConsumerKeyMissing()
|
||||
|
||||
body: dict[str, Any] = {"agent_id": agent_id}
|
||||
if end_user_id is not None:
|
||||
body["end_user_id"] = end_user_id
|
||||
resp = await client.post("/sessions", json=body)
|
||||
headers: dict[str, str] = {}
|
||||
if bifrost is not None:
|
||||
body["bifrost"] = {
|
||||
"endpoint_url": bifrost.endpoint_url,
|
||||
"scope": bifrost.scope,
|
||||
}
|
||||
# INV-001: the bound create authenticates with the consumer key,
|
||||
# overriding the httpx client's default canary bearer per-request.
|
||||
headers["Authorization"] = f"Bearer {consumer_key}"
|
||||
resp = await client.post("/sessions", json=body, headers=headers)
|
||||
if resp.status_code == 404:
|
||||
raise AgentNotFound(agent_id=agent_id)
|
||||
# POST-002 (INV-002): a 502 on a BOUND create is the synchronous Bifrost
|
||||
# handshake failing. Gated on `bifrost is not None` — an unbound create's
|
||||
# 502 is a generic upstream fault and stays SessionApiFailed.
|
||||
if bifrost is not None and resp.status_code == 502:
|
||||
raise BifrostHandshakeFailed(
|
||||
bifrost_error=_bifrost_error_from(resp), body=resp.content
|
||||
)
|
||||
if resp.status_code != 201:
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
body = resp.json()
|
||||
|
||||
+32
-1
@@ -38,6 +38,8 @@ from ratatoskr.sessions import (
|
||||
AgentNotAvailable,
|
||||
AgentNotFound,
|
||||
AuthScopeDenied,
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
create_session,
|
||||
@@ -1504,11 +1506,33 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
||||
assert chosen_agent_id is not None
|
||||
try:
|
||||
info = await create_session(
|
||||
client, chosen_agent_id, end_user_id=args.end_user_id
|
||||
client,
|
||||
chosen_agent_id,
|
||||
end_user_id=args.end_user_id,
|
||||
bifrost=args.bifrost,
|
||||
consumer_key=args.consumer_key,
|
||||
)
|
||||
except AgentNotFound as exc:
|
||||
sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
|
||||
return 12
|
||||
except BifrostConsumerKeyMissing as exc:
|
||||
# INV-001/INV-002: bind failures land on real stderr BEFORE the
|
||||
# alt-screen opens (mirrors cli._amain exit codes / vocab, INV-006).
|
||||
sys.stderr.write(
|
||||
f"[bifrost_consumer_key_missing] {exc} "
|
||||
f"(set RATATOSKR_BIFROST_CONSUMER_KEY)\n"
|
||||
)
|
||||
return 22
|
||||
except BifrostHandshakeFailed as exc:
|
||||
sys.stderr.write(
|
||||
f"[bifrost_handshake_failed] bifrost_error={exc.bifrost_error}\n"
|
||||
)
|
||||
if exc.bifrost_error == "bifrost.auth_rejected":
|
||||
sys.stderr.write(
|
||||
" bound create requires the consumer key "
|
||||
"(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
|
||||
)
|
||||
return 23
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(
|
||||
f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
|
||||
@@ -1517,6 +1541,13 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||
return 21
|
||||
# Issue #17 bound-state indicator (pre-alt-screen, mirrors cli._amain).
|
||||
if args.bifrost is not None:
|
||||
plane = args.bifrost_plane or "direct"
|
||||
sys.stderr.write(
|
||||
f". bifrost: status=bound plane={plane} "
|
||||
f"endpoint={args.bifrost.endpoint_url}\n"
|
||||
)
|
||||
session_id = info.session_id
|
||||
agent_id: str | None = info.agent_id
|
||||
else:
|
||||
|
||||
@@ -704,7 +704,20 @@ async function loadPersona(agentId) {
|
||||
renderPersonaPane(snap);
|
||||
setPersonaStrip(snap);
|
||||
} else {
|
||||
$("pane-persona").innerHTML = `<div class="empty">persona not available (HTTP ${esc(r.status)})</div>`;
|
||||
let code = "";
|
||||
try { code = (await r.json()).error_code || ""; } catch (_) {}
|
||||
let msg;
|
||||
if (r.status === 404 && code === "persona_not_configured" && agentId.includes(":")) {
|
||||
msg = "persona telemetry isn't exposed for Tier-3 (consumer-defined) agents on this Worldtree yet — " +
|
||||
"the agent still responds in character; only this affect / OCEAN readout is gated.";
|
||||
} else if (r.status === 404 && code === "persona_not_configured") {
|
||||
msg = "this agent has no persona configured.";
|
||||
} else if (r.status === 403) {
|
||||
msg = "persona telemetry requires the persona.read scope.";
|
||||
} else {
|
||||
msg = `persona unavailable (HTTP ${esc(r.status)}${code ? " · " + esc(code) : ""}).`;
|
||||
}
|
||||
$("pane-persona").innerHTML = `<div class="empty">${msg}</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
$("pane-persona").innerHTML = `<div class="empty">persona fetch failed</div>`;
|
||||
|
||||
+134
-13
@@ -19,6 +19,7 @@ from ratatoskr.cli import (
|
||||
_run_turn,
|
||||
main,
|
||||
)
|
||||
from ratatoskr.sessions import BifrostBinding
|
||||
from ratatoskr.sse_client import (
|
||||
Cancelled,
|
||||
Done,
|
||||
@@ -86,6 +87,8 @@ def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
|
||||
monkeypatch.delenv("WORLDTREE_API_URL", raising=False)
|
||||
monkeypatch.delenv("RATATOSKR_END_USER_ID", raising=False)
|
||||
monkeypatch.delenv("RATATOSKR_BIFROST_CONSUMER_KEY", raising=False)
|
||||
monkeypatch.delenv("RATATOSKR_PROVIDER_VISIBLE_HOST", raising=False)
|
||||
|
||||
|
||||
class TestParseArgs:
|
||||
@@ -1290,25 +1293,16 @@ class TestAmain:
|
||||
|
||||
def test_no_textual_import(self) -> None:
|
||||
"""no_textual_import [scenario]: …"""
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
# Clear any prior textual import to make this test honest in isolation
|
||||
textual_was_imported = "textual" in sys.modules
|
||||
# We cannot reliably remove textual mid-suite (other tests might rely on it via dev deps),
|
||||
# so the assertion is: importing ratatoskr.cli does not REQUIRE textual.
|
||||
importlib.reload(__import__("ratatoskr.cli", fromlist=["_amain"]))
|
||||
# The boundary is the INV-001 import-only rule. If ratatoskr/cli.py grew an
|
||||
# `import textual` directly, the import would still succeed (textual is installed)
|
||||
# but the source-level boundary is the load-bearing check — covered by a static-grep
|
||||
# smoke test pattern. Do that here:
|
||||
# INV-001 import-only boundary: cli.py must not import textual/rich at the
|
||||
# source level. The load-bearing check is a static source grep (NOT a live
|
||||
# `importlib.reload`, which would mutate the shared module in place and break
|
||||
# class identity — isinstance / pytest.raises — for every later test).
|
||||
import pathlib
|
||||
|
||||
src = pathlib.Path(__file__).parent.parent / "src" / "ratatoskr" / "cli.py"
|
||||
text = src.read_text()
|
||||
for forbidden in ("import textual", "from textual", "import rich", "from rich"):
|
||||
assert forbidden not in text, f"INV-001 violation: cli.py contains '{forbidden}'"
|
||||
_ = textual_was_imported # avoid unused warning
|
||||
|
||||
|
||||
class TestMain:
|
||||
@@ -1419,3 +1413,130 @@ class TestMain:
|
||||
assert tui_calls[0].send_content is None
|
||||
assert tui_calls[0].session_id == "s-1"
|
||||
assert amain_calls == []
|
||||
|
||||
|
||||
class TestBifrostBindCli:
|
||||
"""Issue #17 slice 3a — the CLI Bifrost-bind trigger (INV-008, one of three)."""
|
||||
|
||||
def test_plane_and_host_build_binding(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""tracer: --bifrost-plane + --bifrost-host resolve a BifrostBinding via
|
||||
endpoint_for_plane; the consumer key comes from the env."""
|
||||
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
|
||||
args = _parse_args(
|
||||
[
|
||||
"--send", "hi", "--new", "--agent", "ratatoskr:sindra", "--api-key", "k",
|
||||
"--bifrost-plane", "memory", "--bifrost-host", "10.100.10.50",
|
||||
]
|
||||
)
|
||||
assert args.bifrost == BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
assert args.bifrost_plane == "memory"
|
||||
assert args.consumer_key == "ck"
|
||||
|
||||
def test_host_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--bifrost-host falls back to RATATOSKR_PROVIDER_VISIBLE_HOST."""
|
||||
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
|
||||
monkeypatch.setenv("RATATOSKR_PROVIDER_VISIBLE_HOST", "10.0.0.9")
|
||||
args = _parse_args(
|
||||
["--send", "hi", "--new", "--agent", "a", "--api-key", "k",
|
||||
"--bifrost-plane", "affect"]
|
||||
)
|
||||
assert args.bifrost == BifrostBinding(endpoint_url="http://10.0.0.9:8390")
|
||||
|
||||
def test_direct_url_bypasses_plane(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--bifrost-url is the direct (HTTPS/prod) endpoint, bypassing the plane
|
||||
shortcut; no plane label."""
|
||||
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
|
||||
args = _parse_args(
|
||||
["--send", "hi", "--new", "--agent", "a", "--api-key", "k",
|
||||
"--bifrost-url", "https://prov.example:8391"]
|
||||
)
|
||||
assert args.bifrost == BifrostBinding(endpoint_url="https://prov.example:8391")
|
||||
assert args.bifrost_plane is None
|
||||
|
||||
def test_no_bifrost_flags_leaves_binding_none(self) -> None:
|
||||
"""regression: no bifrost flags → bifrost/consumer_key None (pre-#17 path)."""
|
||||
args = _parse_args(
|
||||
["--send", "hi", "--new", "--agent", "mimir", "--api-key", "k"]
|
||||
)
|
||||
assert args.bifrost is None
|
||||
assert args.consumer_key is None
|
||||
|
||||
def test_plane_and_url_mutually_exclusive(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
|
||||
with pytest.raises(UsageError):
|
||||
_parse_args(
|
||||
["--send", "hi", "--new", "--agent", "a", "--api-key", "k",
|
||||
"--bifrost-plane", "memory", "--bifrost-host", "h",
|
||||
"--bifrost-url", "https://x:8391"]
|
||||
)
|
||||
|
||||
def test_plane_without_host_is_usage_error(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
|
||||
with pytest.raises(UsageError):
|
||||
_parse_args(
|
||||
["--send", "hi", "--new", "--agent", "a", "--api-key", "k",
|
||||
"--bifrost-plane", "memory"]
|
||||
)
|
||||
|
||||
def test_bind_with_existing_session_is_usage_error(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A binding is a session-CREATE concern; --session (existing) + bind is
|
||||
a usage error."""
|
||||
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
|
||||
with pytest.raises(UsageError):
|
||||
_parse_args(
|
||||
["--send", "hi", "--session", "s-1", "--api-key", "k",
|
||||
"--bifrost-plane", "memory", "--bifrost-host", "h"]
|
||||
)
|
||||
|
||||
@respx.mock
|
||||
async def test_amain_bound_create_carries_binding_and_routes_502(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""_amain on a bound create sends the bifrost body + the consumer-key
|
||||
bearer; a 502 auth_rejected routes to BifrostHandshakeFailed with the
|
||||
consumer-key-mismatch hint (INV-001/002, 401-message scoping)."""
|
||||
route = respx.post("http://w/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
502,
|
||||
json={
|
||||
"error_code": "bifrost_handshake_failed",
|
||||
"detail": {"bifrost_error": "bifrost.auth_rejected"},
|
||||
},
|
||||
)
|
||||
)
|
||||
args = ParsedArgs(
|
||||
send_content="hi", session_id=None, new=True, agent_id="ratatoskr:sindra",
|
||||
api_key="canary", server_url="http://w", raw=False, end_user_id="smoke-user",
|
||||
bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"),
|
||||
bifrost_plane="memory", consumer_key="ck",
|
||||
)
|
||||
rc = await _amain(args)
|
||||
assert rc == 23
|
||||
body = json.loads(route.calls[0].request.content)
|
||||
assert body["bifrost"] == {
|
||||
"endpoint_url": "http://10.100.10.50:8391", "scope": None
|
||||
}
|
||||
assert route.calls[0].request.headers["Authorization"] == "Bearer ck"
|
||||
err = capsys.readouterr().err
|
||||
assert "bifrost.auth_rejected" in err
|
||||
assert "consumer key" in err # the 401-scoping hint
|
||||
|
||||
async def test_amain_bind_without_consumer_key_exits(self) -> None:
|
||||
"""_amain on a bind with no consumer key raises BifrostConsumerKeyMissing
|
||||
(before HTTP) → a clean exit code, never a canary fallback."""
|
||||
args = ParsedArgs(
|
||||
send_content="hi", session_id=None, new=True, agent_id="a",
|
||||
api_key="canary", server_url="http://w", raw=False, end_user_id=None,
|
||||
bifrost=BifrostBinding(endpoint_url="http://x:8391"),
|
||||
bifrost_plane="memory", consumer_key=None,
|
||||
)
|
||||
rc = await _amain(args)
|
||||
assert rc == 22
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Tests for the Tier-3 Bifrost affect provider (ratatoskr.provider.affect_store).
|
||||
|
||||
Contract: docs/contracts/bifrost_affect_provider.contract.md
|
||||
Vertical tracer-first: basic_emit -> opacity -> lww -> replay -> conflict ->
|
||||
missing_key -> #195 parity vs InMemoryAffectStore.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from bifrost.affect import AffectIdempotencyConflict, AffectInvalidArguments
|
||||
|
||||
from ratatoskr.provider.affect_store import build_affect_provider_app, open_affect_store
|
||||
|
||||
|
||||
def _ctx(sub: str = "sub-1"):
|
||||
# Mirrors bifrost's _ctx_actor: actor = jwt_sub (test ctx) or session_id.
|
||||
return types.SimpleNamespace(jwt_sub=sub)
|
||||
|
||||
|
||||
def _snapshot(agent: str = "a1", user: str = "u1", **payload):
|
||||
base = {
|
||||
"agent_id": agent,
|
||||
"end_user_id": user,
|
||||
"pad": {"p": 0.1, "a": 0.2, "d": 0.3},
|
||||
"valence": 0.5,
|
||||
"persona_baselines": {"warmth": 0.7},
|
||||
"emitted_at": "2026-06-14T00:00:00Z",
|
||||
}
|
||||
base.update(payload)
|
||||
return base
|
||||
|
||||
|
||||
def _row_count(store, table: str) -> int:
|
||||
return store._conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
||||
|
||||
|
||||
# --- open_affect_store ---
|
||||
|
||||
def test_open_advertises_capability_and_schema():
|
||||
store = open_affect_store(":memory:")
|
||||
assert store.affect_supported is True
|
||||
# both tables queryable
|
||||
store._conn.execute("SELECT * FROM affect_snapshots")
|
||||
store._conn.execute("SELECT * FROM affect_idempotency")
|
||||
|
||||
|
||||
def test_reopen_existing_file_is_idempotent(tmp_path):
|
||||
db = str(tmp_path / "affect.db")
|
||||
open_affect_store(db) # first open creates schema
|
||||
store = open_affect_store(db) # reopen: CREATE TABLE IF NOT EXISTS is a no-op
|
||||
assert store.affect_supported is True
|
||||
store._conn.execute("SELECT * FROM affect_snapshots")
|
||||
store._conn.execute("SELECT * FROM affect_idempotency")
|
||||
|
||||
|
||||
# --- emit ---
|
||||
|
||||
async def test_basic_emit_stores_and_round_trips():
|
||||
store = open_affect_store(":memory:")
|
||||
snap = _snapshot()
|
||||
result = await store.emit(snap, idempotency_key="k1", ctx=_ctx())
|
||||
assert result == {"stored": True}
|
||||
assert store.get("a1", "u1") == snap
|
||||
|
||||
|
||||
async def test_opacity_arbitrary_payload_round_trips_and_addressing_invariant():
|
||||
store = open_affect_store(":memory:")
|
||||
# arbitrary extra/unknown payload fields persist + round-trip verbatim
|
||||
snap = _snapshot(weird_field={"nested": [1, 2, 3]}, mystery="x")
|
||||
assert await store.emit(snap, idempotency_key="k1", ctx=_ctx()) == {"stored": True}
|
||||
assert store.get("a1", "u1") == snap
|
||||
# two snapshots for the same key differing ONLY in payload address the SAME row
|
||||
snap2 = _snapshot(weird_field={"nested": [9]}, mystery="y", valence=0.99)
|
||||
await store.emit(snap2, idempotency_key="k2", ctx=_ctx())
|
||||
assert store.get("a1", "u1") == snap2
|
||||
assert _row_count(store, "affect_snapshots") == 1 # same row overwritten
|
||||
|
||||
|
||||
async def test_lww_by_arrival_ignores_emitted_at():
|
||||
store = open_affect_store(":memory:")
|
||||
a = _snapshot(valence=0.1, emitted_at="2026-06-14T10:00:00Z")
|
||||
b = _snapshot(valence=0.9, emitted_at="2026-06-14T08:00:00Z") # OLDER emitted_at
|
||||
await store.emit(a, idempotency_key="ka", ctx=_ctx())
|
||||
await store.emit(b, idempotency_key="kb", ctx=_ctx()) # distinct key -> arrival wins
|
||||
assert store.get("a1", "u1") == b # later arrival wins despite older emitted_at
|
||||
|
||||
|
||||
async def test_replay_noop_same_key_same_payload():
|
||||
store = open_affect_store(":memory:")
|
||||
snap = _snapshot()
|
||||
assert await store.emit(snap, idempotency_key="k1", ctx=_ctx()) == {"stored": True}
|
||||
assert await store.emit(snap, idempotency_key="k1", ctx=_ctx()) == {"stored": True}
|
||||
assert store.get("a1", "u1") == snap
|
||||
assert _row_count(store, "affect_snapshots") == 1 # replay did not duplicate
|
||||
|
||||
|
||||
async def test_idempotency_conflict_same_key_different_payload():
|
||||
store = open_affect_store(":memory:")
|
||||
first = _snapshot(valence=0.1)
|
||||
await store.emit(first, idempotency_key="k1", ctx=_ctx())
|
||||
with pytest.raises(AffectIdempotencyConflict):
|
||||
await store.emit(_snapshot(valence=0.2), idempotency_key="k1", ctx=_ctx())
|
||||
assert store.get("a1", "u1") == first # prior snapshot untouched
|
||||
|
||||
|
||||
async def test_same_key_distinct_actor_is_not_a_conflict():
|
||||
# idempotency is actor-scoped (INV-006/-008): same key, different ctx actor
|
||||
store = open_affect_store(":memory:")
|
||||
await store.emit(_snapshot(valence=0.1), idempotency_key="k1", ctx=_ctx("sub-A"))
|
||||
# different actor, same key, different payload -> NOT a conflict (distinct id)
|
||||
assert await store.emit(
|
||||
_snapshot(valence=0.2), idempotency_key="k1", ctx=_ctx("sub-B")
|
||||
) == {"stored": True}
|
||||
|
||||
|
||||
async def test_missing_end_user_id_raises_and_no_write():
|
||||
store = open_affect_store(":memory:")
|
||||
bad = _snapshot()
|
||||
del bad["end_user_id"]
|
||||
with pytest.raises(AffectInvalidArguments):
|
||||
await store.emit(bad, idempotency_key="k1", ctx=_ctx())
|
||||
assert _row_count(store, "affect_snapshots") == 0
|
||||
|
||||
|
||||
async def test_missing_agent_id_raises_and_no_write():
|
||||
# PRE-001 guards BOTH addressing keys symmetrically.
|
||||
store = open_affect_store(":memory:")
|
||||
bad = _snapshot()
|
||||
del bad["agent_id"]
|
||||
with pytest.raises(AffectInvalidArguments):
|
||||
await store.emit(bad, idempotency_key="k1", ctx=_ctx())
|
||||
assert _row_count(store, "affect_snapshots") == 0
|
||||
|
||||
|
||||
async def test_empty_idempotency_key_raises():
|
||||
store = open_affect_store(":memory:")
|
||||
with pytest.raises(AffectInvalidArguments):
|
||||
await store.emit(_snapshot(), idempotency_key="", ctx=_ctx())
|
||||
|
||||
|
||||
# --- get ---
|
||||
|
||||
def test_get_absent_returns_none():
|
||||
store = open_affect_store(":memory:")
|
||||
assert store.get("nope", "nope") is None
|
||||
|
||||
|
||||
async def test_get_after_emit_returns_equal():
|
||||
store = open_affect_store(":memory:")
|
||||
snap = _snapshot()
|
||||
await store.emit(snap, idempotency_key="k1", ctx=_ctx())
|
||||
assert store.get("a1", "u1") == snap
|
||||
|
||||
|
||||
# --- build_affect_provider_app ---
|
||||
|
||||
def test_build_app_exposes_handshake_and_affect_routes():
|
||||
store = open_affect_store(":memory:")
|
||||
app = build_affect_provider_app(store, heimdall_key=b"secret-key")
|
||||
routes = {getattr(r, "path", None): r for r in app.routes}
|
||||
assert "/bifrost/handshake" in routes
|
||||
assert "/bifrost/affect-call" in routes
|
||||
assert "POST" in routes["/bifrost/affect-call"].methods # POST-001: the verb, not just the path
|
||||
|
||||
|
||||
def test_build_app_rejects_non_advertising_store():
|
||||
store = open_affect_store(":memory:")
|
||||
store.affect_supported = False
|
||||
with pytest.raises(ValueError):
|
||||
build_affect_provider_app(store, heimdall_key=b"k")
|
||||
|
||||
|
||||
def test_build_app_rejects_empty_key():
|
||||
store = open_affect_store(":memory:")
|
||||
with pytest.raises(ValueError):
|
||||
build_affect_provider_app(store, heimdall_key=b"")
|
||||
|
||||
|
||||
# --- #195 conformance: parity vs the reference store through the real engine ---
|
||||
|
||||
def _dispatch_ctx(*scopes: str, session_id: str = "actor-1"):
|
||||
return types.SimpleNamespace(
|
||||
scope=list(scopes), session_id=session_id, jwt_sub=session_id
|
||||
)
|
||||
|
||||
|
||||
def _env(snap: dict, key: str = "sess-1:1:affect") -> dict:
|
||||
return {
|
||||
"operation": "affect.emit",
|
||||
"args": snap,
|
||||
"idempotency_key": key,
|
||||
"idempotency_class": "short-retry",
|
||||
}
|
||||
|
||||
|
||||
def _ref_shaped_snapshot(*, pleasure: float = 0.5, emitted_at: str = "2026-06-14T12:00:00Z"):
|
||||
# Mirror the reference test's snapshot shape so the envelope validates.
|
||||
return {
|
||||
"agent_id": "agent-1",
|
||||
"end_user_id": "user-1",
|
||||
"pad": {"pleasure": pleasure, "arousal": 0.2, "dominance": -0.1},
|
||||
"valence": [{"entity_id": "e1", "regard": 0.7, "familiarity": 0.3}],
|
||||
"emitted_at": emitted_at,
|
||||
}
|
||||
|
||||
|
||||
async def test_parity_vs_reference_store_through_dispatch():
|
||||
from bifrost.affect import dispatch_affect_call
|
||||
from bifrost.consumer.testing import InMemoryAffectStore
|
||||
|
||||
ref = InMemoryAffectStore()
|
||||
mine = open_affect_store(":memory:")
|
||||
ctx = _dispatch_ctx("affect:write")
|
||||
snap = _ref_shaped_snapshot()
|
||||
|
||||
# happy persist: wire bodies must agree
|
||||
assert await dispatch_affect_call(_env(snap), ctx, ref) == await dispatch_affect_call(
|
||||
_env(snap), ctx, mine
|
||||
)
|
||||
# replay (same key + same payload): both no-op {stored: true}
|
||||
assert await dispatch_affect_call(_env(snap), ctx, ref) == await dispatch_affect_call(
|
||||
_env(snap), ctx, mine
|
||||
)
|
||||
# conflict (same key + different payload): both map to the same error envelope
|
||||
other = _ref_shaped_snapshot(pleasure=0.99)
|
||||
assert await dispatch_affect_call(_env(other), ctx, ref) == await dispatch_affect_call(
|
||||
_env(other), ctx, mine
|
||||
)
|
||||
@@ -0,0 +1,512 @@
|
||||
"""Tests for the Tier-3 Bifrost memory provider (ratatoskr.provider.memory_store).
|
||||
|
||||
Contract: docs/contracts/bifrost_memory_provider.contract.md (v1.1)
|
||||
Vertical tracer-first: fresh_db -> basic_upsert (round-trip) -> replay -> conflict
|
||||
-> optimistic_lock -> injection_rule -> search/scope_isolation -> get/get_many ->
|
||||
delete_many -> build_memory_provider_app -> #195 parity vs InMemoryMemoryStore.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from bifrost.memory import (
|
||||
IdempotencyConflict,
|
||||
InvalidArguments,
|
||||
InvalidFilter,
|
||||
RevisionMismatch,
|
||||
)
|
||||
|
||||
from ratatoskr.provider.memory_store import (
|
||||
build_memory_provider_app,
|
||||
open_memory_store,
|
||||
)
|
||||
|
||||
EMBEDDING_DIM = 8
|
||||
|
||||
|
||||
def _ctx(sub: str = "sub-1"):
|
||||
# Mirrors bifrost reference _ctx_actor: actor = job_id | jwt_sub | session_id.
|
||||
return types.SimpleNamespace(jwt_sub=sub)
|
||||
|
||||
|
||||
def _vec(*head: float) -> list[float]:
|
||||
v = list(head) + [0.0] * EMBEDDING_DIM
|
||||
return v[:EMBEDDING_DIM]
|
||||
|
||||
|
||||
def _chunk(cid: str = "c1", *, embedding=None, scope=None, **extra) -> dict:
|
||||
rec = {
|
||||
"id": cid,
|
||||
"embedding": embedding if embedding is not None else _vec(1.0),
|
||||
"scope": scope if scope is not None else {"end_user": "u1"},
|
||||
"origin": "worldtree",
|
||||
"distillate": {"summary": f"distillate-{cid}"},
|
||||
"content": f"content-{cid}",
|
||||
}
|
||||
rec.update(extra)
|
||||
return rec
|
||||
|
||||
|
||||
def _row_count(store, table: str) -> int:
|
||||
return store._conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
||||
|
||||
|
||||
# --- open_memory_store ---
|
||||
|
||||
def test_fresh_db_advertises_v1_caps_and_schema():
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
caps = store.describe_store()
|
||||
assert caps["relational_edges_supported"] is False
|
||||
assert caps["optimistic_locking_supported"] is True
|
||||
assert caps["atomic_supersede_supported"] is False
|
||||
assert caps["transaction_supported"] is False
|
||||
assert caps["filterable_metadata_fields"] == []
|
||||
# tables + vec index queryable
|
||||
store._conn.execute("SELECT * FROM memory_chunks")
|
||||
store._conn.execute("SELECT * FROM memory_idempotency")
|
||||
store._conn.execute("SELECT * FROM memory_vec")
|
||||
|
||||
|
||||
def test_reopen_existing_file_is_idempotent(tmp_path):
|
||||
db = str(tmp_path / "memory.db")
|
||||
open_memory_store(db, embedding_dim=EMBEDDING_DIM) # first open creates schema
|
||||
store = open_memory_store(db, embedding_dim=EMBEDDING_DIM) # reopen: IF NOT EXISTS no-op
|
||||
assert isinstance(store.describe_store(), dict)
|
||||
store._conn.execute("SELECT * FROM memory_chunks")
|
||||
store._conn.execute("SELECT * FROM memory_vec")
|
||||
|
||||
|
||||
# --- upsert_many + get (tracer round-trip) ---
|
||||
|
||||
async def test_basic_upsert_round_trips_verbatim_with_revision():
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
c1 = _chunk("c1", embedding=_vec(1.0))
|
||||
c2 = _chunk("c2", embedding=_vec(0.0, 1.0))
|
||||
result = await store.upsert_many([c1, c2], idempotency_key="k1", ctx=_ctx())
|
||||
assert result == {"upserted": 2, "replayed": False}
|
||||
# INV-001: each chunk round-trips verbatim, with a revision key attached (first insert -> 1)
|
||||
assert await store.get("c1") == {**c1, "revision": 1}
|
||||
assert await store.get("c2") == {**c2, "revision": 1}
|
||||
|
||||
|
||||
async def test_replay_same_key_same_payload_no_rewrite():
|
||||
# INV-002: same idempotency_key + same digest -> replay (no second write, revision frozen)
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
c1 = _chunk("c1")
|
||||
assert await store.upsert_many([c1], idempotency_key="k1", ctx=_ctx()) == {
|
||||
"upserted": 1,
|
||||
"replayed": False,
|
||||
}
|
||||
assert await store.upsert_many([c1], idempotency_key="k1", ctx=_ctx()) == {
|
||||
"upserted": 1,
|
||||
"replayed": True,
|
||||
}
|
||||
assert (await store.get("c1"))["revision"] == 1 # replay did not re-write / re-increment
|
||||
assert _row_count(store, "memory_chunks") == 1
|
||||
|
||||
|
||||
async def test_conflict_same_key_different_payload_raises_and_keeps_first():
|
||||
# INV-002: same key, different digest -> IdempotencyConflict; the first batch is intact
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
first = _chunk("c1", content="first")
|
||||
await store.upsert_many([first], idempotency_key="k1", ctx=_ctx())
|
||||
with pytest.raises(IdempotencyConflict):
|
||||
await store.upsert_many(
|
||||
[_chunk("c1", content="second")], idempotency_key="k1", ctx=_ctx()
|
||||
)
|
||||
assert await store.get("c1") == {**first, "revision": 1} # untouched
|
||||
|
||||
|
||||
async def test_optimistic_lock_stale_expected_revision_raises_nothing_written():
|
||||
# INV-003: a stale expected_revisions entry rolls back the whole batch
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
c1 = _chunk("c1", content="v1")
|
||||
await store.upsert_many([c1], idempotency_key="k1", ctx=_ctx()) # revision 1
|
||||
with pytest.raises(RevisionMismatch):
|
||||
await store.upsert_many(
|
||||
[_chunk("c1", content="v2")],
|
||||
idempotency_key="k2", # distinct key: not replay/conflict
|
||||
ctx=_ctx(),
|
||||
expected_revisions={"c1": 5}, # stale: stored revision is 1
|
||||
)
|
||||
assert await store.get("c1") == {**c1, "revision": 1} # nothing written
|
||||
assert _row_count(store, "memory_chunks") == 1
|
||||
|
||||
|
||||
async def test_optimistic_lock_match_upserts_and_increments_revision():
|
||||
# INV-003: a matching expected_revisions writes and increments (1 -> 2)
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await store.upsert_many([_chunk("c1", content="v1")], idempotency_key="k1", ctx=_ctx())
|
||||
v2 = _chunk("c1", content="v2")
|
||||
assert await store.upsert_many(
|
||||
[v2], idempotency_key="k2", ctx=_ctx(), expected_revisions={"c1": 1}
|
||||
) == {"upserted": 1, "replayed": False}
|
||||
assert await store.get("c1") == {**v2, "revision": 2} # re-upsert increments
|
||||
assert _row_count(store, "memory_chunks") == 1
|
||||
|
||||
|
||||
async def test_injection_rule_injected_without_source_raises_no_write():
|
||||
# INV-007: origin == injected_context requires injection_source
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
bad = _chunk("c1", origin="injected_context") # no injection_source
|
||||
with pytest.raises(InvalidArguments):
|
||||
await store.upsert_many([bad], idempotency_key="k1", ctx=_ctx())
|
||||
assert _row_count(store, "memory_chunks") == 0
|
||||
|
||||
|
||||
async def test_injection_rule_non_injected_with_source_raises_no_write():
|
||||
# INV-007: a non-injected record carrying injection_source is rejected
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
bad = _chunk("c1", origin="worldtree", injection_source="elsewhere")
|
||||
with pytest.raises(InvalidArguments):
|
||||
await store.upsert_many([bad], idempotency_key="k1", ctx=_ctx())
|
||||
assert _row_count(store, "memory_chunks") == 0
|
||||
|
||||
|
||||
# --- search ---
|
||||
|
||||
async def test_basic_search_ranks_by_cosine_with_recalled_view():
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
scope = {"end_user": "u1"}
|
||||
c1 = _chunk("c1", embedding=_vec(1.0, 0.0), scope=scope)
|
||||
c3 = _chunk("c3", embedding=_vec(0.9, 0.1), scope=scope)
|
||||
await store.upsert_many(
|
||||
[c1, _chunk("c2", embedding=_vec(0.0, 1.0), scope=scope), c3],
|
||||
idempotency_key="k1",
|
||||
ctx=_ctx(),
|
||||
)
|
||||
results = await store.search(_vec(1.0, 0.0), top_k=2, scope_all=scope)
|
||||
assert [r["chunk_id"] for r in results] == ["c1", "c3"] # nearest to [1,0] by cosine
|
||||
top = results[0]
|
||||
assert top["chunk"] == c1 # verbatim chunk, no revision attached
|
||||
assert top["recalled_view"] == {"summary": "distillate-c1"} # = chunk["distillate"]
|
||||
assert top["revision"] == 1
|
||||
assert isinstance(top["score"], float)
|
||||
|
||||
|
||||
async def test_scope_isolation_excludes_other_scope_even_if_closer():
|
||||
# INV-005: an out-of-scope chunk that scores HIGHER must not leak; only in-scope returned
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await store.upsert_many(
|
||||
[
|
||||
_chunk("u2-near", embedding=_vec(1.0, 0.0), scope={"end_user": "u2"}), # closest
|
||||
_chunk("u1-far", embedding=_vec(0.0, 1.0), scope={"end_user": "u1"}), # in-scope, far
|
||||
],
|
||||
idempotency_key="k1",
|
||||
ctx=_ctx(),
|
||||
)
|
||||
results = await store.search(_vec(1.0, 0.0), top_k=2, scope_all={"end_user": "u1"})
|
||||
assert [r["chunk_id"] for r in results] == ["u1-far"] # u2-near excluded despite ranking first
|
||||
|
||||
|
||||
async def test_search_empty_store_returns_empty():
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
assert await store.search(_vec(1.0), top_k=5) == []
|
||||
|
||||
|
||||
async def test_search_non_empty_metadata_filter_rejected():
|
||||
# PRE-002: v1 advertises no filterable metadata fields
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
with pytest.raises(InvalidArguments):
|
||||
await store.search(_vec(1.0), top_k=5, metadata_filter={"x": 1})
|
||||
|
||||
|
||||
async def test_search_wrong_vector_dim_rejected():
|
||||
# PRE-001: vector length must equal the pinned embedding_dim
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
with pytest.raises(InvalidArguments):
|
||||
await store.search([1.0, 0.0], top_k=5)
|
||||
|
||||
|
||||
async def test_search_non_dict_scope_all_rejected():
|
||||
# search STEP 1: scope_all must be a flat {axis: value} dict
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
with pytest.raises(InvalidArguments):
|
||||
await store.search(_vec(1.0), top_k=5, scope_all="u1")
|
||||
|
||||
|
||||
async def test_search_non_list_scope_any_rejected():
|
||||
# search STEP 1: scope_any must be a LIST of {axis: value} dicts (#11)
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
with pytest.raises(InvalidArguments):
|
||||
await store.search(_vec(1.0), top_k=5, scope_any={"end_user": "u1"})
|
||||
|
||||
|
||||
async def test_search_out_of_lattice_scope_axis_rejected():
|
||||
# v0.6 scope lattice = {end_user, group, tenant, agent_self}; an axis outside
|
||||
# it is InvalidFilter (-> memory.invalid_filter 400) in EITHER field, matching the reference.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
with pytest.raises(InvalidFilter):
|
||||
await store.search(_vec(1.0), top_k=5, scope_all={"bogus_axis": "x"})
|
||||
with pytest.raises(InvalidFilter):
|
||||
await store.search(_vec(1.0), top_k=5, scope_any=[{"bogus_axis": "x"}])
|
||||
|
||||
|
||||
async def test_search_agent_self_axis_accepted():
|
||||
# agent_self became canonical at wire v0.5 (#10) — admitted, not rejected.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await store.upsert_many(
|
||||
[_chunk("a1", scope={"agent_self": "ratatoskr:smoke"})],
|
||||
idempotency_key="k1",
|
||||
ctx=_ctx(),
|
||||
)
|
||||
results = await store.search(
|
||||
_vec(1.0), top_k=5, scope_all={"agent_self": "ratatoskr:smoke"}
|
||||
)
|
||||
assert [r["chunk_id"] for r in results] == ["a1"]
|
||||
|
||||
|
||||
async def test_search_top_k_zero_returns_empty():
|
||||
# POST-001: at most top_k — zero means zero
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await store.upsert_many([_chunk("c1")], idempotency_key="k1", ctx=_ctx())
|
||||
assert await store.search(_vec(1.0), top_k=0, scope_all={"end_user": "u1"}) == []
|
||||
|
||||
|
||||
async def test_search_no_scope_matches_all():
|
||||
# v0.6: both fields empty -> no scope constraint (match all, within top_k).
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await store.upsert_many(
|
||||
[
|
||||
_chunk("u1", scope={"end_user": "u1"}),
|
||||
_chunk("u2", scope={"end_user": "u2"}),
|
||||
],
|
||||
idempotency_key="k1",
|
||||
ctx=_ctx(),
|
||||
)
|
||||
results = await store.search(_vec(1.0), top_k=10)
|
||||
assert {r["chunk_id"] for r in results} == {"u1", "u2"}
|
||||
|
||||
|
||||
async def test_search_scope_any_unions_across_scopes():
|
||||
# v0.6 (#11): scope_any is OR/union over a LIST of conjunctive scopes. A {end_user:u1}
|
||||
# chunk AND an {agent_self:a} chunk are BOTH recalled in ONE call — the capability
|
||||
# that resolves the #295/#297 silent-zero AND foot-gun (subset-scoped chunks now recall).
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await store.upsert_many(
|
||||
[
|
||||
_chunk("subj", embedding=_vec(1.0, 0.0), scope={"end_user": "u1"}),
|
||||
_chunk("self", embedding=_vec(0.9, 0.1), scope={"agent_self": "ratatoskr:sindra"}),
|
||||
_chunk("other", embedding=_vec(0.8, 0.2), scope={"end_user": "u9"}),
|
||||
],
|
||||
idempotency_key="k1",
|
||||
ctx=_ctx(),
|
||||
)
|
||||
results = await store.search(
|
||||
_vec(1.0, 0.0),
|
||||
top_k=10,
|
||||
scope_any=[{"end_user": "u1"}, {"agent_self": "ratatoskr:sindra"}],
|
||||
)
|
||||
assert {r["chunk_id"] for r in results} == {"subj", "self"} # union; u9 excluded
|
||||
|
||||
|
||||
async def test_search_scope_all_and_scope_any_compose_by_and():
|
||||
# v0.6: a record passes iff (record ⊇ scope_all) AND (matches ≥1 scope_any element).
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await store.upsert_many(
|
||||
[
|
||||
# tenant t1 AND (end_user u1 OR u2) — only these pass
|
||||
_chunk("t1u1", embedding=_vec(1.0, 0.0), scope={"tenant": "t1", "end_user": "u1"}),
|
||||
_chunk("t1u2", embedding=_vec(0.9, 0.1), scope={"tenant": "t1", "end_user": "u2"}),
|
||||
_chunk("t1u9", embedding=_vec(0.8, 0.2), scope={"tenant": "t1", "end_user": "u9"}),
|
||||
_chunk("t2u1", embedding=_vec(0.7, 0.3), scope={"tenant": "t2", "end_user": "u1"}),
|
||||
],
|
||||
idempotency_key="k1",
|
||||
ctx=_ctx(),
|
||||
)
|
||||
results = await store.search(
|
||||
_vec(1.0, 0.0),
|
||||
top_k=10,
|
||||
scope_all={"tenant": "t1"},
|
||||
scope_any=[{"end_user": "u1"}, {"end_user": "u2"}],
|
||||
)
|
||||
assert {r["chunk_id"] for r in results} == {"t1u1", "t1u2"} # t1u9 fails any; t2u1 fails all
|
||||
|
||||
|
||||
async def test_scope_isolation_fills_top_k_from_in_scope_past_higher_out_of_scope():
|
||||
# INV-005: top_k counts IN-SCOPE hits. An out-of-scope chunk ranking #1 is skipped,
|
||||
# and top_k is still filled from the in-scope set when enough in-scope chunks exist.
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await store.upsert_many(
|
||||
[
|
||||
_chunk("u2-nearest", embedding=_vec(1.0, 0.0), scope={"end_user": "u2"}), # ranks #1
|
||||
_chunk("u1-near", embedding=_vec(0.95, 0.05), scope={"end_user": "u1"}),
|
||||
_chunk("u1-mid", embedding=_vec(0.8, 0.2), scope={"end_user": "u1"}),
|
||||
_chunk("u1-far", embedding=_vec(0.0, 1.0), scope={"end_user": "u1"}),
|
||||
],
|
||||
idempotency_key="k1",
|
||||
ctx=_ctx(),
|
||||
)
|
||||
results = await store.search(_vec(1.0, 0.0), top_k=2, scope_all={"end_user": "u1"})
|
||||
# exactly top_k in-scope (the 2 nearest u1 chunks); the higher-ranked u2 chunk is excluded
|
||||
assert [r["chunk_id"] for r in results] == ["u1-near", "u1-mid"]
|
||||
|
||||
|
||||
# --- get / get_many ---
|
||||
|
||||
async def test_get_absent_returns_none():
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
assert await store.get("nope") is None
|
||||
|
||||
|
||||
async def test_get_many_returns_found_records_only():
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
c1 = _chunk("c1")
|
||||
await store.upsert_many([c1], idempotency_key="k1", ctx=_ctx())
|
||||
assert await store.get_many(["c1", "absent"]) == [{**c1, "revision": 1}]
|
||||
|
||||
|
||||
# --- delete_many ---
|
||||
|
||||
async def test_delete_hit_removes_chunk_and_vec_row():
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
await store.upsert_many([_chunk("c1"), _chunk("c2")], idempotency_key="k1", ctx=_ctx())
|
||||
assert await store.delete_many(["c1"]) == {"deleted": 1}
|
||||
assert await store.get("c1") is None
|
||||
assert _row_count(store, "memory_chunks") == 1
|
||||
assert _row_count(store, "memory_vec") == 1 # c1's vec row gone too (no orphan)
|
||||
# delete_hit: search no longer surfaces it (vec/chunk coupling held)
|
||||
hits = await store.search(_vec(1.0), top_k=5, scope_all={"end_user": "u1"})
|
||||
assert all(r["chunk_id"] != "c1" for r in hits)
|
||||
|
||||
|
||||
async def test_delete_absent_counts_zero():
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
assert await store.delete_many(["nope"]) == {"deleted": 0}
|
||||
|
||||
|
||||
# --- build_memory_provider_app ---
|
||||
|
||||
def test_build_app_exposes_handshake_and_memory_routes():
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
app = build_memory_provider_app(store, heimdall_key=b"secret-key")
|
||||
routes = {getattr(r, "path", None): r for r in app.routes}
|
||||
assert "/bifrost/handshake" in routes
|
||||
assert "/bifrost/memory-call" in routes
|
||||
assert "POST" in routes["/bifrost/memory-call"].methods
|
||||
assert "POST" in routes["/bifrost/handshake"].methods # both routes are POST (incl. POST)
|
||||
|
||||
|
||||
def test_build_app_rejects_empty_key():
|
||||
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||
with pytest.raises(ValueError):
|
||||
build_memory_provider_app(store, heimdall_key=b"")
|
||||
|
||||
|
||||
# --- #195 conformance: parity vs the reference store through the real engine ---
|
||||
|
||||
def _dispatch_ctx(*scopes: str, session_id: str = "actor-1"):
|
||||
return types.SimpleNamespace(
|
||||
scope=list(scopes), session_id=session_id, jwt_sub=session_id, job_id=None
|
||||
)
|
||||
|
||||
|
||||
def _ref_record(chunk_id: str, vector: list[float], *, end_user: str = "u1") -> dict:
|
||||
# Mirrors bifrost's reference `record` helper so the envelope validates.
|
||||
return {
|
||||
"id": chunk_id,
|
||||
"embedding": vector,
|
||||
"distillate": {"text": chunk_id},
|
||||
"metadata": {"worldtree.appraisal_confidence": 0.8},
|
||||
"scope": {"end_user": end_user, "tenant": "t1"},
|
||||
"origin": "worldtree",
|
||||
"source_role": "assistant",
|
||||
"trust_tier": "tier-3",
|
||||
"provenance": {"trace": chunk_id},
|
||||
}
|
||||
|
||||
|
||||
async def test_parity_upsert_many_vs_reference_through_dispatch():
|
||||
from bifrost.consumer.testing import InMemoryMemoryStore
|
||||
from bifrost.memory import dispatch_memory_call
|
||||
|
||||
ref = InMemoryMemoryStore()
|
||||
mine = open_memory_store(":memory:", embedding_dim=2)
|
||||
wctx = _dispatch_ctx("memory:write")
|
||||
env = {
|
||||
"operation": "upsert_many",
|
||||
"args": {"records": [_ref_record("a", [1.0, 0.0]), _ref_record("b", [0.0, 1.0])]},
|
||||
"idempotency_key": "k1",
|
||||
}
|
||||
# happy persist + replay: wire bodies must agree
|
||||
assert await dispatch_memory_call(env, wctx, ref) == await dispatch_memory_call(env, wctx, mine)
|
||||
assert await dispatch_memory_call(env, wctx, ref) == await dispatch_memory_call(env, wctx, mine)
|
||||
|
||||
|
||||
async def test_parity_search_ranked_ids_vs_reference_through_dispatch():
|
||||
from bifrost.consumer.testing import InMemoryMemoryStore
|
||||
from bifrost.memory import dispatch_memory_call
|
||||
|
||||
ref = InMemoryMemoryStore()
|
||||
mine = open_memory_store(":memory:", embedding_dim=2)
|
||||
wctx = _dispatch_ctx("memory:write")
|
||||
rctx = _dispatch_ctx("memory:read")
|
||||
up = {
|
||||
"operation": "upsert_many",
|
||||
"args": {
|
||||
"records": [
|
||||
_ref_record("a", [1.0, 0.0]),
|
||||
_ref_record("b", [0.0, 1.0]),
|
||||
_ref_record("c", [0.9, 0.1]),
|
||||
]
|
||||
},
|
||||
"idempotency_key": "k1",
|
||||
}
|
||||
await dispatch_memory_call(up, wctx, ref)
|
||||
await dispatch_memory_call(up, wctx, mine)
|
||||
search_env = {
|
||||
"operation": "search",
|
||||
"args": {"vector": [1.0, 0.0], "top_k": 2, "scope_all": {"end_user": "u1"}},
|
||||
}
|
||||
rstatus, rbody = await dispatch_memory_call(search_env, rctx, ref)
|
||||
mstatus, mbody = await dispatch_memory_call(search_env, rctx, mine)
|
||||
assert rstatus == mstatus == 200
|
||||
# #195: same ranked chunk_ids and the same per-result shape (scores may differ in the
|
||||
# last float digit between vec0's cosine and the reference's Python cosine).
|
||||
assert [r["chunk_id"] for r in rbody["results"]] == [r["chunk_id"] for r in mbody["results"]]
|
||||
assert set(rbody["results"][0]) == set(mbody["results"][0])
|
||||
|
||||
|
||||
async def test_parity_expected_revisions_vs_reference_through_dispatch():
|
||||
# #195: pins the partial-map optimistic-lock semantics against the reference
|
||||
# (does an expected_revisions map that omits some batch records lock only the
|
||||
# listed ones?). Resolves the contract's ambiguous "each record's stored revision".
|
||||
from bifrost.consumer.testing import InMemoryMemoryStore
|
||||
from bifrost.memory import dispatch_memory_call
|
||||
|
||||
ref = InMemoryMemoryStore()
|
||||
mine = open_memory_store(":memory:", embedding_dim=2)
|
||||
wctx = _dispatch_ctx("memory:write")
|
||||
|
||||
seed = {
|
||||
"operation": "upsert_many",
|
||||
"args": {"records": [_ref_record("a", [1.0, 0.0]), _ref_record("b", [0.0, 1.0])]},
|
||||
"idempotency_key": "seed",
|
||||
}
|
||||
ref_seed = await dispatch_memory_call(seed, wctx, ref)
|
||||
mine_seed = await dispatch_memory_call(seed, wctx, mine)
|
||||
assert ref_seed == mine_seed
|
||||
|
||||
# partial map: only "a" is locked (revision 1); "b" is omitted from expected_revisions
|
||||
partial = {
|
||||
"operation": "upsert_many",
|
||||
"args": {
|
||||
"records": [_ref_record("a", [1.0, 0.0]), _ref_record("b", [0.0, 1.0])],
|
||||
"expected_revisions": {"a": 1},
|
||||
},
|
||||
"idempotency_key": "partial",
|
||||
}
|
||||
assert await dispatch_memory_call(partial, wctx, ref) == await dispatch_memory_call(
|
||||
partial, wctx, mine
|
||||
)
|
||||
|
||||
# stale lock: both map to the same RevisionMismatch wire error
|
||||
stale = {
|
||||
"operation": "upsert_many",
|
||||
"args": {"records": [_ref_record("a", [1.0, 0.0])], "expected_revisions": {"a": 99}},
|
||||
"idempotency_key": "stale",
|
||||
}
|
||||
assert await dispatch_memory_call(stale, wctx, ref) == await dispatch_memory_call(
|
||||
stale, wctx, mine
|
||||
)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Tests for the dispatch-layer observe feed (ratatoskr.provider.opfeed).
|
||||
|
||||
Issue #17 slice 2 (the Observe half). These drive the REAL bifrost provider ASGI
|
||||
app end-to-end through `instrument_provider_app`, minting a valid dispatch JWT with
|
||||
bifrost's own `mint_dispatch_jwt` — so the envelopes and the session_id claim are
|
||||
the real wire shapes, not hand-mocked guesses (the repo's "test against the
|
||||
shipped lib" posture).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from bifrost.core.dispatch_jwt import mint_dispatch_jwt
|
||||
|
||||
from ratatoskr.provider.affect_store import (
|
||||
build_affect_provider_app,
|
||||
open_affect_store,
|
||||
)
|
||||
from ratatoskr.provider.memory_store import (
|
||||
build_memory_provider_app,
|
||||
open_memory_store,
|
||||
)
|
||||
from ratatoskr.provider.opfeed import instrument_provider_app
|
||||
|
||||
_KEY = "shared-secret"
|
||||
_DIM = 8
|
||||
_CONSUMER = "ratatoskr"
|
||||
|
||||
|
||||
class _RecordingSink:
|
||||
"""An OpSink that just records events (so a test can assert on them)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list = []
|
||||
|
||||
def emit(self, event) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
def _mint(session_id: str, scope: list[str]) -> str:
|
||||
return mint_dispatch_jwt(
|
||||
session_id=session_id,
|
||||
consumer_id=_CONSUMER,
|
||||
issuer="worldtree",
|
||||
scope=scope,
|
||||
secret_or_key=_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
|
||||
def _wrapped_memory_app(sink):
|
||||
store = open_memory_store(":memory:", embedding_dim=_DIM)
|
||||
app = build_memory_provider_app(
|
||||
store, heimdall_key=_KEY.encode(), consumer_id=_CONSUMER
|
||||
)
|
||||
return instrument_provider_app(app, plane="memory", sink=sink), store
|
||||
|
||||
|
||||
def _wrapped_affect_app(sink):
|
||||
store = open_affect_store(":memory:")
|
||||
app = build_affect_provider_app(
|
||||
store, heimdall_key=_KEY.encode(), consumer_id=_CONSUMER
|
||||
)
|
||||
return instrument_provider_app(app, plane="affect", sink=sink), store
|
||||
|
||||
|
||||
def _chunk(cid: str, scope: dict | None = None) -> dict:
|
||||
return {
|
||||
"id": cid,
|
||||
"scope": scope or {"end_user": "u1"},
|
||||
"embedding": [0.1] * _DIM,
|
||||
"content": "x",
|
||||
}
|
||||
|
||||
|
||||
class _RaisingSink:
|
||||
def emit(self, event) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
async def _post(app, path: str, body: dict, jwt: str | None) -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
headers = {"Authorization": f"Bearer {jwt}"} if jwt else {}
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://provider"
|
||||
) as client:
|
||||
return await client.post(path, json=body, headers=headers)
|
||||
|
||||
|
||||
class TestOpFeedMemory:
|
||||
async def test_search_emits_one_opevent(self) -> None:
|
||||
"""search [tracer]: a memory search dispatched through the wrapped app
|
||||
emits EXACTLY ONE OpEvent — plane=memory, op=search, session_id from the
|
||||
JWT sub, status=ok, scope-only req/resp summaries (POST-001/002, INV-005).
|
||||
Empty store → 0 hits."""
|
||||
sink = _RecordingSink()
|
||||
app, _store = _wrapped_memory_app(sink)
|
||||
jwt = _mint("session-abc", ["memory:read"])
|
||||
body = {
|
||||
"operation": "search",
|
||||
"args": {
|
||||
"vector": [0.0] * _DIM,
|
||||
"top_k": 5,
|
||||
"scope_all": {"end_user": "u1"},
|
||||
"scope_any": [],
|
||||
},
|
||||
}
|
||||
resp = await _post(app, "/bifrost/memory-call", body, jwt)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert len(sink.events) == 1
|
||||
ev = sink.events[0]
|
||||
assert ev.plane == "memory"
|
||||
assert ev.op == "search"
|
||||
assert ev.session_id == "session-abc"
|
||||
assert ev.status == "ok"
|
||||
assert ev.req_summary == {
|
||||
"scope_all": {"end_user": "u1"},
|
||||
"scope_any": [],
|
||||
"top_k": 5,
|
||||
}
|
||||
assert ev.resp_summary["hit_count"] == 0
|
||||
assert ev.turn_id is None
|
||||
assert ev.ts # non-empty capture timestamp
|
||||
|
||||
async def test_upsert_many_summary(self) -> None:
|
||||
"""upsert_many: req carries record_count + per-record scopes (no bodies);
|
||||
resp carries upserted + replayed."""
|
||||
sink = _RecordingSink()
|
||||
app, _store = _wrapped_memory_app(sink)
|
||||
jwt = _mint("session-up", ["memory:write"])
|
||||
body = {
|
||||
"operation": "upsert_many",
|
||||
"args": {"records": [_chunk("c1"), _chunk("c2", {"end_user": "u2"})]},
|
||||
"idempotency_key": "k1",
|
||||
}
|
||||
resp = await _post(app, "/bifrost/memory-call", body, jwt)
|
||||
|
||||
assert resp.status_code == 200
|
||||
ev = sink.events[-1]
|
||||
assert ev.op == "upsert_many"
|
||||
assert ev.status == "ok"
|
||||
assert ev.req_summary == {
|
||||
"record_count": 2,
|
||||
"scopes": [{"end_user": "u1"}, {"end_user": "u2"}],
|
||||
}
|
||||
assert ev.resp_summary == {"upserted": 2, "replayed": False}
|
||||
|
||||
async def test_get_and_delete_summaries(self) -> None:
|
||||
"""get -> found_count; delete_many -> deleted; both req carry ids only."""
|
||||
sink = _RecordingSink()
|
||||
app, _store = _wrapped_memory_app(sink)
|
||||
await _post(
|
||||
app,
|
||||
"/bifrost/memory-call",
|
||||
{
|
||||
"operation": "upsert_many",
|
||||
"args": {"records": [_chunk("c1")]},
|
||||
"idempotency_key": "k1",
|
||||
},
|
||||
_mint("s", ["memory:write"]),
|
||||
)
|
||||
await _post(
|
||||
app,
|
||||
"/bifrost/memory-call",
|
||||
{"operation": "get", "args": {"chunk_id": "c1"}},
|
||||
_mint("s", ["memory:read"]),
|
||||
)
|
||||
get_ev = sink.events[-1]
|
||||
assert get_ev.op == "get"
|
||||
assert get_ev.req_summary == {"ids": ["c1"]}
|
||||
assert get_ev.resp_summary == {"found_count": 1}
|
||||
|
||||
await _post(
|
||||
app,
|
||||
"/bifrost/memory-call",
|
||||
{"operation": "delete_many", "args": {"ids": ["c1"]}},
|
||||
_mint("s", ["memory:write"]),
|
||||
)
|
||||
del_ev = sink.events[-1]
|
||||
assert del_ev.op == "delete_many"
|
||||
assert del_ev.req_summary == {"ids": ["c1"]}
|
||||
assert del_ev.resp_summary == {"deleted": 1}
|
||||
|
||||
async def test_error_status_records_the_bifrost_code(self) -> None:
|
||||
"""error [adversarial]: an unknown operation -> status=error and the
|
||||
bifrost error `code` is recorded, never hidden (INV-007)."""
|
||||
sink = _RecordingSink()
|
||||
app, _store = _wrapped_memory_app(sink)
|
||||
resp = await _post(
|
||||
app,
|
||||
"/bifrost/memory-call",
|
||||
{"operation": "bogus", "args": {}},
|
||||
_mint("s", ["memory:read"]),
|
||||
)
|
||||
assert resp.status_code != 200
|
||||
assert len(sink.events) == 1
|
||||
ev = sink.events[0]
|
||||
assert ev.op == "bogus"
|
||||
assert ev.status == "error"
|
||||
assert ev.resp_summary == {"error": "memory.invalid_arguments"}
|
||||
|
||||
async def test_missing_jwt_session_id_none_still_emits(self) -> None:
|
||||
"""no_jwt [boundary]: a call with NO Authorization still emits exactly one
|
||||
OpEvent with session_id=None (INV-005) and status=error (auth rejected)."""
|
||||
sink = _RecordingSink()
|
||||
app, _store = _wrapped_memory_app(sink)
|
||||
resp = await _post(
|
||||
app,
|
||||
"/bifrost/memory-call",
|
||||
{"operation": "search", "args": {"vector": [0.0] * _DIM, "top_k": 1}},
|
||||
None,
|
||||
)
|
||||
assert resp.status_code != 200
|
||||
assert len(sink.events) == 1
|
||||
assert sink.events[0].session_id is None
|
||||
assert sink.events[0].status == "error"
|
||||
|
||||
async def test_handshake_op_from_path(self) -> None:
|
||||
"""handshake: op is derived from the PATH (handshake bodies carry no
|
||||
`operation` field); still exactly one OpEvent (POST-001 incl. handshake)."""
|
||||
sink = _RecordingSink()
|
||||
app, _store = _wrapped_memory_app(sink)
|
||||
resp = await _post(
|
||||
app,
|
||||
"/bifrost/handshake",
|
||||
{"bifrost_version": "99.0.0", "mcp_version": "0.4.0"},
|
||||
None,
|
||||
)
|
||||
assert resp.status_code != 200 # version-major mismatch, cleanly rejected
|
||||
assert len(sink.events) == 1
|
||||
assert sink.events[0].op == "handshake"
|
||||
|
||||
async def test_sink_failure_never_breaks_dispatch(self) -> None:
|
||||
"""sink_swallow [adversarial]: a raising sink must NOT break the dispatch
|
||||
path — the search still returns 200 (POST-003 / INV-007)."""
|
||||
app, _store = _wrapped_memory_app(_RaisingSink())
|
||||
resp = await _post(
|
||||
app,
|
||||
"/bifrost/memory-call",
|
||||
{
|
||||
"operation": "search",
|
||||
"args": {"vector": [0.0] * _DIM, "top_k": 1, "scope_all": {}},
|
||||
},
|
||||
_mint("s", ["memory:read"]),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestOpFeedAffect:
|
||||
async def test_emit_op_normalized_and_plane_affect(self) -> None:
|
||||
"""affect emit: op is the bare verb (affect.emit -> emit), plane=affect,
|
||||
session_id from the JWT; affect stays conduit-opaque (empty req_summary)."""
|
||||
sink = _RecordingSink()
|
||||
app, _store = _wrapped_affect_app(sink)
|
||||
jwt = _mint("session-aff", ["affect:write"])
|
||||
body = {
|
||||
"operation": "affect.emit",
|
||||
"args": {
|
||||
"agent_id": "ratatoskr:sindra",
|
||||
"end_user_id": "u1",
|
||||
"pad": {"p": 0.1, "a": 0.2, "d": 0.3},
|
||||
"valence": 0.5,
|
||||
"emitted_at": "2026-06-18T00:00:00Z",
|
||||
},
|
||||
"idempotency_key": "k1",
|
||||
}
|
||||
resp = await _post(app, "/bifrost/affect-call", body, jwt)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert len(sink.events) == 1
|
||||
ev = sink.events[0]
|
||||
assert ev.plane == "affect"
|
||||
assert ev.op == "emit"
|
||||
assert ev.session_id == "session-aff"
|
||||
assert ev.status == "ok"
|
||||
assert ev.req_summary == {} # conduit-opaque
|
||||
assert ev.resp_summary == {"stored": True}
|
||||
|
||||
|
||||
class TestInstrumentGuards:
|
||||
def test_unknown_plane_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
instrument_provider_app(object(), plane="persona", sink=_RecordingSink())
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for the affect-provider serve entrypoint (ratatoskr.provider.serve).
|
||||
|
||||
Only the env -> app seam is unit-tested; uvicorn.run is the untestable shell.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ratatoskr.provider.serve import build_app_from_env
|
||||
|
||||
|
||||
def test_build_app_from_env_requires_heimdall_key():
|
||||
with pytest.raises(RuntimeError):
|
||||
build_app_from_env({"RATATOSKR_AFFECT_DB": ":memory:"})
|
||||
|
||||
|
||||
def test_build_app_from_env_builds_app_with_routes():
|
||||
app = build_app_from_env(
|
||||
{"RATATOSKR_HEIMDALL_KEY": "shared-secret", "RATATOSKR_AFFECT_DB": ":memory:"}
|
||||
)
|
||||
paths = {getattr(r, "path", None) for r in app.routes}
|
||||
assert "/bifrost/handshake" in paths
|
||||
assert "/bifrost/affect-call" in paths
|
||||
|
||||
|
||||
def test_opfeed_path_wraps_app(tmp_path):
|
||||
# Issue #17 slice 2: RATATOSKR_OPFEED_PATH opts the dispatch op-feed in; the
|
||||
# returned app is then the instrumented ASGI wrapper, not the raw Starlette.
|
||||
app = build_app_from_env(
|
||||
{
|
||||
"RATATOSKR_HEIMDALL_KEY": "shared-secret",
|
||||
"RATATOSKR_AFFECT_DB": ":memory:",
|
||||
"RATATOSKR_OPFEED_PATH": str(tmp_path / "ops.jsonl"),
|
||||
}
|
||||
)
|
||||
assert not hasattr(app, "routes") # wrapped: a bare ASGI callable
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for the memory-provider serve entrypoint (ratatoskr.provider.serve_memory).
|
||||
|
||||
Only the env -> app seam is unit-tested; uvicorn.run is the untestable shell.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ratatoskr.provider.serve_memory import build_memory_app_from_env
|
||||
|
||||
|
||||
def test_build_memory_app_from_env_requires_heimdall_key():
|
||||
with pytest.raises(RuntimeError):
|
||||
build_memory_app_from_env(
|
||||
{"RATATOSKR_MEMORY_DB": ":memory:", "RATATOSKR_MEMORY_EMBEDDING_DIM": "8"}
|
||||
)
|
||||
|
||||
|
||||
def test_build_memory_app_from_env_requires_embedding_dim():
|
||||
# A wrong/missing dim silently breaks vector search -> require it explicitly.
|
||||
with pytest.raises(RuntimeError):
|
||||
build_memory_app_from_env(
|
||||
{"RATATOSKR_HEIMDALL_KEY": "k", "RATATOSKR_MEMORY_DB": ":memory:"}
|
||||
)
|
||||
|
||||
|
||||
def test_build_memory_app_from_env_rejects_non_positive_dim():
|
||||
with pytest.raises(RuntimeError):
|
||||
build_memory_app_from_env(
|
||||
{
|
||||
"RATATOSKR_HEIMDALL_KEY": "k",
|
||||
"RATATOSKR_MEMORY_DB": ":memory:",
|
||||
"RATATOSKR_MEMORY_EMBEDDING_DIM": "0",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_build_memory_app_from_env_builds_app_with_routes():
|
||||
app = build_memory_app_from_env(
|
||||
{
|
||||
"RATATOSKR_HEIMDALL_KEY": "shared-secret",
|
||||
"RATATOSKR_MEMORY_DB": ":memory:",
|
||||
"RATATOSKR_MEMORY_EMBEDDING_DIM": "8",
|
||||
}
|
||||
)
|
||||
paths = {getattr(r, "path", None) for r in app.routes}
|
||||
assert "/bifrost/handshake" in paths
|
||||
assert "/bifrost/memory-call" in paths
|
||||
|
||||
|
||||
def test_opfeed_path_wraps_app(tmp_path):
|
||||
# Issue #17 slice 2: RATATOSKR_OPFEED_PATH opts the dispatch op-feed in; the
|
||||
# returned app is then the instrumented ASGI wrapper, not the raw Starlette.
|
||||
app = build_memory_app_from_env(
|
||||
{
|
||||
"RATATOSKR_HEIMDALL_KEY": "shared-secret",
|
||||
"RATATOSKR_MEMORY_DB": ":memory:",
|
||||
"RATATOSKR_MEMORY_EMBEDDING_DIM": "8",
|
||||
"RATATOSKR_OPFEED_PATH": str(tmp_path / "ops.jsonl"),
|
||||
}
|
||||
)
|
||||
assert not hasattr(app, "routes") # wrapped: a bare ASGI callable
|
||||
@@ -9,11 +9,15 @@ from ratatoskr.sessions import (
|
||||
AgentNotAvailable,
|
||||
AgentNotFound,
|
||||
AuthScopeDenied,
|
||||
BifrostBinding,
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
InvalidCursor,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
SessionPage,
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
get_persona_state,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
@@ -206,6 +210,186 @@ class TestCreateSession:
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
class TestCreateSessionBifrostBind:
|
||||
"""Issue #17 slice 1 — the create_session Bifrost-bind primitive."""
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_happy_consumer_key_and_body(self) -> None:
|
||||
"""bind_happy [tracer]: a bifrost binding makes the body carry the
|
||||
`bifrost` field AND overrides the bearer to the consumer key (NOT the
|
||||
client's canary default), 201 → SessionInfo. Proves the bind path
|
||||
end-to-end (FN create_session STEPS 1-2, POST-001, INV-001)."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"session_id": "s-bound",
|
||||
"agent_id": "ratatoskr:sindra",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-06-18T12:00:00+00:00",
|
||||
"last_active": "2026-06-18T12:00:00+00:00",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://w.example",
|
||||
headers={"Authorization": "Bearer canary-key"},
|
||||
) as client:
|
||||
info = await create_session(
|
||||
client,
|
||||
"ratatoskr:sindra",
|
||||
end_user_id="smoke-user",
|
||||
bifrost=binding,
|
||||
consumer_key="consumer-key",
|
||||
)
|
||||
req = route.calls[0].request
|
||||
body = _json.loads(req.content)
|
||||
# body carries the bifrost field alongside agent_id/end_user_id
|
||||
assert body == {
|
||||
"agent_id": "ratatoskr:sindra",
|
||||
"end_user_id": "smoke-user",
|
||||
"bifrost": {
|
||||
"endpoint_url": "http://10.100.10.50:8391",
|
||||
"scope": None,
|
||||
},
|
||||
}
|
||||
# bearer overridden to the consumer key (INV-001: never the canary default)
|
||||
assert req.headers["Authorization"] == "Bearer consumer-key"
|
||||
assert info.session_id == "s-bound"
|
||||
assert info.agent_id == "ratatoskr:sindra"
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_without_consumer_key_raises_before_http(self) -> None:
|
||||
"""missing_key [adversarial]: bifrost set but consumer_key None →
|
||||
BifrostConsumerKeyMissing BEFORE any HTTP (PRE-001, INV-001: never fall
|
||||
back to the canary key)."""
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, content=b"{}")
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(BifrostConsumerKeyMissing):
|
||||
await create_session(client, "ratatoskr:sindra", bifrost=binding)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_with_empty_consumer_key_raises_before_http(self) -> None:
|
||||
"""empty_key [adversarial]: empty-string consumer_key is also rejected
|
||||
before HTTP (PRE-001 requires a NON-EMPTY str)."""
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, content=b"{}")
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(BifrostConsumerKeyMissing):
|
||||
await create_session(
|
||||
client, "ratatoskr:sindra", bifrost=binding, consumer_key=""
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_handshake_failure_maps_to_502(self) -> None:
|
||||
"""handshake_502 [adversarial]: a bound create that 502s with
|
||||
detail.bifrost_error → BifrostHandshakeFailed carrying the bifrost_error
|
||||
+ raw body (POST-002, INV-002 bind-time failure). 'bifrost.auth_rejected'
|
||||
is the canary-key-instead-of-consumer-key tell."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
502,
|
||||
json={
|
||||
"error_code": "bifrost_handshake_failed",
|
||||
"detail": {"bifrost_error": "bifrost.auth_rejected"},
|
||||
},
|
||||
)
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(BifrostHandshakeFailed) as exc_info:
|
||||
await create_session(
|
||||
client, "ratatoskr:sindra", bifrost=binding, consumer_key="ck"
|
||||
)
|
||||
assert exc_info.value.bifrost_error == "bifrost.auth_rejected"
|
||||
# the raw 502 body is carried for debugging
|
||||
assert exc_info.value.body
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_ephemeral_rejection_is_session_api_failed(self) -> None:
|
||||
"""ephemeral_422 [boundary]: 422 ephemeral_does_not_accept_bifrost is a
|
||||
generic create failure → SessionApiFailed, NOT a distinct exception
|
||||
(POST-003 — deliberate, an operator config error)."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
422, json={"error_code": "ephemeral_does_not_accept_bifrost"}
|
||||
)
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc_info:
|
||||
await create_session(
|
||||
client, "echo", bifrost=binding, consumer_key="ck"
|
||||
)
|
||||
assert exc_info.value.status == 422
|
||||
|
||||
@respx.mock
|
||||
async def test_unbound_create_unchanged_no_auth_override(self) -> None:
|
||||
"""unbound_unchanged [regression]: with no bifrost, the body is the
|
||||
pre-#17 shape AND create_session sends NO per-request Authorization
|
||||
override — the client's default canary bearer governs (INV-001: the two
|
||||
call sites never cross)."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"session_id": "s1",
|
||||
"agent_id": "mimir",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-04-15T12:00:00+00:00",
|
||||
"last_active": "2026-04-15T12:00:00+00:00",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://w.example",
|
||||
headers={"Authorization": "Bearer canary-key"},
|
||||
) as client:
|
||||
await create_session(client, "mimir")
|
||||
req = route.calls[0].request
|
||||
body = _json.loads(req.content)
|
||||
assert body == {"agent_id": "mimir"}
|
||||
# the client default bearer is used unchanged — no consumer-key override
|
||||
assert req.headers["Authorization"] == "Bearer canary-key"
|
||||
|
||||
|
||||
class TestEndpointForPlane:
|
||||
"""Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL."""
|
||||
|
||||
def test_memory_plane_maps_to_8391(self) -> None:
|
||||
"""memory [tracer]: 'memory' → http://<host>:8391 (POST-001)."""
|
||||
assert (
|
||||
endpoint_for_plane("memory", "10.100.10.50")
|
||||
== "http://10.100.10.50:8391"
|
||||
)
|
||||
|
||||
def test_affect_plane_maps_to_8390(self) -> None:
|
||||
"""affect: 'affect' → http://<host>:8390 (POST-001)."""
|
||||
assert (
|
||||
endpoint_for_plane("affect", "10.100.10.50")
|
||||
== "http://10.100.10.50:8390"
|
||||
)
|
||||
|
||||
def test_unknown_plane_raises_value_error(self) -> None:
|
||||
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
|
||||
with pytest.raises(ValueError):
|
||||
endpoint_for_plane("persona", "10.100.10.50")
|
||||
|
||||
|
||||
def _list_item(
|
||||
*,
|
||||
session_id: str = "s1",
|
||||
|
||||
@@ -9,6 +9,7 @@ import respx
|
||||
from textual.widgets import RichLog
|
||||
|
||||
from ratatoskr.cli import ParsedArgs
|
||||
from ratatoskr.sessions import BifrostBinding
|
||||
from ratatoskr.sse_client import (
|
||||
Cancelled,
|
||||
Done,
|
||||
@@ -2849,3 +2850,98 @@ class TestResolveThenRunWithPicker:
|
||||
err = capsys.readouterr().err
|
||||
assert "[no_agents]" in err
|
||||
assert picker_called is False
|
||||
|
||||
|
||||
class TestTuiBifrostBind:
|
||||
"""Issue #17 slice 3b — TUI bind trigger: bind failures route to the real
|
||||
stderr BEFORE the alt-screen opens (INV-002, mirrors issue #6; same exit
|
||||
codes/vocabulary as cli._amain per INV-006)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_handshake_failure_routes_pre_altscreen(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
from ratatoskr.tui import _resolve_then_run
|
||||
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
502,
|
||||
json={
|
||||
"error_code": "bifrost_handshake_failed",
|
||||
"detail": {"bifrost_error": "bifrost.auth_rejected"},
|
||||
},
|
||||
)
|
||||
)
|
||||
args = _args_new(
|
||||
agent_id="ratatoskr:sindra",
|
||||
bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"),
|
||||
bifrost_plane="memory",
|
||||
consumer_key="ck",
|
||||
)
|
||||
rc = await _resolve_then_run(args)
|
||||
assert rc == 23
|
||||
err = capsys.readouterr().err
|
||||
assert "bifrost.auth_rejected" in err
|
||||
assert "consumer key" in err # the 401-scoping hint
|
||||
|
||||
@respx.mock
|
||||
async def test_consumer_key_missing_routes_pre_altscreen(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
from ratatoskr.tui import _resolve_then_run
|
||||
|
||||
args = _args_new(
|
||||
agent_id="a",
|
||||
bifrost=BifrostBinding(endpoint_url="http://x:8391"),
|
||||
consumer_key=None,
|
||||
)
|
||||
rc = await _resolve_then_run(args)
|
||||
assert rc == 22
|
||||
assert "bifrost_consumer_key_missing" in capsys.readouterr().err
|
||||
|
||||
@respx.mock
|
||||
async def test_bound_create_carries_binding_and_consumer_key(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A successful bound create sends the bifrost body + the consumer-key
|
||||
bearer and prints the bound-state indicator (run_async stubbed so no
|
||||
alt-screen opens)."""
|
||||
from ratatoskr import tui as tui_mod
|
||||
from ratatoskr.tui import _resolve_then_run
|
||||
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"session_id": "s-bound",
|
||||
"agent_id": "ratatoskr:sindra",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-06-18T12:00:00+00:00",
|
||||
"last_active": "2026-06-18T12:00:00+00:00",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def fake_run_async(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(tui_mod.RatatoskrApp, "run_async", fake_run_async)
|
||||
args = _args_new(
|
||||
agent_id="ratatoskr:sindra",
|
||||
bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"),
|
||||
bifrost_plane="memory",
|
||||
consumer_key="ck",
|
||||
)
|
||||
rc = await _resolve_then_run(args)
|
||||
assert rc == 0
|
||||
import json as _json
|
||||
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body["bifrost"] == {
|
||||
"endpoint_url": "http://10.100.10.50:8391", "scope": None
|
||||
}
|
||||
assert route.calls[0].request.headers["Authorization"] == "Bearer ck"
|
||||
err = capsys.readouterr().err
|
||||
assert "bifrost: status=bound" in err
|
||||
assert "plane=memory" in err
|
||||
|
||||
@@ -188,6 +188,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bifrost"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
|
||||
dependencies = [
|
||||
{ name = "jsonschema" },
|
||||
]
|
||||
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.8.0/bifrost-0.8.0.tar.gz", hash = "sha256:28194877c81a056a0803b052e86902c092e965d4ce63a5623d7a31240cedb645" }
|
||||
wheels = [
|
||||
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.8.0/bifrost-0.8.0-py3-none-any.whl", hash = "sha256:2aac5e4a7828d718389748a78dae6baeb5e9ee4a801a10c427c06c5cc7ed6597" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.5.20"
|
||||
@@ -419,6 +431,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.11.0"
|
||||
@@ -1013,7 +1052,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatoskr"
|
||||
version = "0.17.0"
|
||||
version = "0.17.11"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1033,6 +1072,13 @@ dev = [
|
||||
{ name = "textual-dev" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
provider = [
|
||||
{ name = "bifrost" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "sqlite-vec" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
web = [
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
@@ -1040,21 +1086,39 @@ web = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "bifrost", marker = "extra == 'provider'", specifier = ">=0.8.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "httpx-sse", specifier = ">=0.4" },
|
||||
{ name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
|
||||
{ name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6" },
|
||||
{ name = "ratatoskr", extras = ["web"], marker = "extra == 'dev'" },
|
||||
{ name = "ratatoskr", extras = ["web"], marker = "extra == 'provider'" },
|
||||
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
|
||||
{ name = "sqlite-vec", marker = "extra == 'provider'", specifier = ">=0.1.6" },
|
||||
{ name = "starlette", marker = "extra == 'web'", specifier = ">=0.40" },
|
||||
{ name = "textual", specifier = ">=0.85" },
|
||||
{ name = "textual-dev", marker = "extra == 'dev'", specifier = ">=1.5" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.30" },
|
||||
]
|
||||
provides-extras = ["web", "dev"]
|
||||
provides-extras = ["web", "provider", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "respx"
|
||||
@@ -1081,6 +1145,116 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "2026.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975 },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127 },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144 },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148 },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766 },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916 },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583 },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778 },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866 },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.13"
|
||||
@@ -1106,6 +1280,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-vec"
|
||||
version = "0.1.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434 },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388 },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.1.0"
|
||||
|
||||
Reference in New Issue
Block a user