From ec68b1f3a521961be2f8d93e4da083148b8e98e6 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 19 Jul 2026 13:41:34 -0700 Subject: [PATCH] feat(#20): worldtree-sdk cutover teardown (slice-7) + v0.22.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last slice of the consumer-layer cutover. Teardown only — zero runtime-logic change; the 494-green suite is the regression gate. - Drop `httpx-sse` from pyproject + lockfile: slice-6 deleted its last user, nothing imports `httpx_sse`, the SDK owns SSE parsing now. - Module boundary (operator decision): KEEP `sessions.py` + `sse_client.py` as pure caller-semantic type/exception homes (no rename, no fold — A3 was blocked by the `AgentNotAvailable` name collision + `wt.py` would mis-home `endpoint_for_plane`). Docstrings updated to stop claiming "client"; the `AdminEvent`/`SseId`/exception homes stay put (resolves the slice-6 deferred-home item). - Retire wire contracts #2 (sessions) + #15 (tier3): DEC-1 phase-2 — normative authority already transferred to the cutover contract; the code they specified is gone, so the files are deleted. #1 (SSE event vocab) and `first_message` stay (ratatoskr-owned, not retired). - Final coverage-map re-anchor: tools/list_sessions re-homed to `wt.py`; the Last-Event-ID SSE-resume sub-gap CLOSED (folded into `stream_turn` auto-resume); Surface-2 SSE parsing re-anchored to the SDK. - Stale doc-rot fix: the cli.py transport comment no longer calls `seed_preset_first_message` "not-yet-migrated" (it rides `wt`). - v0.22.0 (minor, DEC-6, operator-approved): publishes the full 6-slice cutover milestone. --- docs/contracts/issues/15.contract.md | 290 -------- docs/contracts/issues/2.contract.md | 625 ------------------ .../worldtree_sdk_cutover.contract.md | 37 ++ docs/coverage-map.md | 19 +- pyproject.toml | 9 +- src/ratatoskr/cli.py | 5 +- src/ratatoskr/sessions.py | 10 +- src/ratatoskr/sse_client.py | 9 +- tests/test_sessions.py | 2 +- uv.lock | 13 +- 10 files changed, 72 insertions(+), 947 deletions(-) delete mode 100644 docs/contracts/issues/15.contract.md delete mode 100644 docs/contracts/issues/2.contract.md diff --git a/docs/contracts/issues/15.contract.md b/docs/contracts/issues/15.contract.md deleted file mode 100644 index 76f946c..0000000 --- a/docs/contracts/issues/15.contract.md +++ /dev/null @@ -1,290 +0,0 @@ ---- -contract_version: "2.1" -target_module: "ratatoskr.tier3" -scope: "New module `ratatoskr.tier3` exposing Worldtree's Tier 3 (consumer-defined) agent lifecycle: `define_agent` (POST /agents/define), `patch_agent` (PATCH /agents/), `delete_agent` (DELETE /agents/), plus `Tier3AgentInfo` frozen dataclass. Plus a thin CLI entry point (`python -m ratatoskr.tier3 `) that mirrors `ratatoskr.cli`'s env-var posture (`WORLDTREE_API_URL`, `WORLDTREE_API_KEY`). Convention-aligned with `ratatoskr.sessions` (issue #2): caller-owned httpx.AsyncClient, no Worldtree imports, response parsing into frozen dataclass, exception `.body` truncated to `[:1024]`. Picker stays generic — agents with `:` in agent_id show in the list like any other per issue #8's out-of-scope clause. Goal: ratatoskr operators can define, mutate, and delete Tier 3 agents from the command line, then exercise the full session flow against them to observe how Tier 3 agent_ids (colon-containing) flow through the picker / session-create / SSE stream." -depends_on: - - "httpx" -used_by: [] -language: "python" -complexity: "low" -estimated_loc: 250 -confidence: 0.9 -assumptions: - - "Tier 3 endpoints land at the same `WORLDTREE_API_URL` as the rest of the Conversation API — no separate hostname / port. Auth via the same bearer key. The caller's user_id is derived server-side from the API key's owner; the agent's `agent_id` is constructed as `:`. Live probe against personal Worldtree (2026-05-25) confirmed: POST with `{agent_name: 'smoke-test', ...}` and `Authorization: Bearer ` returned `agent_id=ratatoskr:smoke-test`, `user_id=ratatoskr`." - - "Per Worldtree spec §2576-2750: `agent_name` is a strict slug `[a-z][a-z0-9-]{2,63}` and immutable after definition. `user_id` is derived from the auth, must be slug-safe (`[a-z][a-z0-9-]{2,63}` per Phase 2.0 gate). PATCH accepts ONLY `system_prompt` and/or `model`; any other key (including the immutable `agent_name`, `user_id`, or layer fields `persona`/`motivational`/`valence`/`memory` — even with `null` value) returns 422 `field_not_mutable` BEFORE the DB lookup." - - "**Layer fields are explicitly null** on define. Phase 2.0 ships baseline addressing + ownership + lifecycle only; `persona` / `motivational` / `valence` / `memory` are schema-reserved. Non-null on these → 422 `layer_deferred`. The module's `define_agent` does NOT expose these as parameters at all — sending them would require an amendment when a future Phase enables them." - - "**`model` field is a provider model ID, not a profile alias.** Live probe found: `model='default'` (an llm_profiles profile name) returns 422 `model_not_available`; `model='qwen3.6-35-a3b'` (an actual provider model ID) returns 201. The CLI / module take the string verbatim and pass through — validation is server-side. Operators discover valid IDs via the model `metadata` on existing sessions or out-of-band." - - "**Quota: 50 Tier 3 agents per Heimdall key.** 51st define → 429 `agent_quota_exceeded` with `Retry-After: 0`. The module raises `Tier3QuotaExceeded(retry_after=0)` — the retry_after field captures the header value verbatim for forward-compat if Worldtree later returns a non-zero throttle." - - "**Key-revocation cascade is server-side.** When an API key is revoked (`DELETE /admin/keys/{key_id}`), every Tier 3 agent with `owner_key_hash` equal to the revoked key's hash is soft-deleted in the same SQL transaction. Active sessions on those agents return 401 `auth_revoked` on next message. The ratatoskr module doesn't track or simulate this — operators discover it via runtime 401s and the admin-side audit log." - - "**Picker integration is implicit** — no changes to `ratatoskr.tui.AgentPickerApp` for this issue. Tier 3 agents appear in `GET /agents` if defined and the picker's existing format `{agent_id} · {name} — {description}` renders the colon-containing agent_id without special-casing. Per issue #8 out-of-scope clause, ratatoskr does not visually distinguish Tier 1 vs Tier 3 in the picker — same UX surface." - - "**Session-create with colon-containing agent_id works unchanged.** Issue #5 already routes `end_user_id` into the POST /sessions body, which Tier 3 session-create requires from Phase 2.0 (per spec §2649-2664). No `ratatoskr.sessions` change needed." - - "**CLI uses argparse with subparsers** (define / patch / delete). The subparsers entry point lives at `python -m ratatoskr.tier3` via `__main__.py`. Output on success: prints a one-line summary (`defined ratatoskr:wizard (qwen3.6-35-a3b)` / `patched ratatoskr:wizard` / `deleted ratatoskr:wizard`). Output on error: `[] ` to stderr + non-zero exit. Exit codes mirror `ratatoskr.cli`: 0 happy / 10 usage / 11 auth / 20 api-failure / 21 network." - - "**No `list` subcommand in v1.** A `tier3 list` operation would have to filter `GET /agents` by prefix-matching the caller's user_id, but that prefix isn't exposed in the response — only the agent_id is, and you'd have to introspect the auth's user_id. Operators discover their own Tier 3 agents by reading the `GET /agents` list (which the picker already surfaces) and looking for `:*` entries. Add `list` in a follow-up if operators report friction." - - "**Module is standalone**: does NOT import or interact with `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` / `ratatoskr.cli` beyond reusing the `USER_AGENT` constant from `ratatoskr.cli`. Cross-module use is one-way (cli supplies the user-agent string; tier3 does not import sessions). This keeps the module surface minimal and testable in isolation." - - "**The CLI's `python -m ratatoskr.tier3` entry point uses sys.argv handling that mirrors `ratatoskr.cli`** — a top-level `main(argv: list[str] | None = None) -> int` function that argparse-dispatches to subcommand handlers. Each subcommand handler is an async coroutine wrapped by `asyncio.run(...)`. Auth resolution: `--api-key` flag > `$WORLDTREE_API_KEY` env > `_AuthError` (exit 11). Server URL: `--server` > `$WORLDTREE_API_URL` > default `http://localhost:8000` (same default as `ratatoskr.cli`)." - - "**Tests use `respx` for HTTP mocking** (same pattern as `tests/test_sessions.py`). New test file: `tests/test_tier3.py`. Cover all success + error response codes per the ERROR_ROUTING matrix below. No live network in unit tests — the live smoke is in the acceptance criteria, not the unit tests." -open_questions: - - "Should `define_agent` accept an optional `bifrost` parameter for Bifrost-bound Tier 3 sessions? The spec §2658 shows `bifrost` as a session-create field (not define-time). Draft: no — Bifrost binding is per-session; if a Tier 3 agent needs Bifrost on every session, that's an orthogonal feature on POST /sessions, not POST /agents/define. Issue #5's `--end-user-id` already covers the session-create-side parameters." - - "Should the CLI also offer `--end-user-id` for sessions created via tier3 + ratatoskr-cli composition? Draft: no — once an agent is defined, operators use the main `ratatoskr --new --agent --end-user-id ` flow; tier3 CLI is define/patch/delete only." - - "Should `delete_agent` support a `--force` flag for 'really delete even if active sessions exist'? Per spec §2634-2639, `DELETE` already cancels active sessions and revokes the per-resource scope grant on the owner — there's no soft fail. Draft: no — the spec's hard-delete-with-cascade behavior is the right shape; ratatoskr doesn't need to wrap it." -prd: - issue: 15 - issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/15" - body_sha256_16: "03367d7b451ab17f" - lock_in_comment_id: null - lock_in_sha256_16: null - lock_in_at: null - pinned_at: "2026-05-25T03:21:38+00:00" -dependencies: - - issue: 2 - path: "src/ratatoskr/sessions.py" - reason: "Convention dependency, not a code dependency. Issue #2 (`ratatoskr.sessions`) is the posture template: caller-owned httpx client, async-native, no Worldtree imports, response-parsing into frozen dataclasses, exception body truncation to [:1024]. `ratatoskr.tier3` follows the same shape verbatim." - - issue: 3 - path: "src/ratatoskr/cli.py" - reason: "Convention dependency only. `ratatoskr.tier3.__main__` mirrors `ratatoskr.cli`'s argparse + env-fallback + exit-code shape. Imports `USER_AGENT` from `ratatoskr.cli` so outbound HTTP carries the same identity string." ---- - -# Tier 3 — Consumer-defined agent lifecycle module - -## Context - -Worldtree's Tier 3 (Phase 2.0, spec §2576-2750) lets the consumer define their own agents at `:`. The agent's `user_id` is the auth's user identity (derived from the API key's owner); the `agent_name` is supplied at define-time. The lifecycle is owner-only — only the key that defined an agent can patch / delete it (modulo the key-revocation cascade). - -`ratatoskr.tier3` exposes this lifecycle as a Python module + small CLI tool. Picker integration is implicit (Tier 3 agents already appear in `GET /agents` per issue #8). Session-create works unchanged through `ratatoskr.sessions.create_session` since the colon-containing agent_id is opaque to that layer. - -## Public surface - -```python -@dataclass(frozen=True) -class Tier3AgentInfo: - """Worldtree Tier 3 agent envelope returned by define / patch.""" - - agent_id: str # f"{user_id}:{agent_name}" - user_id: str - agent_name: str - system_prompt: str - model: str - created_at: str # ISO 8601 with offset - updated_at: str # ISO 8601 with offset - - -async def define_agent( - client: httpx.AsyncClient, - *, - agent_name: str, - system_prompt: str, - model: str, -) -> Tier3AgentInfo: - """POST /agents/define → 201 with Tier3AgentInfo. See FN define_agent.""" - - -async def patch_agent( - client: httpx.AsyncClient, - agent_id: str, - *, - system_prompt: str | None = None, - model: str | None = None, -) -> Tier3AgentInfo: - """PATCH /agents/ → 200 with updated Tier3AgentInfo. See FN patch_agent.""" - - -async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None: - """DELETE /agents/ → 204. See FN delete_agent.""" -``` - -## Exception classes - -```python -class Tier3QuotaExceeded(Exception): - """429 agent_quota_exceeded — 50-agent cap reached on the Heimdall key.""" - def __init__(self, *, retry_after: int) -> None: ... - retry_after: int - -class Tier3UserIdUnsupported(Exception): - """403 tier3_user_id_unsupported — auth's user_id not slug-safe.""" - -class Tier3FieldNotMutable(Exception): - """422 field_not_mutable — PATCH carrying an immutable key.""" - def __init__(self, *, field: str | None) -> None: ... - field: str | None - -class Tier3LayerDeferred(Exception): - """422 layer_deferred — define carrying non-null layer field.""" - def __init__(self, *, field: str | None) -> None: ... - field: str | None - -class Tier3AgentNotFound(Exception): - """404 — patch/delete on non-existent agent.""" - def __init__(self, *, agent_id: str) -> None: ... - agent_id: str - -# Reused from ratatoskr.sessions (one-way import — sessions doesn't depend on tier3): -# SessionApiFailed(status, body) for all other non-2xx responses. -``` - -## Functions - -### FN define_agent - -``` -FN define_agent( - client: httpx.AsyncClient, - *, agent_name: str, system_prompt: str, model: str, -) -> Tier3AgentInfo -BRIEF: POST /agents/define → 201 with Tier3AgentInfo. - -PRE-001: agent_name matches `[a-z][a-z0-9-]{2,63}` (slug guard — client-side - assert; the server enforces too, but this prevents wire round-trip - for trivially-bad input). -PRE-002: system_prompt is non-empty. -PRE-003: model is non-empty. - -STEPS: - 1. assert PRE-001/002/003. - 2. body = { - "agent_name": agent_name, - "system_prompt": system_prompt, - "model": model, - } - 3. resp = await client.post("/agents/define", json=body) - 4. ROUTE response status: - 201 → parse body into Tier3AgentInfo, return. - 422 → inspect error_code: - layer_deferred → raise Tier3LayerDeferred(field=err.get("field")) - (others) → raise SessionApiFailed(status=422, body=resp.content) - 403 + tier3_user_id_unsupported → raise Tier3UserIdUnsupported - 429 → raise Tier3QuotaExceeded(retry_after=int(resp.headers.get("Retry-After", 0))) - other → raise SessionApiFailed(status, body) - -POST-001: returned Tier3AgentInfo has agent_id of shape ":". -``` - -### FN patch_agent - -``` -FN patch_agent( - client: httpx.AsyncClient, agent_id: str, - *, system_prompt: str | None = None, model: str | None = None, -) -> Tier3AgentInfo -BRIEF: PATCH /agents/ → 200 with updated Tier3AgentInfo. - -PRE-001: agent_id contains `:` (Tier 3 shape). -PRE-002: at least one of system_prompt or model is non-None (no-op patches - are still server-accepted but client-side assert avoids the round-trip). - -STEPS: - 1. assert PRE-001/002. - 2. body = {}; if system_prompt is not None: body["system_prompt"] = system_prompt; - if model is not None: body["model"] = model. - 3. resp = await client.patch(f"/agents/{agent_id}", json=body) - 4. ROUTE response status: - 200 → parse, return. - 404 → raise Tier3AgentNotFound(agent_id=agent_id) - 422 + field_not_mutable → raise Tier3FieldNotMutable(field=err.get("field")) - other → raise SessionApiFailed(status, body) -``` - -### FN delete_agent - -``` -FN delete_agent(client: httpx.AsyncClient, agent_id: str) -> None -BRIEF: DELETE /agents/ → 204. - -PRE-001: agent_id contains `:` (Tier 3 shape). - -STEPS: - 1. assert PRE-001. - 2. resp = await client.delete(f"/agents/{agent_id}") - 3. ROUTE response status: - 204 → return None. - 404 → raise Tier3AgentNotFound(agent_id=agent_id) - other → raise SessionApiFailed(status, body) -``` - -## CLI surface (`python -m ratatoskr.tier3`) - -``` -$ python -m ratatoskr.tier3 define --name wizard \ - --system-prompt "You are a guided-elicitation wizard..." \ - --model qwen3.6-35-a3b -defined ratatoskr:wizard (qwen3.6-35-a3b) - -$ python -m ratatoskr.tier3 patch ratatoskr:wizard --system-prompt "New prompt" -patched ratatoskr:wizard - -$ python -m ratatoskr.tier3 delete ratatoskr:wizard -deleted ratatoskr:wizard -``` - -Auth + server URL: same env-var fallback as `ratatoskr.cli`. Exit codes: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21 (network). - -## Invariants - -- **INV-001**: `define_agent` request body carries exactly `{agent_name, system_prompt, model}` — no layer fields, no `bifrost`, no `metadata`. Phase 2.0 baseline shape only. -- **INV-002**: `patch_agent` request body carries ONLY `system_prompt` and/or `model` — every other key is omitted. Server-side 422 `field_not_mutable` is the safety net; client-side body-construction is the first line. -- **INV-003**: `delete_agent` is fire-and-confirm — no body, no retry, no soft-delete. Cascade handling is server-side; ratatoskr doesn't track it. -- **INV-004**: All exceptions carry a `[:1024]` body cap (when applicable) per the issue #2 convention. -- **INV-005**: CLI auth resolution mirrors `ratatoskr.cli`: `--api-key` flag > `$WORLDTREE_API_KEY` > exit 11. -- **INV-006**: CLI server URL resolution mirrors `ratatoskr.cli`: `--server` > `$WORLDTREE_API_URL` > `http://localhost:8000`. -- **INV-007**: Module never imports `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` (one-way: only `cli.USER_AGENT` is imported, and only by `__main__.py` for the outbound User-Agent header). -- **INV-008**: All HTTP through caller-owned `httpx.AsyncClient` — module never constructs its own client. (`__main__` constructs one for the CLI entry point per ratatoskr.cli's pattern.) - -## TESTS (tests/test_tier3.py — new file) - -``` -- test_define_happy: 201 + full response shape → Tier3AgentInfo populated. -- test_define_quota_exceeded: 429 + Retry-After header → Tier3QuotaExceeded(retry_after=N). -- test_define_user_id_unsupported: 403 tier3_user_id_unsupported → Tier3UserIdUnsupported. -- test_define_layer_deferred_persona: 422 layer_deferred → Tier3LayerDeferred (would only fire if the body sent a layer field; the module never sends one, so this asserts server-side defense but reflecting a 422 we don't actually generate. Test exercises the response path, not the request). -- test_define_bad_slug: PRE-001 assertion fires before HTTP for agent_name="X" (uppercase) or "ab" (too short). -- test_define_empty_prompt: PRE-002 assertion fires for empty system_prompt. -- test_define_other_5xx: 503 → SessionApiFailed(status=503). -- test_patch_happy_both_fields: 200 + updated body → Tier3AgentInfo. -- test_patch_happy_single_field: 200 with only system_prompt set; body omits model. -- test_patch_field_not_mutable: 422 field_not_mutable → Tier3FieldNotMutable. -- test_patch_404: 404 → Tier3AgentNotFound(agent_id=...). -- test_patch_no_args: PRE-002 assertion fires (both None). -- test_patch_non_tier3_id: PRE-001 assertion fires for agent_id without `:`. -- test_delete_happy: 204 → returns None. -- test_delete_404: 404 → Tier3AgentNotFound. -- test_delete_non_tier3_id: PRE-001 assertion fires. -- test_delete_other_5xx: 500 → SessionApiFailed. -- test_cli_define_happy: argv → 201 mock → stdout="defined ratatoskr:wizard (qwen3.6-35-a3b)" + exit 0. -- test_cli_patch_happy: argv → 200 mock → stdout="patched ratatoskr:wizard" + exit 0. -- test_cli_delete_happy: argv → 204 mock → stdout="deleted ratatoskr:wizard" + exit 0. -- test_cli_missing_auth: no API key → stderr "[auth_error]" + exit 11. -- test_cli_api_failed: 500 mock → stderr "[api_failed]" + exit 20. -``` - -## ERROR_ROUTING (module + CLI) - -| HTTP shape | error_code | Exception (module) | CLI label | Exit | -|---|---|---|---|---| -| 201 / 200 / 204 | — | (none — happy) | one-line confirmation on stdout | 0 | -| 429 | agent_quota_exceeded | `Tier3QuotaExceeded(retry_after=N)` | `[quota_exceeded] retry_after=N` | 20 | -| 403 | tier3_user_id_unsupported | `Tier3UserIdUnsupported` | `[user_id_unsupported]` | 20 | -| 404 | — | `Tier3AgentNotFound(agent_id=...)` | `[agent_not_found] ` | 20 | -| 422 | field_not_mutable | `Tier3FieldNotMutable(field=...)` | `[field_not_mutable] field=...` | 20 | -| 422 | layer_deferred | `Tier3LayerDeferred(field=...)` | `[layer_deferred] field=...` | 20 | -| any other non-2xx | — | `SessionApiFailed(status, body)` | `[api_failed] status=N body=...` | 20 | -| httpx.ConnectError / ReadTimeout / TransportError | — | propagates | `[network_error] T: M` | 21 | -| PRE-001/002/003 assertion violation | — | `AssertionError` | `[usage_error] ` | 10 | -| no auth | — | `_AuthError` (reused from cli) | `[auth_error] no API key` | 11 | - -## Layout after this module lands - -``` -src/ratatoskr/ - __init__.py - cli.py (existing, unchanged) - sessions.py (existing, unchanged) - sse_client.py (existing, unchanged) - tui.py (existing, unchanged) - tier3.py NEW - __main__/ (no change — main cli still entry-point) - -# CLI invocation: -$ python -m ratatoskr.tier3 define --name wizard ... -$ python -m ratatoskr.tier3 patch ratatoskr:wizard ... -$ python -m ratatoskr.tier3 delete ratatoskr:wizard -``` diff --git a/docs/contracts/issues/2.contract.md b/docs/contracts/issues/2.contract.md deleted file mode 100644 index 5a051c2..0000000 --- a/docs/contracts/issues/2.contract.md +++ /dev/null @@ -1,625 +0,0 @@ ---- -contract_version: "2.1" -target_module: "ratatoskr.sessions" -scope: "Implement the Worldtree Conversation API session-lifecycle client for Ratatoskr. Two entry points: create_session (POST /sessions) and list_sessions (GET /sessions with cursor pagination), plus two shared frozen dataclasses (SessionInfo, SessionPage). Consumed by ratatoskr.cli for --send --new (single session create) and by ratatoskr.tui for the startup session picker (list). No core.* / worldtree.* imports; caller owns httpx.AsyncClient and Authorization header lifecycle. Convention-aligned with ratatoskr.sse_client (issue #1) — same posture, no shared types." -depends_on: - - "httpx" -used_by: - - "ratatoskr.cli" - - "ratatoskr.tui" -language: "python" -complexity: "low" -estimated_loc: 150 -confidence: 0.9 -assumptions: - - "Worldtree spec pin (`docs/conversation-api-spec.md` at v1.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) is the wire contract. POST /sessions response shape (§POST /sessions) and GET /sessions response shape (§GET /sessions) are read FROM the spec, not from any Worldtree source import." - - "POST /sessions returns 201 Created with a body matching the documented shape (session_id, agent_id, message_count, created_at, last_active, metadata). The created_at/last_active fields are ISO 8601 strings with +HH:MM offsets." - - "GET /sessions cursor pagination uses the `v1.` envelope (§Pagination); the consumer treats cursors as opaque strings (does not parse or construct them)." - - "Bifrost binding (Worldtree issue #160) is NOT used. create_session does not accept a `bifrost` parameter and never sends one in the request body." -open_questions: - - "Should SessionInfo split into two dataclasses (CreatedSessionInfo with message_count vs ListedSessionInfo with archived/tags/name)? Draft uses one SessionInfo with origin-conditional fields whose defaults are codified in INV-001 (create) and INV-002 (list). Splitting would force callers to handle two types where they currently handle one; collapsing felt right for v1 but reconsider if presenters end up branching by origin." - - "Should list_sessions transparently paginate (iterate all pages) or surface one page at a time? Draft surfaces one page (SessionPage with next_cursor). Caller decides whether to iterate. Matches Worldtree's pagination idiom and lets the TUI render lazily." -prd: - issue: 2 - issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/2" - body_sha256_16: "01fbbd52b6d90eb0" - lock_in_comment_id: null - lock_in_sha256_16: null - lock_in_at: null - pinned_at: "2026-05-21T04:45:06+00:00" -dependencies: - - issue: 1 - path: "src/ratatoskr/sse_client.py" - reason: "Convention dependency, not a code dependency. Issue #1 establishes the API-consumption posture (caller-owns httpx client, async-native, no Worldtree imports, response-parsing into frozen dataclasses, exception body truncation to [:1024]). sessions.py follows the same shape." ---- - -# Sessions — Worldtree Conversation API session lifecycle - -## Context - -`ratatoskr.sessions` is Ratatoskr's session-lifecycle client. Two entry points (`create_session`, `list_sessions`) plus two shared frozen dataclasses (`SessionInfo`, `SessionPage`). The module is the surface that `ratatoskr.cli` calls when `--send --new` mints a fresh session against Worldtree, and that `ratatoskr.tui` calls to populate the startup picker's `DataTable` of existing sessions. - -The module deliberately does NOT cover per-turn operations (those live in `ratatoskr.sse_client`), session mutation (`PATCH /sessions/{id}` is out of scope per design-brief §4 negative clauses), or session deletion (`DELETE /sessions/{id}` is admin work via `sessions_cli.py`). - -Convention-aligned with issue #1: caller owns the `httpx.AsyncClient` and Authorization header; the module never imports Worldtree source; responses are parsed into typed frozen dataclasses; exception `.body` payloads are truncated to `[:1024]` at construction. - -## Data flow - -**Input:** -- `httpx.AsyncClient` (caller-owned, base_url + bearer auth on the client). -- `agent_id: str` — for `create_session`. -- `include_archived: bool`, `limit: int`, `cursor: str | None` — for `list_sessions`. - -**Output:** -- `create_session` → `SessionInfo`: - - `session_id: str` - - `agent_id: str` - - `created_at: str` (ISO 8601 with offset) - - `last_active: str` - - `metadata: dict[str, Any]` (defaults to `{}` if the response omits the field — see INV-001) - - `message_count: int | None` (present from POST response; `None` when SessionInfo was sourced from a list item per spec §GET /sessions) - - `name: str | None` (always `None` when sourced from POST response; `None` if absent from list item; otherwise the list item's value) - - `archived: bool` (always `False` when sourced from POST response; defaults to `False` if absent or null in a list item; otherwise the list item's value) - - `tags: list[str]` (always `[]` when sourced from POST response; defaults to `[]` if absent or null in a list item; otherwise the list item's value) -- `list_sessions` → `SessionPage`: - - `items: list[SessionInfo]` - - `next_cursor: str | None` (None on the last page; opaque string otherwise) - -**Side effects:** outbound HTTP only; no disk I/O, no global state. - -## Invariants - -- **INV-001 [hard]**: `create_session` returns a `SessionInfo` whose `session_id`, `agent_id`, `created_at`, `last_active`, and `metadata` are sourced from the 201 response body. `metadata` is taken from `body["metadata"]` when present and defaults to `{}` when absent (defensive against minor server-side spec drift; spec example always shows it present). `message_count` is taken from `body["message_count"]` (strict — bracket access, not `.get()`; the spec lists it as a response field and absent should surface as KeyError rather than silently default to None). List-only fields are fixed: `name=None`, `archived=False`, `tags=[]`. -- **INV-002 [hard]**: `list_sessions` returns a `SessionPage` where every `SessionInfo` has `session_id`, `agent_id`, `created_at`, `last_active`, and `metadata` from the response item (same defensive `metadata` default as INV-001). `name` is `item.get("name")` (may be `None`). `archived` is `item.get("archived") or False` — absent, explicit-null, or explicit-false all yield `False`; explicit-true passes through. (Note: `item.get(key, default)` only fires `default` for absent keys, NOT for explicit-null values, so the `or False` form is load-bearing here.) `tags` is `item.get("tags") or []` (absent, explicit-null, or empty list all yield `[]`; a populated list passes through). `message_count` is `None` (the list endpoint does not include it — spec §GET /sessions: "`message_count` is not included in list items"). -- **INV-003 [hard]**: `list_sessions` treats cursors as opaque strings. The module never parses, base64-decodes, or constructs a cursor — it threads the server-provided `next_cursor` back verbatim on the next call. Per spec §Pagination ("Cursors are opaque to clients — do not parse or construct them."). -- **INV-004 [hard]**: Both functions truncate exception `.body` payloads to `[:1024]` at construction. Matches the issue #1 precedent (`SseConnectFailed`, `CancelFailed`). -- **INV-005 [hard]**: No `core.*` or `worldtree.*` imports. Boundary verified by `tests/test_no_worldtree_imports.py`. -- **INV-006 [hard]**: `list_sessions` rejects out-of-range `limit` values (`< 1` or `> 200`) client-side before issuing any HTTP request. Spec §GET /sessions specifies the server returns 422 on out-of-range; the client refuses to send an obviously-invalid request rather than depending on the server to reject it. - -## Constraints - -- **[compatibility]** Module must work against the spec pin (`55101e909abcd2219833266b6f905c5bc956e0f0`, Worldtree v0.19.0). -- **[security]** Module does not log full response bodies (they may carry user-readable session names + tags). Logging limited to status code + session_id when present. -- **[style]** Async-native. No sync entry points. Consistent with `sse_client`. - -## Out of scope - -- **Bifrost binding** (Worldtree issue #160). `create_session` does not accept or send a `bifrost` field. Ratatoskr is not a Bifrost consumer; consumer-side tool injection is an advanced feature outside the dev TUI's purpose. -- **Ephemeral / Saga sessions.** Separate session class with TTL semantics; not needed for hands-on dev probing. -- **`GET /sessions/{id}` (single fetch), `PATCH /sessions/{id}` (mutation), `DELETE /sessions/{id}` (deletion).** Per design-brief §4 negative clauses; admin operations live outside Ratatoskr. -- **`GET /sessions/{id}/messages` (history pagination).** Deferred until the TUI needs scrollback replay; `--send` doesn't need history. -- **Transparent multi-page iteration.** `list_sessions` returns one page; caller threads `next_cursor` for the next call. Don't add an `iter_all_sessions()` until the TUI proves it needs that shape. -- **Server retry / backoff.** Caller's policy. The module does not retry on 5xx; it surfaces failure once and returns control. - ---- - -```contract -FN create_session(client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None) -> SessionInfo -BRIEF: POST /sessions with {"agent_id": agent_id} (and {"end_user_id": end_user_id} when non-None) to create a new conversation session. Returns SessionInfo populated from the 201 response. Per issue #5: keyword-only `end_user_id` for per-end-user agents (lofn etc.); default-None preserves the pre-#5 baseline. -PRE: [PRE-001 hard] client is not None -- assert client is not None -PRE: [PRE-002 hard] agent_id is a non-empty string -- assert agent_id and isinstance(agent_id, str) -PRE: [PRE-003 hard, issue #5] end_user_id is None OR a non-empty string -- assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id) -POST: [POST-001 side_effect] exactly one POST to /sessions was issued; body is {"agent_id": agent_id} when end_user_id is None, OR {"agent_id": agent_id, "end_user_id": end_user_id} when non-None (issue #5 INV-002: omitting the field when None is NOT the same as sending empty) -POST: [POST-002 return_value] returns SessionInfo with session_id, agent_id, created_at, last_active, metadata populated from response -- assert all 5 fields non-None -POST: [POST-003 return_value] returns SessionInfo where message_count == response["message_count"] (typically 0 for a fresh session) and list-only fields carry the create-origin fixed defaults per INV-001 -- assert info.message_count is not None and info.name is None and info.archived is False and info.tags == [] -ERROR_ROUTING: - HTTP 404 unknown_agent_id: - local_handling: raise AgentNotFound(agent_id=agent_id) - flow_control: abort - state_recovery: none (caller passed an unknown agent_id; that's a user error) - HTTP 422 validation_failed: - local_handling: raise SessionApiFailed(status=422, body=resp.content[:1024]) - flow_control: abort - state_recovery: none (typically client bug; surface for debugging. Issue #5: a `end_user_id_required` 422 indicates the agent requires --end-user-id; raw label is honest, hint translation deferred.) - httpx.HTTPStatusError (other status): - local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content[:1024]) - flow_control: abort - state_recovery: none -STEPS: - 1. [setup, flexibility=prescriptive] Validate inputs per PRE-001, PRE-002, PRE-003 - 2. [sequential, flexibility=prescriptive] Build body = {"agent_id": agent_id}; IF end_user_id is not None: body["end_user_id"] = end_user_id - 3. [sequential, flexibility=prescriptive] CALL client.post("/sessions", json=body) - tool: { destructive: false, idempotent: false, read_only: false, open_world: false } - 4. [branch, flexibility=prescriptive] IF resp.status_code == 404: RAISE AgentNotFound - ELIF resp.status_code != 201: RAISE SessionApiFailed - 5. [sequential] Parse resp.json() → body - 6. [cleanup] RETURN SessionInfo( - session_id=body["session_id"], - agent_id=body["agent_id"], - created_at=body["created_at"], - last_active=body["last_active"], - metadata=body.get("metadata", {}), # INV-001 defensive default - message_count=body["message_count"], # INV-001/POST-003: required, never defaulted - name=None, # INV-001 fixed for create-origin - archived=False, # INV-001 fixed for create-origin - tags=[], # INV-001 fixed for create-origin - ) -TESTS: - happy_create [happy,tracer]: mock returns 201 with full body → returns SessionInfo with all create-side fields populated; list-only fields are at create-origin defaults (name=None, archived=False, tags=[]) - happy_create_with_metadata [happy]: response includes metadata={"model": "glm5-turbo"} → SessionInfo.metadata == {"model": "glm5-turbo"} - request_body_shape [trace]: outbound JSON body is exactly {"agent_id": } when end_user_id omitted — no Bifrost field, no extra keys - unknown_agent_id [error]: mock returns 404 → raises AgentNotFound(agent_id="mimir") - validation_failed [error]: mock returns 422 → raises SessionApiFailed(status=422); body truncated to ≤1024 bytes - unexpected_status_truncates [error]: mock returns 500 with 5000-byte body → SessionApiFailed; .body is exactly the first 1024 bytes - empty_agent_id [adversarial]: agent_id="" → AssertionError; no HTTP issued - happy_create_with_end_user_id [happy, issue #5]: end_user_id="alice" → outbound JSON body == {"agent_id": "mimir", "end_user_id": "alice"} byte-for-byte; SessionInfo populated as today - default_omits_end_user_id [trace, issue #5]: omit end_user_id kwarg → outbound JSON body == {"agent_id": "mimir"} (no end_user_id key); preserves the pre-#5 baseline - empty_end_user_id [adversarial, issue #5]: end_user_id="" → AssertionError before HTTP (PRE-003) -``` - -```contract -FN list_sessions(client: httpx.AsyncClient, *, include_archived: bool = False, limit: int = 50, cursor: str | None = None) -> SessionPage -BRIEF: GET /sessions with cursor pagination. Returns one SessionPage. Caller threads next_cursor for subsequent pages. -PRE: [PRE-001 hard] client is not None -- assert client is not None -PRE: [PRE-002 hard] limit is in [1, 200] -- assert 1 <= limit <= 200 (INV-006: refuse out-of-range client-side; do not depend on server 422) -PRE: [PRE-003 hard] cursor is None or a non-empty string -- assert cursor is None or (isinstance(cursor, str) and cursor) -POST: [POST-001 side_effect] exactly one GET to /sessions was issued -- assert mock_router.calls.call_count == 1 -POST: [POST-002 side_effect] query string carries `limit=` always; `include_archived=true` iff caller passed include_archived=True; `cursor=` iff caller passed a cursor -- assert URL params match -POST: [POST-003 return_value] returns SessionPage(items=[SessionInfo, ...], next_cursor=str|None) per response -- assert isinstance(result.items, list) and (result.next_cursor is None or isinstance(result.next_cursor, str)) -POST: [POST-004 return_value] each SessionInfo in items has list-side fields (name, archived, tags) populated and message_count=None per INV-002 -- assert all(info.message_count is None for info in result.items) -ERROR_ROUTING: - HTTP 422 (cursor_invalid): - local_handling: parse body for error_code; raise InvalidCursor(raw=cursor) if error_code == "cursor_invalid"; else raise SessionApiFailed - flow_control: abort - state_recovery: caller policy — restart from page 1 (cursor=None) - HTTP 422 (other validation_failed): - local_handling: raise SessionApiFailed(status=422, body=resp.content[:1024]) - flow_control: abort - state_recovery: none (PRE-002/003 should have caught client-side issues; server-side 422 means spec mismatch) - httpx.HTTPStatusError (other status): - local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content[:1024]) - flow_control: abort - state_recovery: none -STEPS: - 1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003 - 2. [sequential, flexibility=prescriptive] Build params dict: {"limit": limit}; ADD "include_archived": "true" iff include_archived; ADD "cursor": cursor iff cursor is not None - 3. [sequential, flexibility=prescriptive] CALL client.get("/sessions", params=params) - tool: { destructive: false, idempotent: true, read_only: true, open_world: false } - 4. [branch, flexibility=prescriptive] IF resp.status_code == 422: - Parse body; IF body.get("error_code") == "cursor_invalid": RAISE InvalidCursor(raw=cursor) - ELSE: RAISE SessionApiFailed(status=422, body=resp.content[:1024]) - ELIF resp.status_code != 200: RAISE SessionApiFailed - 5. [sequential] Parse resp.json() → body - 6. [loop] FOR EACH item in body["items"]: CONSTRUCT SessionInfo( - session_id=item["session_id"], - agent_id=item["agent_id"], - created_at=item["created_at"], - last_active=item["last_active"], - metadata=item.get("metadata", {}), # INV-002 defensive default - message_count=None, # not in list response per spec - name=item.get("name"), # INV-002: may be None - archived=item.get("archived") or False, # INV-002: absent/null/false → False (the `or` form is load-bearing — .get(k, default) does not fire default on explicit null) - tags=item.get("tags") or [], # INV-002: absent/null/[] → [] - ) - 7. [cleanup] RETURN SessionPage(items=infos, next_cursor=body.get("next_cursor")) -TESTS: - happy_first_page [happy,tracer]: GET /sessions, mock returns {items: [one full session shape], next_cursor: "v1.abc..."} → SessionPage(items=[1], next_cursor="v1.abc...") - happy_last_page [happy]: mock returns {items: [...], next_cursor: null} → SessionPage with next_cursor=None - empty_results [happy]: mock returns {items: [], next_cursor: null} → SessionPage([], None) - include_archived_query [trace]: include_archived=True → URL has include_archived=true; default (include_archived=False) → URL has NO include_archived param at all (STEP 2 prescribes "ADD include_archived='true' iff include_archived" — the test asserts absence on default, not an explicit false) - cursor_threaded [trace]: cursor="opaque-from-prev-page" → URL has cursor=opaque-from-prev-page - limit_query [trace]: limit=10 → URL has limit=10 - invalid_cursor_server [error]: mock returns 422 with body {"error_code":"cursor_invalid","message":"..."} → raises InvalidCursor(raw=) - other_validation_failed [error]: mock returns 422 with body {"error_code":"validation_failed",...} → raises SessionApiFailed(status=422); body truncated - unexpected_status_truncates [error]: mock returns 500 with 5000-byte body → SessionApiFailed; .body is exactly the first 1024 bytes - limit_below_one [adversarial]: limit=0 → AssertionError; no HTTP issued - limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued - empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued -``` - -## Amendment 2026-06-30 — boot-time introspection reads (v1 coverage-audit: capabilities+me) - -The v1 coverage-audit added two read-only server-introspection endpoints as -cheap debug primitives (surfaced via a new `ratatoskr --whoami` one-shot). Both -mirror `get_persona_state`: GET, 200 → parsed dict verbatim, any non-200 → -`SessionApiFailed`. The frozen OpenAPI types both responses as freeform objects, -so the wrappers return `dict[str, Any]` (not a typed dataclass). - -```contract -FN get_me(client: httpx.AsyncClient) -> dict[str, Any] -BRIEF: GET /me — the authenticated principal's identity + key metadata (spec §GET /me). Boot-time whoami: verify the key without agent-config side effects. Returns parsed JSON verbatim; spec documents {user_id, scopes, tier, display_name?, key_id?, key_label?, ...} with optional fields OMITTED (not null). Read-only, rate-exempt, no audit emission. -PRE: [PRE-001 hard] client is not None -- assert client is not None -POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json() -ERROR_ROUTING: - HTTP non-200 (incl. 401 bad/absent key when auth enabled): - local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content) - flow_control: abort - state_recovery: none (caller decides: bad key → re-key; degraded tier="unknown" is still a 200) -STEPS: - 1. [setup, prescriptive] assert client is not None - 2. [sequential, prescriptive] resp = await client.get("/me") - 3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed -TESTS: - happy_authenticated [happy,tracer]: 200 {user_id, scopes, tier, key_id} → dict returned verbatim - anonymous_dev_mode: 200 {user_id:"anonymous", tier:"anonymous"} → dict; no key_* fields (omitted) - 401_raises [error]: 401 → SessionApiFailed(status=401) - -FN get_capabilities(client: httpx.AsyncClient) -> dict[str, Any] -BRIEF: GET /capabilities — server capability discovery (spec §Ephemeral Templates). Returns {ephemeral_templates: {echo: {allowed_models, default_model, system_prompt_max_bytes}}}. Any authenticated caller may read it (no instantiate scope). Parsed dict verbatim; any non-200 → SessionApiFailed. -PRE: [PRE-001 hard] client is not None -- assert client is not None -POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json() -ERROR_ROUTING: - HTTP non-200: - local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content) - flow_control: abort - state_recovery: none -STEPS: - 1. [setup, prescriptive] assert client is not None - 2. [sequential, prescriptive] resp = await client.get("/capabilities") - 3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed -TESTS: - happy [happy]: 200 {ephemeral_templates:{echo:{...}}} → dict returned verbatim - non_200_raises [error]: 500 → SessionApiFailed(status=500) -``` - -## Amendment 2026-07-01 — session tool introspection (v1 coverage-audit) - -Owner-scoped tool-inventory read (spec #183, `GET /sessions/{id}/tools`), -surfaced in the TUI Tools pane on session-attach. Same shape as the other -introspection wrappers: GET, 200 → parsed dict verbatim, non-200 → -`SessionApiFailed`. Reachable with the consumer key (no admin scope), unlike the -admin variant `GET /admin/sessions/{id}/tools`. - -```contract -FN get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any] -BRIEF: GET /sessions/{session_id}/tools — owner-scoped merged tool inventory (spec #183) the LLM saw at turn-fire: {agent_id, builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}. Owner gate (ctx.user_id == session.user_id); cross-owner → 404 session_not_found (existence-hiding), revoked → 401 auth_revoked. Parsed dict verbatim; any non-200 → SessionApiFailed. -PRE: [PRE-001 hard] client is not None -- assert client is not None -PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str) -POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json() -ERROR_ROUTING: - HTTP non-200 (incl. 404 session_not_found cross-owner/unknown, 401 auth_revoked): - local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content) - flow_control: abort - state_recovery: none -STEPS: - 1. [setup, prescriptive] assert PRE-001, PRE-002 - 2. [sequential, prescriptive] resp = await client.get(f"/sessions/{session_id}/tools") - 3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed -TESTS: - happy [happy,tracer]: 200 {agent_id, builtin_tools:[], bifrost_tools:[{name,...}]} → dict verbatim - cross_owner_404 [error]: 404 session_not_found → SessionApiFailed(status=404) - empty_session_id [adversarial]: "" → AssertionError; no HTTP issued -``` - -## Amendment 2026-07-01 — admin BifrostState read (v1 coverage-audit) - -Admin-scoped Bifrost dispatch-state read (spec #176, `GET /admin/sessions/{id}/bifrost`), -surfaced in the TUI BifrostState pane on session-attach. The first admin-key -consumer in ratatoskr: requires the `admin.sessions.read` scope, so the request -OVERRIDES the Authorization header with the caller-supplied `admin_key` (distinct -from the client's default consumer key). Same result-shape convention as the -other introspection wrappers: 200 → parsed dict verbatim, non-200 → `SessionApiFailed`. - -```contract -FN get_session_bifrost(client: httpx.AsyncClient, session_id: str, *, admin_key: str) -> dict[str, Any] -BRIEF: GET /admin/sessions/{session_id}/bifrost — admin-scoped live Bifrost binding (spec #176): {endpoint_url, consumer_id, connected, capabilities_granted, tools:[{name, description}]}. Requires admin.sessions.read; the request sets Authorization: Bearer (override), NOT the client's default consumer bearer. Parsed dict verbatim; any non-200 → SessionApiFailed — notably 403 auth_scope_denied and 404 session_not_bifrost_bound. -PRE: [PRE-001 hard] client is not None -- assert client is not None -PRE: [PRE-002 hard] session_id is non-empty str -- assert session_id and isinstance(session_id, str) -PRE: [PRE-003 hard] admin_key is non-empty str -- assert admin_key and isinstance(admin_key, str) -POST: [POST-001 return_value] on 200 returns resp.json() unmodified -- assert result == resp.json() -POST: [POST-002 state_change] the outbound request Authorization header == f"Bearer {admin_key}" (override) -- assert request.headers["Authorization"] == "Bearer " + admin_key -ERROR_ROUTING: - HTTP non-200 (incl. 403 auth_scope_denied, 404 session_not_found / session_not_bifrost_bound): - local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content) - flow_control: abort - state_recovery: none (caller decides: 403 → key lacks scope; 404 not-bound → benign unbound session) -STEPS: - 1. [setup, prescriptive] assert PRE-001..PRE-003 - 2. [sequential, prescriptive] resp = await client.get(f"/admin/sessions/{session_id}/bifrost", headers={"Authorization": f"Bearer {admin_key}"}) - 3. [branch, prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed -TESTS: - happy_uses_admin_bearer [happy,tracer]: 200 {endpoint_url, connected, capabilities_granted, tools} → dict verbatim; request Authorization == "Bearer " (override) - scope_denied_403 [error]: 403 → SessionApiFailed(status=403) - not_bound_404 [error]: 404 session_not_bifrost_bound → SessionApiFailed(status=404) - empty_admin_key [adversarial]: admin_key="" → AssertionError; no HTTP issued -``` - -## Amendment 2026-07-01 — Tier-2: transient characters + persona-state write (v1 coverage-audit) - -The last in-scope client I/O points. Transient-character CRUD (#161) surfaced -via a `--characters` one-shot lifecycle probe; persona-state write surfaced via -`--set-persona-pad "p,a,d"` (requires `--session`). All mirror the existing -wrappers: parsed dict verbatim (or None on 204), any off-status → SessionApiFailed. -**Note:** `set_persona_state`'s request body is FREEFORM — the frozen OpenAPI 2.2.0 -declares no request schema and the prose spec documents only the GET counterpart, -so the caller supplies the snapshot shape. **Canonical (worldtree-dev prose #317, -`c9e59ec`): `{pad:{pleasure,arousal,dominance}}` — a named-key dict, NOT a list; -`--set-persona-pad` builds + sends the named dict (each float in [-1,1]).** - -```contract -FN list_character_models(client) -> dict[str, Any] -BRIEF: GET /models/available-for-characters (character.read). Returns {items:[{name, description, thinking}]}. Non-200 → SessionApiFailed. -PRE: [PRE-001 hard] client is not None -POST: [POST-001 return_value] on 200 returns resp.json() unmodified -STEPS: - 1. [sequential, prescriptive] resp = await client.get("/models/available-for-characters"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed -TESTS: - list_models [happy,tracer]: 200 {items:[{name:"fast"}]} → dict verbatim - -FN create_character(client, character: dict, *, state: dict | None = None) -> dict[str, Any] -BRIEF: POST /characters (character.write). Body {character, state}. Returns 201 {character_id, ttl_expires_at}; non-201 → SessionApiFailed. -PRE: [PRE-001 hard] client is not None; [PRE-002 hard] character is a non-empty dict -POST: [POST-001 return_value] on 201 returns resp.json(); [POST-002 side_effect] outbound body == {"character": , "state": } -STEPS: - 1. [sequential, prescriptive] resp = await client.post("/characters", json={"character": character, "state": state}); IF 201 RETURN resp.json(); ELSE RAISE SessionApiFailed -TESTS: - create [happy]: 201 → {character_id}; body is {character, state:null} - create_403 [error]: 403 auth_scope_denied → SessionApiFailed(403) - -FN get_character_state(client, character_id: str) -> dict[str, Any] -BRIEF: GET /characters/{id}/state (character.read). Live PAD/emotions snapshot; refreshes TTL. Non-200 → SessionApiFailed. -PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str -POST: [POST-001 return_value] on 200 returns resp.json() -STEPS: - 1. [sequential, prescriptive] resp = await client.get(f"/characters/{character_id}/state"); IF 200 RETURN resp.json(); ELSE RAISE SessionApiFailed -TESTS: - get_state [happy]: 200 {pad:[...]} → dict verbatim - -FN delete_character(client, character_id: str) -> None -BRIEF: DELETE /characters/{id} (character.write). 200/204 → None; other → SessionApiFailed. -PRE: [PRE-001 hard] client not None; [PRE-002 hard] character_id non-empty str -POST: [POST-001 return_value] on 200/204 returns None -STEPS: - 1. [sequential, prescriptive] resp = await client.delete(f"/characters/{character_id}"); IF status in (200,204) RETURN None; ELSE RAISE SessionApiFailed -TESTS: - delete [happy]: 204 → None - -FN set_persona_state(client, session_id: str, snapshot: dict) -> None -BRIEF: POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection). Request body is the FREEFORM snapshot (caller-supplied; unpinned in the frozen surface). 204 → None; other → SessionApiFailed. -PRE: [PRE-001 hard] client not None; [PRE-002 hard] session_id non-empty str; [PRE-003 hard] snapshot is a dict -POST: [POST-001 return_value] on 204 returns None; [POST-002 side_effect] outbound body == snapshot verbatim -STEPS: - 1. [sequential, prescriptive] resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot); IF 204 RETURN None; ELSE RAISE SessionApiFailed -TESTS: - happy [happy]: 204 → None; body == {"pad":{"pleasure","arousal","dominance"}} verbatim (canonical named-key dict, #317) - non_204 [error]: 422 → SessionApiFailed(422) -``` - -## Amendment 2026-07-06 — authored-history write (#347, v1 coverage-audit re-open) - -Worldtree shipped #347 (authored-history-write) as OpenAPI 2.3.0: a new -`POST /sessions/{session_id}/history` primitive that writes ONE model-visible -turn into a session's ledger AS the bound agent, WITHOUT a generation and -WITHOUT lived-turn side effects (the SillyTavern "first message"). The re-vendor -(2.2.0→2.3.0, pin `879cefe`) re-opened the v1 coverage-audit with this one new -in-scope REST path-group; this amendment closes it on the consumer side and also -un-defers `GET /sessions/{id}/messages` (previously §Out of scope) as the seed's -read-back. - -**Hide-existence (server INV-347-1) — the load-bearing consumer contract.** The -`session.history.write` grant is checked FIRST — an ungranted caller (or a -non-owner, or an unknown session) gets a 404 **byte-identical** to a genuine -`session_not_found`, never a 403/409/422 that would reveal the feature exists. -The consumer MUST honor this: treat 404 as **feature-absent**, fall back (a -production consumer to a model-generated greeting), and NEVER capability-probe to -tell feature-absent from ungranted from session-absent. The wrapper encodes it by -raising a DISTINCT `AuthoredHistoryUnavailable` on 404 (NOT `SessionApiFailed`), -so a caller branches feature-absent without inspecting a status code. - -**Request body — v1-minimal, wire-pinned by the server.** The frozen OpenAPI 2.3.0 -exports an empty request schema, but the server pins `AuthoredWriteRequest` -(`extra="forbid"`): `{author, content, idempotency_key, effects?, -claimed_original_at?}`. v1: `author="assistant"` (only value), `content` (UTF-8, -server-bounded at `authored_content_max_bytes`=8192), `idempotency_key` (REQUIRED, -per-session dedup), `effects` omitted (== "none"; only value). Because -`extra="forbid"`, the wrapper omits `effects`/`claimed_original_at` when None -(never sends null). Success is 201 (fresh) OR 200 (idempotent replay, -byte-identical body); both return the `AuthoredTurnResponse` `{author, -content_chars, injected_at, phase, seq, session_id, turn_id}` verbatim (provenance -is audit-only, NEVER on this body — INV-347-7). - -**Assistant-first provider constraint (deferred, inert for the probe).** A -create-time first-message makes the assistant seq-0 (assistant-first history); -Anthropic-family providers 400 the *next generation*, vLLM/openai_compat tolerate -it. The `--seed-first-message` probe seeds but does NOT generate, so the -constraint is inert for the probe — a real consumer that then generates must bind -an assistant-first-tolerant provider. - -```contract -FN write_authored_history(client: httpx.AsyncClient, session_id: str, *, content: str, idempotency_key: str, author: str = "assistant", effects: str | None = None, claimed_original_at: str | None = None) -> dict[str, Any] -BRIEF: POST /sessions/{session_id}/history — the #347 authored-history-write primitive (write one model-visible turn as the bound agent, no generation, no side effects). Body {author, content, idempotency_key} + "effects"/"claimed_original_at" only when non-None (server AuthoredWriteRequest is extra="forbid"). Success 200 (replay) or 201 (fresh) → AuthoredTurnResponse dict verbatim. 404 → AuthoredHistoryUnavailable (hide-existence: feature-absent/ungranted/session-absent, indistinguishable by design — consumer falls back, never probes). Any other non-2xx → SessionApiFailed. -PRE: [PRE-001 hard] client is not None -- assert client is not None -PRE: [PRE-002 hard] session_id is a non-empty str -- assert session_id and isinstance(session_id, str) -PRE: [PRE-003 hard] content is a non-empty str -- assert content and isinstance(content, str) -PRE: [PRE-004 hard] idempotency_key is a non-empty str -- assert idempotency_key and isinstance(idempotency_key, str) -PRE: [PRE-005 hard] author is a non-empty str -- assert author and isinstance(author, str) -POST: [POST-001 side_effect] exactly one POST to /sessions/{session_id}/history; body == {"author": author, "content": content, "idempotency_key": idempotency_key} plus "effects" iff effects is not None plus "claimed_original_at" iff claimed_original_at is not None (no null-valued keys — extra="forbid") -POST: [POST-002 return_value] on 200 or 201 returns resp.json() unmodified -ERROR_ROUTING: - HTTP 404 (hide-existence session_not_found): - local_handling: raise AuthoredHistoryUnavailable(session_id=session_id) - flow_control: abort - state_recovery: caller treats as feature-absent; fall back to a model-generated greeting; NEVER capability-probe (INV-347-1) - HTTP other non-2xx (incl. 409 generation_active, 422 content_too_long/validation_failed, 401 auth_revoked, 410 session_retired): - local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content) - flow_control: abort - state_recovery: none (409 retryable; 422 caller bug/oversize) -STEPS: - 1. [setup, flexibility=prescriptive] assert PRE-001..PRE-005 - 2. [sequential, flexibility=prescriptive] body = {"author": author, "content": content, "idempotency_key": idempotency_key}; IF effects is not None: body["effects"] = effects; IF claimed_original_at is not None: body["claimed_original_at"] = claimed_original_at - 3. [sequential, flexibility=prescriptive] resp = await client.post(f"/sessions/{session_id}/history", json=body) - tool: { destructive: false, idempotent: true, read_only: false, open_world: false } - 4. [branch, flexibility=prescriptive] IF resp.status_code in (200, 201): RETURN resp.json(); ELIF resp.status_code == 404: RAISE AuthoredHistoryUnavailable(session_id=session_id); ELSE RAISE SessionApiFailed(status=resp.status_code, body=resp.content) -TESTS: - happy_fresh_201 [happy,tracer]: 201 {author:"assistant", seq:0, phase:"seeded", turn_id, content_chars, session_id, injected_at} → dict verbatim; outbound body == {"author":"assistant","content":,"idempotency_key":} exactly (no effects/claimed_original_at keys) - happy_replay_200 [happy]: 200 (same-key replay, byte-identical body) → dict verbatim - body_includes_effects [trace]: effects="none" → outbound body has "effects":"none"; claimed_original_at="2020-01-01T00:00:00Z" → body has that key too - hide_existence_404 [error]: 404 {error_code:"session_not_found"} → raises AuthoredHistoryUnavailable(session_id=), NOT SessionApiFailed - generation_active_409 [error]: 409 {error_code:"generation_active"} → SessionApiFailed(status=409) - content_too_long_422 [error]: 422 {error_code:"content_too_long"} → SessionApiFailed(status=422) - empty_content [adversarial]: content="" → AssertionError; no HTTP issued - empty_idempotency_key [adversarial]: idempotency_key="" → AssertionError; no HTTP issued - empty_session_id [adversarial]: session_id="" → AssertionError; no HTTP issued - -FN get_session_messages(client: httpx.AsyncClient, session_id: str) -> dict[str, Any] -BRIEF: GET /sessions/{session_id}/messages — the session's message history (spec §GET /sessions/{id}/messages), un-deferred as the #347 probe's read-back so a seeded turn can be confirmed to render as a normal role=assistant message (model-invisible provenance — a seed is indistinguishable from a lived turn on read). Returns {session_id, items:[{seq, role, content, ...}], next_cursor} verbatim. Owner-scoped; any non-200 → SessionApiFailed. v1 reads the server default page (no pagination params — the probe reads a fresh 1-message session; add limit/cursor when a caller needs scrollback). -PRE: [PRE-001 hard] client is not None -- assert client is not None -PRE: [PRE-002 hard] session_id is a non-empty str -- assert session_id and isinstance(session_id, str) -POST: [POST-001 return_value] on 200 returns resp.json() unmodified -ERROR_ROUTING: - HTTP non-200 (incl. 404 session_not_found cross-owner/unknown): - local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content) - flow_control: abort - state_recovery: none -STEPS: - 1. [setup, flexibility=prescriptive] assert PRE-001, PRE-002 - 2. [sequential, flexibility=prescriptive] resp = await client.get(f"/sessions/{session_id}/messages") - 3. [branch, flexibility=prescriptive] IF resp.status_code == 200: RETURN resp.json(); ELSE RAISE SessionApiFailed -TESTS: - happy [happy]: 200 {session_id, items:[{seq:0, role:"assistant", content:"…"}], next_cursor:null} → dict verbatim - not_found_404 [error]: 404 → SessionApiFailed(status=404) - empty_session_id [adversarial]: "" → AssertionError; no HTTP issued -``` - -## Amendment 2026-07-18 — ephemeral-template (Echo) session creation - -**Motivation.** `create_session` could only mint *foundational* sessions -(`{"agent_id": }`). Attempting to start an **ephemeral -template** session — e.g. `agent_id="echo"` — returned `422 -ephemeral_requires_config` because the request carried no `config`. Ephemeral -templates (issue #161: Echo, a blank-slate per-session host) require the consumer -to supply a `config` object with the session's `system_prompt` at create time; -that config is frozen for the session's lifetime. This amendment threads a -`config` passthrough through `create_session`, captures the two new response -fields (`kind`, `config`) on `SessionInfo`, and corrects the `get_capabilities` -metadata shape. - -**Canonical grounding (role, NOT model).** worldtree-dev confirmed on althing -(thread `01KXT976NN91DRBZBPXNZ2BVZR`, 2026-07-18) that the model→role cutover -(commit `bb4d551`, "Complete model role cutover", ADR-0012 role-based model -access) is canonical NOW on both surfaces: -- `GET /capabilities` ephemeral-template metadata keys are **`allowed_roles` / - `default_role`** (NOT `allowed_models` / `default_model`). -- The create-time selector is **`config.role`** (NOT `config.model`). A non-empty - `config.model` **hard-rejects** with `model_not_allowed` (the error code was - repurposed to mean "the `model` field itself is not permitted here"). Omitted / - null `role` resolves server-side to the template's `default_role` (`"echo"`). -- The stored/echoed config snapshot is `{"system_prompt": , "role": }`. - -Ratatoskr therefore stays **canonical-agnostic at the wrapper** (`config` is an -opaque passthrough dict) and **role-correct at the CLI** (builds -`{"system_prompt": ...}`; never emits `model`). The pinned -`docs/conversation-api-spec.md` was re-synced to **v1.1** (worldtree commit -`b4a278c`): its echo section now documents `allowed_roles`/`default_role`, -`config.role` (omitted → `default_role` "echo"), the repurposed -`model_not_allowed` (any non-empty `config.model` hard-rejects), and the new -`role_required` error; the frozen OpenAPI is untouched. Empirically -confirmed against the live v0.16.2 target: `POST /sessions -{"agent_id":"echo","config":{"system_prompt":"..."}}` → `201` with -`{"kind":"ephemeral","config":{"system_prompt":"...","role":"echo"}}`. - -### SessionInfo — two new response fields - -`SessionInfo` gains two optional fields, defaulted so every existing -construction site and caller is unaffected (both `create_session` and -`list_sessions` build `SessionInfo` with keyword args; no positional callers -exist): - -- `kind: str | None = None` — `"ephemeral"` for Echo sessions, `"foundational"` - for all others. Present on both the create 201 and `GET /sessions` list items - (spec §Ephemeral Templates). Captured defensively via `.get("kind")` (None when - a pre-cutover server omits it). -- `config: dict[str, Any] | None = None` — the frozen ephemeral config - (`{"system_prompt", "role"}`) on the create 201; `None` for foundational - sessions and (typically) list items. Captured via `.get("config")`. - -- **INV-001 amendment [hard]**: `create_session` additionally populates - `kind = body.get("kind")` and `config = body.get("config")` from the 201 body. - The five original create-side fields and their fixed list-only defaults - (`name=None, archived=False, tags=[]`) are unchanged. -- **INV-002 amendment [hard]**: `list_sessions` additionally populates - `kind = item.get("kind")` and `config = item.get("config")`. In practice the - list endpoint does NOT echo the frozen config, so `config` is `None` for list - items today; the `.get("config")` form is deliberate forward-compat — if a - future server includes it on list items, it passes through unmodified rather - than being force-nulled. (Heid panel 2026-07-18: earlier "stays None" wording - over-claimed against the passthrough; corrected here.) - -### create_session — `config` passthrough (supersedes the FN block above) - -```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, config: Mapping[str, Any] | None = None) -> SessionInfo -BRIEF: POST /sessions to create a session. Foundational: {"agent_id": agent_id} (+ end_user_id / bifrost per issues #5/#17). Ephemeral (issue #161): when `config` is non-None it is passed through verbatim as the request body's "config" key — the caller (CLI) builds {"system_prompt": } for Echo; the wrapper is role/model-agnostic and NEVER injects a selector. Returns SessionInfo populated from the 201, now including kind + config. (bifrost / consumer_key params + their PRE-001/POST-002 semantics are specified in issue #17's contract; shown here only to keep the signature honest.) -PRE: [PRE-001 hard] client is not None -- assert client is not None -PRE: [PRE-002 hard] agent_id is a non-empty string -- assert agent_id and isinstance(agent_id, str) -PRE: [PRE-003 hard, issue #5] end_user_id is None OR a non-empty string -PRE: [PRE-004 hard, issue #161] config is None OR a Mapping -- assert config is None or isinstance(config, Mapping) -PRE: [PRE-005 hard, issue #161] config and bifrost are not BOTH set — ephemeral sessions do not accept a Bifrost binding (server would 422 ephemeral_does_not_accept_bifrost); the CLI enforces this at arg-parse, this assert is defense-in-depth -- assert not (config is not None and bifrost is not None) -POST: [POST-001 side_effect] exactly one POST to /sessions; body carries "agent_id" always, "end_user_id"/"bifrost" per issues #5/#17, and "config": config iff config is not None. No "config" key when config is None (foundational baseline byte-identical to pre-#161). -POST: [POST-002 return_value] returns SessionInfo with session_id, agent_id, created_at, last_active, metadata, message_count populated per INV-001, PLUS kind = body.get("kind") and config = body.get("config"). -ERROR_ROUTING: - HTTP 404 unknown_agent_id: raise AgentNotFound(agent_id=agent_id); abort - HTTP 422 (ephemeral validation, issue #161): raise SessionApiFailed(status=422, body=resp.content). The body's error_code names the fault; recognized ephemeral codes: ephemeral_requires_config (config absent for an ephemeral template), foundational_does_not_accept_config (config sent to a foundational agent), system_prompt_required / system_prompt_empty / system_prompt_too_large (config.system_prompt missing / whitespace / >32768 bytes), model_not_allowed (config.model present — forbidden post-cutover), ephemeral_does_not_accept_bifrost. NOT mapped to per-code typed exceptions — the raw code in .body is honest + debuggable (mirrors the #5 end_user_id_required posture). abort. - HTTP 422 (other validation_failed) / other non-201: raise SessionApiFailed(status=resp.status_code, body=resp.content); abort. (bifrost 502 → BifrostHandshakeFailed per #17.) -STEPS: - 1. [setup] Validate PRE-001..PRE-005 - 2. [sequential] body = {"agent_id": agent_id}; IF end_user_id is not None: body["end_user_id"] = end_user_id; IF bifrost is not None: body["bifrost"] = {...} (per #17); IF config is not None: body["config"] = config - 3. [sequential] headers per #17 (bound create uses consumer_key); CALL client.post("/sessions", json=body, headers=headers) - 4. [branch] IF 404 → AgentNotFound; ELIF bifrost and 502 → BifrostHandshakeFailed (#17); ELIF != 201 → SessionApiFailed - 5. [sequential] body = resp.json() - 6. [cleanup] RETURN SessionInfo(... unchanged create-side fields ..., kind=body.get("kind"), config=body.get("config")) -TESTS: - happy_ephemeral_create [happy,tracer]: config={"system_prompt":"You are X."}, agent_id="echo" → outbound body == {"agent_id":"echo","config":{"system_prompt":"You are X."}} byte-for-byte; 201 {"kind":"ephemeral","config":{"system_prompt":"You are X.","role":"echo"},...} → SessionInfo.kind=="ephemeral" and .config=={"system_prompt":"You are X.","role":"echo"} - foundational_omits_config [trace]: config omitted, agent_id="mimir" → outbound body has NO "config" key (byte-identical to pre-#161 baseline); 201 without kind/config → SessionInfo.kind is None and .config is None - foundational_captures_kind [happy]: 201 {"kind":"foundational",...} for a normal agent → SessionInfo.kind=="foundational", .config is None - ephemeral_requires_config_422 [error]: agent_id="echo", config omitted → 422 {"error_code":"ephemeral_requires_config"} → SessionApiFailed(status=422); .body contains the code - model_not_allowed_422 [error]: config={"system_prompt":"x","model":"glm5-turbo"} → 422 {"error_code":"model_not_allowed"} → SessionApiFailed(status=422) (regression guard: the CLI never sends model, but the wrapper passes config through verbatim, so a caller that injects model gets the honest server rejection) - config_and_bifrost_conflict [adversarial]: config={...} AND bifrost=BifrostBinding(...) → AssertionError (PRE-005); no HTTP issued - config_not_a_mapping [adversarial]: config="not-a-dict" → AssertionError (PRE-004); no HTTP issued -``` - -### get_capabilities — corrected ephemeral-template metadata shape - -The 2026-06-30 amendment's `get_capabilities` BRIEF documented the pre-cutover -`{allowed_models, default_model}` shape. Canonical (per the althing grounding -above) is **`{allowed_roles, default_role, system_prompt_max_bytes}}`**. The -wrapper is unaffected (returns the parsed dict verbatim, no field access), but -its BRIEF is corrected for honesty, and the **`--whoami` renderer -(`ratatoskr.cli`) is fixed** to read `allowed_roles` / `default_role` (it -currently reads the dead `allowed_models` / `default_model` keys and renders -`default=? models=[]` against a live server). - -- get_capabilities BRIEF now reads: `GET /capabilities → {ephemeral_templates: - {echo: {allowed_roles, default_role, system_prompt_max_bytes}}}`. Behavior, - PRE, POST, ERROR_ROUTING, STEPS unchanged (verbatim dict passthrough). - -### CLI surface (ratatoskr.cli — consumer glue, TDD'd in test_cli) - -- New `--system-prompt ` flag → builds `config={"system_prompt": }` for - the `--new` create. `ParsedArgs.system_prompt: str | None = None`. -- Validation: `--system-prompt`, when passed, must be non-empty, requires `--new` - + `--agent`, and is **mutually exclusive with the bifrost flags** - (`--bifrost-url` / `--bifrost-plane`) — ephemeral sessions reject a binding. -- `_amain` passes `config` to `create_session`; the demoted create line surfaces - `kind=` when present. -- No `--role` / `--model` flag in this amendment: Echo's only `allowed_role` is - `"echo"` and omitted role defaults server-side, so a selector flag is premature - (add `--role` if/when a template advertises multiple roles). - -### Supersession + Heid panel triage (2026-07-18) - -- **Supersedes the "Bifrost binding out of scope" out-of-scope bullet** (the - base "create_session does not accept or send a `bifrost` field" line). That - bullet is stale: issue #17 made bifrost an accepted create parameter, and this - amendment's FN block reflects the current signature (`bifrost` / `consumer_key` - present, semantics owned by #17). Read the base out-of-scope bifrost line as - historical. -- **Error-body truncation (INV-004).** INV-004 [hard] specifies exception `.body` - truncated to `[:1024]`. The implemented module dropped that truncation - module-wide (every `SessionApiFailed` raise passes `resp.content`), so INV-004 - is stale against the code independent of this amendment. This amendment's - create_session error routing follows the module's actual practice - (`resp.content`) for consistency with its sibling endpoints; reconciling - INV-004 vs the code across the whole module is a separate cleanup, flagged not - fixed here. (Heid panel convergent finding, all three arms.) -- **CLI section is documentation, not module-acceptance.** This contract's - `target_module` is `ratatoskr.sessions`; the `--system-prompt` flag + - `--whoami` renderer changes live in `ratatoskr.cli` and are verified in - `test_cli`, not by this module contract's acceptance. They are documented here - only so the sessions-surface change and its single consumer read as one unit. -- **Deferred (pre-existing #2 coherence items, not this amendment's scope):** - frontmatter "two entry points" scope line is stale vs the ~15 amended FNs; - `item.get("metadata", {})` does not defend against an explicit-null `metadata` - (unlike the `or` idiom on `archived`/`tags`); and the panel's recurring - structural rec — a "current effective surface" map for this 7-amendment - contract. Surfaced to the operator as separate cleanup candidates. diff --git a/docs/contracts/worldtree_sdk_cutover.contract.md b/docs/contracts/worldtree_sdk_cutover.contract.md index b58ae31..83b2ec0 100644 --- a/docs/contracts/worldtree_sdk_cutover.contract.md +++ b/docs/contracts/worldtree_sdk_cutover.contract.md @@ -335,6 +335,43 @@ re-anchor its coverage-map rows. `except (…, MalformedSseId, MalformedSseData)` stays a harmless defensive superset (pre-existing, not introduced here). +### Slice-7 notes (Teardown — the LAST slice, decided at teardown) + +- **Module boundary: KEEP `sessions.py` + `sse_client.py` as pure type/exception + homes (operator decision A1, 2026-07-19).** Post-cutover both modules hold NO + client — only ratatoskr's caller-semantic exception surface + a couple of + dataclasses (`BifrostBinding`; `SseId`, `AdminEvent`) + the `endpoint_for_plane` + provider helper. Options weighed: (A1) keep as-is + fix docstrings; (A2) rename to + honest names (`session_errors`/`stream_errors`), re-point ~7 importers; (A3) + consolidate into one `errors.py` / fold into `wt.py`. **A1 chosen** — teardown is + deletion + dep-drop, not a rename refactor; A3 is blocked by the `AgentNotAvailable` + name collision (two distinct classes: persona-404 in `sessions` vs eager-turn-409 in + `sse_client`) which would force renaming a contract-level caller-semantic type + its + § Error-map rows + catch sites, and folding into `wt.py` mis-homes + `endpoint_for_plane` (provider-side). Naming-honesty (principle-2) addressed by the + one-line docstring note, not a rename. **Resolves the slice-6 open item** (line ~326): + the ratatoskr `AdminEvent`/`SseId` + exceptions stay in `sse_client.py`; the + session/tier3 exceptions + `BifrostBinding` stay in `sessions.py`. +- **`httpx-sse` dropped from `pyproject.toml` + lockfile.** Slice-6 deleted its last + user (`sse_client.stream_admin_events`); a tree grep confirmed nothing imports + `httpx_sse`. `uv sync` physically pruned it; suite green (494) with the module absent. +- **Wire contracts #2 (sessions) + #15 (tier3) retired (files DELETED, DEC-1 + phase-2).** Their normative authority transferred to this contract at authoring; + the code they specified is gone, so the files are removed now. **#1 (SSE event + vocabulary) is NOT retired** — it stays current (amended `4bd9abd` 2026-07-18) as + ratatoskr's SSE-event-rendering reference; **`first_message` is NOT retired** (DEC-1, + ratatoskr-owned usage contract). Accepted side-effect: `issues/5.contract.md`'s + historical "amended #2/#3/#4 in-place" line now points at a deleted #2 — left as-is + (frozen issue-record of a past action; not expanding DEC-1's #2/#15 scope). +- **Final coverage-map re-anchor.** `GET /sessions/{id}/tools` → `wt.py get_session_tools` + (SDK `sessions.tools`) → `web/server.py` (the old `sessions.py`→`tui.py` row was + stale; TUI deleted). `GET /sessions` `list_sessions` re-homed to `wt.py`, still + caller-less (picker was a TUI frontier, now moot). The `Last-Event-ID` SSE-resume + sub-gap is CLOSED — `reconnect_turn` deleted, resume folded into `wt.py stream_turn` + auto-resume. Surface-2 SSE parsing re-anchored to the SDK (`_envelope_for_type` gone). +- **Ships as v0.22.0 (minor, DEC-6, operator-approved 2026-07-19).** Publishes the + full 6-slice consumer-layer cutover milestone. + ## Out of scope - Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched. diff --git a/docs/coverage-map.md b/docs/coverage-map.md index dead070..7242ff2 100644 --- a/docs/coverage-map.md +++ b/docs/coverage-map.md @@ -96,7 +96,7 @@ sub-gap). | `DELETE /agents/{id}` | ✅ | `wt.py` `delete_agent` (SDK `agents.delete`) → `tier3.py` `_run_delete` | **wt-adapter re-anchored (slice-4, #20)** — 204→None; 404→Tier3AgentNotFound (route-discriminated, NOT hide-existence). **LIVE-SMOKE 2026-07-19**: `deleted ratatoskr:slice4-smoke` + local index → `[]` | | `GET /me` | ✅ | `wt.py` `get_me` (SDK `me.get`) → `cli.py` `--whoami` | **wt-adapter re-anchored (slice-5, #20)** — open-world identity dict verbatim; any error→SessionApiFailed default (401 on a bad/absent key), transport→ConnectFailed→exit 21. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): identity rendered (user_id ratatoskr, tier user, scopes incl. `character.*`, key_id c990f0be) | | `GET /capabilities` | ✅ | `wt.py` `get_capabilities` (SDK `capabilities.get`) → `cli.py` `--whoami` | **wt-adapter re-anchored (slice-5, #20)** — open-world advertisement verbatim; `_format_whoami` reads `allowed_roles`/`default_role` and degrades on a null/non-mapping template (slice-4 hardening); matches conversation-api-spec **v1.1** (`b4a278c`). **LIVE-SMOKE 2026-07-19**: `ephemeral_template echo: default=echo max_bytes=32768 roles=[echo]` | -| `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools` → `tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) | +| `GET /sessions/{id}/tools` | ✅ | `wt.py` `get_session_tools` (SDK `sessions.tools`) → `web/server.py` `_session_tools_endpoint` | **wt-adapter re-anchored (slice-7 teardown, #20)** — owner-scoped tool inventory (#183); consumer bearer (no admin scope), open-world dict verbatim, any error→SessionApiFailed default. (Consumer is `web/server.py`; the old `sessions.py`→`tui.py` row was stale — the TUI is deleted.) | | `GET /admin/sessions/{id}/bifrost` | ✅ | `wt.py` `get_session_bifrost` (SDK `admin.sessions.bifrost`) → `web/server.py` `_session_bifrost_endpoint` | **wt-adapter re-anchored (slice-6, #20)** — admin-scoped BifrostState (#176); admin_auth rides on the wt client (`_wt_client(admin_key=…)`), NOT a per-call header; open-world dict verbatim, any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on :8081 (readonly-admin key): admin-authed end-to-end (404 `session_not_bifrost_bound` clean envelope — auth + route + mapping proven). (Consumer is `web/server.py`, not `tui.py` — the old row was stale.) | | `GET /admin/events` (SSE) | ✅ | `wt.py` `stream_admin_events` (SDK `admin.stream_events`) → `web/server.py` `_admin_events_endpoint` | **wt-adapter re-anchored (slice-6, #20)** — admin lifecycle SSE (#11), session-filtered; admin_auth on the wt client; the adapter re-wraps the SDK's `AdminEvent`→ratatoskr's (nan `admin_id`→id 0, None type/data→`""`/`{}`), non-200 open `ApiError`→SseConnectFailed, `ConnectionDropped`→SseConnectionDropped. **LIVE-SMOKE 2026-07-19**: a real `session.created` event (id=32) re-wrapped cleanly on live wire. (Consumer is `web/server.py`, not `tui.py` — stale row corrected.) | | `GET /models/available-for-characters` | ✅ | `wt.py` `list_character_models` (SDK `models.available_for_characters`) → `cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — open-world catalog verbatim; the probe reads `items` null-safe (`or []`); any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19**: `character models: char-rp` | @@ -107,11 +107,13 @@ sub-gap). **Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method on the same path is an unwired frontier item — see frontier Tier 1): -- `GET /sessions` — `sessions.py:198` `list_sessions` exists, **no caller**: the - startup session-picker (design-brief §4 v1) was never wired. -- `POST /sessions/{id}/messages` + `Last-Event-ID` — `sse_client.py:524` - `reconnect_turn` exists, **no caller**: the reference SSE-resume impl - (design-brief §8d) was never wired. +- `GET /sessions` — `wt.py:234` `list_sessions` (SDK `sessions.list`) exists, + **no caller**: the startup session-picker (design-brief §4 v1) was a TUI feature + and the TUI is now deleted, so the frontier is moot unless a web picker is wired. +- `POST /sessions/{id}/messages` + `Last-Event-ID` (SSE-resume) — **CLOSED (slice-2 + teardown)**: the old hand-rolled `sse_client.reconnect_turn` is deleted; resume is + now folded into `wt.py:280` `stream_turn` (the SDK's resilient auto-resume), which + IS the wired presenter default. No longer an unwired sub-gap. - `GET /agents/{id}` — consumer-agent lookup (`GET /agents/:` with the owner key) is **manual-curl-only**, not in code. @@ -173,8 +175,9 @@ a turn flow through it / is it a layer worth watching live?* ## Surface 2 — SSE events (11/11 ✅) -Every frozen SSE event type is parsed in `sse_client.py:_envelope_for_type` -(342-411) and rendered by all three presenters (cli/tui/web). **Full coverage.** +Every frozen SSE event type is now parsed by **worldtree-sdk** (`sessions.stream_turn`, +yielding `TurnEvent`s — the hand-rolled `sse_client._envelope_for_type` is deleted) and +rendered by both presenters (cli/web; the TUI is deleted). **Full coverage.** `text` · `worker_phase` · `thinking` · `text_boundary` · `tool_start` · `tool_result` · `done` · `error` · `cancelled` · `awaiting_llm_first_token` · diff --git a/pyproject.toml b/pyproject.toml index 08b9f4d..1d261c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.21.20" +version = "0.22.0" description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" readme = "README.md" requires-python = ">=3.12" @@ -12,12 +12,11 @@ license = { file = "LICENSE" } authors = [{ name = "Vuong Hoang" }] keywords = ["worldtree", "debug", "sse", "web", "observability"] -# Network + SSE consumer. -# See docs/design-brief.md §3 (httpx-sse). +# Network transport for the injected AsyncClient (INV-CUT-1); SSE parsing is +# owned by worldtree-sdk post-cutover (#20 slice-7 dropped httpx-sse). dependencies = [ "httpx>=0.27", - "httpx-sse>=0.4", # #20 slice-7 teardown drops this once the SDK owns SSE parsing - "worldtree-sdk==1.0.0", # #20 cutover: the consumer client layer (gitea PyPI); slices retire the hand-rolled wrappers behind ratatoskr.wt + "worldtree-sdk==1.0.0", # #20 cutover: the consumer client layer (gitea PyPI); the hand-rolled wrappers now live behind ratatoskr.wt ] [project.optional-dependencies] diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index b99b865..2ea4859 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -632,8 +632,9 @@ async def _amain(args: ParsedArgs) -> int: # ratatoskr owns the transport (INV-CUT-1): the SDK is injected with it and # never closes it. The transport carries base_url / User-Agent / timeout AND the # default bearer — the SDK overrides Authorization per request (so a bound create - # still uses its consumer_key), while the not-yet-migrated hand-rolled - # `seed_preset_first_message` reuses the transport's default bearer directly. + # still uses its consumer_key), while the best-effort first-message seed + # (`seed_preset_first_message` → `wt.write_authored_history`) rides the + # transport's default bearer. async with httpx.AsyncClient( base_url=args.server_url, headers={ diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index ad1c51b..578c9ef 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -1,6 +1,12 @@ -"""Worldtree Conversation API session-lifecycle client. +"""Worldtree Conversation API caller-semantic types + exceptions. -Implements docs/contracts/issues/2.contract.md. +Post-#20 cutover this module holds NO client: the worldtree-sdk adapter +(`ratatoskr.wt`) owns the session/agents wire. What remains is ratatoskr's +caller-semantic exception surface (raised by `wt`, caught by the CLI/web +presenters), the `BifrostBinding` dataclass, and the `endpoint_for_plane` +provider helper. The former issue #2 wire contract is retired (cutover DEC-1); +the Tier-3 exceptions are homed here (not in `tier3`) for the class-identity +reason noted below. """ from __future__ import annotations diff --git a/src/ratatoskr/sse_client.py b/src/ratatoskr/sse_client.py index 4aae17e..74060a3 100644 --- a/src/ratatoskr/sse_client.py +++ b/src/ratatoskr/sse_client.py @@ -1,6 +1,11 @@ -"""SSE consumer for the Worldtree Conversation API. +"""Worldtree Conversation API SSE / turn-stream caller-semantic types + exceptions. -Implements docs/contracts/issues/1.contract.md. +Post-#20 cutover the worldtree-sdk adapter (`ratatoskr.wt`) owns the SSE byte +parsing; this module holds NO consumer. What remains is the turn-stream +caller-semantic type surface (`SseId`, `AdminEvent`) + the exception classes `wt` +maps the SDK's stream/cancel/resume errors onto. Issue #1 (the SSE event +vocabulary) stays current and is NOT retired — see +docs/contracts/issues/1.contract.md. """ from __future__ import annotations diff --git a/tests/test_sessions.py b/tests/test_sessions.py index b730f37..e661652 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1,4 +1,4 @@ -"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md. +"""Tests for ratatoskr.sessions (caller-semantic types + `endpoint_for_plane`). Post worldtree-sdk cutover the `sessions` module is down to `endpoint_for_plane` (the Bifrost provider-plane helper) + the caller-semantic exception classes the diff --git a/uv.lock b/uv.lock index 0266d8b..6b0b0f5 100644 --- a/uv.lock +++ b/uv.lock @@ -183,15 +183,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, -] - [[package]] name = "idna" version = "3.15" @@ -472,11 +463,10 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.21.20" +version = "0.22.0" source = { editable = "." } dependencies = [ { name = "httpx" }, - { name = "httpx-sse" }, { name = "worldtree-sdk" }, ] @@ -507,7 +497,6 @@ web = [ requires-dist = [ { name = "bifrost", marker = "extra == 'provider'", specifier = "==1.1.4", 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" },