v0.20.15
69
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
be2c577884 |
feat(provider): mark_superseded verb — Worldtree #364 contradiction retirement + bifrost 1.1.4
Implement `mark_superseded(ids, *, superseded_by=None, reason=None)` — the SOLE supersession verb Worldtree #364's promotion-hygiene reconciliation calls to retire contradicted facts (wire shape confirmed by worldtree-dev, bifrost_memory_store.py:293). Live re-verify (2026-07-16) proved our provider 500-crashed on this call (unimplemented) → #364's retirement couldn't land + a retry-storm bloated the store; the readout only passed via transient recency-eviction. - `mark_superseded` mirrors the reference `_mark_lifecycle`: sets top-level `superseded=True` (+ `superseded_by`/`superseded_reason` when non-None), increments revision, NON-destructive (get still returns; recoverable). Unknown ids skipped. - `_is_live` (INV-011) now short-circuits on `superseded is True`, so a retired chunk is excluded from `scan` (person-prime) — durable retirement, not just recency-eviction. search is unfiltered (matches reference; WT re-checks liveness client-side). - Contract: un-defer mark_superseded (+ FN spec, INV-011); TDD 5/5 (retires-from-scan tracer, non-destructive-get, unknown-id no-op, non-None-fields-only, parity #195). - bifrost 1.1.1→1.1.4: hasattr-gate backstop for the maintenance verbs (unimplemented verb → unsupported_capability 400, never AttributeError/500/retry-storm — the gap we surfaced) + the 1.1.3 scan/cursor conformance harness. Full suite 644 green. |
||
|
|
f46ccbae1c |
fix(provider): sortable_chunk_fields needs required type — handshake was broken
DEPLOY-BREAKER caught by driving the live bind (unit tests + worldtree-dev's
name-only parser + heid-bug-hunt all missed it). bifrost handshake_response
`SortableChunkField` requires BOTH `name` and `type` (additionalProperties:false).
We advertised `[{"name":"updated_at"}]` (no `type`), so the handshake_response
failed wire-schema validation → `bifrost.schema_validation_failed` → the ENTIRE
Bifrost bind (memory + affect) broke, not just the sort. Advertise
`{"name":"updated_at","type":"timestamp"}` (matches the reference; `type` is
advisory-only). Regression guard added to the caps test (asserts required name+type,
no extra keys). Full suite 639 green.
|
||
|
|
25ccb5c75b |
fix(provider): scan rejects non-dict sort with InvalidArguments, never AttributeError
heid-bug-hunt panel (Gróa + Hulda, confirmed-from-code) caught that a truthy
non-dict `sort` (e.g. sort="updated_at" or sort=["updated_at"]) reached
`(sort or {}).get(...)` and crashed with AttributeError instead of the
InvalidArguments PRE-003 promises for malformed caller-controlled input. Add an
isinstance guard before field extraction. Test covers str/list/int sort values.
|
||
|
|
8fc757aa61 |
feat(provider): person-prime scan verb + sortable_chunk_fields cap (WT #349)
Implement the memory-store `scan` verb — a query-LESS, LIVE-only, globally
ordered top-N-by-recency read — and advertise `sortable_chunk_fields=
[{updated_at}]` at the Bifrost handshake. Advertising the cap is what lights
up Worldtree's #349 person-prime turn-1 durable-fact injection (Branch-A
`"updated_at" in caps.sort_fields_supported`); the fix is ZERO Worldtree
change — the running provider announcing the cap is the trigger.
scan is:
- LIVE-only server-side (INV-009): superseded/tombstoned excluded — a dead
fact can never inject; person-prime's `lifecycle_state=live` does not ride
the scan wire, so server-side is authoritative.
- Globally ordered before pagination (INV-010): the full scope-filtered live
set is ordered by (sort.field, direction) globally; missing value LAST,
chunk_id tiebreak. Backed by an expression index on
json_extract(record_json,'$.updated_at') to stay in the 500ms budget.
- Cursor = offset into the global order; emits a next cursor only when a
further match exists (no empty trailing page — matches the reference).
Sort is dispatch-gated: an unadvertised sort.field raises InvalidArguments,
never a silent unsorted fallback.
Contract amended: un-defers scan, adds the FN spec + INV-009/INV-010 +
sortable_chunk_fields to INV-006. TDD 7/7 green (scan_recency tracer,
live_only, scope_isolation, unadvertised_sort, person_prime_record_shape,
cursor pagination, parity_vs_reference vs InMemoryMemoryStore #195). Full
suite 638 green.
|
||
|
|
e5ec63967e |
feat(web): memory viewer + design iteration-3 + markdown pass-2 (v0.20.7)
Web-UI iteration-3 — three queued items landed together.
(A) Design iteration-3 into static/index.html:
- sparkline grid background (<pattern id=sparkGrid> + bg rect behind each
relation-row spark polyline);
- PAD strips → per-turn Δ bars: replace the vertical polyline strip
(stripPoints/proj3 removed) with padDeltas→deltaStrip, a 12-cell HTML
column of diverging bars (newest at bottom, magnitude→width, age→opacity);
- mood orbit → dimetric open box (viewBox 124×140, az35/el25,
D-right/A-left-back/P-up): ghost A×P wall + D×A floor +
orbitProj/orbitShadowY/orbitWallPt/orbitAxisPt, JS-driven animated replay
(orbitFrame per rAF via a singleton startOrbitAnim reading live ORBIT_HIST;
reduced-motion → static final-state).
(B) Memory viewer — a non-bifrost debug read on OUR own store, mirroring the
#18-D2 affect read:
- provider: RatatoskrMemoryStore.list_chunks + count_chunks + shared
add_memory_read_route (GET /memory/chunks?agent_id=&end_user_id=), wired
into build_memory_provider_app + the combined :8392 provider. end_user
strict, agent_id lenient (an {end_user}-only chunk stays visible);
{chunks,count,total}, empty match = 200 (not 404);
- web: _memory_chunks_endpoint (GET /api/memory/chunks) supplies end_user_id
server-side, forwards the browser agent_id, proxies to memory_read_url;
create_app gains memory_read_url, entrypoint reads RATATOSKR_MEMORY_READ_URL;
- pane: loadMemory/renderMemory/setMemHead — a live-polling MEMORY console
pane (content·scope·origin·revision per chunk; count/total distinguish
empty-store from scope-mismatch), polled on open + post-turn.
(C) Markdown pass-2 in markdownSafe: GFM pipe tables (mdTable, alignment
colons), indentation-nested lists (child list inside the open <li>),
ordered-list start=N numbering, streaming robustness (partial fence →
code block; header-without-delimiter → paragraph until it streams in).
esc-first → INV-004 held.
Contract web_debug_surface.contract.md amended in-commit (create_app
memory_read_url; dimetric-orbit + Δ-bar renderConsole POST-002; memory-viewer
+ markdown-pass-2 function contracts). 631 tests green; Playwright-verified
all render paths (dark+light).
|
||
|
|
263ec2917b |
fix: render the seeded first-message on the web UI (v0.19.9)
The #347 auto-seed worked (the greeting was in the session ledger at seq-0), but the web UI never showed it: there was no GET /api/sessions/{id}/messages route and startSession() went straight from create to persona/tools/admin hydration, so the transcript only filled from the live turn stream + user echoes — a seeded turn-0 was invisible. - server: new proxy route GET /api/sessions/{id}/messages -> get_session_messages (mirrors the tools/bifrost proxies; status-preserving envelope). - SPA: loadTranscript(sessionId) fetches it on open and renders existing turns (assistant -> .response .md-body via markdownSafe escape-first; user -> .prompt-echo via textContent), called after the workspace opens. Best-effort. web_debug_surface contract amended (endpoint + loadTranscript). 2 web route tests, suite 617 green. Playwright DOM check proved the render end-to-end (drive the real UI -> Sindra's greeting bubble appears). |
||
|
|
be171304f5 |
feat: authored first-message presets — auto-seed on session-create (v0.19.8)
Codifies 'give an agent a first message' (Worldtree #347): new module ratatoskr.first_message (FIRST_MESSAGE_PRESETS + seed_preset_first_message) seeds a preset agent's opening as a #347 authored turn-0 on every new session, wired into all three create paths — cli._amain (--send --new), tui._resolve_then_run (bare --new), web._create_session_endpoint (POST /api/sessions). seed_preset_first_message is strictly best-effort (INV-001): it soft-guards its inputs (return None, never assert), bounds the write with asyncio.wait_for so a stalled /history can't block create (the CLI/TUI clients disable read timeout for SSE), and swallows every exception except asyncio.CancelledError (which propagates) — so it can NEVER raise into or block the session-create path it is wired into. Per-content idempotency key → idempotent replay, no dup. Seeded with ratatoskr:sindra, whose opening greeting moved out of her card: her live system_prompt was PATCHed (non-destructive) to drop the Startup workaround the #347 first-message now replaces. Quality gate (both cross-frontier panels): heid-code-review returned zero implementation drift (2 test-only fixups applied); heid-bug-hunt caught the gap the conformance lens can't see — code matched the contract's narrow ERROR_ROUTING but INV-001's 'never raises' is broader — driving the broad-except + soft-guard + wait_for hardening above. Contract docs/contracts/first_message.contract.md (module-scoped, validated). TDD: 12 unit + 1 web wire-in; the 3 existing sindra bind tests gained a history-endpoint mock (creating a preset agent now auto-seeds). Suite 615 green, ruff+mypy clean. Auto-seed live-proven generation-free against personal :8081. |
||
|
|
e643d38f58 |
fix: persona_state SET body → canonical {pad:{pleasure,arousal,dominance}} + re-vendor Tier-3 prose (v0.19.7)
worldtree-dev landed the Tier-3 persona/memory/persona_state prose docs
(c9e59ec) — shapes that serialize as freeform Any in the OpenAPI, so the
prose markdown is their source of truth. Re-vendored docs/conversation-api-spec.md
(tolerate_drift markdown pin; worldtree-spec-rev 879cefe→c9e59ec).
Consumer alignment: --set-persona-pad / _set_persona_probe was building
{pad:[list]}, but the canonical POST /sessions/{id}/persona_state body (#317)
is {pad:{pleasure,arousal,dominance}} (named dict). Aligned the probe to the
named dict + a len!=3 guard; updated contract #2's note, the set_persona_state
docstring, and the tests. The set_persona_state wrapper was already correct
(freeform pass-through) — only the CLI probe's body construction drifted.
Suite 602 green. (Also this session: heid-code-review on the #347 slice
returned unanimous zero drift across all three panel arms.)
|
||
|
|
6bf2a84ccd |
feat: authored-history-write consumer side (Worldtree #347) — v0.19.6
Consumer side of Worldtree's #347 authored-history-write (the SillyTavern first-message primitive), shipped via direct in-session TDD: - write_authored_history (POST /sessions/{id}/history): v1 author=assistant, effects=none, per-session idempotency; body server-pinned (AuthoredWriteRequest extra=forbid) so null effects/claimed_original_at are omitted; 200 replay / 201 fresh both return the AuthoredTurnResponse dict. - AuthoredHistoryUnavailable: the hide-existence 404 (feature-absent / ungranted / session-absent, indistinguishable by design — INV-347-1) raised DISTINCT from SessionApiFailed so callers branch feature-absent and never capability-probe. - get_session_messages (GET /sessions/{id}/messages): un-deferred as the seed read-back — confirms a seed renders as a normal role=assistant turn (model-invisible provenance). - --seed-first-message probe: create session -> seed -> read-back; a 404 reports a benign feature-absent result (exit 0), never a capability-probe. Contract #2 amended (2 FNs, validated OK). 19 new tests (12 wrapper + 7 cli), suite 601 green. Coverage-map re-converged: REST 19/41 (the #347 route + the messages read-back close the one gap the 2.3.0 re-vendor opened). Live-proof pending the session.history.write grant (requested infra-ops). |
||
|
|
75dec016eb |
fix(web): heid-review findings — SSE lifecycle teardown + test-shape gaps (v0.19.3)
Cross-frontier panel (Gróa/Hulda/Regin) on the v0.19.2 web surface, triaged: - FIX (Gróa #1, drift): the turn EventSource `onerror` (raw transport drop) now calls hideThinkingNote() — a drop mid-reasoning no longer leaves the "<Agent> is pondering…" line + its setInterval running (INV-LIFECYCLE). - FIX (Gróa #4 + Hulda #1, convergent drift): openAdminEvents now closes the EventSource + clears state.adminES on `stream_error` (server signalled end) and on a PERMANENT onerror (readyState CLOSED) — native EventSource no longer auto-reconnects into a retry loop; transient CONNECTING drops still reconnect. - TEST (Gróa #2 + Hulda #3): test_routes_registered asserts the 3 new routes; test_state_attached asserts app.state.admin_key (create_app POST-001/002). - TEST (Gróa #3 + Regin #3): AdminEvents stream_error-on-connect-failure test — upstream non-200 -> exactly one `stream_error` frame, then ends (POST-003). - CONTRACT (Hulda #2 + Regin #2, accepted): clarified the Tools inventory renders NAMES only by design (descriptions live in the BifrostState pane); code unchanged. Also lands the web_debug_surface contract as the trail. Accepted-no-op: 403-bifrost / non-404-tools tests (identical code path to the tested 404). Panel found ZERO functional server-side drift; INV-004 escaping confirmed clean across the new panes. 60 web tests pass; JS + ruff clean. |
||
|
|
a0a9d5f5e4 |
feat(web): debug-surface parity — BifrostState + AdminEvents + Tools panes, PAD-poll fix, reasoning indicator
Bring the browser surface to TUI parity as the primary debug surface:
- Tools inventory (GET /sessions/{id}/tools) folded into the tools pane —
what the LLM has at turn-fire, above the live tool events.
- BifrostState pane (GET /admin/sessions/{id}/bifrost) — admin-scoped
dispatch state; the admin key stays server-side (app.state.admin_key),
never reaches the browser (INV-003 precedent).
- AdminEvents pane (GET /admin/events SSE) — admin lifecycle, session-
filtered SERVER-side (heartbeats + other-session events dropped); one
fixed "admin_event" browser event so every type renders (no drops).
- PAD refresh: poll a window (1.5/3.5/6.5/10.5s) instead of a single 2s
shot that raced the post-turn-async affect.emit (issue #18 foot-gun).
- Reasoning indicator: ephemeral "<Agent> is pondering…" in the transcript
on `thinking` deltas, cleared when text begins — clearly non-engine.
Admin key wired through entrypoint -> create_app. 9 new respx/route tests
(admin-bearer override, filter unit, SSE stream-filter); 59 web tests pass.
Live-proven against ratatoskr:sindra (bifrost connected, both caps; 253
thinking events -> indicator fires; affect emit lands -> PAD poll catches it).
|
||
|
|
af07a2329a |
feat(#2): Tier-2 — transient characters + persona-state write; audit converges
v1 coverage-audit: the last in-scope client I/O points. The audit now CONVERGES — REST 17/40 covered with zero in-scope gaps (23 excluded-by- design), SSE 11/11, Bifrost planes 8/8. - sessions.py: list_character_models / create_character / get_character_state / delete_character (#161, character.read/write) + set_persona_state (POST /sessions/{id}/persona_state — freeform body, unpinned in the frozen surface). 200/201 -> dict (or None on 204), off-status -> SessionApiFailed. - cli.py: two one-shot probes (mirror --whoami): --characters (CRUD lifecycle report) + --set-persona-pad "p,a,d" (requires --session). New ParsedArgs.characters/set_persona_pad + probe mutual-exclusion. - Contract #2 amended (5 FNs) + validated. TDD: 7 wrapper + 5 cli tests. Suite 573 green; touched code ruff-clean. - Char read side live-proven (GET /models/available-for-characters -> 200). Coverage-map: convergence frontier CLOSED — scope-A "done" (every frozen I/O point classified) is met; ratatoskr cuts v1 when Worldtree tags 1.0. |
||
|
|
a3c92b68dc |
feat(#11): AdminEvents pane — GET /admin/events SSE (session-filtered)
v1 coverage-audit: the last unbuilt design-brief §5 debug pane. #11's blocker was already satisfied (admin key carries admin.events.read). Completes the admin/debug-observability core. - sse_client.py: AdminEvent dataclass + stream_admin_events — a new long-lived SSE consumer for the admin lifecycle stream (envelope {id,type,timestamp,data}), admin-scoped (bearer-override), Last-Event-ID resume. non-200 -> SseConnectFailed; mid-drop -> SseConnectionDropped. - tui.py: "AdminEvents" TabPane + _format_admin_event + _admin_event_matches (design-brief §6 filter: active-session + non-heartbeat system.*) + _stream_admin_events long-lived best-effort worker (unconditional on_mount; self-labels not-configured / unavailable / stream-ended). - Contract-skipped for stream_admin_events (out of #1's turn-SSE scope; spec § Admin Event Stream is the reference). TDD: 4 sse_client + 5 tui tests. Suite 561 green. - LIVE-AUTH-PROVEN on :8081 (GET /admin/events -> HTTP 200 under admin key). Coverage: REST 12/40. Tier 1 debug-observability core complete. |
||
|
|
9ce83d5fdc |
feat(#2): BifrostState pane — GET /admin/sessions/{id}/bifrost (admin-key)
v1 coverage-audit: the last unbuilt design-brief §5 debug widget. First
admin-key consumer in ratatoskr.
- sessions.py: get_session_bifrost(client, session_id, *, admin_key) —
admin-scoped (admin.sessions.read); the request overrides Authorization
with admin_key (distinct from the consumer bearer). 200 -> dict, non-200
-> SessionApiFailed (403 scope-denied, 404 not-bound).
- cli.py: --admin-key flag + RATATOSKR_ADMIN_API_KEY env -> ParsedArgs.admin_key.
- tui.py: new "Bifrost" TabPane + _format_bifrost_state + _hydrate_bifrost_state
best-effort worker (unconditional on_mount). Writes {endpoint, connected,
caps, tools} + audits; self-labels "not configured" / "not bound" / graceful
on 403+error, never crashes.
- Contract #2 amended (FN, incl. the bearer-override POST) + validated. TDD:
4 wrapper tests + 1 format unit + 3 hydrate integration. Suite 552 green.
- LIVE-AUTH-PROVEN on :8081 (admin key reached resource-layer 404, not 401/403).
Ledger correction: #11 (AdminEvents) is NO LONGER BLOCKED — the admin key
was verified to carry admin.events.read; only the pane is unbuilt. Coverage:
REST 11/40.
|
||
|
|
e62208d8e3 |
feat(#2): consume GET /sessions/{id}/tools — Tools-pane inventory hydrate
v1 coverage-audit Tier-2 quick win. The owner-scoped tool-inventory endpoint (#183) had no caller; wire it into the TUI Tools pane. - sessions.py: get_session_tools (GET /sessions/{id}/tools) — owner- scoped (consumer key, no admin scope), 200 -> parsed dict verbatim, non-200 -> SessionApiFailed. Mirrors get_persona_state / get_me. - tui.py: _format_tool_inventory helper + _hydrate_session_tools best-effort worker (mirrors _hydrate_persona), wired unconditionally in on_mount. Writes the merged {agent_id, builtin_tools, bifrost_tools} inventory the LLM saw at turn-fire into the Tools pane + audits; never crashes on failure. - Covers the design-brief 5 "Tools widget" via the reachable owner endpoint (the admin variant stays a gap only for cross-user debug). - Contract #2 amended (FN) + validated. TDD: 3 wrapper tests + 1 format-helper unit + 2 hydrate integration tests. Coverage: REST 10/40. Suite 544 green; touched code ruff-clean. |
||
|
|
387ac4ab2c |
feat(#2): consume GET /me + GET /capabilities via --whoami one-shot
v1 coverage-audit slice (capabilities+me). Both endpoints had no caller; add them as cheap boot-time debug primitives. - sessions.py: get_me (GET /me — identity/whoami) + get_capabilities (GET /capabilities — Echo ephemeral-template discovery). Mirror get_persona_state: 200 -> parsed dict verbatim, non-200 -> SessionApiFailed. Freeform dicts (frozen OpenAPI types both as objects). - cli.py: new --whoami one-shot mode (mirrors --send). Fetches both, prints an identity + capabilities report, exits. Standalone probe: mutually exclusive with --send/--session/--new/--agent; opens no session. New ParsedArgs.whoami field + main() dispatch. - Contract #2 amended (2 FNs) + validated. TDD: 5 wrapper tests + 5 cli tests (validation + mode + error). Coverage map: REST 9/40. Suite 538 green; touched code ruff-clean. Audit note: /capabilities is the Echo ephemeral-template discovery endpoint, not a generic server-caps endpoint (coverage-map framing corrected). TUI-surfacing of /me + /capabilities deferred. |
||
|
|
5c1b9816d4 |
feat(#6): startup session picker for bare TUI mode
v1 coverage-audit slice b2. The audit found list_sessions had no caller — the startup session picker (design-brief §4) was never built; bare TUI mode was a hard usage error. Add SessionPickerApp (mirrors AgentPickerApp) and resolve bare mode in _resolve_then_run. - Bare TUI mode (no --session/--new) now valid → session picker. Resolution: 0 sessions -> [no_sessions] exit 14 (resume-only per §4 "no in-app creation, --new only"); exactly 1 -> auto-resume (§4 "picker only when >1"); >=2 -> SessionPickerApp -> resume pick (Esc/Ctrl-D -> exit 0). - cli._parse: bare TUI valid; --send still requires one flag; --agent forbidden in bare mode. run_tui PRE-002 xor -> mutually-exclusive. - Contract #6 amended (SessionPickerApp + bare-mode resolution) + validated. TDD: 3 picker pilot tests + 5 resolution tests + 3 cli validation tests. Suite 528 green; touched code ruff-clean. Design note: bare + 0 sessions errors (honors §4's no-in-app-creation clause); the friendlier auto-fall-through-to-new is deferred pending operator preference. |
||
|
|
0c7660791f |
feat(#1): shared SSE resume orchestration; wire cli --send
v1 coverage-audit slice b1. The audit found reconnect_turn had no caller — every presenter dropped the stream on disconnect instead of resuming, leaving the "reference SSE-resume implementation" (design- brief §3/§8d) unreachable. Add stream_turn_resilient as the single shared resume surface (design-brief §8b "share the consumer, branch the presenter") and route cli --send through it. - stream_turn_resilient wraps stream_turn + reconnect_turn: on SseConnectionDropped (mid-stream drop or clean EOF before terminal), resume from the last-seen sse_id via reconnect_turn (Last-Event-ID), up to max_reconnects (default 5). last_seen persists across attempts. - Non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed) propagate unchanged, per contract #1's "surface, not recover". - cli.py: --send consumer now drives stream_turn_resilient (transparent reconnect). tui/web still consume bare stream_turn (follow-up). - Contract #1 amended (FN stream_turn_resilient) + validated; 8 TDD cases (happy, resume-after-1/2-drops, clean-EOF resume, unresumable zero-event, max-reconnects-exhausted, zero-budget, buffer-expired- propagates). Suite 518 green; ruff + mypy clean on touched code. |
||
|
|
e4317f6a73 |
fix: pin the eager-503 default error_code to not_ready (Worldtree b2)
worldtree-dev finalized the #331 503 turn-launch error_code as `not_ready` (re-pinned from internal_error; retryable, matching the /readyz 503 sense) and re-froze the OpenAPI at 2.2.0 documenting the 409/503 statuses our v0.18.3 mapping already handles. Tighten our fallback default from the placeholder `turn_launch_unavailable` to the canonical `not_ready` (the default only fires when the body omits error_code — the real code is surfaced verbatim regardless). +1 test, suite 510 green. NOTE: a full conversation-api-spec.md re-vendor to the b2 era is a separate, larger pin-refresh (ratatoskr vendors the markdown spec, not the OpenAPI JSON) — deferred, to bundle with the v1 coverage-audit / when personal is on b2. |
||
|
|
b2e4901264 |
feat: map Worldtree b1 eager turn-launch statuses (409/503) in stream_turn
Worldtree v1.0.0b1 (#331) decoupled turn execution from the SSE connection, so turn-launch failures now arrive EAGERLY as an HTTP status before any stream: 409 agent_not_available (pre-b1 was a 200 + in-stream error event) and 503 (retryable turn-launch / infra failure). stream_turn previously funneled both into a generic SseConnectFailed. Map them to typed SseConnectFailed subclasses — AgentNotAvailable (409) and TurnLaunchUnavailable (503, retryable=True) — carrying the parsed error_code/message from the {detail:{error_code,message}} envelope. Subclassing keeps existing `except SseConnectFailed` handlers working with zero changes (POST-003 preserved — no synthetic event yielded; raise mirrors reconnect_turn's 400/410/412 pattern). worldtree-dev confirmed 409/503 are real runtime statuses; the OpenAPI 2.1.0 gap (not enumerating them) is theirs to fix (doc-completeness, not a wire break). The 503 error_code is being re-pinned upstream (today internal_error -> likely not_ready); our handling keys on STATUS so it's robust to the final code — tighten the 503 default once they confirm. Body shape live-confirmed against demo b1's 404/401 responses. Suite 509 green. Contract docs/contracts/issues/1.contract.md updated. |
||
|
|
719e4d605b |
feat: web SPA bind — add 'combined' (:8392) both-plane option as default
The bind dropdown offered only memory/affect single-plane binds; #18's composite endpoint (:8392, both planes in one session) was never reachable from the SPA. Add 'combined' as the default-selected option, keeping memory-only / affect-only for single-plane isolation diagnostics. - endpoint_for_plane: combined -> :8392 (sessions.py) - web server: accept bifrost_plane="combined" (server.py) - dropdown: combined (:8392) default-selected, single-plane retained (index.html) - #17 contract: endpoint_for_plane FN + plane-selector spec updated to combined - tests: endpoint_for_plane combined, server combined bind -> :8392, dropdown default Suite 506 green. Live-verified on :8765 (current code). |
||
|
|
d60b77d4f1 |
fix(#17): op-feed handshake reads the real capabilities field, not capabilities_requested
The dispatch-layer op-feed's handshake req-summary read req.get("capabilities_requested"),
a field that never exists on the wire — bifrost's handshake handler reads
request_body["capabilities"] (reference_server/_protocol.py:181). So the op-feed's
caps_requested was silently ALWAYS None on every handshake. Read the real field.
Surfaced by the heid-code-review panel (Regin) during the #18 D1 review — a latent
#17 observability bug, not D1 drift. Regression test asserts caps_requested is
populated from a handshake body's capabilities.
Suite 502 -> 503 green.
|
||
|
|
7f4ceaab2b |
feat(#18): composite Bifrost endpoint — build_combined_app (Deliverable 1)
One ASGI app fronting BOTH the memory.* and affect.* planes (:8392), so a single bound Worldtree session both remembers AND shows live PAD. Closes #18 end-to-end (D2 PAD read-endpoint shipped v0.17.14; D1 was bifrost-blocked, now unparked by bifrost 0.10.0's public build_combined_app + FR-1 resolved — zero Worldtree change). - provider/combined.py: build_combined_provider_app wraps bifrost.consumer.build_combined_app over both stores + mounts the shared affect read route. Advertises both caps by store presence; per-route call-time isolation is bifrost's (INV-013). - affect_store.py: extract add_affect_read_route shared helper (the D2 INV-007 promise — composite + standalone mount the SAME read route over the same affect.db, INV-011). - opfeed.py: plane='combined' derives the OpEvent plane per request path (memory-call->memory, affect-call->affect, handshake->combined; INV-012). - serve_combined.py + ratatoskr-combined-provider console script on :8392 (additive — standalone :8390/:8391 untouched, INV-014). - contract: 18.contract.md § Deliverable 1 (INV-009..INV-014); D1 un-deferred. Latent bug fixed (exposed by the contract-mandated memory `search` dispatch test running through TestClient = a worker thread): open_memory_store lacked check_same_thread=False — the SAME sqlite thread-safety bug already fixed in the affect store (D2). The composite serves the memory plane over HTTP, so a memory-call on uvicorn's threadpool would trip it. Fix: check_same_thread=False + PRAGMA busy_timeout=5000 (memory contract Concurrency note). heid-code-review panel (Groa/Hulda/Regin): ZERO drift findings; the implementation matches INV-009..INV-014 at function-block level. Folded the genuine test-fidelity fix (memory leg describe_store -> search per the contract TEST) + added the PRE-001/PRE-002 guard tests. Suite 486 -> 502 green. |
||
|
|
ca6af6bdaa |
feat(#18): affect.fetch — adopt bifrost 0.10.0 mandatory fetch (D1 prerequisite)
bifrost 0.10.0's _supports_affect_plane (bifrost/affect.py:75-80) now requires a
callable fetch for the affect capability to advertise/dispatch at all (INV-012
strong-or-absent), so an emit-only store 400s on EVERY affect op — repinning past
the affect.fetch release (#12/#13) breaks our shipped affect plane until fetch
exists. Implement affect.fetch as a thin async wrapper over the existing get()
read seam, conformed verbatim to the reference InMemoryAffectStore.fetch:
{"found": False} or {"found": True, "snapshot": <verbatim>}, AffectInvalidArguments
on empty ids, opaque (INV-001 — never reads pad/valence).
This is the forced prerequisite for the #18 D1 composite (build_combined_app),
and a new Worldtree I/O point consumed (affect read-back over bifrost).
- Repin bifrost>=0.8.0 -> >=0.10.0 (uv lock: 0.8.0 -> 0.10.0)
- affect_store.py: add async fetch() over get()
- contract bifrost_affect_provider v1.2: fetch FN block + INV-010 (cap = supported+emit+fetch)
- tests: 3 fetch unit + parity_vs_reference_fetch through dispatch_affect_call
- suite 482 -> 486 green
|
||
|
|
39eebd1a55 |
feat(#18): PAD read-endpoint — web pane renders live PAD/valence from our affect store (Deliverable 2)
The web persona pane now renders live PAD/valence for Tier-3 agents from our
:8390 affect store, closing the persona-telemetry gap (Worldtree persona_state
404s for Tier-3 per ADR-0009; Tier-3 emits no affect_update SSE).
- provider: non-bifrost GET /affect/state/{agent_id} on the affect-store-owning
app (add_route — keeps /bifrost/* top-level + op-feed-skipped); explicit
no_affect_snapshot 404 (never a zeroed PAD); busy_timeout + check_same_thread
on the connection.
- web: GET /api/affect/{agent_id} proxy — end_user_id server-supplied (never the
browser), colon-id round-trip, configured RATATOSKR_AFFECT_READ_URL.
- pane: honest affect render (pad + valence + emitted_at, labelled "affect", no
fabricated Tier-1 fields); explicit empty-state; polls 2s post-turn.
Contract-first (docs/contracts/issues/18.contract.md, Deliverable-2-scoped;
Deliverable 1 / composite endpoint deferred — bifrost-blocked on a public
build_combined_app, WT dispatch confirmed single-endpoint caps-routed).
Heid-code-review panel: 1 INV-001 drift (strip fabricated "neutral") + 4
test-gaps fixed. Live-smoke PROVEN: web->provider->affect.db chain returns real
sindra/vuong PAD; Playwright DOM check confirms the pane render + the fix.
Suite 482 green.
|
||
|
|
2806abac44 |
feat(#17): web Bifrost-bind — server side (slice 3c, INV-008 lockstep complete)
Slice 3c of issue #17 — the web surface of the bind trigger, server side. Closes the INV-008 lockstep (CLI + TUI + web all carry the bind now). Implements the contract's "web bind split": the browser selects only the PLANE; the consumer key and the Worldtree-visible host are SERVER-HELD config and never reach the browser. - create_app gains bifrost_consumer_key + bifrost_visible_host (server-held, from env via the entrypoint: RATATOSKR_BIFROST_CONSUMER_KEY / RATATOSKR_PROVIDER_VISIBLE_HOST). - _create_session_endpoint reads an optional `bifrost_plane` from the browser body, builds the BifrostBinding SERVER-SIDE via endpoint_for_plane(plane, visible_host), and calls create_session(bifrost=, consumer_key=). The 201 response echoes bound-state {plane, endpoint, status: bound} for the UI indicator — never the key (INV-008/INV-009). - Error routing: invalid plane / unconfigured server -> 400; BifrostHandshakeFailed -> 502 {bifrost_error}; BifrostConsumerKeyMissing (server misconfig) -> 400. 5 new web bind tests (server constructs binding + key-never-leaks + upstream carries bifrost body + consumer-key bearer; unconfigured -> 400; invalid plane; handshake 502; no-plane unbound regression). Full suite 470 green; added lines ruff + mypy clean (pre-existing web-file backlog untouched). Follow-on: the index.html plane selector (UI trigger) — the server capability is complete and TDD'd; the browser-side dropdown is a thin separate change. LIVE-SMOKE PROVEN (this session): the CLI bind drove a bound sindra session against personal Worldtree :8081 -> handshake 200 -> the op-feed captured 2 recall searches correlated to the EXACT bound session_id (2c0c7482), with the real #297/#298 union-recall scopes. Bind + observe proven end-to-end live. |
||
|
|
016defcc01 |
feat(#17): TUI Bifrost-bind trigger (slice 3b of the INV-008 lockstep)
Slice 3b of issue #17 — the TUI surface of the bind trigger (web is 3c). The TUI consumes the same ParsedArgs the cli already parses (--bifrost-plane / --bifrost-url / consumer key from RATATOSKR_BIFROST_CONSUMER_KEY), so this wires the bind into _resolve_then_run's pre-flight create_session: - bifrost + consumer_key threaded into create_session at the pre-alt-screen resolution layer, so bind failures land on the operator's REAL stderr BEFORE the Textual alt-screen opens (INV-002, mirrors issue #6's pre-alt-screen routing) — never eaten by the alt-screen teardown. - BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit 23 with the same 401-scoping hint, keyed on bifrost_error == bifrost.auth_rejected. Exit codes + label vocabulary match cli._amain exactly (INV-006). - Bound-state indicator on success (pre-alt-screen): ". bifrost: status=bound plane=... endpoint=...". 3 new TUI bind tests (handshake-fail / consumer-key-missing / bound-create carries binding + indicator, run_async stubbed). Full suite 465 green; added lines ruff + mypy clean (pre-existing tui.py lint/type backlog untouched per surgical-changes). |
||
|
|
0bebad74ad |
feat(#17): CLI Bifrost-bind trigger (slice 3a of the INV-008 lockstep)
Slice 3a of issue #17 — the CLI surface of the bind trigger (TUI + web follow, INV-008 lockstep). ratatoskr can now self-drive a bound session from the CLI: - New flags: --bifrost-plane {memory,affect} (dev shortcut -> endpoint_for_plane over --bifrost-host / RATATOSKR_PROVIDER_VISIBLE_HOST) and --bifrost-url (the direct HTTPS/prod endpoint, bypassing the plane shortcut). Mutually exclusive; a binding is a session-CREATE concern (forbidden with --session). - Consumer key resolved from RATATOSKR_BIFROST_CONSUMER_KEY only (the privileged handshake identity — never a CLI flag, distinct from the canary WORLDTREE_API_KEY). - _amain threads bifrost + consumer_key into create_session and routes the bind failures: BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit 23 with the 401-scoping hint ("use the consumer key, not WORLDTREE_API_KEY") keyed on bifrost_error == bifrost.auth_rejected. - Bound-state indicator on success: ". bifrost: status=bound plane=... endpoint=..." — shows WHICH identity/endpoint bound, not a bare boolean. Also fixes a pre-existing test-isolation bug: test_no_textual_import did a live importlib.reload(ratatoskr.cli) that mutated the shared module in place, breaking class identity (isinstance / pytest.raises) for every test after it. The real check is the static source grep; the reload was vestigial and is removed. 9 new CLI bind tests; full suite 462 green; ruff clean (no new mypy errors). |
||
|
|
8ebe227ae4 |
feat(#17): dispatch-layer op-feed for the provider (slice 2 — Observe)
Slice 2 of issue #17 — the OBSERVE half. New ratatoskr.provider.opfeed: - OpEvent{ts, plane, op, session_id, status, req_summary, resp_summary, turn_id=None} — scope-only summaries, never record bodies / PAD content - OpSink Protocol + JsonlOpSink (continuous append-only JSONL, INV-007) - instrument_provider_app(app, *, plane, sink): an ASGI middleware over the built bifrost provider app. Buffers+replays the request, captures the response, reads session_id off the dispatch JWT's "sub" claim (INV-005 — present for ALL verbs incl. search/get/delete, which bifrost withholds from the store method), emits exactly one OpEvent per inbound bifrost-call incl. handshake + errors. Read-only over dispatch; store scope semantics untouched (INV-004). A sink/summary failure is swallowed + logged, never breaks serve (POST-003). - Per-verb summaries: search {scope_all,scope_any,top_k}->{hit_count,hits}; upsert_many {record_count,scopes}->{upserted,replayed}; get/get_many/ delete_many {ids}->{found_count|deleted}; emit (affect, opaque)->{stored}; handshake {caps_requested}->{caps_granted,ok}; error->{error: code} - serve_memory/serve wired: opt-in via RATATOSKR_OPFEED_PATH (maybe_instrument) Resolves the contract's open question: the dispatch JWT DOES carry session_id (= the "sub" claim). Tests drive the REAL bifrost dispatch end-to-end with minted JWTs. 11 new tests; full suite 453 green; ruff + mypy clean (opfeed.py). |
||
|
|
7be162e84d |
feat(#17): create_session Bifrost-bind primitive (slice 1)
Slice 1 of issue #17 (Bifrost-binding the chat client) — the client-side BIND primitive, TDD'd against docs/contracts/issues/17.contract.md. - BifrostBinding{endpoint_url, scope=None} frozen dataclass (#160 shape) - create_session(..., bifrost=, consumer_key=): carries the bifrost body field and OVERRIDES the bearer to the consumer key per-request (INV-001 — never falls back to the canary key) - BifrostConsumerKeyMissing: raised BEFORE any HTTP when a binding lacks a non-empty key (PRE-001) - BifrostHandshakeFailed: 502 on a BOUND create -> carries detail.bifrost_error (both-shape unwrap per the persona_state wire lesson); gated on bifrost!=None so an unbound 502 stays SessionApiFailed (INV-002) - endpoint_for_plane: memory->:8391 / affect->:8390, invalid->ValueError 7 new tests; full suite 442 green; ruff clean. |
||
|
|
96d61a4bb1 |
feat(provider): split memory search scope_filter → scope_all + scope_any (bifrost 0.8.0/wire v0.6)
Repin bifrost 0.7.0→0.8.0 and reimplement the memory store's search scope filter to the v0.6 split (#11): scope_all (AND/intersection) + scope_any (OR/union over a list of conjunctive scopes), at parity with the v0.6 reference _matches_scope / _validate_scope. No-compat: scope_filter removed. scope_any is the union-visibility primitive that resolves the #295/#297 silent-zero AND foot-gun — a subset-scoped chunk now recalls via an OR member. End-to-end cold recall now gated only on Worldtree emitting scope_any on its recall path (#297, upstream). - store: search(scope_all, scope_any); _scope_subset + _matches_scope + _validate_scope - contract v1.2: search FN sig, INV-005 recomposed, PRE-003 both fields, scope_any_union test - tests: scope_any union, scope_all∧scope_any compose, both-empty match-all; parity vs real 0.8.0 dispatch (433 green) - #17 contract: sync stale scope_filter/_scope_matches-AND refs to scope_all/scope_any - runbook + persistent-memory updated; provider bounced onto 0.8.0 (fresh empty db) v0.17.6 |
||
|
|
aac4353933 |
feat(provider): validate scope_filter against the v0.5 4-axis lattice
bifrost 0.7.0 (wire v0.5) makes agent_self canonical: the scope lattice is
now {end_user, group, tenant, agent_self}. Our store was MORE permissive than
bifrost's reference (no _validate_scope_filter), which silently 0-zeroed the
#295 cold recall instead of a loud 400. Now matched: search rejects an
out-of-lattice axis with InvalidFilter (-> memory.invalid_filter 400), agent_self
admitted. Purely additive — everything that validated before still validates.
Closes the parity gap our own foot-gun flag opened (bifrost-dev shipped the
lattice add #10 off it). Pin bumped bifrost>=0.6.1 -> >=0.7.0. Contract
search PRE-003 + lattice_axes test; 2 new store tests; full suite 429 green.
|
||
|
|
cd12951aca |
feat(provider): memory plane — SQLite+sqlite-vec store + dev shell
The second plane of ratatoskr's Tier-3 Bifrost consumer: a durable memory store Worldtree writes agent memory chunks into (upsert_many) and recalls by vector similarity (search), with point reads + deletes. Implements bifrost's own MemoryDataStore Protocol; conformance is #195 parity vs InMemoryMemoryStore through the real dispatch_memory_call. Store (memory_store.py): open_memory_store, describe_store, upsert_many (replay/conflict idempotency, optimistic locking, injection rule, atomic batch), search (cosine over sqlite-vec vec0, scope isolation INV-005, over-fetch-then-filter so top_k counts in-scope), get/get_many, delete_many, build_memory_provider_app. Dev shell (serve_memory.py): ratatoskr-memory-provider entrypoint, port 8391. TDD + heid-code-review (panel Groa/Hulda/Regin, zero true drift). Adopted fixups: scope_filter dict guard, top_k<=0 -> [], stronger scope-isolation + delete-hit-search + handshake-POST tests. Partial-map optimistic-lock semantics pinned against the reference via a new expected_revisions parity test. 26 memory + 4 serve tests; #195 parity (upsert/search/expected_revisions) green; ruff clean. Deps: +sqlite-vec. |
||
|
|
bcdcd71090 |
feat(provider): runnable affect-server entrypoint + ratatoskr-provider script
Stand-up glue for the live affect negotiation smoke vs Worldtree v0.35.2. - ratatoskr.provider.serve: build_app_from_env (tested env->app seam) + main (uvicorn shell). Config from env: RATATOSKR_HEIMDALL_KEY (required), RATATOSKR_AFFECT_DB, RATATOSKR_CONSUMER_ID, RATATOSKR_PROVIDER_HOST/PORT. - console-script ratatoskr-provider = ratatoskr.provider.serve:main. - Heimdall key utf-8-encoded into HS256 key_bytes; revisit if Worldtree signs with base64/raw bytes (confirm format at wire-up). |
||
|
|
d90a58dc48 |
feat(provider): SQLite affect store + ASGI wiring — #195 parity green
The first slice of ratatoskr-as-Tier-3-Bifrost-consumer: a SQLite-backed, conduit-opaque affect store Worldtree emits Tier-3 persona/affect snapshots into, plus the thin build_affect_provider_app wiring. The bifrost library owns the wire; this owns only the store + glue. - ratatoskr.provider.affect_store: open_affect_store / emit / get / build_affect_provider_app. Two-table SQLite schema (snapshots + idempotency). - Conduit-opaque (INV-001): reads only the two addressing keys; whole-blob hash only. LWW-by-arrival across distinct keys; replay-or-conflict idempotency (actor-scoped from ctx), raising bifrost's typed exceptions. - Conformance: #195 parity vs InMemoryAffectStore through the real dispatch_affect_call engine. 17 provider tests; 395 full suite. - Contract v1.1: idempotency model corrected to bifrost's actual semantics (caught by real-lib TDD; the artifact-only review structurally could not). Heid-panel reviewed (contract + code); idempotency-cache TTL pruning, memory.* plane, and the combined two-plane server deferred (see Out of scope). |
||
|
|
f7ff5a4c77 |
fix(web): close Heid pass-2 findings — stream vocab + disconnect catch (v0.16.1)
Second Heid panel pass (thread 01KSPBMFRRQE) on the v0.16.0 tree:
Gróa returned zero findings; Hulda surfaced two minor tightening
items, both closed here.
1. test-gap — TestStreamFullEventVocab drove only 8 of 11 Event types
through the stream endpoint (omitted Error, Cancelled, AffectUpdate).
Serialization for all 11 was already covered by the presentation-
contract fixture tests; this was a stream-integration coverage gap.
- Added AffectUpdate to the vocab stream (non-terminal, coexists
with done).
- Added dedicated test_error_terminal_event + test_cancelled_terminal_event
(terminal events are mutually exclusive with done, so they can't
share one stream).
2. precision — the disconnect-cancel path caught bare `except Exception:
pass`, silently swallowing real CancelFailed / transport errors. The
contract intent is to swallow only the cooperative race
(CancelAlreadyCompleted). Narrowed: swallow CancelAlreadyCompleted /
CancelTurnNotFound as the no-op race; log unexpected cancel failures
as a structured stderr line for diagnosability. Never re-raises (we're
unwinding the cancelled generator and must not mask CancelledError).
Tests: +2 (376 → 378). Patch per SemVer discipline — coverage +
diagnosability tightening, no behavior change observable to callers.
|
||
|
|
369857d3f1 |
feat(web): address Heid code-review findings — issue #16 (v0.16.0)
Heid panel review (Gróa + Hulda, thread 01KSP5P6CSJH) on v0.15.0/
v0.15.1 surfaced one load-bearing bug + several precision items. This
pass closes them.
Load-bearing fix — cancel paths targeted the wrong turn_id:
- `_TURN_COUNTER` allocates browser-local ids (1, 2, 3…); the real
upstream Worldtree turn_id (e.g. 799) only arrives in the first SSE
event. The v0.15.x cancel/disconnect/shutdown paths posted to
/sessions/{sid}/turns/{LOCAL_ID}/cancel — wrong URL upstream.
- TurnHandle.upstream_response (dead field) → upstream_turn_id: int|None.
Captured from the first event's sse_id.turn_id in the stream
generator. All cancel paths now target it. Cancel before the upstream
stream starts (upstream_turn_id None) is a no-op
({"cancelled": false, "reason": "not_started"}).
- The old cancel tests mocked the local-id URL, so they encoded the bug;
rewritten to assert the UPSTREAM id is targeted.
Behavior change (minor-bump driver) — server-side end_user_id:
- create_app gains end_user_id kwarg; entrypoint reads
RATATOSKR_END_USER_ID and threads it in. POST /api/sessions uses
app.state.end_user_id, IGNORING any browser-supplied value (a client
can't impersonate an arbitrary end-user partition). JS no longer
sends end_user_id.
Precision fixes:
- Entrypoint missing-extras ImportError catch scoped to starlette/
uvicorn ONLY; baseline-dep / first-party import failures now
propagate as real tracebacks instead of masking as exit-12.
- Lifespan shutdown logs per-pending session_id + upstream_turn_id
(was a single aggregate count).
Tests (+18; 376 total):
- disconnect_triggers_upstream_cancel (INV-005 load-bearing — drives
the stream generator directly + cancels the consuming task; would
have caught the turn_id bug)
- cancel_targets_upstream_turn_id, cancel_before_started_is_noop,
cancel_failed_500
- server-side end_user_id: uses / ignores-body / omits-when-unset
- create_app: routes_registered / state_attached / factory_stored
- entrypoint: default_host / port_zero / happy_argv / open / no-open
- real_import_bug_propagates (precision guard)
- full_event_vocab at the stream-endpoint layer
Contract #16 amended: v0.16.0 amendment banner + INV-005/006 reworded
for upstream_turn_id + FN sketches corrected (server-side end_user_id,
upstream_response→upstream_turn_id, manual client lifecycle vs the
non-executable async-with sketch, not-started cancel branch).
|
||
|
|
0fbbeb171c |
fix(sessions): unwrap FastAPI detail envelope in get_persona_state (v0.15.1)
Live smoke against personal:8081 during the v0.15.0 web-companion
verification surfaced that real Worldtree returns persona_state
errors in the FastAPI default envelope shape:
{"detail": {"error_code": "auth_scope_denied", "message": "..."}}
The v0.12.0 `get_persona_state` parser only inspected the top-level
`error_code` key. When the field was nested under `detail`, the
typed exception (AuthScopeDenied / PersonaNotConfigured /
AgentNotAvailable) wasn't raised; the call fell through to
SessionApiFailed, which then surfaced through the web companion as
an opaque HTTP 500 on /api/agents/{id}/persona_state.
The original test_sessions.py mocks used the flat-shape envelope, so
the bug was invisible in unit tests until the real-wire smoke.
Fix: extract error_code from either `err.get("error_code")` (flat)
OR `err.get("detail", {}).get("error_code")` (FastAPI default).
Patch per SemVer discipline — bug fix to v0.12.0 surface, no public
signature change, no new behavior. Callers that were getting the
wrong exception now get the right one; callers that were already
getting the right exception (flat-shape paths) are unchanged.
Tests: 2 new regression cases in TestGetPersonaState — one each for
the detail-envelope shape of 403 auth_scope_denied and 404
persona_not_configured. Suite: 358 passing.
|
||
|
|
1228c37e6f |
feat(web): in-browser debug companion — issue #16 (v0.15.0)
Browser-based debug companion to the Ratatoskr TUI, reusing the
existing wire-layer modules unchanged. Same five surfaces (transcript,
thinking, tools, debug, persona) over the same Worldtree Conversation
API SSE wire, viewable from any device on the operator's LAN.
Per docs/contracts/issues/16.contract.md (full v2.1 module contract
with 11 FN blocks + 9 invariants + Heid panel review pass merged).
Architecture:
- New module `ratatoskr.web` with `server.py` (Starlette app, ~250 LOC),
`entrypoint.py` (lazy-import gate, ~100 LOC), `static/index.html`
(single-page vanilla JS UI, ~360 LOC)
- Optional-deps group `[web]` = starlette + uvicorn[standard]; dev
pulls these in transitively
- New console script `ratatoskr-web`
- Streaming via browser-native `EventSource` GET; prompt-submit is a
separate POST (load-bearing Hulda finding from R13 panel — EventSource
is GET-only)
- Small in-memory turn registry maps (session_id, turn_id) → upstream
request handle for cancel + browser-disconnect cleanup
Endpoint surface (9 routes):
- `GET /` → static index.html
- `GET /static/*` → static assets
- `GET /version` → {"ratatoskr": "<version>"}
- `GET /api/agents` → upstream /agents + local Tier 3 merge
- `POST /api/sessions` → upstream POST /sessions
- `GET /api/agents/{id}/persona_state` → upstream persona-state
- `POST /api/turns/{sid}` → allocate turn_id, register in turn registry
- `GET /api/turns/{sid}/stream?turn_id=N` → proxy upstream SSE to browser
- `POST /api/turns/{sid}/cancel?turn_id=N` → upstream cancel
Trust model: internal LAN debug surface. Binds 0.0.0.0:8765 default;
no auth, no CORS guard (operator direction). What stays disciplined
regardless of network trust:
- Transcript HTML-escapes assistant content (INV-004 — model output
is untrusted text; adversarial HTML must not execute in browser)
- Upstream API key never reaches browser DOM (INV-003 — proxy-only)
Lifecycle:
- Browser disconnect mid-stream → upstream cancel (INV-005;
asyncio.CancelledError caught in stream handler)
- Server Ctrl-C → lifespan shutdown drains turn registry within 5s
budget (INV-006; structured-log line on timeout)
Tests (37 new, 356 total; previous 319 baseline preserved):
- tests/test_web_server.py (23 cases): endpoint contract via Starlette
TestClient + respx mocks; covers each endpoint, browser-disconnect →
upstream cancel, lifespan shutdown draining the registry
- tests/test_web_presentation_contract.py (11 cases): proxy
serialization matches tests/fixtures/presentation_contract.json
for one of each Event type — drift detection between server-side
serializer and the JS presenter without forcing a shared abstraction
- tests/test_web_packaging.py (4 cases): static asset packaging via
importlib.resources; AST-checked lazy-import discipline (no top-
level starlette/uvicorn import in entrypoint.py); missing-API-key
exit-11 path; missing-extras exit-12 path
Provenance:
- Scope v1 → Heid panel review (Gróa + Hulda, R13) → 8 load-bearing
corrections (POST→GET split, Starlette > FastAPI, lazy-import
discipline, browser-disconnect → upstream cancel, presentation-
contract fixture, error event contract, static-asset packaging,
escaped plain-text Markdown deferred) merged into scope v2
- Operator direction: internal-LAN debug surface; auth + CORS
deliberately omitted
Not yet (deferred to v0.16.x+):
- Cross-reload session resume via Last-Event-ID
- Tier 3 lifecycle UI (define/patch/delete in browser)
- Markdown rendering with vendored safe-subset renderer
- TLS + real auth (only if a non-LAN use case ever surfaces)
|
||
|
|
78bfcadb9e |
feat(sse,tui): bump spec pin to v0.29.0 + AwaitingLlmFirstToken (v0.14.0)
Spec pin moved da93ca7 (v0.28.0) → 562001a (v0.29.0); vendored conversation-api-spec.md + conversation_api.contract.md re-snapshotted. The only material delta is Worldtree #201's awaiting_llm_first_token SSE heartbeat — a top-level event (NOT a worker_phase extension, per INV-053's three-field stability) that fires at a configurable interval (default 5s) during the BuildingPrompt → CallingLLM gap. Wire layer (sse_client.py): - New `AwaitingLlmFirstToken` dataclass: sse_id / turn_id / elapsed_ms_since_building_prompt (server-authoritative monotonic) - Added to Event union + _envelope_for_type dispatch branch - Without this, ratatoskr would crash on any slow-first-token turn from a v0.29.0 server (unknown SSE event type → ValueError) TUI layer (tui.py): - Audit pipeline: per-event debug-pane line with elapsed in seconds - Live transcript indicator: first heartbeat mounts a Static ("awaiting first token · 5.0s"); subsequent heartbeats update it in place; any non-heartbeat event removes it (the gap closed) - Turn-summary line now carries heartbeat count - Indicator demoted via .awaiting-label CSS so it reads as ambient progress, not content Tests: 2 wire-layer (single + monotonic sequence) + 3 presenter (audit line shape, single-mount semantic, indicator removal on gap close). Suite: 318 passing. |
||
|
|
44138590ad |
feat(tui): persona surface — sticky header + TabPane (v0.13.0)
Step 3 of the Worldtree #204 integration: visible persona-state UX. Pairs with v0.11.0's AffectUpdate SSE event + v0.12.0's get_persona_state HTTP client — together those gave the data; this bump surfaces it. Two surfaces (Option C: both): Sticky persona-header (top of screen, dock=top, height=1): - Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions active` — concise enough for at-a-glance scan above the chat - Starts hidden via `.empty` CSS class; height collapses to 0 when the agent has no persona surface - Refreshes on AffectUpdate(status="current") snapshots Persona TabPane (Ctrl+4): - Full snapshot detail: dominant emotion, PAD axes with baseline + delta, mood drift, active emotions with intensity + decay (minutes-rounded), last_updated_at footer - Replaced (not appended) on each new snapshot — snapshots are absolute state, not incremental Lifecycle: - on_mount spawns a Textual worker that calls get_persona_state to hydrate header + pane before turn 1 - PersonaNotConfigured (domari, muninn, Tier 3) → pane carries an italic placeholder, header stays empty - AgentNotAvailable / AuthScopeDenied / network error → italic failure placeholder; audit-logged; never crashes - Presenter's render() takes an optional `on_persona_snapshot` callback so AffectUpdate(current) refreshes both surfaces during a live turn (no widget coupling — App owns the callback) Tests: 10 new (4 formatters, 3 presenter callback, 4 layout/binding/ hydration). Full suite: 313 passing. |
||
|
|
d516537b08 |
feat(sessions): get_persona_state client + persona error taxonomy (v0.12.0)
Adds the read-side half of Worldtree #204's persona-state observability surface. Pairs with v0.11.0's AffectUpdate SSE event — together they let a consumer hydrate a persona pane on session-open (this GET) and keep it live as turns fire (the SSE event). Public surface: - `get_persona_state(client, agent_id) -> dict[str, Any]` — GET /agents/{agent_id}/persona_state, returns the same `snapshot` dict shape as AffectUpdate.snapshot - New exception types mapped from the spec's documented 4xx error_codes: - `PersonaNotConfigured` (404 persona_not_configured) — agent has no persona surface (domari, muninn, all Tier 3 in Phase 2.0) - `AgentNotAvailable` (404 agent_not_available) — unknown agent_id - `AuthScopeDenied` (403 auth_scope_denied) — key lacks the requested scope (persona.read here; reusable for future scoped endpoints) - Other non-2xx falls through to the existing SessionApiFailed precedent so novel failure modes aren't silently absorbed Tests: 6 new cases covering happy snapshot return, each typed 4xx sub-code, unknown 404 fall-through, and 5xx SessionApiFailed parity. Not yet consumed: TUI persona surface (Persona TabPane / sticky header line). UX shape pending operator direction — step 3. |
||
|
|
92aa05c688 |
feat(sse,tui): bump spec pin to v0.28.0 + AffectUpdate event (v0.11.0)
Spec pin moved 55101e9 (v0.19.0) → da93ca7 (v0.28.0); vendored conversation-api-spec.md + conversation_api.contract.md re-snapshotted from Worldtree at the new SHA. The only material delta consumed in this bump is Worldtree #204's affect_update SSE event surface. Wire layer (sse_client.py): - New AffectUpdate dataclass: sse_id / status / turn_id / snapshot (snapshot is None for status="scheduled") - Added to Event union + _envelope_for_type dispatch branch - Without this, ratatoskr would crash on any persona-enabled turn from a v0.28.0 server (unknown SSE event type → ValueError) TUI layer (tui.py): - AffectUpdate routes through the v0.10.0 audit pipeline only — one debug-pane line per arrival with dominant_emotion + PAD for status="current", lightweight status+turn_id for status="scheduled" - No transcript / tools / thinking pane writes — the persona UX shape (Persona TabPane vs sticky header line) is deferred to a separate bump pending operator direction Tests: 2 new wire-layer tests for current+scheduled parsing + 2 new presenter audit tests for routing and audit-line shape. Not yet consumed: GET /agents/{id}/persona_state endpoint (step 2 of the integration plan). |
||
|
|
209427ab23 |
feat(tui): debug-pane audit logging surface (v0.10.0)
Adds wire-level visibility appropriate for a debugging TUI. Every SSE event arrival now lands as one debug-pane line; token-rate Text and Thinking deltas get aggregated counters surfaced in a per-turn summary instead of per-delta spam. Audit surfaces added (all routed to the debug pane): - per-event arrival: timestamp + event type + sse_id + event-specific summary for WorkerPhase / ToolStart / ToolResult / TextBoundary / Done / Error / Cancelled - turn-summary at terminal events: text_deltas / text_bytes / thinking_deltas / thinking_bytes / elapsed_ms - app-level state-machine transitions via new RatatoskrApp._transition helper (idle → streaming → cancelling → idle, with reason) - worker_spawn line at on_input_submitted with content_len - ctrl_c / ctrl_d audit lines documenting action + exit code - cancel POST lifecycle: _cancel_via_sse takes an optional audit callback and emits issued / ok / failed lines - app_mounted bootstrap line at on_mount (server + agent + session tail + raw + end_user_id) - wire-error exception class + body audit at _stream_turn_worker Helpers: - TuiPresenterState: text_delta_count / text_byte_count / thinking_delta_count / thinking_byte_count / turn_start_ts - module-level _ts() + _audit_line() + RatatoskrApp._audit() / _transition() Tests: 6 new test cases lock in audit-line shape, turn-summary aggregation, cancel-POST lifecycle callback, and the silence of per-Text-delta debug writes. |
||
|
|
139771c8d8 |
feat(tui): live Markdown rendering during text streaming (v0.9.0)
Replaces v0.8.2's drop-Markdown patch with proper in-place Markdown
rendering. The transcript becomes a VerticalScroll container; each
turn's response body lives as a single Static widget whose content
is updated as Text deltas arrive — Markdown is re-rendered in place
rather than re-printed on Done. Eliminates the v0.8.x double-print
without sacrificing rich formatting.
- transcript: RichLog → VerticalScroll (#transcript-scroll)
- Text deltas: mount Static(Markdown(buffer)) on first delta;
Static.update(Markdown(buffer)) on subsequent deltas
- --raw mode: bypass Markdown, mount Static(plain_str) for the same
in-place update semantics
- Terminal events (Done/Error/Cancelled) mount styled label Statics
- _cancel_via_sse: write → mount Static on the new container
- _write_turn_headers: transcript gets a styled RichText Static
("── turn N ──"); other panes still receive Rule renderables
- Test suite reshape: bulk rename `log` → `transcript` for the
presenter contract, `_mounted_renderables` helper extracts
Static.content for assertion, `_spy_writes` captures both
RichLog.write and VerticalScroll.mount
|
||
|
|
489cfee1f0 |
fix(tui): drop post-Done Markdown body re-render (v0.8.2)
Operator: "first turn double prints agent's turn." Root cause: v0.8.1 wrote both the streamed Text lines AND the post- Done `Markdown(event.response)` body into the transcript. Same content rendered twice — once as plain streaming, once as a full markdown re-render. The v0.8.1 commit message documented this as "some duplication is acceptable" but the live UX read as a bug. ## Fix Drop the post-Done `Rule + Markdown(response)` writes in non-raw mode. The streamed text IS the response; whatever the model emitted flows into the transcript line-by-line via coalesce-on-newline. Markdown formatting (bold, lists, code blocks) renders as plain text — a known regression from v0.8.1's polished output but the right tradeoff vs the duplication bug. ## What this loses temporarily Pre-v0.8.2 (after Done): [done] turn_id=... ─── ─── (Rule separator) ─── **Bold text** rendered bold, `code` highlighted, lists as bullets, etc. v0.8.2 (after Done): [done] turn_id=... ─── **Bold text** as plain asterisks, `code` as backticks, lists as plain dashes ## v0.9.0 plan Restore markdown rendering via LIVE rendering during the stream (not post-Done re-render). Replace `RichLog#transcript` with a `VerticalScroll` container that mounts a fresh `Markdown` widget per turn; Text deltas update the widget; markdown renders as content arrives. No duplication, no snap, full formatting. Operator-confirmed direction (2026-05-25 AskUserQuestion). ## Tests 287/287 GREEN; ruff clean. Two tests updated for the new shape: - test_done_renders_markdown_after_label → renamed test_done_flushes_tail_and_writes_label; asserts NO Markdown, NO Rule (post-Done) in the writes. - test_happy_text_done_renders_markdown → renamed test_happy_text_done_no_double_print; asserts NO Markdown in the spy. Patch bump (v0.8.1 → v0.8.2): bug fix; no public API change. |
||
|
|
11ef6830ab |
fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
Two related fixes for the same user-reported bug pattern from a
running session against ratatoskr:sindra (qwen3.6-35-a3b-heretic):
## 1. Streaming text overlapping the transcript
Operator: "new text comes at the bottom and overwrites the existing
pane information instead of pushing it up naturally."
Root cause: the v0.6.0 `#current-text` Static was `dock: bottom`
with `height: auto`, sitting between the transcript RichLog (1fr)
and the prompt Input (dock: bottom). As text streamed, the Static
grew UPWARD but Textual didn't dynamically resize the 1fr transcript
to accommodate — the growing Static visually OVERLAPPED the
transcript's bottom rows. On Done, `current_text.update("")` snapped
it to height 0 and the transcript re-laid-out — "boom, everything
updates."
Fix: remove `#current-text` Static entirely. Apply the same
coalesce-on-newline pattern v0.7.1 used for thinking — Text deltas
accumulate in `TuiPresenterState.text_chunk_buffer`, flushing whole
lines (each `\n` boundary) directly to `log` (transcript). On Done:
flush remaining tail, then [done] label + Rule + Markdown body.
Trade-off accepted: streamed lines + post-Done Markdown body are
both in the transcript (some content duplication). The Markdown
body re-renders the same content with proper formatting (lists,
bold, code blocks). Acceptable — operator gets both the live-progress
streaming AND the canonical rendered version.
## 2. MalformedSseId raw='' crashing every turn
Operator: "current session is erroring on every turn with
[malformed_sse_id] raw=''"
Worldtree's qwen3.6-35-a3b-heretic provider emits some events
without `id:` lines (observed 2026-05-25 mid-stream). When the FIRST
such event arrives before any prior id has been seen, httpx_sse's
`ServerSentEvent.id` is `""`. `_parse_sse_id('')` raised ValueError
→ MalformedSseId → turn worker bailed → operator saw the label
every turn.
Per SSE RFC, events without `id:` are legitimate (they just don't
update Last-Event-ID). Issue #7 already covered the empty-DATA
keepalive case with skip-silently semantics. Empty-id is the same
shape of wire weirdness; same fix shape:
if sse.id == "":
continue # treat as keepalive
Ordered AFTER the empty-data branch so an empty-data + empty-id
event still gets skipped on the data check.
## Tests + smoke
287/287 GREEN (was 286, +1 for empty-id skip; +1 net Text-flow test
adjustments). Ruff clean.
Verified Worldtree alive when the user hit the empty-id bug
(/healthz returned ok in 18ms) — not a server-down issue, just
wire-format mid-stream.
## Caveats
The fix doesn't recover content from the dropped empty-id event.
If the event happened to carry meaningful data (not a true
keepalive), we silently lose it. Acceptable trade-off: pre-v0.8.1
EVERY turn died on the offending agent; post-v0.8.1 the turn
continues and any single dropped frame is recoverable from logs if
debugging. Worldtree-side fix (always emit ids) is the right
upstream answer; ratatoskr just stops panicking on wire weirdness.
Patch bump (v0.8.0 → v0.8.1) — both fixes are bug fixes; no public
API change. The `TuiPresenterState.render` signature loses the
`current_text` parameter (was added v0.6.0), but presenter is an
internal contract; no external callers.
|
||
|
|
9fade55901 |
feat(local_agents): tier-3 index + picker merge (v0.8.0)
Worldtree's GET /agents doesn't return consumer-defined (tier-3)
agents — the public list excludes them by design. Confirmed live in
v0.7.0's smoke. Without server-side knowledge, ratatoskr's picker
couldn't show tier-3 agents the operator had defined; the workflow
was "remember the agent_id, pass --agent ratatoskr:<name>
explicitly." Friction grows with every tier-3 agent.
## Fix: client-side index, merged at picker time
New module `ratatoskr.local_agents` maintains a JSON-backed index at
$XDG_CONFIG_HOME/ratatoskr/local_agents.json (override via
$RATATOSKR_LOCAL_AGENTS). `tier3` CLI define / patch / delete update
the index as side-effects. `tui._resolve_then_run` loads the index
after `list_agents(client)` and appends entries not already in the
remote list (dedup by agent_id; remote wins on conflict).
Library-level `tier3.define_agent` / `patch_agent` / `delete_agent`
stay pure — local persistence lives in the CLI layer (`_run_define`
etc.), not in the library functions. Tests of the library don't
touch the filesystem.
## Public surface
ratatoskr.local_agents:
LocalAgentEntry (frozen dataclass)
load_local_agents() -> list[LocalAgentEntry]
add_local_agent(entry)
update_local_agent(entry) # same semantics as add (agent_id key)
remove_local_agent(agent_id)
make_description(system_prompt) -> str # synthetic picker label
Failure modes are lenient: missing file → empty index; corrupt JSON
or schema mismatch → empty index (no crash). The picker continues
to show foundational agents either way; tier-3 surface degrades to
the pre-v0.8.0 workflow.
## Picker integration
Local entries convert to ratatoskr.sessions.AgentInfo with synthetic
fields:
name = agent_name (from LocalAgentEntry)
description = "(tier 3) <first non-empty line of system prompt>"
version, capabilities, supported_models, persona_traits, ui_hints
= None / [] / [] / {} / {}
If Worldtree later starts returning tier-3 in GET /agents, this
module's role narrows to redundant local cache; can be removed
cleanly since the dedup-by-agent-id keeps remote-wins behavior.
## Tests
286/286 GREEN (was 265, +21: 20 local_agents + 1 picker-merge
integration). Ruff clean. Tests isolate the index via
$RATATOSKR_LOCAL_AGENTS pointed at pytest's tmp_path — no pollution
of operator's real ~/.config/ratatoskr/.
## Manual smoke
Sindra-like define against personal Worldtree:
python -m ratatoskr.tier3 define --name foo --system-prompt "..." --model X
cat ~/.config/ratatoskr/local_agents.json
# ratatoskr --new picker now shows ratatoskr:foo alongside mimir et al.
Cross-machine: the file is per-host. Operator can sync via dotfiles
if needed; out of scope for this commit.
Minor bump (v0.7.1 → v0.8.0) — new public module + new picker
behavior (more agents shown). No caller-side breaking changes.
|
||
|
|
9918c10acf |
fix(tui): coalesce thinking deltas on \n (v0.7.1)
Operator: "thinking tokens seem to be split by token — each on a
newline, is that correct? We don't want that."
Root cause: v0.6.5 wrote each Thinking SSE delta as its own
`thinking_log.write(event.content)` call. Worldtree emits Thinking
events at token granularity (per-token or per-few-tokens), so EACH
token became its own RichLog line — visually choppy, one short
fragment per visual row. Wrong UX.
## Fix: coalesce-on-newline
Thinking deltas accumulate in `TuiPresenterState.thinking_chunk_buffer`
(new str field). On each Thinking event:
1. Append delta content to buffer.
2. Flush every COMPLETE line (chars before each `\n`) as one
thinking_log.write(line) call.
3. Leave the post-final-`\n` tail in the buffer for the next delta.
On any non-thinking event (run close):
1. Flush remaining buffer tail (if any) as one final line.
2. Write Rule(end).
Empty lines (blank paragraph separators in the model's `\n\n` flow)
are skipped — they'd render as no-content RichLog entries which
just add vertical noise. Natural paragraph breaks become single
visible lines; multi-paragraph thinking renders top-to-bottom.
## Verified live (tier-3 smoke against personal Worldtree)
Defined a `thinky-smoke` agent via `python -m ratatoskr.tier3 define`,
asked "What is 12 times 13?". Thinking pane rendered with natural
paragraph chunks:
── turn N · thinking #1 start ──
Thinking Process:
1. **Analyze the Request:** The user wants to know the result of $12 \times 13$.
2. **Calculate:**
* Method 1: Standard multiplication.
$$12 \times 10 = 120$$
$$12 \times 3 = 36$$
$$120 + 36 = 156$$
* Method 2: $(10 + 2)(10 + 3) = 100 + 30 + 20 + 6 = 156$.
── turn N · thinking #1 end ──
Each line = one natural paragraph or list item. No per-token fragments.
## Edge cases noted
- Long-running thinking with NO `\n` at all stays buffered until run
close → operator sees nothing until close. Possible follow-up: add
a length-threshold flush (e.g., > 500 chars → flush at the last
space). For now this is acceptable; thinking content typically has
`\n` breaks every few sentences.
- Empty deltas (`""`) are ignored implicitly — no buffer growth, no
flush.
- `\n` at the very start of a delta flushes whatever was buffered
before, then leaves the empty post-`\n` tail (empty string) in the
buffer, which doesn't show up as an empty line because of the
`if line:` guard.
## Contract amendment
docs/contracts/issues/13.contract.md INV-022 amended for v0.7.1
coalesce semantics. Drift-check clean.
## Tests
265/265 GREEN; ruff clean. Two updated tests:
- `test_thinking_streams_into_thinking_log` → renamed
`test_thinking_coalesces_until_newline`: 3 token-shaped deltas
with no `\n` → only Rule(start) writes, buffer holds accumulated.
- NEW `test_thinking_flushes_on_newline`: delta carrying `\n` →
Rule(start) + accumulated line + clear buffer.
- `test_thinking_closes_to_thinking_log`: 2 deltas "a", "b" +
close → Rule(start) + tail-flush "ab" + Rule(end) = 3 writes
(was 4 with per-delta).
Patch bump (v0.7.0 → v0.7.1) — internal presenter routing change;
no public-API or layout change.
|
||
|
|
c086ae2b32 |
feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
Issue #15. Worldtree Phase 2.0 ships Tier 3 (consumer-defined) agents at `<user_id>:<agent_name>`; ratatoskr now exposes their lifecycle via a dedicated module + CLI tool. The picker handles the colon-containing agent_id generically (per issue #8 out-of- scope clause); session creation works unchanged. What was missing was a way to DEFINE / PATCH / DELETE these agents from ratatoskr itself — operators previously had to curl the API directly. ## Public surface (ratatoskr.tier3) Tier3AgentInfo (frozen dataclass) define_agent (client, *, agent_name, system_prompt, model) → Info patch_agent (client, agent_id, *, system_prompt?, model?) → Info delete_agent (client, agent_id) → None Tier3QuotaExceeded — 429 agent_quota_exceeded (50-agent cap) Tier3UserIdUnsupported — 403 tier3_user_id_unsupported Tier3FieldNotMutable — 422 field_not_mutable (PATCH) Tier3LayerDeferred — 422 layer_deferred (define, defense-only) Tier3AgentNotFound — 404 SessionApiFailed (reused) — all other non-2xx Caller-owned httpx.AsyncClient posture (same as ratatoskr.sessions). Module is standalone — does NOT import sessions/sse_client/tui/cli beyond reusing the USER_AGENT constant from cli. ## CLI (python -m ratatoskr.tier3 <subcommand>) define --name <slug> --system-prompt <str> --model <id> patch <agent_id> [--system-prompt <str>] [--model <id>] delete <agent_id> Auth resolution mirrors ratatoskr.cli verbatim — --api-key flag > $WORLDTREE_API_KEY > exit 11. Server URL via --server > $WORLDTREE_API_URL > http://localhost:8000. Exit codes follow the cli.py matrix: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21 (network). ## Real-world finding from live smoke Tier-3 agents do NOT appear in `GET /agents` — the public list filters them out. The picker won't surface tier-3 agents; operators bypass it via `ratatoskr --send "..." --new --agent ratatoskr:<n>` directly. This contradicts the contract's acceptance assumption ("the new tier-3 agent should appear in the list") — caught at smoke time. The picker integration was hopeful; the real shape is "you know your tier-3 agent_id because you defined it." Adding a ratatoskr-side `tier3 list` subcommand would need a Worldtree endpoint that doesn't exist today; surfacing to worldtree-dev as a followup. ## Live lifecycle smoke (personal Worldtree v0.16.2) $ python -m ratatoskr.tier3 define --name smoke-tier3 \ --system-prompt "..." --model qwen3.6-35-a3b → defined ratatoskr:smoke-tier3 (qwen3.6-35-a3b) $ ratatoskr --send "hello via tier-3" --new --agent ratatoskr:smoke-tier3 → [done] turn_id=286 model=qwen3.6-35-a3b duration=14.2s usage 44 in → 390 out (434 total, 0 cached) $ python -m ratatoskr.tier3 delete ratatoskr:smoke-tier3 → deleted ratatoskr:smoke-tier3 $ python -m ratatoskr.tier3 delete ratatoskr:smoke-tier3 → [agent_not_found] ratatoskr:smoke-tier3 (exit 20) The colon-containing agent_id flowed transparently through ratatoskr.sessions.create_session, the SSE stream's text + worker_phase + done events all rendered correctly, and the ratatoskr.sessions module needed zero changes. ## Contract docs/contracts/issues/15.contract.md — new module spec; drift-check clean. Acceptance criterion about "appears in GET /agents" should be amended in a follow-up to reflect the empirical finding. ## Tests +26 tests (264 total GREEN, was 238). Covers all error paths via respx mocking — quota, user_id, layer_deferred, field_not_mutable, 404, 5xx — plus CLI happy + error paths. ruff clean. Minor bump (v0.6.5 → v0.7.0) per SemVer etiquette: new public module + CLI surface; new caller-visible behavior. |