|
|
|
@@ -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.<base64url>` 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": <arg>} 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=<limit>` always; `include_archived=true` iff caller passed include_archived=True; `cursor=<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=<the cursor passed in>)
|
|
|
|
|
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 <admin_key> (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 <admin_key>" (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": <arg>, "state": <state|null>}
|
|
|
|
|
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":<c>,"idempotency_key":<k>} 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=<arg>), 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": <persistent-agent>}`). 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": <str>, "role": <str>}`.
|
|
|
|
|
|
|
|
|
|
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": <str>} 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 <str>` flag → builds `config={"system_prompt": <str>}` 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=<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.
|