Compare commits

...

17 Commits

Author SHA1 Message Date
vh ec68b1f3a5 feat(#20): worldtree-sdk cutover teardown (slice-7) + v0.22.0
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.
2026-07-19 13:41:34 -07:00
vh 8274ed2d89 memory: /snapshot — worldtree-sdk cutover slice-6 complete (de9a5ba→11ae2f0)
Slice-6 (admin: bifrost inspection + admin-events SSE) done through the full House
Code Discipline, v0.21.19–.20, suite 494 green, live-proven (real session.created
event re-wrapped end-to-end on :8081), both heid gates cleared. Current state advanced
to slice-7 (teardown, LAST) next; Recent-decisions index entry + detail file added;
substrate at v0.21.20. Consumer client layer now fully cut over (6/7 slices).

persistent-memory.md stays ~345 lines (over the ~300 soft cap): dominated by the
non-archivable Current state block + <30-day July entries (guarded), so archival can't
reach the 250 target — left as-is per the stop-where-the-guards-stop rule.
2026-07-19 13:16:58 -07:00
vh 11ae2f056e fix(#20): heid-bug-hunt fixups — admin-stream + bifrost hardening (slice-6)
Cold spec-free panel (Gróa + Hulda + Regin, source-verified by Heid): the adapter's
core re-wrap is sound, but 4 real hardening gaps the conformance CR couldn't see —
all in failure-path normalization + open-world degrade, judged against the general
ConnectFailed floor + the degrade-never-crash promise. All fixed:

- [bug, 3/3] `stream_admin_events` never mapped `ConnectFailed` — the SDK admin-stream
  open raises it on a connect-time / auth-resolution failure (the general transport
  floor; confirmed in the SDK source), and `stream_turn` + the bifrost GET both catch
  it, and this endpoint's OWN comment claimed it did. An unmapped ConnectFailed escaped
  the web gen's `except (Sse*)` and aborted the SSE with no `stream_error`. Now mapped
  → `SseConnectFailed`, mirroring stream_turn.
- [bug, 2/3] non-str `type` crashed the web filter — the re-wrap used `ev.type or ""`
  (falsy-only), so a truthy non-str `type` (123, a list) reached `.startswith` →
  AttributeError. Now `ev.type if isinstance(ev.type, str) else ""` (matches the
  admin_id/data isinstance guards — same container-type class as slice-5).
- [robustness] `_session_bifrost_endpoint` did `dict(bstate)` on the open-world 200
  body — a non-mapping (list/scalar) → TypeError/500. Now degrades to `{}` (I introduced
  this in slice-6 by changing `JSONResponse(bstate)` → `dict(bstate)`).
- [robustness] `_admin_events_endpoint.gen` allocated the transport + built `_wt_client`
  BEFORE the try/finally — a construction failure would leak the httpx transport. Moved
  `_wt_client` inside the try so the finally always closes it.

Voided (Heid): Regin's `dict(ev.data)` TypeError — the `isinstance(_, Mapping)` guard
already routes non-mappings to `{}` before `dict()`.

Added adapter tests (ConnectFailed→SseConnectFailed; non-str type→"") + a web test
(non-mapping bifrost body → 200 {}). Suite 494 green; my code ruff-clean (13 E501/F841
in test_web_server.py are PRE-EXISTING, HEAD-identical, untouched); mypy clean on wt.py.
Live smoke re-run clean (real session.created event re-wrapped; bifrost 404 envelope).
Patch bump 0.21.19 → 0.21.20.
2026-07-19 13:13:44 -07:00
vh bba57e1b39 fix(#20): heid-code-review fixups — stale docstring + None-cursor test (slice-6)
Panel (Gróa + Hulda + Regin): 3/3 no drift — the admin adapter honors the contract
(route map, re-wrap/degrade, error-map ORDER, admin_auth-on-client, INV-CUT-1).
Only minor doc/test looseness, both fixed:

- Stale docstring: `_session_bifrost_endpoint` still said "the wrapper overrides the
  Authorization header with it" — corrected to "rides on the wt client's admin_auth"
  (slice-6 moved admin auth off the per-call header; line 79 already said the new way).
- Test-gap: the admin-stream ConnectionDropped test only exercised the cursor-set case;
  added the connect-time None-cursor case (ConnectionDropped(None) → last_seen_sse_id
  None) to back the map's "both cursor shapes" claim.

Not acted on: `admin_key`→`admin_auth` unit assertion (the SDK's use of admin_auth is
SDK-internal/private — out of scope per "assess use, not definitions"; the LIVE SMOKE
already proved the wiring end-to-end). Hulda's "web endpoints under-tested" flag was
source-VOIDED by Heid: those endpoints ARE covered in test_web_server.py, which wasn't
in the consult embed (excerpt-elides-tests trap).

Suite 491 green; ruff clean. Docs + test only — no version bump (SemVer skip rule).
2026-07-19 12:58:10 -07:00
vh de9a5baf45 feat(#20): admin (bifrost inspection + admin-events stream) onto the wt adapter (slice-6)
Slice-6 of the worldtree-sdk cutover: migrate the two admin routes off the
hand-rolled paths onto the `ratatoskr.wt` adapter over `client.admin.*`, and delete
the retired code. Both are web-only (the coverage-map's `tui.py` rows were stale —
corrected to `web/server.py`).

Adapter (`wt.py`): `get_session_bifrost` → `client.admin.sessions.bifrost` (open-world
dict verbatim, any error → SessionApiFailed default); `stream_admin_events` →
`client.admin.stream_events`, re-wrapping the SDK's `AdminEvent` → ratatoskr's at the
boundary.

Decisions (contract § slice-6 notes):
- Admin auth moves from a per-call `Authorization` header override to the client's
  `admin_auth` (`_wt_client(admin_key=…)`, extended this slice) — the SDK's admin.*
  routes use the provider, not a header.
- `AdminEvent` re-wrap (chosen over yield-through): the SDK's `admin_id`(nan)/None-able
  `type`/`data` diverge from ratatoskr's `id`/`type`/`data` that the web filter reads;
  re-wrapping (nan→0, None→""/{}) degrades the open-world None/nan ONCE at the adapter
  and keeps the web endpoint + `_admin_event_matches_web` + the `AdminEvent` domain type
  unchanged (preserves the web surface). Rejected: yield SDK events + rewire the web
  filter (heavier churn, scattered hardening).
- Admin-stream error map: a NON-200 open raises `ApiError("admin_stream_failed")`
  (NOT `ConnectFailed`) → SseConnectFailed; `ConnectionDropped` (connect-time OR
  mid-stream/resumable-EOF) → SseConnectionDropped. The web integration test caught the
  ApiError-not-ConnectFailed gotcha the unit fake couldn't.

Web (`web/server.py`): both admin endpoints build the wt client with admin_key and call
`wt.*`; the bifrost endpoint gains ConnectFailed→502 handling (cutover foot-gun); the
admin-events endpoint closes the injected transport (INV-CUT-1), never the wt client.

Deleted the hand-rolled `sessions.get_session_bifrost` + `sse_client.stream_admin_events`
(+ orphaned httpx/httpx_sse/json/AsyncIterator imports); the ratatoskr `AdminEvent`
dataclass stays in `sse_client.py` (re-wrap target, imported by wt + web) until slice-7.
Retired `test_sse_client.py` entirely (its last test was the admin stream) and the
`test_sessions.py` `TestGetSessionBifrost`; added the slice-6 adapter tests.

LIVE SMOKE (:8081, readonly-admin key) — INV-CUT-5 / DEC-4 cleared: the web bifrost
endpoint returned an admin-authed clean 404 envelope (auth + route + mapping proven);
a real `session.created` admin event (id=32) re-wrapped cleanly on live wire (driven by
a session-create, throwaway session cleaned up).

Suite 490 green; ruff clean; mypy net-improved on web/server.py (16→12 pre-existing, no
new). Patch bump 0.21.18 → 0.21.19 (the cutover MINOR is deferred to slice-7, DEC-6).
2026-07-19 12:48:04 -07:00
vh 5bc39a092e memory: /snapshot — worldtree-sdk cutover slice-5 complete (deab762→4e20030)
Slice-5 (characters + me/capabilities/models) done through the full House Code
Discipline, tags v0.21.16–.18, suite 488 green, live-smoke-proven on :8081/b128,
both heid gates cleared. Current state / in-flight advanced to slice-6 (admin) next;
Recent-decisions index entry + detail file added; substrate at v0.21.18.

persistent-memory.md stays ~333 lines (over the ~300 soft cap): the length is
dominated by the non-archivable Current state / in-flight block plus <30-day July
entries (guarded), so archival can't reach the 250 target — left as-is per the
stop-where-the-guards-stop rule.
2026-07-19 11:34:10 -07:00
vh 4e20030229 fix(#20): heid-bug-hunt fixups — CLI open-world container-type hardening (slice-5)
Panel (Gróa + Hulda + Regin, source-verified by Heid): adapter/route-map/
ConnectFailed-at-call-sites sound against the declared invariants; 4 real
robustness findings, all in the CLI open-world presenter/probe paths — the
container-type layer BELOW the null/element holes the code-review already fixed.

Fixed (findings 1-3):
- `_format_whoami` (`cli.py`): a non-iterable `scopes`/`allowed_roles` scalar
  (`{"scopes": 123}`) made `x or []` yield `123` → `for s in 123` TypeError. New
  `_display_seq` helper degrades any non-list (scalar / bare string / null / absent)
  to empty; applied to both `scopes` and `allowed_roles`.
- `_characters_probe` (`cli.py`): same class on the model catalog `items` (`{"items":
  123}`) — now guards `models` is a Mapping and `items` is a list before iterating.
- `_characters_probe`: the top-level open-world reads `created` / `state` are now
  `isinstance(_, Mapping)`-guarded before any `.get` — a non-mapping SDK passthrough
  (`created=[...]`) aborts cleanly (exit 20) / renders `pad=None` instead of an
  AttributeError.

Accepted (finding 4, documented in contract § slice-5 notes): the `--characters`
probe leaks its transient character on a mid-lifecycle failure. PRE-EXISTING (the
retired probe had the identical linear no-`finally` structure — cutover did not
worsen it), TTL-bounded, one-shot diagnostic; a `try/finally` would swallow a
happy-path delete-failure (delete is both teardown and a tested step). Gróa + Heid
concur accept is defensible.

Dismissed (finding 5): Hulda flagged `sessions.py` dropping `get_me`/etc. as a
caller-contract break — it is the intended DEC-3 no-backwards-compat migration (all
in-repo callers rewired same-diff); Heid labels it intended-surface-change.

Added CLI tests for the three hardened paths (scalar scopes/roles; scalar items +
non-mapping state; non-mapping create abort). Suite 488 green; ruff clean; live
smoke re-run clean (identical happy-path output). Patch bump 0.21.17 → 0.21.18.
2026-07-19 11:29:10 -07:00
vh d86d6df147 fix(#20): heid-code-review fixups — CLI presenter degrade-not-crash (slice-5)
Panel: Gróa + Regin returned zero (adapter/route-map/error-map faithful);
Hulda flagged two source-confirmed open-world-presenter crash holes — the same
class the slice-4 bug-hunt found in the agents presenters. Both fixed:

- `_format_whoami` scopes (`cli.py`): `', '.join(me.get('scopes', []))` crashes on
  a present-null `scopes` (`.get(k, [])` returns None, not the default) or a
  non-string element. Now `', '.join(str(s) for s in (me.get('scopes') or []))` —
  matching the `allowed_roles` hardening on the same function. The contract names
  `_format_whoami` as the degrade-not-crash exemplar (contract:144-146); the cited
  exemplar had an un-hardened line.
- `_characters_probe` model items (`cli.py`): the slice-5 `or []` guarded the
  list-level null but not each entry — `[None]` / `["x"]` / `[{"name":123}]` would
  raise. Now guards each item is a dict and str-coerces `name` (element-level
  completion of the list-level guard).

Hulda #3 (live-smoke not in the reviewed file set) → accept: the smoke WAS run and
is recorded in deab762 + coverage-map (artifact-only review couldn't see it).

Added CLI tests for both hardened paths (present-null/non-string scopes; malformed
model items). Suite 485 green; ruff clean; live smoke re-run clean (identical
happy-path output). Patch bump 0.21.16 → 0.21.17.
2026-07-19 11:11:12 -07:00
vh deab7627eb feat(#20): characters + me/capabilities/models onto the wt adapter (slice-5)
Slice-5 of the worldtree-sdk cutover: migrate the remaining consumer READS +
transient-character CRUD off the hand-rolled httpx wrappers onto the
`ratatoskr.wt` adapter over the SDK, and delete the retired path.

Adapter (`wt.py`): add `get_me` / `get_capabilities` / `list_character_models`
/ `create_character` / `get_character_state` / `delete_character` over
`client.me` / `client.capabilities` / `client.models` / `client.characters.*`.
All six are open-world reads/acks returned verbatim; none carries a
discriminated SDK error, so each maps any `ApiError` → the `SessionApiFailed`
default (INV-CUT-2) — exact parity with the retired path. No new Error-map rows.

Decisions (contract § slice-5 notes): `create_character` omits `state` when None
(SDK-idiomatic inline literal, server-equivalent to the retired explicit null);
`delete_character` returns the SDK's open ACK verbatim (`-> Mapping|None`, not
normalized to None).

CLI rewire (`cli.py`): `--whoami` (me + capabilities) and `--characters`
(models → create → state → delete) build a `wt.build_client` over the injected
probe transport and catch `wt.SessionApiFailed` + `ConnectFailed`. Open-world
degrade-not-crash carried (cumulative cutover foot-gun): `_characters_probe`
reads `items` null-safe and extracts `character_id` defensively (clean abort,
no hard-index KeyError); `_format_whoami` widened to `Mapping`.

Deleted the six hand-rolled `sessions.py` wrappers (net -5 mypy no-any-return);
`endpoint_for_plane` + `get_session_bifrost` (slice-6) + the exception classes
stay. Retired the corresponding `test_sessions.py` classes; added the slice-5
adapter tests + a CLI malformed-create-abort test.

LIVE SMOKE (:8081, b128) — INV-CUT-5 / DEC-4 cleared: `--whoami` rendered real
identity + capabilities; `--characters` drove the full lifecycle end-to-end
(char-rp catalog → created char_8c00006e… → PAD read-back → deleted).

Suite 483 green; ruff clean; mypy at the 2 pre-existing baseline errors.
Patch bump 0.21.15 → 0.21.16 (the cutover MINOR is deferred to slice-7, DEC-6).
2026-07-19 11:00:41 -07:00
vh 4f74645a00 memory: /snapshot — worldtree-sdk cutover slice-4 complete (c62b4ee→477d98f) 2026-07-19 10:24:35 -07:00
vh 477d98f52e fix(#20): heid-bug-hunt fixups — open-world presenter degrade-not-crash (slice-4)
Panel (Gróa+Hulda+Regin, 5/5/5, no false positives) confirmed two 3/3 crash
sites where open-world dict reads violate the declared "degrade, never crash the
presenter" invariant — the wt adapter tests + the live smoke used full server
dicts, so partial/drifted wire responses were never exercised:

- FIX (tier3.py _run_define/_run_patch): the CLI hard-indexed the open-world
  define/patch dicts (`info["agent_id"]` / `["role"]` / `["agent_name"]`), so a
  partial 2xx → KeyError escaping main()'s exit matrix as a raw traceback (exit 1);
  and `make_description(info.get("system_prompt", ""))` fed None to .splitlines()
  on a present-but-null field → AttributeError. Now reads via `_str_field` (absent/
  null/non-str → default), degrades role to '?', indexes only a well-formed identity,
  and maps a no-usable-agent_id 2xx to [api_failed] exit 20 (controlled, not a crash).
- FIX (web/server.py _agents_endpoint): the upstream dedup hard-indexed each item
  (`{a["agent_id"] for a in upstream}` + `_as_dict`), so a malformed item (`[{}]`,
  `["str"]`, `{"name":…}`, non-str agent_id) or a non-list envelope → 500 before the
  local fallback merged. Now filters to well-formed mappings first; a non-list
  upstream degrades to the local-only list.
- FIX (wt.py _error_field_from_body): type-check the parsed `field` is a str (the
  exception surface is `field: str | None`, the CLI prints it) — restores the retired
  hand-rolled `_extract_error_field` isinstance guard.

Held (triaged, no change): the 429→Tier3QuotaExceeded / bare-404→Tier3AgentNotFound
maps are ungated-by-error_code BY CONTRACT DESIGN (§ Error map route+status rows; the
SDK's ApiError floor drops Retry-After, so retry_after=0 is canonical) — the arms
flagged them spec-free; Heid's source-check confirmed intended. Dual-keying define's
429 for full row consistency is an available tightening (contract amendment), surfaced
not applied. The persona-endpoint SessionApiFailed gap the arms also caught was
already closed in the prior code-review fixup (aed9429).

Suite 475 green (+5).
2026-07-19 10:18:36 -07:00
vh aed942972f fix(#20): heid-code-review fixups — persona-endpoint SessionApiFailed parity (slice-4)
Panel (Gróa+Hulda+Regin) returned zero adapter / error-map / model→role drift;
three actionable items triaged as genuine adds:

- FIX: `_persona_state_endpoint` now catches `wt.SessionApiFailed` and returns the
  `session_api_failed` envelope with the upstream status, for parity with
  `_agents_endpoint` / session-create / admin (2/3 arms flagged it; it was the lone
  sibling letting an unmatched upstream ApiError escape as a raw 500). Confirmed
  NOT a slice-4 regression — the pre-cutover persona endpoint had the same latent
  gap — but closed here since the endpoint's error surface is already being hardened
  (it gained the ConnectFailed catch this slice).
- TESTS: dual-key NEGATIVE rows — a wrong error_code at the same status defaults to
  SessionApiFailed for `define_agent` (403, 422) and `patch_agent` (422); plus the
  flat-`field` body-parse shape for `_error_field_from_body` (only the nested
  detail.field form was exercised). Closes the assertion-symmetry gap with the
  persona route's existing negative test.
- AMEND: contract slice-4 notes document the intentional client-side `":" in
  agent_id` PRE on patch/delete (a Tier-3 id is always <user>:<name>, ADR-0019).

Suite 470 green (+5).
2026-07-19 10:13:11 -07:00
vh c62b4eecb3 feat(#20): agents/tier3 family onto the wt adapter + model→role fold (slice-4)
Cut ratatoskr's consumer agent-lifecycle routes over to worldtree-sdk
(issue #20 slice-4). Five routes now flow through `ratatoskr.wt` over the
SDK's `client.agents.*`, returning open-world dicts and mapping the SDK's
undiscriminated `ApiError` floor by route+(status,error_code) per INV-CUT-2:

- `list_agents`      → `agents.list`
- `get_persona_state`→ `agents.persona_state` (404 persona_not_configured /
                        404 agent_not_available / 403 auth_scope_denied)
- `define_agent`     → `agents.define` (429→Tier3QuotaExceeded(retry_after=0),
                        403→Tier3UserIdUnsupported, 422 layer_deferred→…)
- `patch_agent`      → `agents.patch`  (404→Tier3AgentNotFound, 422 field_not_mutable)
- `delete_agent`     → `agents.delete` (404→Tier3AgentNotFound; NOT hide-existence)

Rewired call-sites: the `python -m ratatoskr.tier3` CLI (define/patch/delete)
and the web `_agents_endpoint` / `_persona_state_endpoint`, both catching the
SDK's `ConnectFailed` transport-failure normalization. Deleted the hand-rolled
paths: `sessions.list_agents` / `get_persona_state` / `AgentInfo`, and
`tier3.define/patch/delete_agent` / `Tier3AgentInfo` / parse+extract helpers.

model→role fold (scope B): the define/patch response echoes `role` (spec 1.2 /
b128), read off the open-world dict; `LocalAgentEntry.model`→`.role`,
local-index schema v1→2 (old index discarded, no-backwards-compat).

The Tier-3 caller-semantic exceptions move to `sessions.py`: running the CLI
as `__main__` while `wt` imports `ratatoskr.tier3` bound two copies of each
exception class, so a raised `Tier3AgentNotFound` escaped the CLI's `except`
as an uncaught traceback. Homing them in `sessions` (never `__main__`) makes
the class identity single. The live smoke — not the unit tests, which call
`main()` in-process — caught this.

Error-map rows + slice-4 notes added to the cutover contract; coverage-map
re-anchored. LIVE-SMOKE on personal :8081 (b128): define(thoughtful-character)
→ patch → list(6 agents) → persona_state(→PersonaNotConfigured mapped) →
delete → index empty; non-existent-id patch via `-m` → [agent_not_found]
exit 20. Suite 465 green.
2026-07-19 09:58:01 -07:00
vh 4f92a21cb9 memory: /snapshot — worldtree-sdk cutover slice-3 complete (ca9a339+fc256bb) 2026-07-19 09:20:18 -07:00
vh fc256bbaa4 fix(#20): heid-bug-hunt fixups — probe ConnectFailed + adapter finite-PAD (slice-3)
The slice-3 heid-bug-hunt panel (3/3) caught a real regression the cutover
introduced, plus a chokepoint-invariant gap:

- ConnectFailed escaped both rewired CLI probes. When --set-persona-pad and
  --seed-first-message moved off raw httpx onto the wt adapter, transport failures
  changed class: the SDK normalizes any pre-response transport error to
  worldtree_sdk.ConnectFailed (request.py), a WorldtreeError (not ApiError), so it
  passed the adapter unmapped AND the probes' httpx-only except tuples → an uncaught
  traceback instead of the graceful [network_error] exit 21. _amain (slice-2) already
  handled it; the probes lagged. Fix: add ConnectFailed to both probe except tuples
  (mirrors _amain). Live-verified at a refused host → [network_error] exit 21.

- Finite-PAD enforced only at the CLI, not the adapter chokepoint. wt.set_persona_state
  delegated finiteness to the caller (documented), so a direct/non-CLI caller passing
  nan/inf got a raw SDK ConfigurationError. Fix: assert finiteness in the adapter
  precondition (consistent with its other precondition asserts) so the invariant holds
  at the chokepoint in ratatoskr's own terms; the CLI pre-check stays for the friendly
  usage error.

Triaged-and-declined (all correct per the panel + Heid's source-check): the deleted
sessions.py exports (intended no-shim cutover, zero un-migrated importers), the
session["session_id"] index (accept-known-risk, matches --new), and Regin's "web
indefinite block" (refuted — the seed is asyncio.wait_for-bounded). The concurrent
heid-code-review panel returned zero drift, no code change.

TDD: 3 RED tests (both probes' ConnectFailed → exit 21; adapter nan/inf/-inf →
AssertionError, never reaches the SDK) → GREEN. Suite 469; ruff clean; mypy no new errors.
2026-07-19 09:13:47 -07:00
vh ca9a339050 feat(#20): persona + authored-history + first-message onto the wt adapter (slice-3)
Slice-3 of the worldtree-sdk cutover: migrate the session persona-state write,
the #347 authored-history write, and get_session_messages onto ratatoskr.wt, and
route the first-message preset seed through the adapter. Retire the last
hand-rolled sessions.py paths the --seed-first-message probe kept alive
(create_session + SessionInfo, set_persona_state, write_authored_history,
get_session_messages, _bifrost_error_from).

- wt.set_persona_state (SDK PadState) — the CLI passes three finite PAD axes; the
  SDK owns the {"pad": {...}} wire (#317). No route-specific error row → the
  SessionApiFailed default.
- wt.write_authored_history (SDK write_history) — v1 author=assistant; 404 →
  AuthoredHistoryUnavailable (hide-existence; the route is the discriminator,
  never the body); every other ApiError → the default. Drops the unused
  author/effects/claimed_original_at params (no caller uses them).
- first_message.seed_preset_first_message now takes a WorldtreeClient and routes
  through wt.write_authored_history; the best-effort invariants (INV-001..004,
  never-raise/never-block/one-write/zero-worldtree-source-import) are unchanged.
  Tests drive a fake WorldtreeClient — the wire is the SDK's to prove.
- CLI --set-persona-pad / --seed-first-message + the _amain and web create-path
  first-message seeds rewired onto the adapter. --set-persona-pad pre-validates
  PAD finiteness (clean usage_error, never a crash on the SDK ConfigurationError).

LIVE-SMOKE on personal :8081 (b128, INV-CUT-5): --seed-first-message → 201
(seq=0, phase=seeded) → read-back verbatim; --set-persona-pad → 204; the --new
create-path preset seed observed routing through the adapter. All slice-3 route
families proven end-to-end through the ratatoskr surface.

docs/coverage-map.md + first_message.contract.md re-anchored onto the adapter;
the slice-2 create/stream/cancel rows re-anchored too (they still named the
deleted sse_client/sessions symbols).

Suite 466 green; mypy no new errors (baseline 22 → 20 in the touched modules);
ruff clean. INV-CUT-1..5 held. Bifrost provider planes untouched.
2026-07-19 08:53:45 -07:00
vh 0b23334c68 memory: /snapshot — worldtree-sdk cutover slice-1+2 complete + pushed (aba1730)
Slice-2 (sessions/turn) done end-to-end through the House Code Discipline; both heid
gates triaged+fixed. Captures the KEY ADAPTER FACTS foot-guns for slices 3-7 (open-world
dicts, body-derived turn_id, ConnectFailed(0) transport normalization, consumer_key
bound-only, nested-detail error_code parsing) + the two-lens gate value proof. Slice-3
(persona/authored-history) next.
2026-07-19 08:23:23 -07:00
31 changed files with 2845 additions and 3671 deletions
+8
View File
@@ -142,6 +142,14 @@ _Archived 2026-07-18._
_Archived 2026-07-18._
`[2026-06-17]` **#296 triage sent to worldtree-dev** (`01KVBBH0…`): extraction SUBJECT-INVERSION (promotes assistant prose, drops the user's fact) + META-DESCRIPTION-not-content; verbose-persona aggravator. WAD-vs-bug resolved to BUG (extraction quality), not idle-gating.
_Archived 2026-07-19._
`[2026-06-18]` **Tier-3 memory PROVEN end-to-end live**`ratatoskr:terse-probe` recalled a seeded user fact in a COLD history-free session (scope_any → 1 hit @ cosine 0.6994). Closes the opening "how far from Tier-3 memory" question for normal agents.
_Archived 2026-07-19._
## Tried and abandoned (archived)
The 2026-05-20 → 2026-05-28 cluster: original-build-era foot-guns. Archived 2026-06-18.
+21 -20
View File
@@ -13,11 +13,12 @@ scope: >
replacement for a system-prompt "startup" instruction. Two entry points:
`preset_for` (lookup) and `seed_preset_first_message` (best-effort seed).
Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server
(the POST /api/sessions endpoint). Depends on ratatoskr.sessions
(write_authored_history + its exceptions); no core.* / worldtree.* imports.
(the POST /api/sessions endpoint). Depends on ratatoskr.wt
(`wt.write_authored_history` + AuthoredHistoryUnavailable) over a WorldtreeClient
(worldtree-sdk cutover slice-3, #20); no core.* / worldtree.* SOURCE imports.
depends_on:
- "httpx"
- "ratatoskr.sessions"
- "worldtree_sdk"
- "ratatoskr.wt"
used_by:
- "ratatoskr.cli"
- "ratatoskr.web.server"
@@ -26,7 +27,7 @@ complexity: "low"
estimated_loc: 60
confidence: 0.9
assumptions:
- "write_authored_history (contract #2 amendment 2026-07-06) is the seed primitive: 200/201 → ack dict, 404 → AuthoredHistoryUnavailable (hide-existence), other non-2xx → SessionApiFailed."
- "wt.write_authored_history (the SDK-adapter seed primitive, #20) writes over the worldtree-sdk client: success → ack mapping, 404 → AuthoredHistoryUnavailable (hide-existence), other ApiError → wt.SessionApiFailed. Behavior/semantics unchanged from the retired hand-rolled path — only the transport moved to the SDK."
- "The preset registry is a static in-module dict keyed by agent_id; editing it is how an operator tunes an agent's opening. Seeded with ratatoskr:sindra only."
- "Auto-seed is BEST-EFFORT and MUST NOT block session creation: an instance without the session.history.write grant returns the hide-404, which is swallowed (session opens with no seeded greeting)."
---
@@ -47,8 +48,8 @@ every new session for a preset agent opens in-character regardless of surface.
## Data flow
**In:** a live `httpx.AsyncClient` (caller-owned, base_url + bearer set), a fresh
`session_id`, and the bound `agent_id`.
**In:** a `WorldtreeClient` (the wt-adapter client, built over ratatoskr's
caller-owned transport), a fresh `session_id`, and the bound `agent_id`.
**Out:** on a preset agent, one `POST /sessions/{session_id}/history` (author=assistant,
the preset text, per-content idempotency key). Returns the seeded content on
@@ -104,19 +105,19 @@ TESTS:
preset_miss [happy]: preset_for("mimir") is None
empty_agent_id [adversarial]: preset_for("") → AssertionError
FN seed_preset_first_message(client: httpx.AsyncClient, session_id: str, agent_id: str) -> str | None
BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, POST it via write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into three create paths.
FN seed_preset_first_message(client: WorldtreeClient, session_id: str, agent_id: str) -> str | None
BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, write it via wt.write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into the CLI + web create paths.
PRE: [PRE-001 hard] client is not None -- soft-guarded: return None (NOT assert) if violated, so a wiring bug can't crash the create path (INV-001)
PRE: [PRE-002 hard] session_id is a non-empty str -- soft-guarded: return None if violated
PRE: [PRE-003 hard] agent_id is a non-empty str -- soft-guarded: return None if violated (also guards FIRST_MESSAGE_PRESETS.get against a non-hashable/non-str id)
POST: [POST-001 return_value] preset agent + successful write → returns the preset text; no-preset, malformed input, OR any swallowed failure → None
POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO HTTP; a preset agent issues exactly one POST /sessions/{session_id}/history with body author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block
POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO writes; a preset agent issues exactly one authored-history write (POST /sessions/{session_id}/history via the SDK) with entry author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block
ERROR_ROUTING:
asyncio.CancelledError:
local_handling: RE-RAISE (cancellation is not a seed failure; never swallow it — and it is a BaseException, so `except Exception` would miss it anyway)
flow_control: propagate
state_recovery: n/a
any other Exception (hide-404 AuthoredHistoryUnavailable, SessionApiFailed 409/422/etc., httpx.HTTPError, TimeoutError from wait_for, any unexpected error):
any other Exception (hide-404 AuthoredHistoryUnavailable, wt.SessionApiFailed 409/422/etc., SDK ConnectFailed, TimeoutError from wait_for, any unexpected error):
local_handling: swallow; return None
flow_control: continue (never blocks session create)
state_recovery: session opens with no seeded greeting
@@ -125,18 +126,18 @@ STEPS:
2. [sequential, prescriptive] content = FIRST_MESSAGE_PRESETS.get(agent_id); IF content is None: RETURN None (INV-002 — zero HTTP)
3. [sequential, prescriptive] Soft-guard: IF client is None OR session_id is not a non-empty str: RETURN None
4. [sequential, prescriptive] key = "ratatoskr-preset-" + sha256(content utf-8)[:12]
5. [sequential, prescriptive] TRY: await asyncio.wait_for(write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S)
5. [sequential, prescriptive] TRY: await asyncio.wait_for(wt.write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S)
tool: { destructive: false, idempotent: true, read_only: false, open_world: false }
6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None
7. [cleanup, prescriptive] RETURN content
TESTS:
seeds_preset [happy,tracer]: preset agent, mock 201 → returns the preset text; exactly one POST /sessions/{id}/history; body author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12]
no_preset_zero_http [happy]: agent "mimir" → returns None; NO HTTP issued
feature_absent_swallowed [error]: preset agent, mock 404 session_not_found → returns None, no raise
session_api_failed_swallowed [error]: preset agent, mock 409 → returns None, no raise
transport_error_swallowed [error]: preset agent, mock httpx.ConnectError → returns None, no raise
TESTS: (driven through a fake WorldtreeClient whose sessions.write_history returns/raises — the wire is the SDK's to prove via its parity corpus)
seeds_preset [happy,tracer]: preset agent, fake write_history returns an ack → returns the preset text; exactly one write_history call; entry author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12]
no_preset_zero_write [happy]: agent "mimir" → returns None; ZERO write_history call
feature_absent_swallowed [error]: preset agent, fake raises ApiError(404) → adapter maps to AuthoredHistoryUnavailable → returns None, no raise
session_api_failed_swallowed [error]: preset agent, fake raises ApiError(409) → wt.SessionApiFailed → returns None, no raise
transport_error_swallowed [error]: preset agent, fake raises SDK ConnectFailed → returns None, no raise
unexpected_exception_swallowed [error]: preset agent, write raises ValueError → returns None, no raise (INV-001 broad never-raise)
cancellation_propagates [error]: preset agent, write raises asyncio.CancelledError → RE-RAISED (never swallowed)
malformed_agent_id_no_http [adversarial]: agent_id=123 (non-str) OR "" → None; NO HTTP; no raise
empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); NO HTTP; no raise
malformed_agent_id_no_write [adversarial]: agent_id=123 (non-str) OR "" → None; ZERO write; no raise
empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); ZERO write; no raise
```
-290
View File
@@ -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/<id>), `delete_agent` (DELETE /agents/<id>), plus `Tier3AgentInfo` frozen dataclass. Plus a thin CLI entry point (`python -m ratatoskr.tier3 <define|patch|delete>`) 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 `<auth_user_id>:<agent_name>`. Live probe against personal Worldtree (2026-05-25) confirmed: POST with `{agent_name: 'smoke-test', ...}` and `Authorization: Bearer <key>` 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: `[<error_code>] <message>` 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 `<their-user-id>:*` 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 <id> --end-user-id <eid>` 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 `<user_id>:<agent_name>`. 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/<id> → 200 with updated Tier3AgentInfo. See FN patch_agent."""
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
"""DELETE /agents/<id> → 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 "<user_id>:<agent_name>".
```
### 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/<id> → 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/<id> → 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] <id>` | 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] <msg>` | 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
```
-625
View File
@@ -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.
@@ -164,7 +164,15 @@ others, they get their own row here — the default is NOT a general "any 404
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` (dual-key: status 422 AND error_code; the flat cursor body surfaces the code) |
| `ApiError(502)` on bound `sessions.create` | `BifrostHandshakeFailed` — NOT gated on error_code (unlike list's 422): INV-002, the synchronous handshake is the SOLE bound-502 cause; and the SDK's envelope parser prefers the nested `detail` (which carries `bifrost_error`, not `error_code`), so no distinguishing top-level `error_code` surfaces. The route+status IS the discriminator. |
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
| `ApiError(429)` on `agents.define` (slice-4) | `Tier3QuotaExceeded(retry_after=0)` — the SDK's `ApiError` floor carries no response headers, so the `Retry-After` header the hand-rolled path read is unavailable; spec §2675 pins Phase-2.0 quota to `Retry-After: 0`, so the adapter defaults to 0. A non-zero forward-compat value is unrecoverable until the SDK surfaces headers (INFORM wtsdk-dev; reference-impl posture). |
| `ApiError(403 tier3_user_id_unsupported)` on `agents.define` (slice-4) | `Tier3UserIdUnsupported` (dual-key: status 403 AND error_code) |
| `ApiError(422 layer_deferred)` on `agents.define` (slice-4) | `Tier3LayerDeferred(field)``field` parsed from the body (`detail.field` / flat `field`); the SDK carries `error_code` but not `field`, so the adapter body-parses it (same posture as bound-502's `bifrost_error`) |
| `ApiError(404)` on `agents.patch` / `agents.delete` (slice-4) | `Tier3AgentNotFound` (route-discriminated; agents CRUD is NOT a hide-existence route — a 404 there IS "no such agent") |
| `ApiError(422 field_not_mutable)` on `agents.patch` (slice-4) | `Tier3FieldNotMutable(field)` (dual-key status+error_code; `field` body-parsed) |
| `ApiError(404 persona_not_configured)` on `agents.persona_state` (slice-4) | `PersonaNotConfigured` (dual-key) |
| `ApiError(404 agent_not_available)` on `agents.persona_state` (slice-4) | `AgentNotAvailable` (the persona-surface `sessions.AgentNotAvailable`, distinct from the eager-turn `sse_client.AgentNotAvailable`; dual-key) |
| `ApiError(403 auth_scope_denied)` on `agents.persona_state` (slice-4) | `AuthScopeDenied(scope="persona.read")` (dual-key) |
| **`ApiError` (any other status/route, incl. `agents.list` and any unmatched agent-route code) — the default** | `SessionApiFailed(status, error_code, body)` |
The default row is load-bearing: any `ApiError` not matched above surfaces as the
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
@@ -206,6 +214,164 @@ re-anchor its coverage-map rows.
SSE parsing); retire contracts #2/#15; final coverage-map re-anchor; minor bump
(DEC-6, operator approval).
### Slice-4 notes (Agents/Tier-3 + `model`→`role` fold, decided at TDD)
- **`model``role` cutover folds in here (scope B).** Worldtree spec 1.2 (`v1.0.0b128`,
live on :8080/:8081) made the `/agents/define` response echo `role`, closing the
old W-4 `model` echo. The adapter returns the SDK's OPEN-WORLD `DefinedAgent` /
`PatchedAgent` dicts verbatim (parity posture); callers read `info["role"]`. The
frozen `Tier3AgentInfo` dataclass (which read `body["model"]` and would KeyError
post-b128) is DELETED — no dataclass normalization layer survives.
- **`ratatoskr.local_agents` schema bump.** `LocalAgentEntry.model``.role` (the
field stores what the wire now calls a role); `_SCHEMA_VERSION` 1→2 so any
pre-cutover on-disk index is discarded cleanly (no-backwards-compat, DEC-3).
- **`AgentNotAvailable` name collision.** `sessions.AgentNotAvailable` (persona-state
404 `agent_not_available`) and `sse_client.AgentNotAvailable` (eager-turn 409) are
distinct types that share a name; `wt` already imports the sse_client one for the
stream, so it imports the persona one ALIASED (`PersonaAgentNotAvailable`) and
raises it from `get_persona_state`. The web endpoint keeps importing the persona
`AgentNotAvailable` from `sessions` (same class), so its `except` is unchanged.
- **`ConnectFailed` at every rewired caller (slice-3 foot-gun).** The SDK normalizes
ANY transport failure to `ConnectFailed(status=0)` (`request.py`), not a raw httpx
error. The rewired tier3 CLI and both web endpoints (`_agents_endpoint`,
`_persona_state_endpoint`) catch `wtsdk.ConnectFailed` → their existing
network-error surface (CLI exit 21 / web 502). The web `test_network_error_returns_502`
(respx `httpx.ConnectError` side-effect) is the RED that proves this.
- **`agents.get(agent_id)`** (SDK `GET /agents/{id}`) is NOT wrapped — ratatoskr has no
`get_agent` consumer; only list/persona_state/define/patch/delete are in coverage.
- **Client-side Tier-3-id PRE on `patch_agent` / `delete_agent`.** Both assert
`":" in agent_id` pre-HTTP (a Tier-3 id is always `<user_id>:<agent_name>`, ADR-0019),
so a non-colon id fails fast with an `AssertionError` rather than reaching the SDK's
route-discriminated 404 → `Tier3AgentNotFound`. Intentional fail-fast on a
wrong-shaped id (carried over from the retired hand-rolled wrappers); documented here
per the heid-code-review slice-4 precision flag (the § Error map 404 rows assume a
well-formed Tier-3 id reaches the route).
### Slice-5 notes (Characters + me/capabilities/models, decided at TDD)
- **No new § Error map rows.** All six routes (`me.get`, `capabilities.get`,
`models.available_for_characters`, `characters.create` / `.state` / `.delete`) are
open-world reads/acks (B-OPEN-2) whose SDK ops carry NO discriminated error (no
`map_error`), so every `ApiError` maps to the default `SessionApiFailed` — exact
parity with the retiring hand-rolled path, which likewise raised only its generic
`SessionApiFailed` on any non-2xx (never discriminating a status/code on these
routes). The route-map table above already lists all six.
- **`create_character` body — omit `state` when None.** The adapter sends
`{"character": …}` plus `"state"` only when the caller supplies a non-None state
(the SDK forwards the body dict as-is via httpx `json=`). This drops the hand-rolled
path's redundant explicit `"state": null` — server-equivalent (Worldtree's
`CreateCharacterRequest.state` defaults None whether omitted or explicit-null),
SDK-idiomatic (matches the SDK's `CreateCharacterInput` `NotRequired` shape), and
invisible at the sole call-site (`--characters` never passes a state). Adopt-
canonical over byte-for-byte wire parity.
- **`delete_character` returns the SDK's open ack verbatim (`-> Mapping | None`).**
The SDK route returns an open-world ack body (not 204 — `CharacterDeleteResult`), so
the adapter passes it through rather than normalizing to the hand-rolled `None`
(parity posture: no None-normalization of an open-world read). On a 204 no-content
the SDK yields `None`, so the return type is `Mapping | None`; the sole call-site
(`--characters`) ignores the value, so the change is unobservable.
- **Open-world presenter degrade-not-crash (cumulative foot-gun).** `_format_whoami`
is already hardened (slice-4 heid bug-hunt). The rewired `_characters_probe` extracts
the created id defensively (`created.get("character_id")` + type-guard → clean abort,
never a hard-index KeyError) since the create ACK is now an open-world SDK read.
- **Container-type hardening (heid code-review + bug-hunt slice-5).** The degrade-not-
crash floor is guarded at THREE levels for the CLI presenters, not just one: (a) the
list-typed fields `scopes` / `allowed_roles` / model `items` degrade a non-list scalar
(`123`) or a bare string to empty via `_display_seq` / an `isinstance(_, list)` guard —
the older `or []` idiom only caught null/absent and would `for x in 123` `TypeError`;
(b) each element is type-guarded (`isinstance(m, dict)`); (c) the top-level open-world
reads `created` / `models` / `state` are `isinstance(_, Mapping)`-guarded before any
`.get` (a non-mapping passthrough would otherwise `AttributeError`). All three feed
`--whoami` / `--characters` only.
- **Accepted (not fixed): the `--characters` probe leaks its transient character on a
mid-lifecycle failure.** create → get-state → delete runs linearly with no `finally`,
so a state/delete failure after a successful create orphans the probe character until
its TTL. This is PRE-EXISTING (the retired hand-rolled probe had the identical
structure — the cutover did not worsen it), TTL-bounded, and `--characters` is a
one-shot diagnostic smoke; a `try/finally` cleanup would also swallow a happy-path
delete-failure (delete is both the teardown AND a tested lifecycle step). Accepted as
known-risk per the heid bug-hunt (Gróa + Heid concur accept is defensible).
- **CLI-only rewire.** `me` / `capabilities` / `characters` / `models` have NO
web-server caller — only the `--whoami` and `--characters` CLI one-shot probes. The
web surface is untouched this slice.
### Slice-6 notes (Admin: bifrost inspection + admin-events stream, decided at TDD)
- **Admin auth moves from a per-call header override to the client's `admin_auth`.**
The SDK's `admin.*` methods authenticate with the client's `admin_auth` provider
(set via `build_client(admin_key=...)`), NOT a per-request `Authorization` header. So
the two web admin endpoints build their wt client WITH `admin_key` (`_wt_client(client,
admin_key=...)`, extended this slice); the hand-rolled per-call `admin_key=` +
header-override is retired. The web already guards `if not admin_key: 400` before the
call, so the SDK's pre-HTTP `ConfigurationError` (missing admin_auth, W-5) is
unreachable from the web surface. **CLI has no admin caller** — both routes are
web-only (the coverage-map's `tui.py` rows were stale; corrected to `web/server.py`).
- **`get_session_bifrost` — no new § Error map row.** `client.admin.sessions.bifrost`
returns the open-world `BifrostInspection` dict verbatim; any `ApiError` (notably 403
`auth_scope_denied`, 404 `session_not_bifrost_bound`) → the `SessionApiFailed` default
— exact parity with the retired path (which mapped every non-200 → `SessionApiFailed`).
- **`stream_admin_events` re-wraps the SDK's `AdminEvent` → ratatoskr's `AdminEvent`
(chosen over yield-through).** The SDK's `AdminEvent` diverges from ratatoskr's:
`admin_id: int|float` (`nan` for an id-less envelope) vs ratatoskr's `id: int` (0
default), and the SDK's `type`/`data` are None-able where ratatoskr's are a dotted-str
/ a `{}`-default dict. The web filter + SSE formatter read `ev.id`/`ev.type`/`ev.data`.
The adapter re-wraps at the boundary — `id = admin_id if int else 0` (nan→0),
`type = type or ""` (None→"" so `.startswith` never crashes), `data = data or {}`
degrading the SDK's open-world None/nan ONCE at the adapter, keeping the web endpoint +
`_admin_event_matches_web` + the ratatoskr `AdminEvent` domain type UNCHANGED (preserves
the web surface per § Out of scope). **Rejected alternative:** yield SDK `AdminEvent`s
through and rewire the web filter for `admin_id`/None/nan (the slice-2 turn-stream
precedent) — heavier web churn + scatters the None/nan hardening through the filter;
re-wrap localizes it. The ratatoskr `AdminEvent` dataclass stays in `sse_client.py` this
slice (imported by `wt` + the web); its home moves in slice-7 teardown if `sse_client.py`
is retired.
- **Admin-stream error mapping (reuses the § Error map stream rows).** The SDK admin
stream raises `ApiError("admin_stream_failed", status=…)` on a NON-200 open (NOT
`ConnectFailed` — a gotcha the web integration test caught that the unit fake could not)
`SseConnectFailed`; and `ConnectionDropped` on a connect-time transport failure
(cursor None) OR a mid-stream drop / the long-lived stream's resumable EOF (cursor set)
`SseConnectionDropped`. The SDK admin stream is best-effort (skips malformed frames —
no `Malformed*`), as was the retired hand-rolled path; the web endpoint's existing
`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.
+30 -27
View File
@@ -84,34 +84,36 @@ sub-gap).
| Endpoint | Status | Where consumed | Note |
|---|---|---|---|
| `POST /sessions` | ✅ | `sessions.py` `create_session` `cli.py`,`tui.py`,`web/server.py` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed. **v0.21.2 (#19): ephemeral-template (Echo) create**`config` passthrough (`--system-prompt`), `role` not `model` (W-4), `kind`/`config` captured; 422 ephemeral_requires_config now reachable-and-handled. Depth enhancement to an already-covered route — count unchanged |
| `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) |
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history``cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **LIVE-PROVEN 2026-07-06** on personal :8081 (grant applied via a rule-based Heimdall allow, worldtree-dev): create mimir session → seed → **201** (seq=0, phase=seeded, turn_id=1798) → GET /messages reads it back as a plain role=assistant turn (model-invisible provenance confirmed). Hide-404 for ungranted is unit+probe covered |
| `GET /sessions/{id}/messages` (history) | ✅ | `sessions.py:635` `get_session_messages``cli.py:758` `--seed-first-message` read-back | un-deferred as the #347 seed read-back — confirms model-invisible provenance (a seed reads back as a normal `role=assistant` turn) |
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped |
| `GET /agents` | ✅ | `sessions.py:341``tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index |
| `GET /agents/{id}/persona_state` | ✅ | `sessions.py:384``tui.py:1132`,`web/server.py:386` | persona hydrate; 404/403 mapped |
| `POST /agents/define` | ✅ | `tier3.py:175``_run_define` | Tier-3 create |
| `PATCH /agents/{id}` | ✅ | `tier3.py:219``_run_patch` | Tier-3 mutate (system_prompt/model) |
| `DELETE /agents/{id}` | ✅ | `tier3.py:242``_run_delete` | Tier-3 hard-delete |
| `GET /me` | ✅ | `sessions.py:411` `get_me``cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed |
| `GET /capabilities` | ✅ | `sessions.py` `get_capabilities``cli.py` `--whoami` | Echo ephemeral-template discovery. **v0.21.2: `--whoami` renderer reads `allowed_roles`/`default_role`** (was the dead `allowed_models`/`default_model`) + tolerates malformed caps; matches conversation-api-spec **v1.1** (`b4a278c`) |
| `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 /admin/sessions/{id}/bifrost` | ✅ | `sessions.py:428` `get_session_bifrost``tui.py` `_hydrate_bifrost_state` | admin-scoped BifrostState pane (#176); admin key (`RATATOSKR_ADMIN_API_KEY`); live-auth-proven |
| `GET /admin/events` (SSE) | ✅ | `sse_client.py` `stream_admin_events` `tui.py` `_stream_admin_events` | admin lifecycle SSE stream (#11), session-filtered AdminEvents pane; admin key; live-auth-proven |
| `GET /models/available-for-characters` | ✅ | `sessions.py` `list_character_models` `cli.py` `--characters` | character-capable model profiles (#161) |
| `POST /characters` | ✅ | `sessions.py` `create_character``cli.py` `--characters` | create transient character (#161) |
| `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state``cli.py` `--characters` | live character PAD/emotions (#161) |
| `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character``cli.py` `--characters` | remove transient character (#161) |
| `POST /sessions/{id}/persona_state` | ✅ | `sessions.py` `set_persona_state``cli.py` `--set-persona-pad` | persona-state write / affect injection (freeform body — unpinned in the frozen surface) |
| `POST /sessions` | ✅ | `wt.py` `create_session` (SDK `sessions.create`) → `cli.py`,`web/server.py` | **wt-adapter re-anchored (slice-2, #20)** — + `end_user_id`, `bifrost` binding (consumer-key via SDK per-request auth), `config` passthrough; 404→AgentNotFound, bound-502→BifrostHandshakeFailed. Ephemeral-template (Echo) create (#19) carried through the adapter. Depth enhancement to an already-covered route — count unchanged |
| `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `wt.py` `stream_turn` (SDK resilient `sessions.stream_turn`, auto-resume) → cli/web | **wt-adapter re-anchored (slice-2, #20)** the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable, drop→SseConnectionDropped, protocol→same-named; absorbs the old `reconnect_turn` |
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `wt.py` `write_authored_history` (SDK `sessions.write_history`)`cli.py` `--seed-first-message`, `first_message.py` `seed_preset_first_message` (create-path seed) | **wt-adapter re-anchored (slice-3, #20)** — SDK owns the entry shape; v1 author=assistant; 404→AuthoredHistoryUnavailable (hide-existence, route is the discriminator, never probe); 409/422→SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): `--seed-first-message` on a sindra session → **201** (seq=0, phase=seeded, turn_id=2294) → read-back verbatim; create-path preset seed observed via `--new`. (Prior 2026-07-06 hand-rolled proof superseded.) |
| `GET /sessions/{id}/messages` (history) | ✅ | `wt.py` `get_session_messages` (SDK `sessions.messages`)`cli.py` `--seed-first-message` read-back, `web/server.py` messages proxy | **wt-adapter re-anchored (slice-3, #20)** — the #347 seed read-back; open-world passthrough. **LIVE-SMOKE 2026-07-19**: read-back rendered the seeded seq-0 turn as a plain role=assistant message (model-invisible provenance confirmed) |
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `wt.py` `cancel_turn` (SDK `sessions.cancel_turn`) → cli/web | **wt-adapter re-anchored (slice-2, #20)** — two-stage Ctrl-C; 404→CancelTurnNotFound, 409→CancelAlreadyCompleted, late-cancel 200 (`cancelled=False`) is a benign result, not an error |
| `GET /agents` | ✅ | `wt.py` `list_agents` (SDK `agents.list`) → `web/server.py` `_agents_endpoint` | **wt-adapter re-anchored (slice-4, #20)** — open-world array verbatim (no AgentInfo normalization), merged with the local tier3 index (remote-wins); error→SessionApiFailed default, transport→ConnectFailed→502. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): 6 agents returned (forseti/lofn/mask/mimir/vili/…) |
| `GET /agents/{id}/persona_state` | ✅ | `wt.py` `get_persona_state` (SDK `agents.persona_state`) → `web/server.py` `_persona_state_endpoint` | **wt-adapter re-anchored (slice-4, #20)** — open-world snapshot; dual-key (status,error_code) map: 404 persona_not_configured→PersonaNotConfigured, 404 agent_not_available→AgentNotAvailable, 403 auth_scope_denied→AuthScopeDenied, else default. **LIVE-SMOKE 2026-07-19**: a tier3 agent → correctly mapped `PersonaNotConfigured` (route+code adapter proven) |
| `POST /agents/define` | ✅ | `wt.py` `define_agent` (SDK `agents.define`) → `tier3.py` `_run_define` | **wt-adapter re-anchored (slice-4, #20)** — sends AgentDefineInput `{agent_name,role,system_prompt}`, returns open-world `DefinedAgent` (echoes `role`, b128); slug pre-validated; 429→Tier3QuotaExceeded(retry_after=0, header-less floor), 403→Tier3UserIdUnsupported, 422 layer_deferred→Tier3LayerDeferred. **LIVE-SMOKE 2026-07-19**: `define --role thoughtful-character``defined ratatoskr:slice4-smoke (thoughtful-character)` |
| `PATCH /agents/{id}` | ✅ | `wt.py` `patch_agent` (SDK `agents.patch`) → `tier3.py` `_run_patch` | **wt-adapter re-anchored (slice-4, #20)** — Tier-3 mutate (system_prompt/**role**, model→role folded in); 404→Tier3AgentNotFound, 422 field_not_mutable→Tier3FieldNotMutable. **LIVE-SMOKE 2026-07-19**: `patched ratatoskr:slice4-smoke`; a non-existent id via `python -m``[agent_not_found]` (exit 20, class-identity fix proven) |
| `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` | ✅ | `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` |
| `POST /characters` | ✅ | `wt.py` `create_character` (SDK `characters.create`)`cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — body `{character}` (+`state` only when set — SDK-idiomatic, drops the redundant explicit null); open-world create ACK verbatim; the probe degrades on a missing `character_id` (no hard-index). **LIVE-SMOKE 2026-07-19**: `created char_8c00006e…` |
| `GET /characters/{id}/state` | ✅ | `wt.py` `get_character_state` (SDK `characters.state`)`cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — open-world live PAD/emotions verbatim; TTL-refreshing read. **LIVE-SMOKE 2026-07-19**: `state pad=[0.234, -0.136, 0.065]` read back |
| `DELETE /characters/{id}` | ✅ | `wt.py` `delete_character` (SDK `characters.delete`)`cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — returns the SDK's open ACK verbatim (`-> Mapping|None`, NOT normalized to None; 204→None); any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19**: `deleted char_8c00006e…` |
| `POST /sessions/{id}/persona_state` | ✅ | `wt.py` `set_persona_state` (SDK `sessions.set_persona_state`, `PadState`)`cli.py` `--set-persona-pad` | **wt-adapter re-anchored (slice-3, #20)** — SDK owns the canonical `{"pad": {...}}` wire (#317); CLI passes the 3 PAD axes (finiteness pre-validated); 204→None, else SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on personal :8081: `--set-persona-pad 0.4,0.1,-0.2`**204** |
**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/<owner>:<name>` 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` ·
@@ -0,0 +1,57 @@
`[2026-07-19]` **worldtree-sdk cutover — SLICE-1 + SLICE-2 COMPLETE + PUSHED (origin `aba1730`, v0.21.10).**
The consumer client layer's biggest, riskiest slice (sessions/turn) is fully migrated onto
`worldtree-sdk (Python) 1.0.0` behind the `ratatoskr.wt` adapter, run end-to-end through the House Code
Discipline. Pushed 2026-07-19 (12-commit arc `b1fbadd``aba1730`, tags v0.21.3.10). Suite **497 green**.
## The arc (all on origin/main)
| Part | Commit | What |
|---|---|---|
| Slice-1 foundation | `12cd864` (v0.21.3) | `build_client` + `translate_error` DEFAULT (ApiError→SessionApiFailed) + passthrough; INV-CUT-1 (injected transport, `_owns_client=False`) test-proven |
| Slice-2 adapter (read/create) | `bb158ae` (v0.21.4) | create/list/messages/tools over `client.sessions.*`, per-route error mapping |
| Slice-2 adapter (stream/cancel) | `b907a7b` (v0.21.5) | resilient `stream_turn` + `cancel_turn`; SDK stream errors re-wrapped → ratatoskr caller-semantic exceptions (DEC-2) |
| Slice-2 CLI rewire | `e3a10ad` (v0.21.6) | `_amain`/`_run_turn`/render onto SDK `TurnEvent`s + open dicts |
| Slice-2 web rewire | `5c595b8` (v0.21.7) | Starlette endpoints + SSE→JSON serialization; browser contract preserved |
| Slice-2 deletion | `59602fe` (v0.21.8) | sse_client 714→224, sessions 677→608; orphaned turn-stream family + Event model removed; **DEC-4 live-smoke PASSED first** |
| heid-code-review fixup | `74d41eb` (v0.21.9) | INV-CUT-2 completeness (cancel/stream ApiError default) + contract error-map amendments |
| heid-bug-hunt fixup | `aba1730` (v0.21.10) | 4 confirmed bugs + open-world render hardening |
## KEY ADAPTER FACTS (foot-guns for slices 3-7)
- **The SDK returns open-world dicts** (`Mapping`, NOT typed objects) for session reads → presenters read
mappings (`info["session_id"]`), never attributes. Ratatoskr's typed `SessionInfo`/`SessionPage` retired.
- **SDK `TurnEvent` shape:** `sse_id: str` (the composite `"{turn}:{seq}"`) + a **body-derived `turn_id`
that is ABSENT (None) on text/thinking frames.** The mid-stream cancel target must parse the turn from
`sse_id`, NOT read `event.turn_id`. (This was a live bug — sigint-cancel saw "no event".)
- **The SDK normalizes transport failures to `ConnectFailed(status=0)`** (not raw httpx errors) → presenters
catch `wtsdk.ConnectFailed` for network paths; a stream `ConnectFailed` maps → `SseConnectFailed`.
- **The adapter re-wraps SDK stream errors → ratatoskr's caller-semantic exceptions** (DEC-2 keeps ratatoskr's
typed exceptions; SessionRetired→SessionApiFailed, ConnectionDropped→SseConnectionDropped, Malformed*/
TurnIdFlip→same-named, ConnectFailed/terminal ResumeError→SseConnectFailed).
- **`consumer_key` is BOUND-create-only** — the adapter nulls it when `bifrost is None`, else the SDK's
credential precedence auths as the consumer instead of the default bearer.
- **The SDK's error-envelope parser PREFERS the nested `detail`** dict when present, so a top-level
`error_code` doesn't surface. That's why create's bound-502 is NOT gated on error_code (unlike list's
422+cursor_invalid, whose flat body surfaces the code) — INV-002 also makes the handshake the sole
bound-502 cause. This is a genuine cross-frontier triage win: 2/3 heid arms flagged the missing gate; it
was correctly REJECTED as category-5 wrong-grounding (the SDK parser wasn't in the arms' file set).
## The two heid gates (the value proof)
- **heid-code-review** (Gróa+Hulda substantive, Regin zero=weak): adopted the cancel/stream ApiError-default
completeness + error-map table amendments; correctly rejected the bound-502-gate finding (above).
- **heid-bug-hunt** (Gróa 8 / Hulda 6 / Regin 6; Heid source-checked, refuted 2 Regin FPs): caught **4 real
confirmed bugs the conformance lens structurally could not see** — SessionRetired(410) uncaught by both
presenters (crash/dropped-stream), cli consumer_key forwarded on unbound create (auth divergence), sse_id
None-crash, cancel never-raise gap — plus open-world render hardening. Rejected 4 as verified FPs (incl.
Hulda's "deleted funcs break callers" — grep-verified zero callers pre-deletion). Both surfaces re-smoked
live after fixup: create+stream+cancel round-trip clean on :8081 (b128).
## What's still hand-rolled (later slices)
`create_session`/`get_session_messages` (the `--seed-first-message` probe uses them — retire in slice-3);
`set_persona_state`/`write_authored_history`/`first_message` (slice-3); agents/tier3 incl. `model``role`
(slice-4, deploy live b128); characters/me/capabilities/models (slice-5); `stream_admin_events`/
`get_session_bifrost` (slice-6). Slice-7 teardown: retire contracts #2/#15, drop `httpx-sse`, minor bump
(DEC-6, operator approval). Full design → auto-memory `project_worldtree_sdk_cutover`.
@@ -0,0 +1,66 @@
# worldtree-sdk cutover — SLICE-3 complete (2026-07-19)
Slice-3 of the #20 consumer-layer cutover: **persona + authored-history +
first-message** migrated onto the `ratatoskr.wt` adapter, and the last
hand-rolled `sessions.py` paths retired. Two commits: `ca9a339` (feat) +
`fc256bb` (heid-bug-hunt fixups), tags v0.21.11 / v0.21.12. Suite **469 green**.
## What moved / what was deleted
- **`wt.set_persona_state`** — passes 3 PAD axes; SDK owns the `{"pad": {…}}` wire
(#317) via `PadState`. No route-specific error row → `SessionApiFailed` default.
Finiteness enforced at the adapter precondition (chokepoint) AND the CLI.
- **`wt.write_authored_history`** — SDK `write_history`; v1 fixes `author="assistant"`
(dropped the unused `author`/`effects`/`claimed_original_at` params — no caller
used them, and a 3-key literal type-checks against the SDK's private
`AuthoredHistoryInput` without importing a non-public symbol). 404 →
`AuthoredHistoryUnavailable` (hide-existence; the ROUTE is the discriminator,
never the body); else the default.
- **`first_message.seed_preset_first_message`** — now takes a `WorldtreeClient`,
routes through `wt.write_authored_history`; best-effort invariants (INV-001..004)
unchanged. Tests rewritten to drive a fake `WorldtreeClient` (the wire is the
SDK's to prove via its parity corpus, not first_message's).
- **CLI `--set-persona-pad` / `--seed-first-message`** + the `_amain` and web
create-path first-message seeds rewired onto the adapter.
- **DELETED from `sessions.py`**: `create_session` + `SessionInfo`,
`set_persona_state`, `write_authored_history`, `get_session_messages`,
`_bifrost_error_from`. (The exceptions + `BifrostBinding` + `AgentInfo` +
slice-4/5/6 functions stay.)
## Live-smoke (INV-CUT-5, personal :8081 b128)
`--seed-first-message` → 201 (seq=0, phase=seeded) → read-back verbatim;
`--set-persona-pad 0.4,0.1,-0.2` → 204; `--new` create-path preset seed observed
routing through the adapter. All three route families proven end-to-end.
## Both heid gates cleared
- **heid-code-review (panel, cutover contract):** ZERO code-vs-contract drift, all
three arms. Regin's 3 secondary items all self-graded `accept` (non-slice-3
routes / web-not-unit-tested / slice-2 stream test) — correctly declined.
- **heid-bug-hunt (panel):** earned its keep — caught **a real regression I shipped
in `ca9a339`**: when the two CLI probes moved off raw httpx onto the adapter,
transport failures changed class — the SDK normalizes ANY pre-response transport
failure to `worldtree_sdk.ConnectFailed` (`request.py:196`), a `WorldtreeError`
(not `ApiError`), so it escaped the adapter unmapped AND the probes' httpx-only
`except` tuples → uncaught traceback instead of `[network_error]` exit 21.
`_amain` (slice-2) already handled it; the probes lagged. **Fix:** add
`ConnectFailed` to both probe tuples (live-verified at a refused host → exit 21).
Second finding (3/3): finite-PAD enforced only at the CLI, not the adapter
chokepoint → **fix:** adapter precondition assert. Declined (correctly): deleted
`sessions.py` exports (intended no-shim cutover, zero un-migrated importers),
`session["session_id"]` index (accept-known-risk, matches `--new`), Regin's
"web hangs forever" (refuted by Heid from source — the seed is `wait_for`-bounded).
## Foot-gun banked
**The SDK wraps ALL transport failures into `ConnectFailed(status=0)`** (not raw
httpx). Every ratatoskr caller that goes through the adapter must `except
ConnectFailed` on its network path — the httpx-only catches are now dead. This bit
the slice-3 probes; watch for it in slice-4+ call-site rewires.
## Next
Slice-4 (agents/Tier-3 — list/get/define/patch/delete/persona_state) which FOLDS
the pending `model``role` cutover ([[project-tier3-agents-model-to-role-pending]]).
See [[2026-07-19-worldtree-sdk-cutover-slice-1-2-complete]] for the slice-1+2 arc.
@@ -0,0 +1,66 @@
`[2026-07-19]` **worldtree-sdk cutover SLICE-4 COMPLETE + committed (`c62b4ee`+`aed9429`+`477d98f`, v0.21.13.15, 475 green).**
Agents/Tier-3 family onto the `ratatoskr.wt` adapter + the `model``role` fold (scope B),
full House Code Discipline end-to-end (contract error-map → TDD → rewire → delete → live
smoke → coverage re-anchor → both heid gates → fixups).
**What moved onto the SDK** (`client.agents.*`, open-world dicts, errors mapped by
route+(status,error_code) per INV-CUT-2): `list_agents` (`agents.list`),
`get_persona_state` (`agents.persona_state`; 404 persona_not_configured /
404 agent_not_available / 403 auth_scope_denied dual-key), `define_agent`
(`agents.define`; 429→Tier3QuotaExceeded(retry_after=0) status-only, 403
tier3_user_id_unsupported, 422 layer_deferred), `patch_agent` (`agents.patch`; bare-404→
Tier3AgentNotFound, 422 field_not_mutable), `delete_agent` (`agents.delete`; bare-404).
Rewired the `python -m ratatoskr.tier3` CLI + the web `_agents_endpoint` /
`_persona_state_endpoint`. DELETED the hand-rolled `sessions.list_agents` /
`get_persona_state` / `AgentInfo` and `tier3.define/patch/delete_agent` /
`Tier3AgentInfo` / parse+extract helpers.
**model→role fold:** define/patch response echoes `role` (spec 1.2 / b128), read off the
open-world dict; `LocalAgentEntry.model``.role`, local-index `_SCHEMA_VERSION` 1→2 (old
index discarded, no-compat). `project_tier3_agents_model_to_role_pending` is RESOLVED by
this — the deferred scope-B work landed here (doing it standalone would have been throwaway).
**KEY SLICE-4 FOOT-GUNS (for slices 5-7 + any tier3 work):**
- **`python -m` double-module exception identity.** Running tier3 as `__main__` while
`wt` imports `ratatoskr.tier3` bound TWO copies of each `Tier3*` class → a raised
`Tier3AgentNotFound` escaped the CLI's `except` as an uncaught traceback (exit 1, not
the mapped exit 20). Unit tests call `main()` in-process (module is `ratatoskr.tier3`,
not `__main__`) so they NEVER hit the split — the LIVE SMOKE caught it. Fix: the `Tier3*`
exceptions live in `sessions.py` (never run as `__main__`) → single class identity, and
it removed the `wt→tier3` import edge. Rule: caller-semantic exceptions the adapter
raises + a `-m` CLI catches must NOT live in the `-m` module.
- **Open-world presenters must degrade, never crash.** The heid-bug-hunt panel (5/5/5, two
3/3 crash sites) caught the CLI (`_run_define`/`_run_patch`) and web (`_agents_endpoint`)
HARD-INDEXING the open-world dicts (`info["agent_id"]`, `{a["agent_id"] for a in
upstream}`) → KeyError/TypeError on a partial/drifted 2xx response (incl. `system_prompt:
null``None.splitlines()` AttributeError). The wt tests + live smoke used FULL server
dicts, so it never surfaced. Fix: `_str_field` (absent/null/non-str → default) in the
CLI; well-formed-mapping filter in the web endpoint; a no-agent_id 2xx → controlled exit
20. The invariant "open-world reads degrade, never crash the presenter" must hold at
EVERY presenter, not just the adapter.
**Both heid gates cleared:**
- **code-review (panel, zero adapter/error-map/model→role drift):** 3 fixups (v0.21.14,
`aed9429`) — persona endpoint now catches `wt.SessionApiFailed` (parity with 3 sibling
endpoints; a latent PRE-cutover gap, NOT a slice-4 regression); dual-key NEGATIVE tests
for define/patch + flat-`field` test; contract documents the `":" in agent_id` PRE.
- **bug-hunt (panel 5/5/5, no false positives):** 3 fixups (v0.21.15, `477d98f`) — the two
open-world presenter crash sites above + `_error_field_from_body` type-checks `field` is
str. HELD (contract-intended, Heid-confirmed): the 429→quota / bare-404→not-found
status-only maps (the arms flagged them spec-free; the § Error map specifies them; the
SDK's ApiError floor drops Retry-After so retry_after=0 is canonical). Dual-keying
define's 429 for full row consistency is an available tightening (contract amendment),
surfaced not applied.
**LIVE-SMOKE on personal :8081 (b128):** valid agent roles are `thoughtful-character` /
`character` / `assistant` (NOT the `/models/available-for-characters` `char-rp` — that's a
character-model, a different vocab; define 422s on it). define→patch→list(6 agents:
forseti/lofn/mask/mimir/vili/…)→persona_state(→PersonaNotConfigured mapped)→delete→index
empty; non-existent-id patch via `-m``[agent_not_found]` exit 20 (double-module fix
proven). Throwaway `ratatoskr:slice4-smoke` deleted, server left clean.
**NEXT = slice-5** (characters + me/capabilities/models — the remaining consumer reads);
then slice-6 (admin: `stream_admin_events` + `get_session_bifrost`, admin_auth), slice-7
(teardown: delete residual hand-rolled, drop `httpx-sse`, retire contracts #2/#15, MINOR
bump per DEC-6 w/ operator approval). Consumer layer ONLY; Bifrost provider planes untouched.
@@ -0,0 +1,115 @@
# worldtree-sdk cutover — SLICE-5 COMPLETE (characters + me/capabilities/models)
`[2026-07-19]` Slice 5 of 7 of the worldtree-sdk consumer cutover (issue #20;
contract `docs/contracts/worldtree_sdk_cutover.contract.md`). Full House Code
Discipline end-to-end: contract slice-notes → TDD → LIVE smoke → heid-code-review →
fixup → heid-bug-hunt → fixup. Both heid panels cleared. Suite **488 green**.
## Commits (tags v0.21.16.18, on `main`, not-yet-pushed)
- **`deab762`** feat — the six routes onto `ratatoskr.wt`, hand-rolled deleted.
- **`d86d6df`** fix — heid-code-review fixups (CLI presenter degrade-not-crash).
- **`4e20030`** fix — heid-bug-hunt fixups (CLI open-world container-type hardening).
## What migrated
`get_me` / `get_capabilities` / `list_character_models` / `create_character` /
`get_character_state` / `delete_character` moved off the hand-rolled httpx wrappers
onto `client.me.get()` / `client.capabilities.get()` /
`client.models.available_for_characters()` / `client.characters.create|state|delete`.
All six are **open-world reads/acks returned verbatim**; none carries a discriminated
SDK error, so each maps any `ApiError` → the `SessionApiFailed` default —
**NO new § Error map rows** (exact parity with the retired path, which never
discriminated a status/code on these routes).
**CLI-only rewire**`--whoami` (me + capabilities) and `--characters`
(models → create → state → delete) build a `wt.build_client` over the injected
`_probe_client` transport and catch `wt.SessionApiFailed` + `ConnectFailed`. **No
web-server caller** for any of these six routes.
Deleted the six hand-rolled `sessions.py` wrappers (net **5 mypy `no-any-return`**
errors); `endpoint_for_plane` + `get_session_bifrost` (slice-6) + the exception
classes stay. Retired the matching `test_sessions.py` classes (`TestGetMe`,
`TestGetCapabilities`, `TestTransientCharacters`); kept `TestEndpointForPlane` +
`TestGetSessionBifrost`.
## Decisions made at TDD (contract § slice-5 notes)
- **`create_character` omits `state` when None** — SDK-idiomatic inline literal
(per branch, to type-check against the SDK's `CreateCharacterInput` TypedDict
without importing its private `_types`); server-equivalent to the retired explicit
`state: null` (Worldtree's field defaults None either way). The only wire-shape
change; the sole call-site never sets state.
- **`delete_character` returns the SDK's open ACK verbatim** (`-> Mapping | None`,
not normalized to the hand-rolled `None`; 204 → None). The CLI ignores it.
## LIVE SMOKE (:8081/b128, `WORLDTREE_API_KEY`, INV-CUT-5 / DEC-4 cleared)
Drove both probes end-to-end through the CLI (`python -c "from ratatoskr.cli import
main; main([...])"` — the `ratatoskr` console script isn't on PATH here; `python -m
ratatoskr.cli` imports without calling `main`, no `__main__` guard). `--whoami`
rendered real identity (user_id ratatoskr, tier user, scopes incl. `character.*`,
key_id c990f0be) + `ephemeral_template echo`. `--characters` drove the full
lifecycle: `char-rp` catalog → `created char_…``state pad=[0.234,0.136,0.065]`
read-back → `deleted`. Observed real success, not merely non-crash.
## heid-code-review (thread 01KXXRN50K…) — 2 fixups
Panel: **Gróa + Regin zero** (adapter/route-map/error-map faithful); **Hulda** flagged
2 source-confirmed CLI open-world-presenter crash holes + a live-smoke test-gap. Both
holes fixed (the null/element layer):
- `_format_whoami` `scopes`: `', '.join(me.get('scopes', []))` crashes on a
present-null `scopes` (`.get(k, [])` returns None, not the default) or a non-string
element. The contract names `_format_whoami` the degrade-not-crash exemplar — the
cited exemplar had an un-hardened line (`allowed_roles` was hardened in slice-4,
`scopes` was not).
- `_characters_probe` model `items`: the slice-5 `or []` guarded the list-level null
but not each entry (`[None]`/`["x"]`/`[{"name":123}]`).
Test-gap (live-smoke not in the file set) → accept (it WAS run + recorded).
## heid-bug-hunt (thread 01KXXS9S45…) — 3 fixups + 1 accept + 1 dismiss
Cold spec-free diff-scoped panel over the post-code-review-fixup diff. Adapter +
route-map + `ConnectFailed`-at-call-sites **sound against the declared invariants
(all arms agree)**. 4 real findings, all CLI open-world paths — the **container-type
layer BELOW** the null/element holes the code-review had just fixed (the code
comments cite the CR; the two consults were firewalled from each other and converged
independently):
- **[bug, fixed] non-iterable `scopes`/`allowed_roles`** — `{"scopes": 123}`
`123 or [] == 123``for s in 123` TypeError. New `_display_seq(value)` helper
degrades any non-list (scalar / bare string / null / absent) to empty; applied to
both.
- **[bug, fixed] non-iterable `items`** — `{"items": 123}`, same class. Guard `models`
is a Mapping AND `items` is a list before iterating.
- **[robustness, fixed] non-mapping top-level `created`/`state`** — a non-mapping SDK
passthrough (`created=[...]`) → `.get` AttributeError. `isinstance(_, Mapping)`
guard → clean exit-20 abort / `pad=None`.
- **[robustness, ACCEPTED] the probe leaks its transient character on a mid-lifecycle
failure** — create → state → delete linear, no `finally`. PRE-EXISTING (retired
probe had the identical structure — "cutover did not worsen it"), TTL-bounded,
one-shot diagnostic; a `try/finally` would swallow a happy-path delete-failure
(delete is both teardown and a tested step). Gróa + Heid concur accept is
defensible. Documented in contract § slice-5 notes.
- **[DISMISSED] `sessions.py` dropped `get_me`/etc.** (Hulda, caller-contract) — the
intended DEC-3 no-backwards-compat migration (all in-repo callers rewired
same-diff); Heid labels it intended-surface-change, not a defect.
**Regin BH calibration note:** Regin (glm-5.2 non-reasoning) found only the leak,
missed the 3 crash paths, and self-graded "0 confirmed" on a control-flow-guaranteed
finding — consistent with the crystallized **Regin-unreliable-on-bug-hunts** pattern
(its code-review work this session was reliable). Gróa was the BH standout.
## The cumulative lesson (foot-gun for slices 6-7)
Open-world SDK reads need degrade-not-crash guarding at **THREE levels**, and the two
heid lenses caught different ones: the CODE-REVIEW (conformance) caught the
null/element layer; the cold BUG-HUNT (robustness) caught the container-type layer
below it. Run BOTH — they are complementary, not redundant. The three levels:
1. **container-type** — the field value may be a truthy non-iterable scalar (`123`) or
a bare string; `or []` only catches null/absent. Guard `isinstance(_, (list, tuple))`.
2. **element-type** — each entry may be a non-mapping; guard `isinstance(m, dict)`.
3. **top-level-mapping** — the whole read may be a non-mapping passthrough; guard
`isinstance(_, Mapping)` before any `.get`.
See also [[2026-07-19-worldtree-sdk-cutover-slice-4-complete]] (the slice-4 arc + the
`-m` double-module class-identity foot-gun).
@@ -0,0 +1,105 @@
# worldtree-sdk cutover — SLICE-6 COMPLETE (admin: bifrost inspection + admin-events SSE)
`[2026-07-19]` Slice 6 of 7 of the worldtree-sdk consumer cutover (issue #20;
contract `docs/contracts/worldtree_sdk_cutover.contract.md`). Full House Code
Discipline; both heid panels cleared. Suite **494 green**. The meatiest slice —
SSE stream + admin auth + an event-shape decision.
## Commits (v0.21.19.20, on `main`, PUSHED this session)
- **`de9a5ba`** feat — the two admin routes onto `ratatoskr.wt`, hand-rolled deleted.
- **`bba57e1`** fix — heid-code-review fixups (stale docstring + None-cursor test).
**NO version bump** (docs + test only, SemVer skip rule).
- **`11ae2f0`** fix — heid-bug-hunt fixups (admin-stream + bifrost hardening).
## What migrated (WEB-only)
`get_session_bifrost``client.admin.sessions.bifrost(id)` (open-world dict verbatim,
any `ApiError``SessionApiFailed` default — no new Error-map row). `stream_admin_events`
`client.admin.stream_events(last_event_id=…)`. **Both consumed ONLY by `web/server.py`**
(`_session_bifrost_endpoint` + `_admin_events_endpoint`) — the coverage-map's `tui.py`
rows were STALE (the grep found zero TUI callers), so the TUI-deprecation wrinkle was
moot. Corrected the coverage-map rows to `web/server.py`.
Deleted the hand-rolled `sessions.get_session_bifrost` + `sse_client.stream_admin_events`
(+ ruff-cleaned the orphaned `httpx`/`httpx_sse`/`json`/`AsyncIterator` imports). Retired
`test_sse_client.py` WHOLESALE (its last test was the admin stream; slice-2 had already
removed the turn-stream tests) + `test_sessions.py`'s `TestGetSessionBifrost`. `sessions.py`
is now down to `endpoint_for_plane` + exception classes; `sse_client.py` to `SseId` +
`AdminEvent` + exception classes.
## Decisions made at TDD (contract § slice-6 notes)
- **Admin auth moves from a per-call `Authorization` header to the client's `admin_auth`.**
The SDK's `admin.*` routes use `admin_auth` (set via `build_client(admin_key=…)`), NOT a
header. So `_wt_client(client, *, admin_key=None, …)` was extended, and the two web
endpoints pass `admin_key`. The web already guards `if not admin_key: 400`, so the SDK's
pre-HTTP `ConfigurationError` (W-5) is unreachable from the surface.
- **`AdminEvent` re-wrap (chosen over yield-through).** The SDK's `AdminEvent` diverges
from ratatoskr's: `admin_id: int|float` (`nan` for id-less) vs `id: int`; None-able
`type`/`data` vs a dotted-str / `{}`-default dict. The web filter + SSE formatter read
`ev.id`/`ev.type`/`ev.data`. The adapter re-wraps at the boundary — `id = admin_id if
int else 0`, `type = ev.type if isinstance str else ""`, `data = dict if Mapping else {}`
— degrading the open-world None/nan ONCE and keeping the web endpoint + filter + the
`AdminEvent` domain type UNCHANGED (preserves the web surface). **Rejected:** yield SDK
events through + rewire the web filter (heavier churn; scatters the None/nan hardening).
This is implementation-level (reversible, no module-boundary change), decided
autonomously + flagged to the operator with the rejected alternative.
## The ApiError-not-ConnectFailed gotcha (TDD → integration test)
First mapped the admin-stream non-200 as `ConnectFailed`. The web INTEGRATION test
(respx mocking a real 500) exposed that the SDK admin stream raises
`ApiError("admin_stream_failed", status=…)` on a non-200 open — the unit fake couldn't
model it. Fixed to `ApiError → SseConnectFailed`. Lesson: a web integration test catches
what the adapter unit fake structurally can't.
## LIVE SMOKE (:8081, readonly-admin key — INV-CUT-5 / DEC-4 cleared)
Drove the WEB surface (via `httpx.ASGITransport` over `create_app(client_factory,
admin_key=…)`) against real :8081. The bifrost endpoint returned an admin-authed clean
404 `session_not_bifrost_bound` envelope (auth + route + mapping proven — a 404 not a
401/403 = the admin key authenticated). :8081's admin stream is idle (no heartbeats in
15s raw), so I generated activity: streamed admin events while concurrently creating a
session (`POST /sessions {agent_id: mimir, end_user_id: …}` → 201) and observed the real
`session.created` admin event (id=32/34) re-wrapped cleanly (id int, type str, data dict);
threwaway session cleaned up (DELETE → 204). NOTE: a raw `POST /sessions` needs
`end_user_id` (422 without it).
## heid-code-review (thread 01KXXYRNNY…) — 3/3 no drift
Gróa + Regin zero; Hulda "no slice-6 implementation drift." Only minor doc/test looseness:
a stale `_session_bifrost_endpoint` docstring ("overrides the Authorization header" →
corrected to "rides on the client's admin_auth"), and an admin-stream None-cursor test-gap
(added). Hulda's "web endpoints under-tested" was **source-VOIDED by Heid** — those tests
live in `test_web_server.py`, which wasn't in the consult embed (excerpt-elides-tests trap).
## heid-bug-hunt (thread 01KXXZBW74…) — 4 real findings, all fixed
The cold spec-free hunt earned its keep: the CR found the admin surface CONFORMANT, but
judging against the general `ConnectFailed` floor + the degrade-never-crash promise it
surfaced 4 hardening gaps:
- **[bug, 3/3] `stream_admin_events` never mapped `ConnectFailed`** — the SDK admin-stream
open DOES raise it (connect-time / auth-resolution; confirmed in SDK source), `stream_turn`
+ the bifrost GET both catch it, and this endpoint's OWN `:633` comment claimed it did.
An unmapped ConnectFailed escaped the web gen's `except (Sse*)` → aborted SSE with no
`stream_error`. Now mapped → `SseConnectFailed`.
- **[bug, 2/3] non-str `type` crashed the web filter** — `ev.type or ""` (falsy-only) let a
truthy non-str `type` (123) reach `.startswith` → AttributeError. Now `isinstance`-guarded
(matches admin_id/data). Same container-type class as the slice-5 bug-hunt.
- **[robustness] `dict(bstate)` 500 on a non-mapping bifrost body** — I introduced it in
slice-6 (`JSONResponse(bstate)``dict(bstate)`). Now degrades to `{}`.
- **[robustness] transport leak** — `_wt_client` ran before the try/finally in the SSE gen;
a construction failure would leak the httpx transport. Moved inside the try.
Voided (Heid): Regin's `dict(ev.data)` TypeError — the `isinstance(_, Mapping)` guard
already handles it.
## Next: slice-7 (teardown, the LAST slice)
Drop the `httpx-sse` dep from `pyproject.toml` (SDK owns SSE parsing — verify nothing else
imports it), retire wire contracts #2/#15, relocate the `AdminEvent`/exception classes if
`sse_client.py`/`sessions.py` end up ~empty, final coverage-map re-anchor, and the **MINOR
bump per DEC-6 (needs operator approval)** publishing the cutover milestone.
See also [[2026-07-19-worldtree-sdk-cutover-slice-5-complete]] (the container-type
degrade-not-crash lesson) and [[2026-07-19-worldtree-sdk-cutover-slice-4-complete]].
@@ -0,0 +1,31 @@
`[2026-07-19]` **wyrd-dev #368 silo-enforcement consult DELIVERED (althing thread `01KXXNDH3JE4`) — ratatoskr's store-side memory silo is CONVENTIONAL, wyrd going STRUCTURAL off my framing.**
wyrd-dev is standing up their `bifrost-memory-store-server` (unit-4) and asked for
ratatoskr's hands-on read of the #368 `(end_user, agent)` silo mechanics before writing
their contract. I answered artifact-only from `src/ratatoskr/provider/memory_store.py`
(reference-impl posture: verified against code, not memory).
**The load-bearing finding:** ratatoskr's silo is **conventional (query-time scope
filter), NOT structural.** Scope axes live inside `record_json` (+ a mirror `scope_json`
column), and every read path (`search`, `scan`, `list_chunks`) calls `_matches_scope` in
Python — "an `if` a caller can forget." Inherited byte-faithfully from bifrost's reference
`InMemoryMemoryStore`. Sharpest leak surfaces: `get`/`get_many` do ZERO scope check
(trust caller-knows-entitled-ids); the vec-search must over-fetch-ALL-then-Python-filter
(a naive `LIMIT top_k` in SQL silently under-recalls or leaks). ratatoskr has NO
`clear_partition` verb — forget is by-id `delete_many` (two-table chunk+vec atomicity in
one txn); the idempotency table is a hidden replay channel a total-forget must purge.
**Outcome — wyrd committed to STRUCTURAL** (msg 3, `01KXXNQYGX0K`): one-DB-file-per-campaign
(campaign_id silo structurally unrepresentable across files) + `(scope_end_user,
scope_agent_self)` first-class NOT NULL composite-indexed columns, every query incl. the
vec JOIN carrying the partition in SQL `WHERE`. Dissolves my two sharpest leak surfaces
(by-id reads enforce the predicate; the vec JOIN filters+ranks+caps in ONE statement).
Folding my foot-guns as contract STEPS/invariants (two-table+producer-candidate delete
atomicity; forget purges idempotency + reaches superseded; `scope_any` = list of
whole-element-conjunctive dicts never flatten axes #297; `check_same_thread=False`;
`SortableChunkField` needs name+type or handshake dies; advertise⟹implement;
revision+idempotency-check before writes in one txn). worldtree-dev already pushed wyrd
structural in the record-shape re-read, so aligned; wyrd will flag the
reference-`InMemoryMemoryStore`-divergence to bifrost-dev (internal enforcement, wire
unchanged). **OPEN LOOP:** I offered to eyeball wyrd's data model once the unit-4 contract
is cut — they took it; they'll ping. No action pending on ratatoskr now.
+63 -37
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-07-18_
_Last updated: 2026-07-19_
> **Always check for `/tmp/ratatoskr-dev-handoff.md`** — if it exists and its
> `Written:` stamp is under an hour old, read it (it carries the in-flight
@@ -44,34 +44,51 @@ upstream API key stays server-side (INV-003).
## Current state / in-flight
_As of 2026-07-18 (evening):_
_As of 2026-07-19:_
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20), starting slice-1.** Operator ruled ADOPT
(2026-07-18): ratatoskr cuts its CONSUMER client layer over to consume **worldtree-sdk (Python) 1.0.0**
retire the hand-rolled httpx wrappers (`sessions`/`sse_client`/`tier3`) behind a thin `ratatoskr.wt`
adapter over the SDK. Both TS + Python SDK 1.0.0 are GA (**Python live + pip-installable on the gitea
PyPI** — DEC-5 gate cleared). Ratatoskr's own **parity pass shaped the Python spine** (open-world reads,
caller-injected transport, per-wire role/model). Design locked (6 DECs, vor-cross'd with worldtree-codex,
heid-panel-reviewed → error-map table added); contract `docs/contracts/worldtree_sdk_cutover.contract.md`
(committed `e45640c`). **SLICE-1 IN PROGRESS:** ✅ dep integrated + DEC-5 verified + committed (`29c4fda`) — `worldtree-sdk==1.0.0`
installs from the gitea registry (reuses bifrost's index auth, NO new token; core dep + `[tool.uv.sources]`),
`WorldtreeClient` constructs with an injected transport (`_owns_client=False`, INV-CUT-1 confirmed live),
suite 534 green. **NEXT = the adapter** `src/ratatoskr/wt.py` (TDD): `build_client(base_url, *, api_key,
admin_key, transport)` → `WorldtreeClient(auth=, admin_auth=, transport=)` (DESIGN CARE: SDK does per-request
auth via the providers; our injected `httpx.AsyncClient` carries base_url/UA/timeout, NOT the Authorization
header — read the SDK `client.py` @ `~/development/worldtree-sdk` for the split, INV-CUT-1) + `translate_error`
DEFAULT (SDK `ApiError``SessionApiFailed`, discriminated `WorldtreeError` subclasses passthrough; route-specific
rows come in later slices). Unit-test; NO surface wiring (slice-2). Then slices 2-7 (route-family + deletions,
live-smoke per slice). Scope: consumer layer ONLY;
Bifrost provider planes untouched. Multi-session grind. Full design → auto-memory
`project_worldtree_sdk_cutover`. This SUPERSEDES the #371 "repin rides the later Python milestone" framing
below (that milestone shipped; we're adopting, not just repinning).
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-16 COMPLETE, slice-7 (teardown, the LAST) next.**
Operator ruled ADOPT (2026-07-18): ratatoskr cuts its CONSUMER client layer over to **worldtree-sdk (Python)
1.0.0**, retiring the hand-rolled httpx wrappers behind a thin `ratatoskr.wt` adapter. Design locked (6 DECs,
vor-cross'd, heid-panel-reviewed); contract `docs/contracts/worldtree_sdk_cutover.contract.md`. **SLICE-1+2
(foundation + sessions/turn) ✅ PUSHED** origin `aba1730`. **SLICE-3 (persona + authored-history + first-message)
✅ DONE** `ca9a339`+`fc256bb`. **SLICE-4 (agents/Tier-3 + `model`→`role` fold) ✅ DONE** `c62b4ee``477d98f`
(v0.21.13.15). **SLICE-5 (characters + me/capabilities/models) ✅ DONE**`deab762` (feat) + `d86d6df`
(heid-code-review fixups) + `4e20030` (heid-bug-hunt fixups), tags v0.21.16.18; full House Code Discipline,
both heid panels cleared. Suite **488 green**; **LIVE SMOKE on :8081/b128** drove `--whoami` (identity+caps) +
`--characters` (models→create→PAD read-back→delete) end-to-end. Slice-5 migrated
`get_me`/`get_capabilities`/`list_character_models`/`create_character`/`get_character_state`/`delete_character`
onto `client.me`/`.capabilities`/`.models`/`.characters.*` (all open-world reads → `SessionApiFailed` default,
**NO new Error-map rows**), rewired `--whoami`/`--characters` (**CLI-only; no web caller**), and DELETED the 6
hand-rolled `sessions.py` wrappers (`endpoint_for_plane`+`get_session_bifrost` [slice-6]+exceptions stay). Full
arc → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-5-complete.md`.
**SLICE-6 (admin: bifrost inspection + admin-events stream) ✅ DONE** — `de9a5ba` (feat) + `bba57e1`
(CR-fixups, no-bump docs+test) + `11ae2f0` (BH-fixups), v0.21.19.20; suite **494 green**; both heid gates
cleared. Migrated `get_session_bifrost``client.admin.sessions.bifrost` + `stream_admin_events`→`client.admin.
stream_events` (**WEB-only** — coverage-map's tui.py rows were STALE); admin auth moved to the client's
`admin_auth` (`_wt_client(admin_key=…)`); the admin-events adapter **re-wraps** the SDK's `AdminEvent`→ratatoskr's
(nan→0, non-str/None type→"", None/non-mapping data→{}) to keep the web filter + AdminEvent domain type stable;
deleted `sessions.get_session_bifrost` + `sse_client.stream_admin_events`, retired `test_sse_client.py` whole.
**LIVE SMOKE :8081**: web bifrost admin-authed 404 clean envelope + a real `session.created` event (id=34)
re-wrapped end-to-end. Full arc → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-6-complete.md`.
**KEY ADAPTER FACTS (foot-guns, cumulative for slices 6-7):** SDK reads = **open-world dicts** — presenters
MUST degrade not crash, guarded at THREE levels (slice-5 needed all three): **container-type** (a scalar `123`
is non-iterable → `for x in 123` TypeError; the `or []` idiom catches null/absent but NOT a truthy non-iterable
— the heid CODE-REVIEW caught null/element, the cold BUG-HUNT caught the container layer below it, run BOTH),
**element-type** (`isinstance(m, dict)`), **top-level-mapping** (`isinstance(_, Mapping)` before any `.get`; a
non-mapping passthrough → AttributeError); never hard-index `info["x"]`. The SDK **normalizes ANY transport
failure to `ConnectFailed(status=0)`** (NOT raw httpx) — every adapter caller `except ConnectFailed`.
**caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the `-m` module** (double-
module class-identity split → uncaught traceback; live smoke catches it, unit tests can't); `TurnEvent` `turn_id`
ABSENT on text/thinking frames; `consumer_key` is BOUND-create-only; envelope parser prefers nested `detail`.
**NEXT = slice-7 (teardown, the LAST slice):** delete any residual hand-rolled paths, drop the `httpx-sse` dep
from `pyproject.toml` (SDK owns SSE parsing now — verify nothing else imports it), retire wire contracts #2/#15,
relocate the `AdminEvent`/exception classes if `sse_client.py`/`sessions.py` end up ~empty, final coverage-map
re-anchor, and the **MINOR bump per DEC-6 (needs operator approval)** publishing the cutover milestone. Scope: consumer layer ONLY; Bifrost provider
untouched. Full design → auto-memory `project_worldtree_sdk_cutover`.
**⏸️ DEFERRED — tier3 agents `model``role` (scope B), on worldtree-dev's deploy flag.** WT renames the
agents-RESPONSE selector `model``role` (spec 1.2, commit `387c67b`, NOT yet deployed). `_parse_tier3_agent_info`
reads `body["model"]` → KeyErrors post-deploy. Operator chose scope B (full tier3 `model``role` incl.
contract #15 + CLI `--model``--role`). Implement ON the deploy flag, not before (breaks the current demo);
folds into cutover slice-4. Auto-memory `project_tier3_agents_model_to_role_pending`.
**✅ RESOLVED — tier3 agents `model``role` (scope B) folded into cutover slice-4** (`c62b4ee`, v0.21.13). The
deferred deploy-gated scope-B work (response `model``role` per spec 1.2 / b128, `LocalAgentEntry`, index schema
v2) landed with the agents-family SDK cutover — no longer pending. See the slice-4 detail file + Recent decisions.
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).**
**No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
@@ -115,13 +132,14 @@ Full record → `persistent-memory.d/2026-07-18-368-silo-test-passed.md`. Siblin
(2) R39 Phase-2 **matched-quartets rebuild** (confirmatory, "whenever"); (3) bifrost **snapshot-cursor
adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
**Substrate / environment:** branch `main` at **v0.21.2**. **origin at `b1fbadd`** (pushed 2026-07-18:
canonical syncs `5d06a27`/`80c8d58`, ephemeral-Echo `c7016f2` #19, coverage-map→SDK-surface `b1fbadd`).
**UNPUSHED local commits** (cutover work — operator hasn't pushed): cutover contract `e45640c` #20, the
`memory:` snapshot, dep-integration `29c4fda` (worldtree-sdk==1.0.0), + this snapshot — **push is the
operator's call**. origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep: `worldtree-sdk==1.0.0`**
(gitea PyPI, `[tool.uv.sources]`). bifrost **`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0,
**conversation-api-spec re-synced to v1.1** (`b4a278c`); **suite 534 green**. Personal WT on **b127**
**Substrate / environment:** branch `main` at **v0.21.20** — slices 1-5 (`b1fbadd``4e20030`, v0.21.3.18)
PUSHED to origin; **slice-6 arc `de9a5ba`→`11ae2f0` (v0.21.19.20) + this snapshot** committed then PUSHED this
session (tags through v0.21.20). origin
`git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep:
`worldtree-sdk==1.0.0`** (gitea PyPI, `[tool.uv.sources]`; `httpx-sse` retires at slice-7). bifrost
**`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`);
**suite 494 green** (slice-6 added the admin adapter tests + heid fixup tests, ~offset by the retired
hand-rolled admin tests + the whole `test_sse_client.py`). Personal WT on **b128**
(`http://10.250.50.152:8081`; #368 silo + #364 promotion-hygiene live both instances). The combined
**:8392** provider (memory+affect) + **:8765** web are THE surfaces, dev-box BACKGROUND SHELLS —
restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets
@@ -144,8 +162,6 @@ relational-dynamics verify (bind `--bifrost-url :8392`); WT #356 resume-durabili
Chronological log of decisions with `[YYYY-MM-DD]` prefix. One line per
decision. Captures rationale that won't be obvious from code alone.
- `[2026-06-17]` **#296 triage sent to worldtree-dev** (`01KVBBH0…`): extraction SUBJECT-INVERSION (promotes assistant prose, drops the user's fact) + META-DESCRIPTION-not-content; verbose-persona aggravator. WAD-vs-bug resolved to BUG (extraction quality), not idle-gating.
- `[2026-06-18]` **Tier-3 memory PROVEN end-to-end live**`ratatoskr:terse-probe` recalled a seeded user fact in a COLD history-free session (scope_any → 1 hit @ cosine 0.6994). Closes the opening "how far from Tier-3 memory" question for normal agents.
- `[2026-06-19]` **#18 D2 SHIPPED (`v0.17.14`, `39eebd1`) and the full #17+#18 arc PUSHED to origin** → `persistent-memory.d/2026-06-19-18-d2-shipped-v0-17-14-39eebd1-and-the-full-1.md`
- `[2026-06-19]` **bifrost repinned 0.8.0→0.10.0; `affect.fetch` became MANDATORY (strong-or-absent)**`persistent-memory.d/2026-06-19-bifrost-repinned-0-8-0-0-10-0-affect-fetch-be.md`
@@ -271,8 +287,18 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-07-18]` **ephemeral-template (Echo) create SHIPPED (`v0.21.2`, `c7016f2`, #19)**`config` passthrough + `--system-prompt` + `SessionInfo.kind/config` + `--whoami` roles fix. Diagnosed from the ignored `session_api_failed` startup line; role/model drift resolved w/ worldtree-dev (spec re-synced v1.1). TDD + heid contract-review + bug-hunt.
- `[2026-07-18]` **Rata = THE reference consumer of the worldtree-sdk Python spine** — parity pass (12 grounded findings) shaped its contract BEFORE build (open-world reads, caller-injected transport, per-wire role/model — adopted); coverage-map re-anchored to the SDK's 41-op ratified surface (`b1fbadd`); 4 ergonomics items parked post-v1 with wtsdk-dev.
- `[2026-07-18]` **worldtree-sdk cutover DECIDED — adopt the Python SDK for the consumer client layer** (operator, overriding "stay hand-rolled"). Issue #20; contract `docs/contracts/worldtree_sdk_cutover.contract.md` (`e45640c`, vor-cross'd + heid-reviewed); auto-memory `project_worldtree_sdk_cutover`. Consumer layer only, Bifrost provider untouched; slice-1 foundation next. See in-flight.
- `[2026-07-19]` **worldtree-sdk cutover SLICE-1 + SLICE-2 COMPLETE + PUSHED (origin `aba1730`, v0.21.10, 497 green).** The biggest, riskiest cutover slice done end-to-end through the full House Code Discipline (adapter→cli→web→delete→live-smoke→both heid gates); the bug-hunt caught 4 real confirmed bugs the conformance lens couldn't, and a convergent code-review finding was correctly REJECTED as category-5 (SDK envelope-parser behavior). Slice-3 next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-1-2-complete.md`
- `[2026-07-19]` **worldtree-sdk cutover SLICE-3 COMPLETE + pushed (`ca9a339`+`fc256bb`, v0.21.11.12, 469 green).** Persona/authored-history/first-message onto the wt adapter; last hand-rolled `sessions.py` paths deleted; both heid gates cleared — code-review ZERO drift, bug-hunt caught + fixed a real regression I shipped (ConnectFailed escaping both rewired probes → uncaught crash; the SDK normalizes ALL transport failures to `ConnectFailed`, not raw httpx) plus a finite-PAD chokepoint gap. Slice-4 (agents/tier3 + model→role) next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-3-complete.md`
_65 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 Bifrost-provider build / #17+#18 / #295-296 era) archived to archival-memory.md._
- `[2026-07-19]` **worldtree-sdk cutover SLICE-4 COMPLETE (agents/Tier-3 + `model`→`role` fold, `c62b4ee`+`aed9429`+`477d98f`, v0.21.13.15, 475 green).** Full House Code Discipline; the LIVE SMOKE caught a `python -m` double-module exception-class-identity bug unit tests structurally can't; both heid panels cleared (code-review zero-drift + 3 fixups; bug-hunt 5/5/5 → open-world-presenter degrade-not-crash fixes). Resolves the deferred scope-B model→role. Slice-5 next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-4-complete.md`
- `[2026-07-19]` **worldtree-sdk cutover SLICE-5 COMPLETE (characters + me/capabilities/models, `deab762`+`d86d6df`+`4e20030`, v0.21.16.18, 488 green).** Last consumer reads + transient-character CRUD onto the wt adapter (CLI-only rewire, NO new Error-map rows — all six routes → SessionApiFailed default); live-smoke-proven on :8081/b128; both heid gates cleared — code-review caught the null/element open-world-presenter degrade holes, the cold bug-hunt caught the **container-type layer below** them (guard container-type + element-type + top-level-mapping). Slice-6 (admin) next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-5-complete.md`
- `[2026-07-19]` **worldtree-sdk cutover SLICE-6 COMPLETE (admin: bifrost inspection + admin-events SSE, `de9a5ba`+`bba57e1`+`11ae2f0`, v0.21.19.20, 494 green).** The two admin routes onto `client.admin.*` (**WEB-only** — the coverage-map's tui.py rows were stale); admin auth moved from a per-call header to the client's `admin_auth`; the admin-events adapter **re-wraps** the SDK's divergent `AdminEvent`→ratatoskr's (nan/None degraded) to preserve the web surface. Live-smoke-proven (real `session.created` event id=34 re-wrapped end-to-end). Both heid gates cleared — code-review 3/3 no-drift (only a stale docstring + a test-gap), the cold bug-hunt caught 4 real hardening gaps the CR couldn't (ConnectFailed unmapped on the admin stream; non-str type crash; `dict(non-mapping)` bifrost 500; a transport leak). Slice-7 (teardown, LAST) next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-6-complete.md`
- `[2026-07-19]` **wyrd-dev #368 silo-enforcement consult delivered — ratatoskr's store-side silo is CONVENTIONAL (query-time filter); wyrd going STRUCTURAL off the framing.** Answered artifact-only from the provider memory-store code; wyrd folded my foot-guns into their unit-4 contract + committed to one-DB-file-per-campaign + first-class partition columns. OPEN LOOP: I'll eyeball their data model once the contract's cut (they'll ping). → `persistent-memory.d/2026-07-19-wyrd-368-silo-consult-delivered.md`
_67 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 Bifrost-provider build / #17+#18 / #295-296 era) archived to archival-memory.md._
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
+4 -5
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.21.10"
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]
+136 -64
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import argparse
import asyncio
import hashlib
import math
import os
import signal
import sys
@@ -42,25 +43,14 @@ from ratatoskr.sessions import (
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
SessionApiFailed,
create_character,
create_session,
delete_character,
endpoint_for_plane,
get_capabilities,
get_character_state,
get_me,
get_session_messages,
list_character_models,
set_persona_state,
write_authored_history,
)
# The turn path (create / stream / cancel) is served by the worldtree-sdk adapter
# (`wt.*`); these caller-semantic exceptions are what the adapter raises, so the
# presenter keeps catching ratatoskr's own types (DEC-2). The hand-rolled probes
# (--whoami / --characters / --set-persona / --seed-first-message) stay on the
# `sessions` wrappers until their own slices.
# The turn path (create / stream / cancel) and all consumer reads are served by the
# worldtree-sdk adapter (`wt.*`); these caller-semantic exceptions are what the adapter
# raises, so the presenter keeps catching ratatoskr's own types (DEC-2). Only the
# Bifrost-binding inputs + `endpoint_for_plane` remain hand-rolled here (the provider
# planes are consumer-orthogonal); `get_session_bifrost`'s admin surface lands in slice-6.
from ratatoskr.sse_client import (
MalformedSseData,
MalformedSseId,
@@ -642,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={
@@ -719,8 +710,8 @@ async def _amain(args: ParsedArgs) -> int:
f"agent_id={info['agent_id']}{kind_suffix}\n"
)
# #347 authored first-message: seed the agent's preset opening (best-effort).
# Uses the transport directly — first_message is a slice-3 hand-rolled path.
if await seed_preset_first_message(transport, session_id, args.agent_id):
# Routed through the wt adapter (slice-3); the seed never blocks create.
if await seed_preset_first_message(client, session_id, args.agent_id):
sys.stderr.write(
f". first_message: seeded preset opening for {args.agent_id}\n"
)
@@ -755,12 +746,29 @@ async def _amain(args: ParsedArgs) -> int:
loop.remove_signal_handler(signal.SIGINT)
def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
def _display_seq(value: Any) -> list[str]:
"""Coerce an open-world wire value to a list of display strings — the degrade-not-
crash floor for a list-typed field (`scopes`, `allowed_roles`, model `items`, ...).
A non-list scalar (absent, null, `123`, or a bare string) empty rather than a
crash: the older `or []` idiom handles absent/null but NOT a truthy non-iterable
(`123 or [] == 123` `for x in 123` `TypeError`) and would char-iterate a bare
string. Only a genuine list/tuple is str-mapped (heid bug-hunt slice-5, findings 1-2).
"""
if not isinstance(value, (list, tuple)):
return []
return [str(x) for x in value]
def _format_whoami(me: Mapping[str, Any], caps: Mapping[str, Any]) -> str:
"""Render the --whoami report: identity (GET /me) + server capabilities."""
lines = ["identity:"]
lines.append(f" user_id: {me.get('user_id', '?')}")
lines.append(f" tier: {me.get('tier', '?')}")
lines.append(f" scopes: {', '.join(me.get('scopes', [])) or '(none)'}")
# Open-world read: `scopes` may be absent, null, a scalar, or carry non-strings —
# `_display_seq` degrades every non-list to empty (the contract names this function
# the degrade-not-crash exemplar; heid code-review + bug-hunt slice-5).
lines.append(f" scopes: {', '.join(_display_seq(me.get('scopes'))) or '(none)'}")
for k in ("display_name", "key_id", "key_label"):
if k in me:
lines.append(f" {k}: {me[k]}")
@@ -769,16 +777,17 @@ def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
if isinstance(templates, dict) and templates:
for name, spec in templates.items():
# A diagnostic renderer must tolerate a malformed / partially-cutover
# server (heid bug-hunt Gróa#1/#2): a non-mapping template value, or an
# explicit-null `allowed_roles` (`.get(k, [])` returns None on null, not
# the default), must degrade — not abort the whole --whoami report.
# server: a non-mapping template value, or an `allowed_roles` that is null
# / a scalar / carries non-strings, must degrade — not abort the whole
# --whoami report (heid bug-hunt slice-5: `_display_seq` guards the
# container type, not just null/element as the prior `or []` did).
if not isinstance(spec, dict):
lines.append(f" ephemeral_template {name}: (malformed)")
continue
# Canonical post-cutover shape (worldtree-dev althing 2026-07-18,
# ADR-0012): roles, not models. `config.role` selects; `config.model`
# is now rejected server-side.
roles = ", ".join(str(r) for r in (spec.get("allowed_roles") or []))
roles = ", ".join(_display_seq(spec.get("allowed_roles")))
lines.append(
f" ephemeral_template {name}: default={spec.get('default_role', '?')} "
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} roles=[{roles}]"
@@ -797,18 +806,23 @@ async def _whoami(args: ParsedArgs) -> int:
vocab + exit codes as the other modes.
"""
assert isinstance(args, ParsedArgs)
async with httpx.AsyncClient(
base_url=args.server_url,
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
) as client:
async with _probe_client(args) as transport:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
try:
me = await get_me(client)
caps = await get_capabilities(client)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
me = await wt.get_me(client)
caps = await wt.get_capabilities(client)
except wt.SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
ConnectFailed, # SDK normalizes a pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
sys.stdout.write(_format_whoami(me, caps))
@@ -829,12 +843,21 @@ async def _characters_probe(args: ParsedArgs) -> int:
(models create get-state delete), print a report, exit. A reference-
consumer smoke of the #161 character surface (needs character.read/write)."""
assert isinstance(args, ParsedArgs)
async with _probe_client(args) as client:
async with _probe_client(args) as transport:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
try:
models = await list_character_models(client)
names = ", ".join(m.get("name", "?") for m in models.get("items", []))
models = await wt.list_character_models(client)
# Open-world reads degrade, never crash (heid code-review + bug-hunt slice-5):
# guard the top-level `models` is a mapping AND `items` is a list before
# iterating (a scalar `items: 123` makes `... or []` yield `123` → `for m in
# 123` TypeError), then guard each entry is a dict with a str-coerced `name`.
raw_items = models.get("items") if isinstance(models, Mapping) else None
items = raw_items if isinstance(raw_items, (list, tuple)) else []
names = ", ".join(
str(m.get("name", "?")) for m in items if isinstance(m, dict)
)
sys.stdout.write(f"character models: {names or '(none)'}\n")
created = await create_character(
created = await wt.create_character(
client,
{
"schema_version": "1",
@@ -848,16 +871,35 @@ async def _characters_probe(args: ParsedArgs) -> int:
"voice_profile_block": "plain",
},
)
cid = created["character_id"]
# Open-world create ACK: degrade, don't hard-index (cumulative cutover
# foot-gun). A non-mapping ACK or an absent/blank character_id aborts the
# probe cleanly (exit 20) rather than raising AttributeError/KeyError — the
# lifecycle needs the id for state + delete (heid bug-hunt slice-5). Past the
# guard, `created`/`state` are known mappings.
cid = created.get("character_id") if isinstance(created, Mapping) else None
if not (isinstance(cid, str) and cid):
sys.stderr.write(
f"[session_api_failed] create returned no character_id: {created!r}\n"
)
return 20
sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n")
state = await get_character_state(client, cid)
sys.stdout.write(f"state: pad={state.get('pad')}\n")
await delete_character(client, cid)
state = await wt.get_character_state(client, cid)
pad = state.get("pad") if isinstance(state, Mapping) else None
sys.stdout.write(f"state: pad={pad}\n")
await wt.delete_character(client, cid)
sys.stdout.write(f"deleted: {cid}\n")
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
except wt.SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
ConnectFailed, # SDK normalizes a pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
return 0
@@ -882,16 +924,34 @@ async def _set_persona_probe(args: ParsedArgs) -> int:
"(pleasure,arousal,dominance), e.g. '0.4,0.1,-0.2'\n"
)
return 10
# Canonical POST /sessions/{id}/persona_state body (#317): a named-key dict,
# NOT a bare list — {"pad": {"pleasure", "arousal", "dominance"}}.
snapshot = {"pad": {"pleasure": pad[0], "arousal": pad[1], "dominance": pad[2]}}
async with _probe_client(args) as client:
# The SDK rejects a non-finite axis pre-HTTP (a NaN/Infinity would serialize to
# null and corrupt the injection). Reject it here as a usage error so the probe
# surfaces a clean message instead of crashing on the SDK's ConfigurationError.
if not all(math.isfinite(x) for x in pad):
sys.stderr.write(
"[usage_error] --set-persona-pad values must be finite floats (no nan/inf)\n"
)
return 10
async with _probe_client(args) as transport:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
try:
await set_persona_state(client, args.session_id, snapshot)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
# The SDK owns the canonical {"pad": {...}} wire body (#317); ratatoskr
# passes the three PAD axes and no longer hand-builds the snapshot.
await wt.set_persona_state(
client, args.session_id, pleasure=pad[0], arousal=pad[1], dominance=pad[2]
)
except wt.SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
ConnectFailed, # SDK normalizes a pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
sys.stdout.write(
@@ -914,19 +974,23 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int:
"""
assert isinstance(args, ParsedArgs)
assert args.agent_id is not None and args.seed_first_message is not None
async with _probe_client(args) as client:
async with _probe_client(args) as transport:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
try:
session = await create_session(
# wt.create_session returns the SDK's open-world create dict; read as a
# mapping (no SessionInfo dataclass — the hand-rolled path is retired).
session = await wt.create_session(
client, args.agent_id, end_user_id=args.end_user_id
)
sys.stdout.write(f"session: {session.session_id} (agent {session.agent_id})\n")
session_id = session["session_id"]
sys.stdout.write(f"session: {session_id} (agent {session.get('agent_id')})\n")
key = "ratatoskr-first-message-" + hashlib.sha256(
args.seed_first_message.encode("utf-8")
).hexdigest()[:12]
try:
ack = await write_authored_history(
ack = await wt.write_authored_history(
client,
session.session_id,
session_id,
content=args.seed_first_message,
idempotency_key=key,
)
@@ -941,17 +1005,25 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int:
f"seeded: seq={ack.get('seq')} phase={ack.get('phase')} "
f"turn_id={ack.get('turn_id')} content_chars={ack.get('content_chars')}\n"
)
history = await get_session_messages(client, session.session_id)
history = await wt.get_session_messages(client, session_id)
items = history.get("items", [])
sys.stdout.write(f"read-back: {len(items)} message(s)\n")
for m in items:
sys.stdout.write(
f" seq={m.get('seq')} role={m.get('role')} content={m.get('content')!r}\n"
)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
except wt.SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
ConnectFailed, # SDK normalizes a pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
return 0
+6 -6
View File
@@ -14,12 +14,12 @@ blocked (the session simply opens with no seeded greeting). See
import asyncio
import hashlib
import httpx
from worldtree_sdk import WorldtreeClient
from ratatoskr.sessions import write_authored_history
from ratatoskr import wt
# Cap the best-effort seed write. The CLI/TUI create paths reuse an httpx client
# with NO read timeout (it streams SSE turns), so an accepted-but-never-answered
# Cap the best-effort seed write. The CLI create path reuses a transport with NO
# read timeout (it streams SSE turns), so an accepted-but-never-answered
# POST /history would otherwise block session creation forever — violating INV-001's
# "never block". asyncio.wait_for bounds the seed regardless of the client's timeout.
_SEED_TIMEOUT_S = 10.0
@@ -39,7 +39,7 @@ def preset_for(agent_id: str) -> str | None:
async def seed_preset_first_message(
client: httpx.AsyncClient, session_id: str, agent_id: str
client: WorldtreeClient, session_id: str, agent_id: str
) -> str | None:
"""Best-effort: seed ``agent_id``'s preset opening as a #347 authored
first-message on ``session_id``; return the seeded text, or None.
@@ -64,7 +64,7 @@ async def seed_preset_first_message(
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
try:
await asyncio.wait_for(
write_authored_history(
wt.write_authored_history(
client, session_id, content=content, idempotency_key=key
),
timeout=_SEED_TIMEOUT_S,
+9 -4
View File
@@ -27,7 +27,11 @@ import os
from dataclasses import asdict, dataclass
from pathlib import Path
_SCHEMA_VERSION = 1
# v2 (worldtree-sdk cutover slice-4): `model` → `role`. The field holds what the
# Worldtree wire now calls a role (spec 1.2 / b128, the define response echoes
# `role`); the version bump discards any pre-cutover on-disk index cleanly
# (no-backwards-compat — old `{"model": …}` rows are dropped, re-defined fresh).
_SCHEMA_VERSION = 2
@dataclass(frozen=True)
@@ -37,16 +41,17 @@ class LocalAgentEntry:
Schema:
- ``agent_id``: full "user_id:agent_name" string (Worldtree-owned).
- ``agent_name``: slug from define (display name).
- ``model``: provider model ID at last define/patch.
- ``role``: model-role at last define/patch (e.g. "thoughtful-character";
the define/patch response's ``role`` field, W-4 resolved / b128).
- ``description``: synthetic display string (typically derived from
the system_prompt's first line + a "(tier 3)" prefix; the picker
uses this in its ``{id} · {name} {description}`` rendering).
- ``defined_at``: ISO-8601 timestamp from the Tier3AgentInfo response.
- ``defined_at``: ISO-8601 timestamp from the define/patch response.
"""
agent_id: str
agent_name: str
model: str
role: str
description: str
defined_at: str
+71 -446
View File
@@ -1,63 +1,17 @@
"""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
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
import httpx
@dataclass(frozen=True)
class SessionInfo:
"""Worldtree session envelope; shared shape for create + list responses.
INV-001 / INV-002: origin-conditional defaults `create_session` sets fixed
`name=None`, `archived=False`, `tags=[]`; `list_sessions` populates from the
response item with the same absent/null defaults but `message_count=None`.
Amendment 2026-07-18 (issue #161): `kind` ("ephemeral" | "foundational") and
`config` (the frozen ephemeral config, create-origin only) captured defensively
via `.get()` both `None` on a pre-cutover server that omits them.
"""
session_id: str
agent_id: str
created_at: str
last_active: str
metadata: dict[str, Any]
message_count: int | None
name: str | None
archived: bool
tags: list[str]
kind: str | None = None
config: dict[str, Any] | None = None
@dataclass(frozen=True)
class AgentInfo:
"""Worldtree agent envelope from GET /agents (issue #8).
INV-005: required fields (`agent_id`, `name`, `description`) take the
response value verbatim. Optional fields default to None / [] / {} when
omitted by the server, mirroring SessionInfo's INV-001/INV-002
origin-conditional defaulting.
"""
agent_id: str
name: str
description: str
version: str | None
capabilities: list[str]
supported_models: list[str]
persona_traits: dict[str, Any]
ui_hints: dict[str, Any]
@dataclass(frozen=True)
@@ -198,6 +152,68 @@ class AuthoredHistoryUnavailable(Exception):
self.session_id = session_id
# ── Tier-3 (consumer-defined) agent lifecycle exceptions ─────────────────────
# The worldtree-sdk adapter (`ratatoskr.wt`) re-raises these from the agents.define/
# patch/delete routes. They live HERE (not in `tier3`) so both `wt` and the
# `python -m ratatoskr.tier3` CLI reference the SAME class objects: `tier3` is run as
# `__main__`, and if these were defined there, `wt`'s `from .tier3 import …` would bind
# a SECOND copy under `ratatoskr.tier3` — so a raised exception would not match the
# CLI's `except` (the exception would escape as an uncaught traceback). Homing them in
# `sessions` (never run as `__main__`) makes the class identity single.
class Tier3QuotaExceeded(Exception):
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
on the Heimdall key. ``retry_after`` captures the Retry-After header
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero).
Post-cutover the adapter constructs this with ``retry_after=0`` the SDK's
``ApiError`` floor carries no response headers, and §2675 pins Phase-2.0 quota
to ``Retry-After: 0``, so the value is spec-canonical."""
def __init__(self, *, retry_after: int) -> None:
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
self.retry_after = retry_after
class Tier3UserIdUnsupported(Exception):
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
is not slug-safe per Phase 2.0 gate (spec §2626)."""
def __init__(self) -> None:
super().__init__("tier3 caller user_id is not slug-safe")
class Tier3FieldNotMutable(Exception):
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
carried a key that's immutable post-define (``agent_name``, ``user_id``,
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
def __init__(self, *, field: str | None) -> None:
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
self.field = field
class Tier3LayerDeferred(Exception):
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
Note: ``define_agent`` never sends layer fields, so this exception is
defense-against-server-side-changes / forward-compat."""
def __init__(self, *, field: str | None) -> None:
super().__init__(f"tier3 layer field deferred: {field!r}")
self.field = field
class Tier3AgentNotFound(Exception):
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
(spec §2634 + §2641)."""
def __init__(self, *, agent_id: str) -> None:
super().__init__(f"tier3 agent not found: {agent_id!r}")
self.agent_id = agent_id
def endpoint_for_plane(plane: str, base_host: str) -> str:
@@ -213,396 +229,5 @@ def endpoint_for_plane(plane: str, base_host: str) -> str:
"""
ports = {"memory": 8391, "affect": 8390, "combined": 8392}
if plane not in ports:
raise ValueError(
f"unknown plane: {plane!r} "
"(expected 'memory', 'affect', or 'combined')"
)
raise ValueError(f"unknown plane: {plane!r} (expected 'memory', 'affect', or 'combined')")
return f"http://{base_host}:{ports[plane]}"
def _bifrost_error_from(resp: httpx.Response) -> str | None:
"""Pull the spec-level `bifrost_error` from a 502 body.
Tolerates both the FastAPI-nested `{"detail": {"bifrost_error": }}` shape
(the spec's documented form, §"Optional Bifrost binding") and a flat
top-level `bifrost_error`, per the both-shape unwrap precedent established for
persona_state errors (the real wire returns the detail-nested form).
"""
try:
err = resp.json()
except ValueError:
return None
if not isinstance(err, dict):
return None
bifrost_error = err.get("bifrost_error")
if bifrost_error is None and isinstance(err.get("detail"), dict):
bifrost_error = err["detail"].get("bifrost_error")
return bifrost_error
async def create_session(
client: httpx.AsyncClient,
agent_id: str,
*,
end_user_id: str | None = None,
bifrost: BifrostBinding | None = None,
consumer_key: str | None = None,
config: Mapping[str, Any] | None = None,
) -> SessionInfo:
"""POST /sessions to create a new session. See contract FN create_session.
Per issue #5: pass `end_user_id` for per-end-user agents (lofn etc.).
When None (default), the body shape matches the pre-#5 baseline
`{"agent_id": agent_id}` so existing callers (mimir smoke) are unaffected.
Empty-string `end_user_id` is rejected before HTTP (PRE-003).
Per issue #17: when `bifrost` is set the request carries the binding and
authenticates with `consumer_key` (NOT the client's default canary bearer);
Worldtree handshakes synchronously to our provider before 201.
"""
assert client is not None
assert agent_id and isinstance(agent_id, str)
assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
# PRE-004 (issue #161): config is None or a Mapping.
assert config is None or isinstance(config, Mapping)
# PRE-005 (issue #161): ephemeral config + Bifrost binding are mutually
# exclusive (server would 422 ephemeral_does_not_accept_bifrost). The CLI
# guards this at arg-parse; this assert is defense-in-depth.
assert not (config is not None and bifrost is not None)
# PRE-001 (INV-001): a bifrost binding REQUIRES a non-empty consumer key,
# enforced before any HTTP so a bound create never falls back to the canary.
if bifrost is not None and not (isinstance(consumer_key, str) and consumer_key):
raise BifrostConsumerKeyMissing()
body: dict[str, Any] = {"agent_id": agent_id}
if end_user_id is not None:
body["end_user_id"] = end_user_id
# Issue #161: ephemeral-template config passthrough — verbatim, role/model-
# agnostic. The caller (CLI) builds {"system_prompt": ...}; the wrapper never
# injects a selector. Absent when None (foundational baseline unchanged).
if config is not None:
body["config"] = dict(config)
headers: dict[str, str] = {}
if bifrost is not None:
body["bifrost"] = {
"endpoint_url": bifrost.endpoint_url,
"scope": bifrost.scope,
}
# INV-001: the bound create authenticates with the consumer key,
# overriding the httpx client's default canary bearer per-request.
headers["Authorization"] = f"Bearer {consumer_key}"
resp = await client.post("/sessions", json=body, headers=headers)
if resp.status_code == 404:
raise AgentNotFound(agent_id=agent_id)
# POST-002 (INV-002): a 502 on a BOUND create is the synchronous Bifrost
# handshake failing. Gated on `bifrost is not None` — an unbound create's
# 502 is a generic upstream fault and stays SessionApiFailed.
if bifrost is not None and resp.status_code == 502:
raise BifrostHandshakeFailed(
bifrost_error=_bifrost_error_from(resp), body=resp.content
)
if resp.status_code != 201:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
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", {}),
message_count=body["message_count"],
name=None,
archived=False,
tags=[],
kind=body.get("kind"), # INV-001 amendment (#161): "ephemeral"|"foundational"|None
config=body.get("config"), # frozen ephemeral config; None for foundational
)
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
"""GET /agents — list available agents. See contract FN list_agents (issue #8).
No request params, no pagination. Returns server-ordered list. Optional
fields are defaulted to None / [] / {} per INV-005.
"""
assert client is not None
resp = await client.get("/agents")
if resp.status_code != 200:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
return [
AgentInfo(
agent_id=item["agent_id"],
name=item["name"],
description=item["description"],
version=item.get("version"),
capabilities=item.get("capabilities") or [],
supported_models=item.get("supported_models") or [],
persona_traits=item.get("persona_traits") or {},
ui_hints=item.get("ui_hints") or {},
)
for item in body
]
async def get_persona_state(
client: httpx.AsyncClient, agent_id: str
) -> dict[str, Any]:
"""GET /agents/{agent_id}/persona_state — fetch current persona snapshot.
Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the
`affect_update` SSE event's `status="current"` emission: pad,
dominant_emotion, emotions_active, baseline_pad, mood_drift,
last_updated_at. Bootstrap read for clients that want to populate a
persona pane on session-open without waiting for turn-1's `affect_update`.
Auth: requires Heimdall `persona.read` scope (user-tier default).
Failure modes (mapped to typed exceptions per the spec error_codes):
- 404 `persona_not_configured` PersonaNotConfigured (persona-disabled
agents: domari / muninn, and all Tier 3 in Phase 2.0)
- 404 `agent_not_available` AgentNotAvailable (unknown agent_id)
- 403 `auth_scope_denied` AuthScopeDenied (key lacks persona.read)
- any other non-2xx SessionApiFailed (preserves the broader-error
precedent from list_agents / list_sessions / create_session)
"""
assert client is not None
assert agent_id and isinstance(agent_id, str)
resp = await client.get(f"/agents/{agent_id}/persona_state")
if resp.status_code == 200:
return resp.json()
# Discriminate the 4xx error_code sub-codes; everything else falls
# through. Worldtree returns errors as either flat `{"error_code": …}`
# OR FastAPI-default `{"detail": {"error_code": …}}` depending on
# which handler raised — unwrap both shapes (real wire observed
# 2026-05-28 returning the detail-nested form for auth_scope_denied
# from /agents/{id}/persona_state).
try:
err = resp.json()
except ValueError:
err = None
error_code: str | None = None
if isinstance(err, dict):
error_code = err.get("error_code")
if error_code is None and isinstance(err.get("detail"), dict):
error_code = err["detail"].get("error_code")
if resp.status_code == 404 and error_code == "persona_not_configured":
raise PersonaNotConfigured(agent_id=agent_id)
if resp.status_code == 404 and error_code == "agent_not_available":
raise AgentNotAvailable(agent_id=agent_id)
if resp.status_code == 403 and error_code == "auth_scope_denied":
raise AuthScopeDenied(scope="persona.read")
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_me(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /me — the authenticated principal's identity + key metadata (spec §GET /me).
Boot-time whoami: verify the key without agent-config side effects. Returns
the parsed dict verbatim (freeform per the frozen OpenAPI; the spec documents
`{user_id, scopes, tier, display_name?, key_id?, key_label?, ...}`, optional
fields omitted-not-null). 401 (bad/absent key when auth is enabled) like
every other non-200 surfaces as SessionApiFailed (get_persona_state
precedent). Read-only, rate-exempt, no audit emission.
"""
assert client is not None
resp = await client.get("/me")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def list_character_models(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /models/available-for-characters — character-capable model profiles (#161).
Requires `character.read`. Returns `{items: [{name, description, thinking}]}`.
Parsed dict verbatim; any non-200 SessionApiFailed.
"""
assert client is not None
resp = await client.get("/models/available-for-characters")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def create_character(
client: httpx.AsyncClient, character: dict[str, Any], *, state: dict[str, Any] | None = None
) -> dict[str, Any]:
"""POST /characters — create a transient character (#161). Requires `character.write`.
Body is `{character, state}` (state optional a CharacterStateSchema for
mid-conversation rehydration). Returns 201 `{character_id, ttl_expires_at}`;
any non-201 SessionApiFailed.
"""
assert client is not None
assert isinstance(character, dict) and character
resp = await client.post("/characters", json={"character": character, "state": state})
if resp.status_code == 201:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_character_state(client: httpx.AsyncClient, character_id: str) -> dict[str, Any]:
"""GET /characters/{character_id}/state — live runtime state (#161). Requires `character.read`.
Returns `{schema_version, pad, emotions_active, mood_drift, goal_signal_history}`;
refreshes the character's TTL. Any non-200 → SessionApiFailed.
"""
assert client is not None
assert character_id and isinstance(character_id, str)
resp = await client.get(f"/characters/{character_id}/state")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def delete_character(client: httpx.AsyncClient, character_id: str) -> None:
"""DELETE /characters/{character_id} — remove a transient character (#161).
Requires `character.write`. Bound sessions detach (next turn 410
character_not_found). 200/204 None; any other status SessionApiFailed.
"""
assert client is not None
assert character_id and isinstance(character_id, str)
resp = await client.delete(f"/characters/{character_id}")
if resp.status_code in (200, 204):
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def set_persona_state(
client: httpx.AsyncClient, session_id: str, snapshot: dict[str, Any]
) -> None:
"""POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection).
The request body is FREEFORM on the wire (the OpenAPI declares no request
schema), but worldtree-dev's prose now pins the canonical shape (#317):
`{"pad": {"pleasure": p, "arousal": a, "dominance": d}}` a named-key dict
(each in [-1, 1]), NOT a bare list; PAD-only, session-scoped, pull-over-push
(#289). The caller supplies the snapshot. 204 No Content → None; any other
status SessionApiFailed.
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert isinstance(snapshot, dict)
resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot)
if resp.status_code == 204:
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_bifrost(
client: httpx.AsyncClient, session_id: str, *, admin_key: str
) -> dict[str, Any]:
"""GET /admin/sessions/{session_id}/bifrost — admin-scoped Bifrost dispatch state (#176).
Returns the live Bifrost binding for a session: `{endpoint_url, consumer_id,
connected, capabilities_granted, tools: [{name, description}]}`. Requires the
`admin.sessions.read` scope (admin tier), so the request OVERRIDES the
Authorization header with `admin_key` (distinct from the client's default
consumer key). Read-only (audited server-side). Parsed dict verbatim; any
non-200 SessionApiFailed notably 403 `auth_scope_denied` (key lacks the
scope) and 404 `session_not_bifrost_bound` (session exists, no live client).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert admin_key and isinstance(admin_key, str)
resp = await client.get(
f"/admin/sessions/{session_id}/bifrost",
headers={"Authorization": f"Bearer {admin_key}"},
)
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /capabilities — server capability discovery (spec §Ephemeral Templates).
Returns `{ephemeral_templates: {echo: {allowed_models, default_model,
system_prompt_max_bytes}}}` what the server offers before a client decides
to instantiate. Any authenticated caller may read it (no scope). Parsed dict
verbatim; any non-200 SessionApiFailed.
"""
assert client is not None
resp = await client.get("/capabilities")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def 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]:
"""POST /sessions/{session_id}/history — the #347 authored-history-write primitive.
Write one model-visible turn into the session's ledger AS the bound agent,
WITHOUT a generation and WITHOUT lived-turn side effects (the SillyTavern
"first message"). v1: `author="assistant"`, `effects` omitted (== "none"),
`idempotency_key` REQUIRED (per-session dedup). The server pins the body
(`AuthoredWriteRequest`, `extra="forbid"`), so `effects` /
`claimed_original_at` are sent only when non-None never as null keys.
Success is 201 (fresh) or 200 (idempotent replay, byte-identical body); both
return the `AuthoredTurnResponse` dict verbatim (`{author, content_chars,
injected_at, phase, seq, session_id, turn_id}` provenance is audit-only,
never on this body).
404 `AuthoredHistoryUnavailable` (hide-existence: feature-absent /
ungranted / session-absent are indistinguishable by design; the caller falls
back and NEVER capability-probes server INV-347-1). Any other non-2xx
`SessionApiFailed` (notably 409 `generation_active`, 422 `content_too_long` /
`validation_failed`).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert idempotency_key and isinstance(idempotency_key, str)
assert author and isinstance(author, str)
body: dict[str, Any] = {
"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
resp = await client.post(f"/sessions/{session_id}/history", json=body)
if resp.status_code in (200, 201):
return resp.json()
if resp.status_code == 404:
raise AuthoredHistoryUnavailable(session_id=session_id)
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_messages(
client: httpx.AsyncClient, session_id: str
) -> dict[str, Any]:
"""GET /sessions/{session_id}/messages — the session's message history.
Un-deferred as the #347 seed read-back: a seeded turn renders as a normal
`role=assistant` message (model-invisible provenance 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
add limit/cursor when a caller needs scrollback).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
resp = await client.get(f"/sessions/{session_id}/messages")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
+7 -55
View File
@@ -1,18 +1,18 @@
"""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
import json
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any, NamedTuple
import httpx
import httpx_sse
class SseId(NamedTuple):
"""Parsed composite SSE wire `id:` per spec §SSE id format."""
@@ -173,51 +173,3 @@ class CancelFailed(Exception):
super().__init__(f"cancel failed: status={status}, body={body[:128]!r}")
self.status = status
self.body = body
async def stream_admin_events(
client: httpx.AsyncClient,
*,
admin_key: str,
last_event_id: int | None = None,
) -> AsyncIterator[AdminEvent]:
"""GET /admin/events SSE — the admin-tier lifecycle broadcast stream (INV-046).
Yields `AdminEvent` envelopes as they arrive. Admin-scoped (admin.events.read):
the request OVERRIDES Authorization with `admin_key` (distinct from the
client's default consumer bearer). `last_event_id` sets the `Last-Event-ID`
header for resume (plain decimal int). Long-lived iterate until the caller
stops or the connection ends. Non-200 SseConnectFailed; a mid-stream drop
SseConnectionDropped (caller may reconnect from the last-seen `AdminEvent.id`).
Malformed frames are skipped (best-effort stream).
"""
assert client is not None
assert admin_key and isinstance(admin_key, str)
headers = {"Authorization": f"Bearer {admin_key}"}
if last_event_id is not None:
headers["Last-Event-ID"] = str(last_event_id)
async with httpx_sse.aconnect_sse(
client, "GET", "/admin/events", headers=headers
) as event_source:
if event_source.response.status_code != 200:
body = await event_source.response.aread()
raise SseConnectFailed(status=event_source.response.status_code, body=body)
try:
async for sse in event_source.aiter_sse():
if sse.data == "":
continue
try:
env = json.loads(sse.data)
except json.JSONDecodeError:
continue # skip a malformed admin frame (best-effort)
yield AdminEvent(
id=env.get("id", 0),
type=env["type"],
timestamp=env.get("timestamp"),
data=env.get("data", {}),
)
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ReadTimeout) as exc:
raise SseConnectionDropped(last_seen_sse_id=None) from exc
+110 -292
View File
@@ -1,259 +1,55 @@
"""Worldtree Tier 3 (consumer-defined) agent lifecycle client.
"""Worldtree Tier 3 (consumer-defined) agent CLI + caller-semantic exceptions.
Implements docs/contracts/issues/15.contract.md. Caller-owned httpx.AsyncClient
posture (same as ratatoskr.sessions). Exposes three lifecycle operations:
The define / patch / delete wire calls route through the worldtree-sdk adapter
(``ratatoskr.wt.define_agent`` / ``patch_agent`` / ``delete_agent``); this module owns
the ``python -m ratatoskr.tier3`` CLI and the Tier-3 caller-semantic exception
taxonomy the adapter re-raises (quota / user-id / field-not-mutable / layer-deferred /
not-found). The picker already handles colon-containing agent_ids generically
(issue #8).
- ``define_agent`` POST /agents/define
- ``patch_agent`` PATCH /agents/<id>
- ``delete_agent`` DELETE /agents/<id>
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
already handles colon-containing agent_ids generically (issue #8); session
creation works unchanged via ``ratatoskr.sessions.create_session``.
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
The old hand-rolled httpx wrappers + the ``Tier3AgentInfo`` dataclass were deleted in
the worldtree-sdk cutover (issue #20, slice-4); the adapter returns the SDK's
open-world define/patch dicts (echoing ``role`` post-b128, spec 1.2), read here as
mappings. ``wt`` imports this module's exceptions at module level; this module imports
``wt`` only lazily inside the CLI handlers, so there is no import cycle.
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass
import sys
from collections.abc import Mapping
from typing import Any
import httpx
from ratatoskr.sessions import SessionApiFailed
# Per spec §2627: agent_name + user_id slugs are `[a-z][a-z0-9-]{2,63}`.
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
# The Tier-3 caller-semantic exceptions live in `sessions` (never run as `__main__`)
# so the adapter's raise and this CLI's `except` reference the SAME class objects —
# see the header note in `sessions.py`. `main()` catches these; `wt` raises them.
from ratatoskr.sessions import (
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
)
@dataclass(frozen=True)
class Tier3AgentInfo:
"""Worldtree Tier 3 agent envelope returned by define / patch.
INV-001: ``agent_id`` is always shape ``"<user_id>:<agent_name>"``
constructed server-side from the auth's user_id + the supplied agent_name.
"""
agent_id: str
user_id: str
agent_name: str
system_prompt: str
model: str
created_at: str
updated_at: str
class Tier3QuotaExceeded(Exception):
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
on the Heimdall key. ``retry_after`` captures the Retry-After header
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero)."""
def __init__(self, *, retry_after: int) -> None:
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
self.retry_after = retry_after
class Tier3UserIdUnsupported(Exception):
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
is not slug-safe per Phase 2.0 gate (spec §2626)."""
def __init__(self) -> None:
super().__init__("tier3 caller user_id is not slug-safe")
class Tier3FieldNotMutable(Exception):
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
carried a key that's immutable post-define (``agent_name``, ``user_id``,
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
def __init__(self, *, field: str | None) -> None:
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
self.field = field
class Tier3LayerDeferred(Exception):
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
Note: ``define_agent`` never sends layer fields, so this exception is
defense-against-server-side-changes / forward-compat. INV-001 in the
request body construction is the first line of defense.
"""
def __init__(self, *, field: str | None) -> None:
super().__init__(f"tier3 layer field deferred: {field!r}")
self.field = field
class Tier3AgentNotFound(Exception):
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
(spec §2634 + §2641)."""
def __init__(self, *, agent_id: str) -> None:
super().__init__(f"tier3 agent not found: {agent_id!r}")
self.agent_id = agent_id
def _extract_error_code(resp: httpx.Response) -> str | None:
"""Pluck the ``detail.error_code`` from a Worldtree error envelope.
Worldtree wraps API errors in ``{"detail": {"error_code": "...", ...}}``
per the spec. Returns None on shape mismatch (so callers fall through
to the generic ``SessionApiFailed`` branch).
"""
try:
body = resp.json()
except ValueError:
return None
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, dict):
code = detail.get("error_code")
if isinstance(code, str):
return code
return None
def _extract_error_field(resp: httpx.Response) -> str | None:
"""Pluck ``detail.field`` from a Worldtree error envelope (used for
``field_not_mutable`` and ``layer_deferred`` to surface which field
triggered the rejection). Returns None on shape mismatch.
"""
try:
body = resp.json()
except ValueError:
return None
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, dict):
field = detail.get("field")
if isinstance(field, str):
return field
return None
def _parse_tier3_agent_info(body: dict) -> Tier3AgentInfo:
"""Parse a Worldtree Tier 3 agent JSON body into the frozen dataclass."""
return Tier3AgentInfo(
agent_id=body["agent_id"],
user_id=body["user_id"],
agent_name=body["agent_name"],
system_prompt=body["system_prompt"],
model=body["model"],
created_at=body["created_at"],
updated_at=body["updated_at"],
)
async def define_agent(
client: httpx.AsyncClient,
*,
agent_name: str,
system_prompt: str,
role: str,
) -> Tier3AgentInfo:
"""POST /agents/define — create a Tier 3 agent.
See contract FN define_agent. Validates the agent_name slug client-side
before the network round-trip; server-side validation is the safety net.
Returns a fully populated Tier3AgentInfo on 201. Routes documented error
codes to typed exceptions; unknown non-2xx SessionApiFailed.
"""
assert client is not None
assert _SLUG_RE.match(agent_name), (
f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
)
assert system_prompt, "system_prompt must be non-empty"
assert role, "role must be non-empty"
# b125 drift: /agents/define takes `role` (a model-role, e.g. "thoughtful-character")
# in the request; the response echoes it back as `model`. See #15 follow-up.
body = {
"agent_name": agent_name,
"system_prompt": system_prompt,
"role": role,
}
resp = await client.post("/agents/define", json=body)
if resp.status_code == 201:
return _parse_tier3_agent_info(resp.json())
if resp.status_code == 429:
# Spec §2675: 51st define → 429 with Retry-After: 0.
try:
retry_after = int(resp.headers.get("Retry-After", "0"))
except (TypeError, ValueError):
retry_after = 0
raise Tier3QuotaExceeded(retry_after=retry_after)
if resp.status_code == 403:
if _extract_error_code(resp) == "tier3_user_id_unsupported":
raise Tier3UserIdUnsupported()
if resp.status_code == 422:
code = _extract_error_code(resp)
if code == "layer_deferred":
raise Tier3LayerDeferred(field=_extract_error_field(resp))
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def patch_agent(
client: httpx.AsyncClient,
agent_id: str,
*,
system_prompt: str | None = None,
role: str | None = None,
) -> Tier3AgentInfo:
"""PATCH /agents/<id> — mutate system_prompt and/or model.
See contract FN patch_agent. Per spec §2641: only system_prompt + model
are mutable in Phase 2.0; any other key returns 422 field_not_mutable.
"""
assert client is not None
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
assert system_prompt is not None or role is not None, (
"patch requires at least one of system_prompt or role"
)
body: dict[str, str] = {}
if system_prompt is not None:
body["system_prompt"] = system_prompt
if role is not None:
body["role"] = role
resp = await client.patch(f"/agents/{agent_id}", json=body)
if resp.status_code == 200:
return _parse_tier3_agent_info(resp.json())
if resp.status_code == 404:
raise Tier3AgentNotFound(agent_id=agent_id)
if resp.status_code == 422:
code = _extract_error_code(resp)
if code == "field_not_mutable":
raise Tier3FieldNotMutable(field=_extract_error_field(resp))
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
"""DELETE /agents/<id> — owner hard-delete (cancels active sessions
server-side per spec §2636).
See contract FN delete_agent. 204 on success; 404 if the agent_id
doesn't exist; other non-2xx → SessionApiFailed.
"""
assert client is not None
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
resp = await client.delete(f"/agents/{agent_id}")
if resp.status_code == 204:
return
if resp.status_code == 404:
raise Tier3AgentNotFound(agent_id=agent_id)
raise SessionApiFailed(status=resp.status_code, body=resp.content)
def _str_field(info: Mapping[str, Any], key: str, *, default: str = "") -> str:
"""A string field off an open-world response dict, or `default` when the key is
absent / null / non-string so a partial or drifted 2xx define/patch response
degrades rather than KeyError/AttributeError-crashing the CLI presenter (the
"open-world reads degrade, never crash the presenter" invariant; heid-bug-hunt)."""
value = info.get(key)
return value if isinstance(value, str) else default
# ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------
#
# Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes
# mirror ratatoskr.cli: 0 happy / 10 usage / 11 auth / 20 api-failure /
# 21 network. Outbound requests carry the same User-Agent string.
# 21 network. Outbound requests carry the same User-Agent string. The wire calls
# route through the worldtree-sdk adapter (ratatoskr.wt) over a ratatoskr-owned
# injected transport (INV-CUT-1: the SDK never closes it).
class _Tier3UsageError(Exception):
@@ -284,7 +80,7 @@ def _build_parser() -> argparse.ArgumentParser:
help="Model role, e.g. thoughtful-character (see GET /models/available-for-characters).",
)
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or model.")
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or role.")
p_patch.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
p_patch.add_argument("--system-prompt", dest="system_prompt", default=None)
p_patch.add_argument("--role", default=None)
@@ -308,46 +104,63 @@ def _resolve_auth(ns: argparse.Namespace) -> tuple[str, str]:
return api_key, server_url
def _transport(server_url: str, api_key: str) -> httpx.AsyncClient:
"""The ratatoskr-owned httpx transport the adapter's WorldtreeClient is built
over. Carries the User-Agent + a generous read timeout for agent CRUD; the SDK
re-applies auth per request (the default bearer here just mirrors it)."""
from ratatoskr.cli import USER_AGENT
return httpx.AsyncClient(
base_url=server_url,
headers={"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
)
async def _run_define(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr import wt
from ratatoskr.local_agents import (
LocalAgentEntry,
add_local_agent,
make_description,
)
async with httpx.AsyncClient(
base_url=server_url,
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": USER_AGENT,
},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
) as client:
info = await define_agent(
async with _transport(server_url, api_key) as transport:
client = wt.build_client(server_url, api_key=api_key, transport=transport)
info = await wt.define_agent(
client,
agent_name=ns.name,
system_prompt=ns.system_prompt,
role=ns.role,
)
# v0.8.0: persist to local index so the picker can show it.
add_local_agent(
LocalAgentEntry(
agent_id=info.agent_id,
agent_name=info.agent_name,
model=info.model,
description=make_description(info.system_prompt),
defined_at=info.created_at,
# Open-world define dict — read defensively (echoes `role` post-b128, not
# `model`). A partial/drifted 2xx must not crash the presenter.
agent_id = _str_field(info, "agent_id")
if not agent_id: # a 2xx with no usable agent_id is a malformed success
sys.stderr.write(f"[api_failed] define returned no usable agent_id: {info!r}\n")
return 20
role = _str_field(info, "role", default="?")
agent_name = _str_field(info, "agent_name")
# v0.8.0: persist to local index so the picker can show it — only for a
# well-formed identity (agent_id + agent_name); else skip the write, still print.
if agent_name:
add_local_agent(
LocalAgentEntry(
agent_id=agent_id,
agent_name=agent_name,
role=role,
description=make_description(_str_field(info, "system_prompt")),
defined_at=_str_field(info, "created_at"),
)
)
)
print(f"defined {info.agent_id} ({info.model})")
print(f"defined {agent_id} ({role})")
return 0
async def _run_patch(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr import wt
from ratatoskr.local_agents import (
LocalAgentEntry,
make_description,
@@ -358,48 +171,43 @@ async def _run_patch(ns: argparse.Namespace) -> int:
raise _Tier3UsageError(
"patch requires at least one of --system-prompt or --role"
)
async with httpx.AsyncClient(
base_url=server_url,
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": USER_AGENT,
},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
) as client:
info = await patch_agent(
async with _transport(server_url, api_key) as transport:
client = wt.build_client(server_url, api_key=api_key, transport=transport)
info = await wt.patch_agent(
client,
ns.agent_id,
system_prompt=ns.system_prompt,
role=ns.role,
)
# v0.8.0: refresh local index with the post-patch state.
update_local_agent(
LocalAgentEntry(
agent_id=info.agent_id,
agent_name=info.agent_name,
model=info.model,
description=make_description(info.system_prompt),
defined_at=info.updated_at,
# Open-world patch dict — read defensively (same degrade-not-crash posture).
agent_id = _str_field(info, "agent_id")
if not agent_id: # a 2xx with no usable agent_id is a malformed success
sys.stderr.write(f"[api_failed] patch returned no usable agent_id: {info!r}\n")
return 20
agent_name = _str_field(info, "agent_name")
# v0.8.0: refresh local index with the post-patch state (well-formed identity only).
if agent_name:
update_local_agent(
LocalAgentEntry(
agent_id=agent_id,
agent_name=agent_name,
role=_str_field(info, "role", default="?"),
description=make_description(_str_field(info, "system_prompt")),
defined_at=_str_field(info, "updated_at"),
)
)
)
print(f"patched {info.agent_id}")
print(f"patched {agent_id}")
return 0
async def _run_delete(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr import wt
from ratatoskr.local_agents import remove_local_agent
async with httpx.AsyncClient(
base_url=server_url,
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": USER_AGENT,
},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
) as client:
await delete_agent(client, ns.agent_id)
async with _transport(server_url, api_key) as transport:
client = wt.build_client(server_url, api_key=api_key, transport=transport)
await wt.delete_agent(client, ns.agent_id)
# v0.8.0: drop from local index so the picker stops listing it.
remove_local_agent(ns.agent_id)
print(f"deleted {ns.agent_id}")
@@ -419,6 +227,10 @@ def main(argv: list[str] | None = None) -> int:
import asyncio
import sys
import worldtree_sdk as wtsdk
from ratatoskr.wt import SessionApiFailed
parser = _build_parser()
try:
ns = parser.parse_args(argv)
@@ -459,10 +271,16 @@ def main(argv: list[str] | None = None) -> int:
return 20
except SessionApiFailed as exc:
sys.stderr.write(
f"[api_failed] status={exc.status} body={exc.body!r}\n"
f"[api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
wtsdk.ConnectFailed, # SDK normalizes any pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
+92 -33
View File
@@ -26,7 +26,13 @@ from starlette.responses import (
)
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from worldtree_sdk import CancelledEvent, DoneEvent, ErrorEvent, WorldtreeClient
from worldtree_sdk import (
CancelledEvent,
ConnectFailed,
DoneEvent,
ErrorEvent,
WorldtreeClient,
)
from ratatoskr import local_agents as _local_agents
from ratatoskr import wt
@@ -39,17 +45,14 @@ from ratatoskr.sessions import (
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
PersonaNotConfigured,
SessionApiFailed,
endpoint_for_plane,
get_persona_state,
get_session_bifrost,
list_agents,
)
# The turn path (create / stream / cancel / tools / messages) is served by the
# worldtree-sdk adapter (`wt.*`), which raises ratatoskr's caller-semantic
# exceptions (DEC-2). The hand-rolled endpoints (persona / agents / admin /
# bifrost) stay on the `sessions` / `sse_client` wrappers until their own slices.
# The turn path (create / stream / cancel / tools / messages), the agents /
# persona-state reads, AND the admin surface (bifrost inspection + admin-events stream)
# are all served by the worldtree-sdk adapter (`wt.*`), which raises ratatoskr's
# caller-semantic exceptions (DEC-2). `AdminEvent` is still ratatoskr's domain event
# type the adapter re-wraps into (imported from `sse_client` until slice-7 teardown).
from ratatoskr.sse_client import (
AdminEvent,
CancelAlreadyCompleted,
@@ -60,16 +63,21 @@ from ratatoskr.sse_client import (
SseConnectFailed,
SseConnectionDropped,
TurnIdFlip,
stream_admin_events,
)
def _wt_client(client: httpx.AsyncClient, *, max_reconnects: int = 5) -> WorldtreeClient:
def _wt_client(
client: httpx.AsyncClient, *, admin_key: str | None = None, max_reconnects: int = 5
) -> WorldtreeClient:
"""Wrap a client_factory transport as the adapter's WorldtreeClient (INV-CUT-1:
the SDK never closes it). base_url + bearer are read off the transport (the
factory bakes them in); the SDK re-applies auth per request, so the extracted
key just mirrors the transport's default. A no-auth test transport falls back to
a placeholder key (respx ignores auth)."""
a placeholder key (respx ignores auth).
`admin_key` is the SERVER-HELD admin credential (slice-6): the SDK's `admin.*`
routes authenticate with the client's `admin_auth`, NOT a per-call header, so an
admin endpoint passes it here. Omitted for the default-tier reads."""
base_url = str(client.base_url) or "http://localhost"
header = client.headers.get("Authorization", "")
# Case-insensitive scheme + tolerant of extra whitespace, so a valid bearer is
@@ -77,7 +85,11 @@ def _wt_client(client: httpx.AsyncClient, *, max_reconnects: int = 5) -> Worldtr
parts = header.split(None, 1)
api_key = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else ""
return wt.build_client(
base_url, api_key=api_key or "ratatoskr", transport=client, max_reconnects=max_reconnects
base_url,
api_key=api_key or "ratatoskr",
admin_key=admin_key,
transport=client,
max_reconnects=max_reconnects,
)
@@ -124,20 +136,31 @@ async def _agents_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
upstream = await list_agents(client)
except SessionApiFailed as exc:
upstream = await wt.list_agents(_wt_client(client))
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_api_failed", "status": exc.status},
status_code=exc.status,
)
except httpx.RequestError as exc:
except (httpx.RequestError, ConnectFailed) as exc:
# The SDK normalizes a transport failure to ConnectFailed(status=0), not a
# raw httpx error; both surface the same network envelope (slice-3 foot-gun).
return JSONResponse(
{"error_code": "network_error", "message": str(exc)},
status_code=502,
)
upstream_ids = {a.agent_id for a in upstream}
# Open-world upstream (parity: no AgentInfo normalization). Degrade, never crash:
# a non-list envelope OR a malformed item (missing/non-str agent_id, non-mapping)
# is dropped rather than KeyError/TypeError'ing the endpoint into a 500 before the
# local fallback merges (heid-bug-hunt: the "open-world reads degrade" invariant).
upstream_items = upstream if isinstance(upstream, list) else []
well_formed = [
a for a in upstream_items
if isinstance(a, Mapping) and isinstance(a.get("agent_id"), str)
]
upstream_ids = {a["agent_id"] for a in well_formed}
local = _local_agents.load_local_agents()
merged = [_as_dict(a) for a in upstream] + [
merged = [_as_dict(a) for a in well_formed] + [
_as_dict(le) for le in local if le.agent_id not in upstream_ids
]
return JSONResponse(merged, status_code=200)
@@ -179,17 +202,17 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
try:
async with client_factory() as client:
wt_client = _wt_client(client)
info = await wt.create_session(
_wt_client(client),
wt_client,
agent_id,
end_user_id=end_user_id,
bifrost=bifrost,
consumer_key=consumer_key if bifrost else None,
)
# #347 authored first-message: seed the agent's preset opening (best-effort;
# never blocks create). first_message is a slice-3 hand-rolled path — it
# reuses the raw transport (its default bearer), not the adapter client.
await seed_preset_first_message(client, info["session_id"], agent_id)
# never blocks create). Routed through the wt adapter (slice-3).
await seed_preset_first_message(wt_client, info["session_id"], agent_id)
except AgentNotFound:
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
except BifrostConsumerKeyMissing:
@@ -433,13 +456,28 @@ async def _persona_state_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
snap = await get_persona_state(client, agent_id)
snap = await wt.get_persona_state(_wt_client(client), agent_id)
except PersonaNotConfigured:
return JSONResponse({"error_code": "persona_not_configured"}, status_code=404)
except AgentNotAvailable:
return JSONResponse({"error_code": "agent_not_available"}, status_code=404)
except AuthScopeDenied:
return JSONResponse({"error_code": "auth_scope_denied"}, status_code=403)
except wt.SessionApiFailed as exc:
# An unmatched upstream ApiError (a 500, or a coded-but-unmapped 4xx) →
# controlled envelope, for parity with _agents_endpoint / create / admin
# (heid-code-review slice-4: the persona endpoint was the lone sibling that
# let it escape as a raw 500 — a latent pre-cutover gap, closed here).
return JSONResponse(
{"error_code": "session_api_failed", "status": exc.status},
status_code=exc.status,
)
except (httpx.RequestError, ConnectFailed) as exc:
# SDK normalizes a transport failure to ConnectFailed(status=0) (slice-3
# foot-gun); surface the network envelope rather than a 500 crash.
return JSONResponse(
{"error_code": "network_error", "message": str(exc)}, status_code=502
)
return JSONResponse(snap, status_code=200)
@@ -534,23 +572,36 @@ async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
"""GET /api/sessions/{session_id}/bifrost → admin-scoped Bifrost dispatch state (#176).
The admin key is SERVER-HELD (app.state.admin_key) and never reaches the
browser (INV-003 precedent upstream credentials stay server-side); the
wrapper overrides the Authorization header with it. Fail-visible when the
admin key isn't configured (never a silent empty pane)."""
browser (INV-003 precedent upstream credentials stay server-side); it rides on
the wt client's `admin_auth` (`_wt_client(admin_key=…)`), which the SDK uses for
the `admin.*` routes (NOT a per-call header slice-6). Fail-visible when the admin
key isn't configured (never a silent empty pane)."""
session_id = request.path_params["session_id"]
admin_key = request.app.state.admin_key
if not admin_key: # PRE-001: fail-visible, never silent
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
bstate = await get_session_bifrost(client, session_id, admin_key=admin_key)
except SessionApiFailed as exc:
async with client_factory() as transport:
# slice-6: the SDK's admin.* routes use the client's admin_auth (built with
# admin_key), not a per-call header — so it rides on the wt client here.
client = _wt_client(transport, admin_key=admin_key)
bstate = await wt.get_session_bifrost(client, session_id)
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "bifrost_state_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(bstate, status_code=200)
except (httpx.RequestError, ConnectFailed) as exc:
# SDK normalizes a transport failure to ConnectFailed(status=0), not a raw
# httpx error; both surface the same network envelope (cutover foot-gun).
return JSONResponse(
{"error_code": "network_error", "message": str(exc)},
status_code=502,
)
# Open-world read: degrade a non-mapping 200 body to {} rather than 500ing on
# `dict(non-mapping)` (heid bug-hunt slice-6).
return JSONResponse(dict(bstate) if isinstance(bstate, Mapping) else {}, status_code=200)
def _admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool:
@@ -578,9 +629,15 @@ async def _admin_events_endpoint(request: Request) -> Response:
client_factory = request.app.state.client_factory
async def gen() -> AsyncIterator[bytes]:
client = client_factory()
transport = client_factory()
try:
async for ev in stream_admin_events(client, admin_key=admin_key):
# slice-6: admin_auth rides on the wt client (built with admin_key); the
# adapter re-wraps the SDK's AdminEvent → ratatoskr's (id/type/data degraded)
# and the stream's terminal SDK errors (incl. ConnectFailed) → the Sse* types
# below. Built INSIDE the try so a construction failure still hits the finally
# that closes the transport — no leak (heid bug-hunt slice-6).
client = _wt_client(transport, admin_key=admin_key)
async for ev in wt.stream_admin_events(client):
if not _admin_event_matches_web(ev, session_id):
continue
# Fixed SSE event name so the browser renders EVERY admin type
@@ -600,7 +657,9 @@ async def _admin_events_endpoint(request: Request) -> Response:
except asyncio.CancelledError:
raise # browser disconnect — let the generator unwind
finally:
await client.aclose()
# ratatoskr owns the transport lifecycle (INV-CUT-1); close the injected
# httpx client, never the wt client (which would no-op the transport anyway).
await transport.aclose()
return StreamingResponse(gen(), media_type="text/event-stream")
+418 -4
View File
@@ -28,25 +28,39 @@ carries the SDK's parsed `error_code`).
from __future__ import annotations
import json
from collections.abc import AsyncGenerator, Mapping
import math
import re
from collections.abc import AsyncGenerator, Mapping, Sequence
from typing import Any
import httpx
import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, AuthProvider, CancelResult, WorldtreeClient
from worldtree_sdk import ApiError, AuthProvider, CancelResult, PadState, WorldtreeClient
# Transitional (slice-2): the caller-semantic exceptions + the BifrostBinding input
# Transitional (slice-2/3): the caller-semantic exceptions + the BifrostBinding input
# type still live in the retiring `sessions` / `sse_client` modules; they relocate
# into this adapter as their call-sites are rewired in later slice-2 commits. wt →
# into this adapter as their call-sites are rewired in later slices. wt →
# sessions / sse_client is one-way (neither imports wt), so there is no cycle.
from .sessions import (
AgentNotAvailable as PersonaAgentNotAvailable,
)
from .sessions import (
AgentNotFound,
AuthoredHistoryUnavailable,
AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
InvalidCursor,
PersonaNotConfigured,
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
)
from .sse_client import (
AdminEvent,
AgentNotAvailable,
CancelAlreadyCompleted,
CancelFailed,
@@ -342,3 +356,403 @@ async def cancel_turn(
except ApiError as exc:
# INV-CUT-2 default: an undiscriminated ApiError on this route → SessionApiFailed.
raise translate_error(exc) from exc
# ── slice-3: persona + authored-history adapter routes ───────────────────────
# The session-scoped affect write (set_persona_state) and the #347 authored-history
# write (write_authored_history). The SDK owns the wire shapes — the canonical
# `{"pad": {...}}` persona body via `PadState`, and the authored-write entry — so
# ratatoskr no longer hand-builds either. Error map (INV-CUT-2): persona has no row
# beyond the default; authored-history's 404 is the sole hide-existence route
# (AuthoredHistoryUnavailable), everything else the SessionApiFailed default.
async def set_persona_state(
client: WorldtreeClient,
session_id: str,
*,
pleasure: float,
arousal: float,
dominance: float,
) -> None:
"""Set a session's PAD persona state (POST /sessions/{id}/persona_state, W-7).
The adapter builds the canonical `PadState`; the SDK owns the wire wrapper
(`{"pad": {pleasure, arousal, dominance}}`, prose-pinned #317) — ratatoskr no
longer hand-assembles it. Resolves on 204 ( None). Error map (INV-CUT-2): no
route-specific row the `SessionApiFailed` default.
Finiteness is enforced HERE at the chokepoint (not only at the CLI): a
non-finite axis would serialize to `null` and corrupt the injection, and the SDK
raises `ConfigurationError` pre-HTTP the precondition asserts it so any caller
gets a clean ratatoskr-side rejection, never a leaked SDK error. (The CLI surface
additionally pre-validates for a friendly usage error.)
"""
assert session_id and isinstance(session_id, str)
assert all(math.isfinite(v) for v in (pleasure, arousal, dominance))
try:
await client.sessions.set_persona_state(
session_id, PadState(pleasure=pleasure, arousal=arousal, dominance=dominance)
)
except ApiError as exc:
raise translate_error(exc) from exc
async def write_authored_history(
client: WorldtreeClient,
session_id: str,
*,
content: str,
idempotency_key: str,
) -> Mapping[str, Any]:
"""Write one authored assistant turn into the session ledger (POST
/sessions/{id}/history, #347) — the durable first-message primitive.
v1 accepts only `author="assistant"` (INV-347-4), so the adapter fixes it; the
caller supplies `content` + the per-content `idempotency_key` (REQUIRED, never
SDK-generated a replay with the same (session, key) is an idempotent 200).
Returns the open-world `AuthoredTurn` ack verbatim. Error map (INV-CUT-2): a 404
`AuthoredHistoryUnavailable` (the ROUTE is the discriminator hide-existence,
never body-sniffed: feature-absent / ungranted / session-absent are one 404 by
design, server INV-347-1); every other `ApiError` (notably 409 generation_active,
422 validation) the `SessionApiFailed` default.
"""
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert idempotency_key and isinstance(idempotency_key, str)
try:
return await client.sessions.write_history(
session_id,
{"author": "assistant", "content": content, "idempotency_key": idempotency_key},
)
except ApiError as exc:
if exc.status == 404:
raise AuthoredHistoryUnavailable(session_id=session_id) from exc
raise translate_error(exc) from exc
# ── slice-4: agents (Tier-3) adapter routes ──────────────────────────────────
# Ratatoskr-semantic surfaces over `WorldtreeClient.agents.*` (list/persona_state/
# define/patch/delete). The SDK returns open-world dicts (parity posture — read as
# mappings, never normalized into a frozen dataclass) and surfaces the tier3/persona
# failure modes on its undiscriminated `ApiError` floor; the adapter maps them by the
# ROUTE + (status, error_code) per the § Error map (INV-CUT-2). The `model`→`role`
# cutover folds in here: the define/patch responses echo `role` (spec 1.2 / b128), so
# callers read `info["role"]` off the open-world dict — no `Tier3AgentInfo` survives.
# Consumer-agent slug (spec §2627): agent_name is `[a-z][a-z0-9-]{2,63}`.
_AGENT_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
def _error_field_from_body(body: str | None) -> str | None:
"""Pull the envelope's `field` from an SDK `ApiError` body string.
The SDK's `ApiError` carries the parsed `error_code` but NOT the envelope's
`field`, so the field-bearing tier3 rejections (`layer_deferred`,
`field_not_mutable`) body-parse it here same both-shape unwrap as
`_bifrost_error_from_body`, tolerant of `{"detail": {"field": }}` and a flat
top-level `field`. Returns None on any parse failure (the exception still carries
a None field, exactly as the hand-rolled path did on shape mismatch).
"""
if not body:
return None
try:
err = json.loads(body)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(err, dict):
return None
field = err.get("field")
if field is None and isinstance(err.get("detail"), dict):
field = err["detail"].get("field")
# The exception surface declares `field: str | None` (and the CLI prints it), so a
# non-string envelope value (`{"field": {…}}` / `{"field": 1}`) collapses to None —
# same guard the retired hand-rolled `_extract_error_field` applied (heid-bug-hunt).
return field if isinstance(field, str) else None
async def list_agents(client: WorldtreeClient) -> Sequence[Mapping[str, Any]]:
"""List the caller's agents (GET /agents) as the SDK's open-world array, verbatim
(parity: each item read as a mapping, tolerant of wire drift no `AgentInfo`
normalization). Any error the `SessionApiFailed` default."""
try:
return await client.agents.list()
except ApiError as exc:
raise translate_error(exc) from exc
async def get_persona_state(client: WorldtreeClient, agent_id: str) -> Mapping[str, Any]:
"""Fetch an agent's persona snapshot (GET /agents/{id}/persona_state, WT #204),
open-world dict verbatim. Error map (INV-CUT-2 dual-key status+error_code): 404
`persona_not_configured` `PersonaNotConfigured`; 404 `agent_not_available`
`AgentNotAvailable` (the persona-surface variant); 403 `auth_scope_denied`
`AuthScopeDenied`; every other error the `SessionApiFailed` default. A 404 with
an unrecognized code stays generic the error_code is the discriminator, never a
bare 404hidden (this route is NOT hide-existence)."""
assert agent_id and isinstance(agent_id, str)
try:
return await client.agents.persona_state(agent_id)
except ApiError as exc:
if exc.status == 404 and exc.error_code == "persona_not_configured":
raise PersonaNotConfigured(agent_id=agent_id) from exc
if exc.status == 404 and exc.error_code == "agent_not_available":
raise PersonaAgentNotAvailable(agent_id=agent_id) from exc
if exc.status == 403 and exc.error_code == "auth_scope_denied":
raise AuthScopeDenied(scope="persona.read") from exc
raise translate_error(exc) from exc
async def define_agent(
client: WorldtreeClient, *, agent_name: str, system_prompt: str, role: str
) -> Mapping[str, Any]:
"""Define a Tier-3 consumer agent (POST /agents/define). Sends the
`AgentDefineInput` body `{agent_name, role, system_prompt}` and returns the SDK's
open-world `DefinedAgent` dict verbatim (echoes `role` post-b128 read
`info["role"]`, no `Tier3AgentInfo`). The slug is validated client-side pre-HTTP
(server-side is the safety net). Error map (INV-CUT-2): 429
`Tier3QuotaExceeded(retry_after=0)` the SDK's `ApiError` floor drops the
`Retry-After` header, and §2675 pins Phase-2.0 quota to 0; 403
`tier3_user_id_unsupported` `Tier3UserIdUnsupported`; 422 `layer_deferred`
`Tier3LayerDeferred(field)`; else the `SessionApiFailed` default."""
assert _AGENT_SLUG_RE.match(agent_name), (
f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
)
assert system_prompt and isinstance(system_prompt, str)
assert role and isinstance(role, str)
try:
# Inline literal so it type-checks structurally against the SDK's
# AgentDefineInput TypedDict (no import of the SDK's private `_types`).
return await client.agents.define(
{"agent_name": agent_name, "role": role, "system_prompt": system_prompt}
)
except ApiError as exc:
if exc.status == 429:
raise Tier3QuotaExceeded(retry_after=0) from exc
if exc.status == 403 and exc.error_code == "tier3_user_id_unsupported":
raise Tier3UserIdUnsupported() from exc
if exc.status == 422 and exc.error_code == "layer_deferred":
raise Tier3LayerDeferred(field=_error_field_from_body(exc.body)) from exc
raise translate_error(exc) from exc
async def patch_agent(
client: WorldtreeClient,
agent_id: str,
*,
system_prompt: str | None = None,
role: str | None = None,
) -> Mapping[str, Any]:
"""Mutate a Tier-3 agent (PATCH /agents/{id}). At least one of `system_prompt` /
`role` is required; the None-valued field is omitted from the body. Returns the
open-world `PatchedAgent` dict verbatim. Error map (INV-CUT-2): 404
`Tier3AgentNotFound` (agents CRUD is NOT hide-existence); 422 `field_not_mutable`
`Tier3FieldNotMutable(field)`; else the `SessionApiFailed` default."""
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
assert system_prompt is not None or role is not None, (
"patch requires at least one of system_prompt or role"
)
changes: dict[str, Any] = {}
if system_prompt is not None:
changes["system_prompt"] = system_prompt
if role is not None:
changes["role"] = role
try:
return await client.agents.patch(agent_id, changes)
except ApiError as exc:
if exc.status == 404:
raise Tier3AgentNotFound(agent_id=agent_id) from exc
if exc.status == 422 and exc.error_code == "field_not_mutable":
raise Tier3FieldNotMutable(field=_error_field_from_body(exc.body)) from exc
raise translate_error(exc) from exc
async def delete_agent(client: WorldtreeClient, agent_id: str) -> None:
"""Hard-delete a Tier-3 agent (DELETE /agents/{id}); resolves on 204 → None. Error
map (INV-CUT-2): 404 `Tier3AgentNotFound` (route-discriminated; NOT
hide-existence); else the `SessionApiFailed` default."""
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
try:
await client.agents.delete(agent_id)
except ApiError as exc:
if exc.status == 404:
raise Tier3AgentNotFound(agent_id=agent_id) from exc
raise translate_error(exc) from exc
# ── slice-5: characters + me/capabilities/models adapter routes ───────────────
# The remaining consumer READS + transient-character CRUD over `client.me` /
# `client.capabilities` / `client.models` / `client.characters.*`. All six are
# open-world reads/acks (B-OPEN-2) returned verbatim; NONE carries a discriminated
# error on the SDK floor (no `map_error`), so each maps any `ApiError` → the
# `SessionApiFailed` default (INV-CUT-2) — exact parity with the retiring hand-rolled
# path, which never discriminated a status/code on these routes. No new § Error map
# rows. CLI-only: `--whoami` (me + capabilities) and `--characters` (models + CRUD);
# no web-server caller this slice.
async def get_me(client: WorldtreeClient) -> Mapping[str, Any]:
"""The caller's identity + key metadata (GET /me), open-world dict verbatim.
The boot whoami verifies the key with no agent-config side effects. Any error
the `SessionApiFailed` default (notably 401 on a bad/absent key when auth is on).
"""
try:
return await client.me.get()
except ApiError as exc:
raise translate_error(exc) from exc
async def get_capabilities(client: WorldtreeClient) -> Mapping[str, Any]:
"""The server capability advertisement (GET /capabilities), open-world verbatim.
Any authenticated caller may read it (no scope). Any error the `SessionApiFailed`
default. The `--whoami` renderer degrades on a malformed advertisement rather than
crashing (`_format_whoami`, already hardened).
"""
try:
return await client.capabilities.get()
except ApiError as exc:
raise translate_error(exc) from exc
async def list_character_models(client: WorldtreeClient) -> Mapping[str, Any]:
"""The character-capable model catalog (GET /models/available-for-characters, #161),
open-world dict verbatim. Requires `character.read`. Any error the
`SessionApiFailed` default."""
try:
return await client.models.available_for_characters()
except ApiError as exc:
raise translate_error(exc) from exc
async def create_character(
client: WorldtreeClient,
character: Mapping[str, Any],
*,
state: Mapping[str, Any] | None = None,
) -> Mapping[str, Any]:
"""Create a transient character (POST /characters, #161). Requires `character.write`.
The body is `{character}` plus `state` ONLY when supplied the SDK forwards the
dict as-is, so ratatoskr omits the hand-rolled path's redundant explicit
`state: null` (server-equivalent Worldtree's `CreateCharacterRequest.state`
defaults None whether omitted or explicit-null; SDK-idiomatic). Returns the
open-world create ACK verbatim (`{character_id, ttl_expires_at, ...}`). Any error
the `SessionApiFailed` default (notably 403 when the key lacks `character.write`).
"""
assert isinstance(character, Mapping) and character
try:
# Inline literals (per branch) so each type-checks structurally against the
# SDK's `CreateCharacterInput` TypedDict (`character` required, `state`
# NotRequired) without importing the SDK's private `_types` — same posture as
# `define_agent`. `state` is present ONLY when supplied (no redundant null).
if state is not None:
return await client.characters.create(
{"character": dict(character), "state": dict(state)}
)
return await client.characters.create({"character": dict(character)})
except ApiError as exc:
raise translate_error(exc) from exc
async def get_character_state(
client: WorldtreeClient, character_id: str
) -> Mapping[str, Any]:
"""The character's live runtime state (GET /characters/{id}/state, #161), open-world
dict verbatim; the read refreshes the character's TTL. Requires `character.read`.
Any error the `SessionApiFailed` default."""
assert character_id and isinstance(character_id, str)
try:
return await client.characters.state(character_id)
except ApiError as exc:
raise translate_error(exc) from exc
async def delete_character(
client: WorldtreeClient, character_id: str
) -> Mapping[str, Any] | None:
"""Delete a transient character (DELETE /characters/{id}, #161). Requires
`character.write`; bound sessions detach (next turn 410 `character_not_found`).
Returns the SDK's open-world delete ACK verbatim (`CharacterDeleteResult` —
Worldtree returns a body here, NOT 204) rather than normalizing to the hand-rolled
`None` (parity: no None-normalization of an open-world read). A 204 no-content
yields `None`, hence the `Mapping | None` return; the sole call-site ignores it.
Any error the `SessionApiFailed` default.
"""
assert character_id and isinstance(character_id, str)
try:
return await client.characters.delete(character_id)
except ApiError as exc:
raise translate_error(exc) from exc
# ── slice-6: admin (bifrost inspection + admin-events stream) adapter routes ──
# The admin surface over `client.admin.*` — admin_auth-scoped (set via
# `build_client(admin_key=...)`, NOT a per-call `Authorization` header). Both are
# web-only. `get_session_bifrost` reads the open-world `BifrostInspection` verbatim
# (any error → the `SessionApiFailed` default); `stream_admin_events` drives the
# long-lived D2 admin-events SSE, re-wrapping the SDK's `AdminEvent` → ratatoskr's
# (degrading the SDK's `admin_id`-nan / None `type`/`data` at the boundary so the web
# filter never crashes) and re-wrapping the stream's terminal errors → ratatoskr's
# `Sse*` types (INV-CUT-2 stream rows).
async def get_session_bifrost(
client: WorldtreeClient, session_id: str
) -> Mapping[str, Any]:
"""The admin-scoped Bifrost dispatch state for a session (GET
/admin/sessions/{id}/bifrost, #176), open-world dict verbatim.
Admin-tier the client MUST carry `admin_auth` (built with `admin_key`); the SDK
uses that provider, not a per-call header. Any error the `SessionApiFailed`
default (notably 403 `auth_scope_denied`, 404 `session_not_bifrost_bound`) the
retired hand-rolled path likewise mapped every non-200 generically.
"""
assert session_id and isinstance(session_id, str)
try:
return await client.admin.sessions.bifrost(session_id)
except ApiError as exc:
raise translate_error(exc) from exc
async def stream_admin_events(
client: WorldtreeClient, *, last_event_id: int | None = None
) -> AsyncGenerator[AdminEvent, None]:
"""Drive the long-lived admin-events SSE (GET /admin/events, #11 / INV-046) and yield
ratatoskr `AdminEvent`s, re-wrapping the SDK's typed `AdminEvent` at the boundary.
Admin-tier (the client MUST carry `admin_auth`). The SDK's `AdminEvent` is open-world
where ratatoskr's is stable: `admin_id` is `nan` for an id-less envelope (→ `id=0`),
and `type`/`data` may be None ( `""` / `{}`) normalized HERE so the web filter +
formatter (`ev.id` / `ev.type` / `ev.data`) never crash on a partial wire (chosen over
yielding SDK events through + rewiring the web filter). Error map (INV-CUT-2, stream
rows): SDK `ConnectFailed` (a connect-time transport / auth-resolution failure the
SDK's general transport floor) → `SseConnectFailed`; SDK `ApiError` (a non-200 open,
`admin_stream_failed`) `SseConnectFailed`; SDK `ConnectionDropped` (a connect-time
transport failure cursor None, OR a mid-stream drop / the long-lived stream's
resumable EOF cursor) `SseConnectionDropped`. The SDK stream is best-effort (skips
malformed frames no `Malformed*`).
"""
try:
async for ev in client.admin.stream_events(last_event_id=last_event_id):
yield AdminEvent(
id=ev.admin_id if isinstance(ev.admin_id, int) else 0,
# isinstance-guard `type` (not `or ""`): a truthy NON-str type (123, a
# list from a partial wire) would otherwise reach `.startswith` in the
# web filter → AttributeError (heid bug-hunt slice-6; match admin_id/data).
type=ev.type if isinstance(ev.type, str) else "",
timestamp=ev.timestamp,
data=dict(ev.data) if isinstance(ev.data, Mapping) else {},
)
except wtsdk.ConnectionDropped as exc:
raise SseConnectionDropped(last_seen_sse_id=exc.last_seen_sse_id) from exc
except wtsdk.ConnectFailed as exc:
# A connect-time transport / auth-resolution failure surfaces as ConnectFailed
# (the SDK's general transport floor) — map it → SseConnectFailed, mirroring
# stream_turn. The web gen catches the Sse* types, so an unmapped ConnectFailed
# would escape and abort the SSE with no labeled stream_error (heid bug-hunt).
raise SseConnectFailed(status=exc.status, body=(exc.message or "").encode()) from exc
except ApiError as exc:
# A non-200 open raises ApiError("admin_stream_failed", status=…) → SseConnectFailed.
raise SseConnectFailed(status=exc.status, body=(exc.body or "").encode()) from exc
+167
View File
@@ -1879,6 +1879,49 @@ class TestWhoami:
assert rc == 20
assert "[session_api_failed]" in capsys.readouterr().err
@respx.mock
def test_whoami_tolerates_null_and_nonstring_scopes(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""scopes present-null / non-string → renders '(none)' or str-coerced, never a
`join(None)` TypeError (heid-code-review slice-5: `_format_whoami` is the
contract's degrade-not-crash exemplar; `allowed_roles` was hardened, `scopes`
was not)."""
# scopes: null (present, not absent) → `.get('scopes', [])` would return None.
respx.get("https://w.example/me").mock(
return_value=httpx.Response(200, json={"user_id": "u", "scopes": None, "tier": "user"})
)
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(200, json={"ephemeral_templates": {}})
)
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
assert "scopes: (none)" in capsys.readouterr().out
@respx.mock
def test_whoami_tolerates_scalar_scopes_and_roles(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""Non-iterable (scalar) `scopes` / `allowed_roles` → degrade to empty, never a
`for x in 123` TypeError (heid bug-hunt slice-5: `_display_seq` guards the
container TYPE, the next layer past the code-review null/element fix)."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(200, json={"user_id": "u", "scopes": 123, "tier": "user"})
)
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(
200,
json={
"ephemeral_templates": {"echo": {"allowed_roles": 7, "default_role": "echo"}}
},
)
)
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
assert "scopes: (none)" in out
assert "roles=[]" in out
class TestTier2Probes:
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write)."""
@@ -1923,6 +1966,102 @@ class TestTier2Probes:
assert "deleted: char_z" in out
assert del_route.call_count == 1 # lifecycle cleaned up
@respx.mock
def test_characters_probe_tolerates_malformed_models(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""models catalog with non-mapping / non-string-name items → degrades (no
AttributeError/TypeError), lifecycle still proceeds (heid-code-review slice-5:
element-level completion of the list-level `or []` guard)."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(
200, json={"items": [None, "x", {"name": 123}, {"name": "ok"}]}
)
)
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "c1", "ttl_expires_at": "t"})
)
respx.get("https://w.example/characters/c1/state").mock(
return_value=httpx.Response(200, json={"pad": [0.0, 0.0, 0.0]})
)
respx.delete("https://w.example/characters/c1").mock(return_value=httpx.Response(204))
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
# non-mappings dropped; {"name":123}→"123", {"name":"ok"}→"ok" — no crash.
assert "character models: 123, ok" in out
assert "created: c1" in out
@respx.mock
def test_characters_probe_tolerates_scalar_items_and_nonmapping_state(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""Scalar `items` (`123`) → '(none)' not a `for m in 123` TypeError; a non-mapping
`state` 'pad=None' not an AttributeError. Lifecycle still completes (heid
bug-hunt slice-5: container-type + top-level-mapping guards)."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": 123})
)
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "c1", "ttl_expires_at": "t"})
)
# non-mapping state body (open-world passthrough of a JSON array).
respx.get("https://w.example/characters/c1/state").mock(
return_value=httpx.Response(200, json=["not", "a", "mapping"])
)
del_route = respx.delete("https://w.example/characters/c1").mock(
return_value=httpx.Response(204)
)
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
assert "character models: (none)" in out
assert "state: pad=None" in out
assert "deleted: c1" in out
assert del_route.call_count == 1
@respx.mock
def test_characters_probe_create_missing_id_aborts(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""create ACK without character_id → clean abort (exit 20), never a hard-index
KeyError (open-world degrade-not-crash; slice-5 cutover foot-gun)."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": []})
)
# 201 but the open-world ACK omits character_id — the probe must degrade.
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"ttl_expires_at": "t"})
)
del_route = respx.delete(url__regex=r"https://w\.example/characters/.+").mock(
return_value=httpx.Response(204)
)
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
assert rc == 20
assert "no character_id" in capsys.readouterr().err
assert del_route.call_count == 0 # aborted before state/delete — nothing to clean
@respx.mock
def test_characters_probe_non_mapping_create_aborts(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""A non-mapping create ACK (open-world passthrough of a JSON array/scalar) →
clean exit-20 abort, never an AttributeError on `created.get(...)` (heid
bug-hunt slice-5, finding #3)."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": []})
)
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json=["not", "a", "mapping"])
)
del_route = respx.delete(url__regex=r"https://w\.example/characters/.+").mock(
return_value=httpx.Response(204)
)
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
assert rc == 20
assert "no character_id" in capsys.readouterr().err
assert del_route.call_count == 0
@respx.mock
def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None:
"""set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204."""
@@ -1950,6 +2089,20 @@ class TestTier2Probes:
)
assert rc == 10
@respx.mock
def test_set_persona_probe_connect_failed(self, capsys: pytest.CaptureFixture[str]) -> None:
"""connect_failed [error-path]: a transport failure the SDK normalizes to
ConnectFailed graceful [network_error], exit 21 (not an uncaught crash)."""
respx.post("https://w.example/sessions/s1/persona_state").mock(
side_effect=httpx.ConnectError("refused")
)
rc = main(
["--set-persona-pad", "0.4,0.1,-0.2", "--session", "s1",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 21
assert "[network_error]" in capsys.readouterr().err
class TestSeedFirstMessageProbe:
"""--seed-first-message one-shot (#347 authored-history-write reference-consumer probe)."""
@@ -2066,3 +2219,17 @@ class TestSeedFirstMessageProbe:
assert rc == 0
assert "feature-absent" in capsys.readouterr().out
assert msgs_route.call_count == 0 # never capability-probes past the 404
@respx.mock
def test_seed_probe_connect_failed(self, capsys: pytest.CaptureFixture[str]) -> None:
"""connect_failed [error-path]: a transport failure on create that the SDK
normalizes to ConnectFailed graceful [network_error], exit 21."""
respx.post("https://w.example/sessions").mock(
side_effect=httpx.ConnectError("refused")
)
rc = main(
["--seed-first-message", "hello", "--agent", "mimir",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 21
assert "[network_error]" in capsys.readouterr().err
+77 -91
View File
@@ -1,12 +1,21 @@
"""Tests for ratatoskr.first_message per docs/contracts/first_message.contract.md."""
"""Tests for ratatoskr.first_message per docs/contracts/first_message.contract.md.
Slice-3 (worldtree-sdk cutover): `seed_preset_first_message` routes through the
`ratatoskr.wt` adapter over a `WorldtreeClient`, no longer the hand-rolled httpx
wrapper. These tests drive it through a fake client whose `sessions.write_history`
returns or raises the SDK's real types — exercising the adapter's error mapping AND
first_message's best-effort swallow in one pass. The wire format itself is the SDK's
to prove (the parity corpus); first_message's contract is behavioral: never block,
never raise (except CancelledError), and exactly one write on a preset hit.
"""
import asyncio
import hashlib
import json
from typing import Any, cast
import httpx
import pytest
import respx
import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, WorldtreeClient
from ratatoskr.first_message import (
FIRST_MESSAGE_PRESETS,
@@ -15,6 +24,33 @@ from ratatoskr.first_message import (
)
class _FakeSessions:
"""Stand-in for `WorldtreeClient.sessions` — records each `write_history` call
and returns a canned ack or raises a canned error (the SDK's real exceptions)."""
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
self._result = result if result is not None else {}
self._error = error
self.calls: list[tuple[str, Any]] = []
async def write_history(self, session_id: str, entry: Any) -> Any:
self.calls.append((session_id, entry))
if self._error is not None:
raise self._error
return self._result
class _FakeClient:
def __init__(self, sessions: _FakeSessions) -> None:
self.sessions = sessions
def _wt(sessions: _FakeSessions) -> WorldtreeClient:
"""Cast the structural fake to the nominal client type (no network; the seed
path only touches `client.sessions.write_history`, which the fake provides)."""
return cast(WorldtreeClient, _FakeClient(sessions))
class TestPresetFor:
"""first_message contract — preset_for (dict lookup)."""
@@ -36,120 +72,70 @@ class TestPresetFor:
class TestSeedPresetFirstMessage:
"""first_message contract — seed_preset_first_message (best-effort #347 seed)."""
@respx.mock
async def test_seeds_preset(self) -> None:
"""seeds_preset [happy,tracer]: preset agent → one history POST, correct body."""
"""seeds_preset [happy,tracer]: preset agent → one write_history, correct entry."""
content = FIRST_MESSAGE_PRESETS["ratatoskr:sindra"]
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(
201,
json={
"author": "assistant",
"seq": 0,
"phase": "seeded",
"turn_id": "t1",
"session_id": "s1",
"content_chars": len(content),
"injected_at": "2026-07-06T00:00:00+00:00",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
fake = _FakeSessions(result={"seq": 0, "phase": "seeded"})
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
assert result == content
assert route.call_count == 1 # POST-002: exactly one history POST
assert json.loads(route.calls[0].request.content) == {
assert len(fake.calls) == 1 # POST-002: exactly one history write
session_id, entry = fake.calls[0]
assert session_id == "s1"
assert entry == {
"author": "assistant",
"content": content,
"idempotency_key": key,
}
@respx.mock
async def test_no_preset_zero_http(self) -> None:
"""no_preset_zero_http [happy]: no-preset agent → None, ZERO HTTP (INV-002)."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "mimir")
async def test_no_preset_zero_write(self) -> None:
"""no_preset_zero_write [happy]: no-preset agent → None, ZERO write (INV-002)."""
fake = _FakeSessions()
result = await seed_preset_first_message(_wt(fake), "s1", "mimir")
assert result is None
assert not route.called
assert fake.calls == []
@respx.mock
async def test_feature_absent_swallowed(self) -> None:
"""feature_absent_swallowed [error]: 404 hide-existence → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
"""feature_absent_swallowed [error]: 404 → AuthoredHistoryUnavailable → None (INV-001)."""
fake = _FakeSessions(error=ApiError("session_not_found", "no", status=404))
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
assert result is None
assert len(fake.calls) == 1 # the write was attempted, then swallowed
@respx.mock
async def test_session_api_failed_swallowed(self) -> None:
"""session_api_failed_swallowed [error]: 409 → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(409, json={"error_code": "generation_active"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
"""session_api_failed_swallowed [error]: 409 → SessionApiFailed → None (INV-001)."""
fake = _FakeSessions(error=ApiError("generation_active", "busy", status=409))
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
assert result is None
@respx.mock
async def test_transport_error_swallowed(self) -> None:
"""transport_error_swallowed [error]: httpx.ConnectError → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock(
side_effect=httpx.ConnectError("boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
"""transport_error_swallowed [error]: SDK ConnectFailed → None, no raise (INV-001)."""
fake = _FakeSessions(error=wtsdk.ConnectFailed("connect_failed", "boom", status=0))
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
assert result is None
@respx.mock
async def test_unexpected_exception_swallowed(self) -> None:
"""unexpected_exception [error]: write raises ValueError → None (broad never-raise)."""
respx.post("https://w.example/sessions/s1/history").mock(
side_effect=ValueError("unexpected")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
fake = _FakeSessions(error=ValueError("unexpected"))
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
assert result is None
async def test_cancellation_propagates(self) -> None:
"""cancellation_propagates [error]: CancelledError from the write is RE-RAISED."""
import ratatoskr.first_message as fm
fake = _FakeSessions(error=asyncio.CancelledError())
with pytest.raises(asyncio.CancelledError):
await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
async def _cancel(*_a: object, **_k: object) -> None:
raise asyncio.CancelledError
async def test_malformed_agent_id_no_write(self) -> None:
"""malformed_agent_id [adversarial]: non-str or empty agent_id → None; no write; no raise."""
fake = _FakeSessions()
assert await seed_preset_first_message(_wt(fake), "s1", 123) is None # type: ignore[arg-type]
assert await seed_preset_first_message(_wt(fake), "s1", "") is None
assert fake.calls == []
orig = fm.write_authored_history
fm.write_authored_history = _cancel # type: ignore[assignment]
try:
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(asyncio.CancelledError):
await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
finally:
fm.write_authored_history = orig # type: ignore[assignment]
@respx.mock
async def test_malformed_agent_id_no_http(self) -> None:
"""malformed_agent_id [adversarial]: non-str or empty agent_id → None; no HTTP; no raise."""
route = respx.post(url__regex=r".*/history$").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await seed_preset_first_message(client, "s1", 123) is None # type: ignore[arg-type]
assert await seed_preset_first_message(client, "s1", "") is None
assert not route.called
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: "" → None (soft guard); no HTTP; no raise."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "", "ratatoskr:sindra")
"""empty_session_id [adversarial]: "" → None (soft guard); no write; no raise."""
fake = _FakeSessions()
result = await seed_preset_first_message(_wt(fake), "", "ratatoskr:sindra")
assert result is None
assert not route.called
assert fake.calls == []
+10 -10
View File
@@ -33,14 +33,14 @@ def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
def _entry(
agent_id: str = "ratatoskr:wizard",
agent_name: str = "wizard",
model: str = "qwen3.6-35-a3b",
role: str = "qwen3.6-35-a3b",
description: str = "(tier 3) test agent",
defined_at: str = "2026-05-25T00:00:00+00:00",
) -> LocalAgentEntry:
return LocalAgentEntry(
agent_id=agent_id,
agent_name=agent_name,
model=model,
role=role,
description=description,
defined_at=defined_at,
)
@@ -91,13 +91,13 @@ class TestLoadEmpty:
local_path.write_text(
json.dumps(
{
"version": 1,
"version": 2,
"agents": [
{"agent_id": "incomplete"}, # missing required fields
{
"agent_id": "ratatoskr:good",
"agent_name": "good",
"model": "m",
"role": "m",
"description": "d",
"defined_at": "t",
},
@@ -124,11 +124,11 @@ class TestAdd:
assert ids == {"ratatoskr:a", "ratatoskr:b"}
def test_add_replaces_same_id(self, local_path: Path) -> None:
add_local_agent(_entry(model="old-model"))
add_local_agent(_entry(model="new-model"))
add_local_agent(_entry(role="old-role"))
add_local_agent(_entry(role="new-role"))
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].model == "new-model"
assert entries[0].role == "new-role"
def test_creates_parent_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -141,11 +141,11 @@ class TestAdd:
class TestUpdate:
def test_update_changes_existing(self, local_path: Path) -> None:
add_local_agent(_entry(model="v1"))
update_local_agent(_entry(model="v2"))
add_local_agent(_entry(role="v1"))
update_local_agent(_entry(role="v2"))
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].model == "v2"
assert entries[0].role == "v2"
class TestRemove:
+10 -1233
View File
File diff suppressed because it is too large Load Diff
-94
View File
@@ -1,94 +0,0 @@
"""Tests for ratatoskr.sse_client — the admin-events stream (#11).
The turn-stream + Event-model tests retired with the worldtree-sdk cutover (#20);
the turn path is now covered by tests/test_wt.py + the CLI/web integration tests.
This module keeps the still-hand-rolled admin-events surface (slice-6)."""
import httpx
import pytest
import respx
from ratatoskr.sse_client import (
AdminEvent,
SseConnectFailed,
stream_admin_events,
)
def _sse_chunk(sse_id: str, body: dict[str, object]) -> bytes:
"""Compose one SSE event in wire format. Trailing blank line per spec."""
import json
return f"id: {sse_id}\ndata: {json.dumps(body)}\n\n".encode()
class TestStreamAdminEvents:
"""docs/conversation-api-spec.md § Admin Event Stream — stream_admin_events (#11)."""
@respx.mock
async def test_happy_multi_event_admin_bearer(self) -> None:
"""happy [happy,tracer]: yields AdminEvent envelopes; request uses the ADMIN bearer."""
env1 = {
"id": 41, "type": "session.created", "timestamp": "2026-05-06T10:00:00.000Z",
"data": {"session_id": "s1", "agent_id": "mimir", "user_id": None},
}
env2 = {
"id": 42, "type": "turn.started", "timestamp": "2026-05-06T10:00:01.000Z",
"data": {"session_id": "s1", "turn_id": 7, "agent_id": "mimir", "user_id": None},
}
stream = _sse_chunk("41", env1) + _sse_chunk("42", env2)
route = respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=stream
)
)
async with httpx.AsyncClient(
base_url="https://w.example", headers={"Authorization": "Bearer consumer"}
) as client:
events = [e async for e in stream_admin_events(client, admin_key="admin-xyz")]
assert [e.type for e in events] == ["session.created", "turn.started"]
assert isinstance(events[0], AdminEvent)
assert events[0].id == 41
assert events[1].data["turn_id"] == 7
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
@respx.mock
async def test_last_event_id_header(self) -> None:
"""last_event_id_header [trace]: empty stream → []; Last-Event-ID header sent."""
route = respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=b""
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_admin_events(client, admin_key="k", last_event_id=99)]
assert events == []
assert route.calls[0].request.headers["Last-Event-ID"] == "99"
@respx.mock
async def test_403_scope_denied(self) -> None:
"""403 [error]: key lacks admin.events.read → SseConnectFailed(403)."""
respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SseConnectFailed) as exc:
_ = [e async for e in stream_admin_events(client, admin_key="k")]
assert exc.value.status == 403
@respx.mock
async def test_skips_malformed_frame(self) -> None:
"""skips_malformed [adversarial]: a bad-JSON frame is skipped, not fatal."""
good = _sse_chunk("41", {"id": 41, "type": "session.created", "data": {"session_id": "s1"}})
bad = b"id: 42\ndata: not-json\n\n"
good2 = _sse_chunk(
"43", {"id": 43, "type": "session.deleted", "data": {"session_id": "s1"}}
)
respx.get("https://w.example/admin/events").mock(
return_value=httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=good + bad + good2
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
events = [e async for e in stream_admin_events(client, admin_key="k")]
assert [e.type for e in events] == ["session.created", "session.deleted"]
+114 -319
View File
@@ -1,4 +1,14 @@
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
"""CLI integration tests for `python -m ratatoskr.tier3`.
The define/patch/delete wire calls route through the worldtree-sdk adapter
(`ratatoskr.wt`); the adapter's body-building + error-mapping are unit-tested in
`test_wt.py` (against a fake `client.agents`). These tests exercise the CLI
end-to-end argv the real SDK over a respx-mocked HTTP layer exit code +
stdout + the local tier3 index side effects.
The define/patch response echoes `role` (Worldtree spec 1.2 / b128), read off the
SDK's open-world dict; `LocalAgentEntry.role` is the v2-schema field.
"""
from pathlib import Path
@@ -6,317 +16,20 @@ import httpx
import pytest
import respx
from ratatoskr.sessions import SessionApiFailed
from ratatoskr.tier3 import (
Tier3AgentInfo,
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
define_agent,
delete_agent,
main,
patch_agent,
)
from ratatoskr.tier3 import main
# The consumer-agent response echoes `role` (b128), not the former `model`.
_FULL_AGENT_RESP = {
"agent_id": "ratatoskr:wizard",
"user_id": "ratatoskr",
"agent_name": "wizard",
"system_prompt": "You are a wizard.",
"model": "qwen3.6-35-a3b",
"role": "qwen3.6-35-a3b",
"created_at": "2026-05-25T03:20:09.703601+00:00",
"updated_at": "2026-05-25T03:20:09.703601+00:00",
}
class TestDefineAgent:
@respx.mock
async def test_happy_define(self) -> None:
"""happy_define [happy,tracer]: 201 → fully populated Tier3AgentInfo."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await define_agent(
client,
agent_name="wizard",
system_prompt="You are a wizard.",
role="qwen3.6-35-a3b",
)
assert isinstance(info, Tier3AgentInfo)
assert info.agent_id == "ratatoskr:wizard"
assert info.user_id == "ratatoskr"
assert info.agent_name == "wizard"
assert info.model == "qwen3.6-35-a3b"
@respx.mock
async def test_request_body_shape(self) -> None:
"""request_body_shape [trace]: outbound JSON is exactly the three keys."""
import json as _json
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await define_agent(
client,
agent_name="wizard",
system_prompt="You are a wizard.",
role="qwen3.6-35-a3b",
)
body = _json.loads(route.calls[0].request.content)
# INV-001: exactly these three keys — no layer fields, no metadata.
assert body == {
"agent_name": "wizard",
"system_prompt": "You are a wizard.",
"role": "qwen3.6-35-a3b",
}
@respx.mock
async def test_quota_exceeded(self) -> None:
"""quota_exceeded [error]: 429 + Retry-After → Tier3QuotaExceeded."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(
429,
headers={"Retry-After": "0"},
json={"detail": {"error_code": "agent_quota_exceeded"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3QuotaExceeded) as exc:
await define_agent(
client,
agent_name="overflow",
system_prompt="x",
role="m",
)
assert exc.value.retry_after == 0
@respx.mock
async def test_user_id_unsupported(self) -> None:
"""user_id_unsupported [error]: 403 + error_code → Tier3UserIdUnsupported."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(
403, json={"detail": {"error_code": "tier3_user_id_unsupported"}}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3UserIdUnsupported):
await define_agent(
client, agent_name="wizard", system_prompt="x", role="m"
)
@respx.mock
async def test_layer_deferred(self) -> None:
"""layer_deferred [error]: 422 + layer_deferred → Tier3LayerDeferred(field)."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(
422,
json={"detail": {"error_code": "layer_deferred", "field": "persona"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3LayerDeferred) as exc:
await define_agent(
client, agent_name="wizard", system_prompt="x", role="m"
)
assert exc.value.field == "persona"
@respx.mock
async def test_bad_slug_assert(self) -> None:
"""bad_slug_assert [adversarial]: agent_name with uppercase → AssertionError, no HTTP."""
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await define_agent(
client, agent_name="Wizard", system_prompt="x", role="m"
)
assert route.call_count == 0
@respx.mock
async def test_short_slug_assert(self) -> None:
"""short_slug_assert [adversarial]: agent_name len < 3 → AssertionError."""
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await define_agent(
client, agent_name="ab", system_prompt="x", role="m"
)
assert route.call_count == 0
@respx.mock
async def test_empty_prompt_assert(self) -> None:
"""empty_prompt_assert [adversarial]: empty system_prompt → AssertionError."""
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await define_agent(
client, agent_name="wizard", system_prompt="", role="m"
)
assert route.call_count == 0
@respx.mock
async def test_other_5xx(self) -> None:
"""other_5xx [error]: 503 → SessionApiFailed(status=503)."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(503, content=b"upstream out")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await define_agent(
client, agent_name="wizard", system_prompt="x", role="m"
)
assert exc.value.status == 503
class TestPatchAgent:
@respx.mock
async def test_happy_patch_both_fields(self) -> None:
"""happy_patch_both_fields: both fields set → request body has both."""
import json as _json
updated = {
**_FULL_AGENT_RESP,
"system_prompt": "new prompt",
"model": "different-model",
}
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(200, json=updated)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await patch_agent(
client,
"ratatoskr:wizard",
system_prompt="new prompt",
role="different-model",
)
body = _json.loads(route.calls[0].request.content)
assert body == {"system_prompt": "new prompt", "role": "different-model"}
assert info.system_prompt == "new prompt"
assert info.model == "different-model"
@respx.mock
async def test_happy_patch_single_field(self) -> None:
"""happy_patch_single_field: omit role → body has system_prompt only."""
import json as _json
updated = {**_FULL_AGENT_RESP, "system_prompt": "only this"}
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(200, json=updated)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await patch_agent(client, "ratatoskr:wizard", system_prompt="only this")
body = _json.loads(route.calls[0].request.content)
# INV-002: body omits the None-valued field entirely
assert body == {"system_prompt": "only this"}
@respx.mock
async def test_field_not_mutable(self) -> None:
"""field_not_mutable [error]: 422 + error_code → Tier3FieldNotMutable(field)."""
respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(
422,
json={
"detail": {"error_code": "field_not_mutable", "field": "agent_name"}
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3FieldNotMutable) as exc:
await patch_agent(
client, "ratatoskr:wizard", system_prompt="x"
)
assert exc.value.field == "agent_name"
@respx.mock
async def test_404(self) -> None:
"""404 [error]: PATCH on non-existent agent → Tier3AgentNotFound."""
respx.patch("https://w.example/agents/ratatoskr:ghost").mock(
return_value=httpx.Response(404, content=b"")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3AgentNotFound) as exc:
await patch_agent(
client, "ratatoskr:ghost", system_prompt="x"
)
assert exc.value.agent_id == "ratatoskr:ghost"
@respx.mock
async def test_no_fields_assert(self) -> None:
"""no_fields_assert [adversarial]: both None → AssertionError, no HTTP."""
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await patch_agent(client, "ratatoskr:wizard")
assert route.call_count == 0
@respx.mock
async def test_non_tier3_id_assert(self) -> None:
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
route = respx.patch("https://w.example/agents/mimir").mock(
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await patch_agent(client, "mimir", system_prompt="x")
assert route.call_count == 0
class TestDeleteAgent:
@respx.mock
async def test_happy_delete(self) -> None:
"""happy_delete [happy,tracer]: 204 → returns None."""
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await delete_agent(client, "ratatoskr:wizard")
assert result is None
@respx.mock
async def test_404(self) -> None:
"""404 [error]: DELETE on non-existent agent → Tier3AgentNotFound."""
respx.delete("https://w.example/agents/ratatoskr:ghost").mock(
return_value=httpx.Response(404)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3AgentNotFound) as exc:
await delete_agent(client, "ratatoskr:ghost")
assert exc.value.agent_id == "ratatoskr:ghost"
@respx.mock
async def test_non_tier3_id_assert(self) -> None:
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
route = respx.delete("https://w.example/agents/mimir").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await delete_agent(client, "mimir")
assert route.call_count == 0
@respx.mock
async def test_other_5xx(self) -> None:
"""other_5xx [error]: 500 → SessionApiFailed."""
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(500, content=b"oops")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await delete_agent(client, "ratatoskr:wizard")
assert exc.value.status == 500
@pytest.fixture
def _isolated_local_agents(
tmp_path: "Path", monkeypatch: pytest.MonkeyPatch
@@ -335,8 +48,8 @@ class TestCli:
monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path",
) -> None:
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation;
local index updated with the new entry (v0.8.0 hook).
"""cli_define_happy [happy,tracer]: argv → 201 mock → stdout confirmation;
local index updated with the new entry (role echoed).
"""
from ratatoskr.local_agents import load_local_agents
@@ -354,11 +67,34 @@ class TestCli:
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "defined ratatoskr:wizard (qwen3.6-35-a3b)"
# v0.8.0: local index now has the new entry.
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
assert entries[0].model == "qwen3.6-35-a3b"
assert entries[0].role == "qwen3.6-35-a3b"
@respx.mock
def test_cli_define_body_shape(
self, monkeypatch: pytest.MonkeyPatch, _isolated_local_agents: "Path"
) -> None:
"""cli_define_body_shape [trace]: outbound JSON is exactly the three keys
(the adapter sends AgentDefineInput, no layer fields)."""
import json as _json
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
main([
"define", "--name", "wizard",
"--system-prompt", "You are a wizard.", "--role", "qwen3.6-35-a3b",
])
body = _json.loads(route.calls[0].request.content)
assert body == {
"agent_name": "wizard",
"role": "qwen3.6-35-a3b",
"system_prompt": "You are a wizard.",
}
@respx.mock
def test_cli_patch_happy(
@@ -384,6 +120,7 @@ class TestCli:
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
assert entries[0].role == "qwen3.6-35-a3b"
@respx.mock
def test_cli_delete_happy(
@@ -401,11 +138,11 @@ class TestCli:
load_local_agents,
)
# Pre-populate so we can verify removal.
# Pre-populate so we can verify removal (v2 schema: role, not model).
add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:wizard",
agent_name="wizard",
model="m",
role="m",
description="d",
defined_at="t",
))
@@ -426,10 +163,7 @@ class TestCli:
"""cli_missing_auth [error]: no api-key → stderr [auth_error] + exit 11."""
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
rc = main([
"define",
"--name", "wizard",
"--system-prompt", "x",
"--role", "m",
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
err = capsys.readouterr().err
assert rc == 11
@@ -446,10 +180,7 @@ class TestCli:
return_value=httpx.Response(500, content=b"upstream out")
)
rc = main([
"define",
"--name", "wizard",
"--system-prompt", "x",
"--role", "m",
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
err = capsys.readouterr().err
assert rc == 20
@@ -470,15 +201,30 @@ class TestCli:
)
)
rc = main([
"define",
"--name", "wizard",
"--system-prompt", "x",
"--role", "m",
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
err = capsys.readouterr().err
assert rc == 20
assert "[quota_exceeded]" in err
@respx.mock
def test_cli_network_error(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_network_error [error]: a transport failure surfaces as the SDK's
ConnectFailed (not a raw httpx error) stderr [network_error] + exit 21."""
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
side_effect=httpx.ConnectError("refused")
)
rc = main([
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
err = capsys.readouterr().err
assert rc == 21
assert "[network_error]" in err
def test_cli_patch_no_fields(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -489,3 +235,52 @@ class TestCli:
err = capsys.readouterr().err
assert rc == 10
assert "[usage_error]" in err
@respx.mock
def test_cli_define_partial_response_degrades(
self,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path",
) -> None:
"""cli_define_partial [error]: a 201 missing `role` + a NULL system_prompt
degrades (role '?', description-safe) and exits 0 never a KeyError/
AttributeError traceback (heid-bug-hunt open-world invariant)."""
from ratatoskr.local_agents import load_local_agents
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json={
"agent_id": "ratatoskr:wizard", "agent_name": "wizard",
"system_prompt": None, # present-but-null: .get(...,'') would NOT default
})
)
rc = main([
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "defined ratatoskr:wizard (?)"
# Well-formed identity → still indexed (role degraded to '?').
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].role == "?"
@respx.mock
def test_cli_define_no_agent_id_is_api_failure(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_define_no_agent_id [error]: a 2xx with no usable agent_id → [api_failed]
+ exit 20 (controlled), not an uncaught traceback."""
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json={"role": "m"}) # no agent_id
)
rc = main([
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
err = capsys.readouterr().err
assert rc == 20
assert "[api_failed]" in err
+80 -2
View File
@@ -66,7 +66,7 @@ class TestAgentsEndpoint:
LocalAgentEntry(
agent_id="ratatoskr:sindra",
agent_name="sindra",
model="artemis-31b-v1i",
role="artemis-31b-v1i",
description="(tier 3) IDENTITY",
defined_at="2026-05-28T00:00:00+00:00",
)
@@ -118,7 +118,7 @@ class TestAgentsEndpoint:
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
add_local_agent(
LocalAgentEntry(
agent_id="ratatoskr:sindra", agent_name="sindra", model="m",
agent_id="ratatoskr:sindra", agent_name="sindra", role="m",
description="local-tier3", defined_at="2026-05-28T00:00:00+00:00",
)
)
@@ -132,6 +132,57 @@ class TestAgentsEndpoint:
# Upstream entry wins (it's first in the merge); local is deduped
assert body[0]["name"] == "Sindra-from-server"
@respx.mock
def test_malformed_upstream_items_degrade_not_500(self, monkeypatch, tmp_path) -> None:
"""malformed_upstream [error]: a non-mapping / agent_id-less upstream item is
dropped, not crashed on the endpoint degrades to the well-formed + local
merge (heid-bug-hunt: open-world reads degrade, never crash)."""
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{"agent_id": "mimir", "name": "Mimir", "description": "k"},
{}, # no agent_id — dropped
{"name": "Ghost"}, # no agent_id — dropped
"not-a-mapping", # non-mapping — dropped
{"agent_id": 123}, # non-str agent_id — dropped
],
)
)
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:local", agent_name="local", role="m",
description="d", defined_at="t",
))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents")
assert resp.status_code == 200
ids = {a["agent_id"] for a in resp.json()}
# Only the one well-formed upstream item + the local entry survive.
assert ids == {"mimir", "ratatoskr:local"}
@respx.mock
def test_non_list_upstream_falls_back_to_local(self, monkeypatch, tmp_path) -> None:
"""non_list_upstream [error]: an envelope (non-list) upstream body degrades to
the local-only list rather than iterating dict keys into a crash."""
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json={"items": [{"agent_id": "mimir"}]})
)
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:local", agent_name="local", role="m",
description="d", defined_at="t",
))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents")
assert resp.status_code == 200
ids = {a["agent_id"] for a in resp.json()}
assert ids == {"ratatoskr:local"}
_CREATE_OK = {
"session_id": "s-1",
@@ -290,6 +341,20 @@ class TestPersonaStateEndpoint:
assert resp.status_code == 403
assert resp.json()["error_code"] == "auth_scope_denied"
@respx.mock
def test_unmatched_error_maps_to_session_api_failed(self) -> None:
"""unmatched_error [error]: an upstream 500 (unmapped ApiError) → the
session_api_failed envelope carrying the upstream status, for parity with
_agents_endpoint (heid-code-review slice-4 fixup was a raw 500 escape)."""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(500, content=b"upstream out")
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents/mimir/persona_state")
assert resp.status_code == 500
assert resp.json()["error_code"] == "session_api_failed"
class TestSubmitTurnEndpoint:
"""submit_turn_endpoint FN — allocate turn_id, register in turn_registry."""
@@ -1211,6 +1276,19 @@ class TestSessionBifrostEndpoint:
assert resp.status_code == 404
assert resp.json()["error_code"] == "bifrost_state_unavailable"
@respx.mock
def test_non_mapping_body_degrades_to_empty(self) -> None:
"""robustness: a non-mapping open-world 200 body (list/scalar) → 200 {} envelope,
never a `dict(non-mapping)` TypeError/500 (heid bug-hunt slice-6)."""
respx.get("https://w.example/admin/sessions/s-1/bifrost").mock(
return_value=httpx.Response(200, json=["not", "a", "mapping"])
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), admin_key="adm-key")
resp = TestClient(app).get("/api/sessions/s-1/bifrost")
assert resp.status_code == 200
assert resp.json() == {}
class TestAdminEventsEndpoint:
"""admin_events_endpoint — SSE proxy of GET /admin/events, session-filtered (#11)."""
+804 -1
View File
@@ -17,16 +17,28 @@ from typing import Any, cast
import httpx
import pytest
import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, CancelResult, WorldtreeClient
from worldtree_sdk import ApiError, CancelResult, PadState, WorldtreeClient
from ratatoskr.sessions import (
AgentNotAvailable as PersonaAgentNotAvailable,
)
from ratatoskr.sessions import (
AgentNotFound,
AuthoredHistoryUnavailable,
AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
InvalidCursor,
PersonaNotConfigured,
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
)
from ratatoskr.sse_client import (
AdminEvent,
AgentNotAvailable,
CancelAlreadyCompleted,
CancelFailed,
@@ -42,12 +54,27 @@ from ratatoskr.wt import (
SessionApiFailed,
build_client,
cancel_turn,
create_character,
create_session,
define_agent,
delete_agent,
delete_character,
get_capabilities,
get_character_state,
get_me,
get_persona_state,
get_session_bifrost,
get_session_messages,
get_session_tools,
list_agents,
list_character_models,
list_sessions,
patch_agent,
set_persona_state,
stream_admin_events,
stream_turn,
translate_error,
write_authored_history,
)
@@ -101,6 +128,12 @@ class _FakeSessions:
async def cancel_turn(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("cancel_turn", *args, **kwargs)
async def set_persona_state(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("set_persona_state", *args, **kwargs)
async def write_history(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("write_history", *args, **kwargs)
class _FakeClient:
def __init__(self, sessions: _FakeSessions) -> None:
@@ -440,3 +473,773 @@ class TestCancelTurn:
with pytest.raises(SessionApiFailed) as ei:
await cancel_turn(_wt(fake), "s", 42)
assert ei.value.status == 500
class TestSetPersonaState:
"""slice-3: set_persona_state → SDK sessions.set_persona_state(PadState). The
adapter builds the canonical PadState (the SDK owns the {"pad": {...}} wire
shape); no error row beyond the § Error map default (SessionApiFailed)."""
async def test_happy_builds_padstate_and_returns_none(self) -> None:
fake = _FakeSessions(result=None) # SDK resolves the 204 to None
out = await set_persona_state(
_wt(fake), "s-1", pleasure=0.4, arousal=0.1, dominance=-0.2
)
assert out is None
name, args, _kwargs = fake.calls[-1]
assert name == "set_persona_state"
assert args[0] == "s-1"
pad = args[1]
assert isinstance(pad, PadState)
assert (pad.pleasure, pad.arousal, pad.dominance) == (0.4, 0.1, -0.2)
async def test_falsy_zero_pad_preserved(self) -> None:
# A 0.0 axis must survive verbatim (not be dropped as falsy).
fake = _FakeSessions(result=None)
await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0)
pad = fake.calls[-1][1][1]
assert (pad.pleasure, pad.arousal, pad.dominance) == (0.0, 0.0, 0.0)
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeSessions(error=ApiError("upstream", "boom", status=500, body="x"))
with pytest.raises(SessionApiFailed) as ei:
await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0)
assert ei.value.status == 500
async def test_non_finite_pad_rejected_at_the_chokepoint(self) -> None:
# The finite-PAD invariant is enforced at the adapter (not only the CLI): a
# non-finite axis would serialize to null and corrupt the injection, so a
# direct caller is rejected pre-SDK — never a leaked SDK ConfigurationError.
fake = _FakeSessions(result=None)
for bad in (float("nan"), float("inf"), float("-inf")):
with pytest.raises(AssertionError):
await set_persona_state(_wt(fake), "s", pleasure=bad, arousal=0.0, dominance=0.0)
assert fake.calls == [] # never reached the SDK
class TestWriteAuthoredHistory:
"""slice-3: write_authored_history → SDK sessions.write_history. Builds the
v1 authored-write entry (author="assistant", the only accepted author);
404 AuthoredHistoryUnavailable (hide-existence); else the default."""
async def test_happy_builds_entry_and_returns_dict(self) -> None:
ack = {"seq": 0, "phase": "seeded", "turn_id": "t1", "content_chars": 3}
fake = _FakeSessions(result=ack)
out = await write_authored_history(
_wt(fake), "s-1", content="hi!", idempotency_key="k1"
)
assert out is ack # open-world passthrough
name, args, _kwargs = fake.calls[-1]
assert name == "write_history"
assert args[0] == "s-1"
assert args[1] == {
"author": "assistant",
"content": "hi!",
"idempotency_key": "k1",
}
async def test_404_maps_to_authored_history_unavailable(self) -> None:
# Hide-existence: the ROUTE is the discriminator (never the body) — any 404
# on write_history → AuthoredHistoryUnavailable, no capability-probe.
fake = _FakeSessions(error=ApiError("session_not_found", "no", status=404))
with pytest.raises(AuthoredHistoryUnavailable) as ei:
await write_authored_history(_wt(fake), "s-1", content="hi", idempotency_key="k")
assert ei.value.session_id == "s-1"
async def test_other_error_maps_to_session_api_failed(self) -> None:
# 409 generation_active (retryable) is NOT a hide-existence 404 → default.
fake = _FakeSessions(error=ApiError("generation_active", "busy", status=409))
with pytest.raises(SessionApiFailed) as ei:
await write_authored_history(_wt(fake), "s", content="hi", idempotency_key="k")
assert ei.value.status == 409
# ── slice-4: agents (Tier-3) adapter routes ──────────────────────────────────
class _FakeAgents:
"""Stand-in for `WorldtreeClient.agents` — records the last call and returns a
canned result or raises a canned error. Same shape as `_FakeSessions`."""
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
self._result = result
self._error = error
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
self.calls.append((name, args, kwargs))
if self._error is not None:
raise self._error
return self._result
async def list(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("list", *args, **kwargs)
async def persona_state(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("persona_state", *args, **kwargs)
async def define(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("define", *args, **kwargs)
async def patch(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("patch", *args, **kwargs)
async def delete(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("delete", *args, **kwargs)
class _FakeAgentsClient:
def __init__(self, agents: _FakeAgents) -> None:
self.agents = agents
def _wta(agents: _FakeAgents) -> WorldtreeClient:
"""Cast the structural agents-fake to the nominal client type (the agent route
functions only touch `client.agents.*`)."""
return cast(WorldtreeClient, _FakeAgentsClient(agents))
class TestListAgents:
"""slice-4: list_agents → SDK agents.list(); open-world array verbatim."""
async def test_happy_returns_array_verbatim(self) -> None:
data = [{"agent_id": "mimir", "name": "Mimir", "description": "k"}]
fake = _FakeAgents(result=data)
out = await list_agents(_wta(fake))
assert out is data # open-world passthrough, no AgentInfo normalization
assert fake.calls[-1][0] == "list"
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeAgents(error=ApiError("upstream", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await list_agents(_wta(fake))
assert ei.value.status == 500
class TestGetPersonaState:
"""slice-4: get_persona_state → SDK agents.persona_state(id); dict verbatim.
404 sub-codes + 403 auth_scope_denied map by (status, error_code)."""
async def test_happy_returns_dict_verbatim(self) -> None:
snap = {"pad": {}, "dominant_emotion": "curiosity"}
fake = _FakeAgents(result=snap)
out = await get_persona_state(_wta(fake), "mimir")
assert out is snap
assert fake.calls[-1] == ("persona_state", ("mimir",), {})
async def test_persona_not_configured(self) -> None:
fake = _FakeAgents(error=ApiError("persona_not_configured", "no", status=404))
with pytest.raises(PersonaNotConfigured) as ei:
await get_persona_state(_wta(fake), "domari")
assert ei.value.agent_id == "domari"
async def test_agent_not_available(self) -> None:
fake = _FakeAgents(error=ApiError("agent_not_available", "no", status=404))
with pytest.raises(PersonaAgentNotAvailable) as ei:
await get_persona_state(_wta(fake), "bogus")
assert ei.value.agent_id == "bogus"
async def test_auth_scope_denied(self) -> None:
fake = _FakeAgents(error=ApiError("auth_scope_denied", "no", status=403))
with pytest.raises(AuthScopeDenied) as ei:
await get_persona_state(_wta(fake), "mimir")
assert ei.value.scope == "persona.read"
async def test_other_404_without_code_maps_to_default(self) -> None:
# A 404 whose error_code is neither persona sub-code → generic default,
# NOT a spurious PersonaNotConfigured (the code is the discriminator).
fake = _FakeAgents(error=ApiError("weird", "no", status=404))
with pytest.raises(SessionApiFailed) as ei:
await get_persona_state(_wta(fake), "mimir")
assert ei.value.status == 404
async def test_empty_agent_id_asserts(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await get_persona_state(_wta(fake), "")
assert fake.calls == []
class TestDefineAgent:
"""slice-4: define_agent → SDK agents.define(); open-world DefinedAgent dict
(echoes `role` post-b128). Slug validated client-side; tier3 error rows."""
async def test_happy_builds_body_and_returns_dict(self) -> None:
resp = {"agent_id": "ratatoskr:wizard", "role": "thoughtful-character"}
fake = _FakeAgents(result=resp)
out = await define_agent(
_wta(fake), agent_name="wizard", system_prompt="You are a wizard.",
role="thoughtful-character",
)
assert out is resp # open-world passthrough (no Tier3AgentInfo)
name, args, _kwargs = fake.calls[-1]
assert name == "define"
# AgentDefineInput body: exactly the three keys, no layer fields.
assert args[0] == {
"agent_name": "wizard",
"role": "thoughtful-character",
"system_prompt": "You are a wizard.",
}
async def test_quota_exceeded_defaults_retry_after_zero(self) -> None:
# The SDK's ApiError floor drops the Retry-After header; spec §2675 pins it
# to 0, so the adapter defaults retry_after=0.
fake = _FakeAgents(error=ApiError("agent_quota_exceeded", "full", status=429))
with pytest.raises(Tier3QuotaExceeded) as ei:
await define_agent(_wta(fake), agent_name="overflow", system_prompt="x", role="m")
assert ei.value.retry_after == 0
async def test_user_id_unsupported(self) -> None:
fake = _FakeAgents(error=ApiError("tier3_user_id_unsupported", "no", status=403))
with pytest.raises(Tier3UserIdUnsupported):
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
async def test_layer_deferred_field_parsed_from_body(self) -> None:
# `field` is not on ApiError — the adapter body-parses detail.field.
fake = _FakeAgents(error=ApiError(
"layer_deferred", "no", status=422,
body='{"detail": {"error_code": "layer_deferred", "field": "persona"}}',
))
with pytest.raises(Tier3LayerDeferred) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.field == "persona"
async def test_layer_deferred_flat_field_body(self) -> None:
# _error_field_from_body also handles a flat top-level `field` (both-shape
# unwrap) — locks the contract's "detail.field / flat field" claim.
fake = _FakeAgents(error=ApiError(
"layer_deferred", "no", status=422, body='{"field": "valence"}',
))
with pytest.raises(Tier3LayerDeferred) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.field == "valence"
async def test_layer_deferred_non_string_field_is_none(self) -> None:
# A non-string `field` value collapses to None — the exception surface is
# `field: str | None` and the CLI prints it (heid-bug-hunt hardening).
fake = _FakeAgents(error=ApiError(
"layer_deferred", "no", status=422, body='{"detail": {"field": {"x": 1}}}',
))
with pytest.raises(Tier3LayerDeferred) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.field is None
async def test_403_wrong_code_maps_to_default(self) -> None:
# Dual-key negative: a 403 whose code is NOT tier3_user_id_unsupported →
# generic default, not a spurious Tier3UserIdUnsupported (INV-CUT-2).
fake = _FakeAgents(error=ApiError("auth_revoked", "no", status=403))
with pytest.raises(SessionApiFailed) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.status == 403
async def test_422_wrong_code_maps_to_default(self) -> None:
# Dual-key negative: a 422 whose code is NOT layer_deferred → default.
fake = _FakeAgents(error=ApiError("validation_failed", "no", status=422))
with pytest.raises(SessionApiFailed) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.status == 422
async def test_bad_slug_asserts_no_call(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await define_agent(_wta(fake), agent_name="Wizard", system_prompt="x", role="m")
assert fake.calls == []
async def test_short_slug_asserts_no_call(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await define_agent(_wta(fake), agent_name="ab", system_prompt="x", role="m")
assert fake.calls == []
async def test_empty_prompt_asserts(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await define_agent(_wta(fake), agent_name="wizard", system_prompt="", role="m")
assert fake.calls == []
async def test_empty_role_asserts(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="")
assert fake.calls == []
async def test_other_5xx_maps_to_default(self) -> None:
fake = _FakeAgents(error=ApiError("upstream", "out", status=503))
with pytest.raises(SessionApiFailed) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.status == 503
class TestPatchAgent:
"""slice-4: patch_agent → SDK agents.patch(id, changes); open PatchedAgent dict."""
async def test_happy_both_fields(self) -> None:
resp = {"agent_id": "ratatoskr:wizard", "role": "different"}
fake = _FakeAgents(result=resp)
out = await patch_agent(
_wta(fake), "ratatoskr:wizard", system_prompt="new", role="different"
)
assert out is resp
name, args, _kwargs = fake.calls[-1]
assert name == "patch"
assert args[0] == "ratatoskr:wizard"
assert args[1] == {"system_prompt": "new", "role": "different"}
async def test_happy_single_field_omits_none(self) -> None:
fake = _FakeAgents(result={})
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="only this")
assert fake.calls[-1][1][1] == {"system_prompt": "only this"}
async def test_404_maps_to_agent_not_found(self) -> None:
fake = _FakeAgents(error=ApiError("not_found", "no", status=404))
with pytest.raises(Tier3AgentNotFound) as ei:
await patch_agent(_wta(fake), "ratatoskr:ghost", system_prompt="x")
assert ei.value.agent_id == "ratatoskr:ghost"
async def test_field_not_mutable_field_parsed(self) -> None:
fake = _FakeAgents(error=ApiError(
"field_not_mutable", "no", status=422,
body='{"detail": {"error_code": "field_not_mutable", "field": "agent_name"}}',
))
with pytest.raises(Tier3FieldNotMutable) as ei:
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
assert ei.value.field == "agent_name"
async def test_422_wrong_code_maps_to_default(self) -> None:
# Dual-key negative: a 422 whose code is NOT field_not_mutable → default,
# not a spurious Tier3FieldNotMutable (INV-CUT-2).
fake = _FakeAgents(error=ApiError("validation_failed", "no", status=422))
with pytest.raises(SessionApiFailed) as ei:
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
assert ei.value.status == 422
async def test_no_fields_asserts_no_call(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await patch_agent(_wta(fake), "ratatoskr:wizard")
assert fake.calls == []
async def test_non_tier3_id_asserts(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await patch_agent(_wta(fake), "mimir", system_prompt="x")
assert fake.calls == []
async def test_other_error_maps_to_default(self) -> None:
fake = _FakeAgents(error=ApiError("upstream", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
assert ei.value.status == 500
class TestDeleteAgent:
"""slice-4: delete_agent → SDK agents.delete(id); None on 204; 404 → not-found."""
async def test_happy_returns_none(self) -> None:
fake = _FakeAgents(result=None)
out = await delete_agent(_wta(fake), "ratatoskr:wizard")
assert out is None
assert fake.calls[-1] == ("delete", ("ratatoskr:wizard",), {})
async def test_404_maps_to_agent_not_found(self) -> None:
fake = _FakeAgents(error=ApiError("not_found", "no", status=404))
with pytest.raises(Tier3AgentNotFound) as ei:
await delete_agent(_wta(fake), "ratatoskr:ghost")
assert ei.value.agent_id == "ratatoskr:ghost"
async def test_non_tier3_id_asserts(self) -> None:
fake = _FakeAgents(result=None)
with pytest.raises(AssertionError):
await delete_agent(_wta(fake), "mimir")
assert fake.calls == []
async def test_other_error_maps_to_default(self) -> None:
fake = _FakeAgents(error=ApiError("upstream", "oops", status=500))
with pytest.raises(SessionApiFailed) as ei:
await delete_agent(_wta(fake), "ratatoskr:wizard")
assert ei.value.status == 500
# ── slice-5: characters + me/capabilities/models adapter routes ───────────────
# One canned result / error per fake (each slice-5 adapter fn touches exactly one
# sub-resource method), recorded by qualified name so the test can assert the route.
class _FakeMe:
def __init__(self, rec: _FakeMisc) -> None:
self._rec = rec
async def get(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("me.get", *a, **k)
class _FakeCapabilities:
def __init__(self, rec: _FakeMisc) -> None:
self._rec = rec
async def get(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("capabilities.get", *a, **k)
class _FakeModels:
def __init__(self, rec: _FakeMisc) -> None:
self._rec = rec
async def available_for_characters(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("models.available_for_characters", *a, **k)
class _FakeCharacters:
def __init__(self, rec: _FakeMisc) -> None:
self._rec = rec
async def create(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("characters.create", *a, **k)
async def state(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("characters.state", *a, **k)
async def delete(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("characters.delete", *a, **k)
class _FakeMisc:
"""Stand-in for the slice-5 client surface — exposes `.me` / `.capabilities` /
`.models` / `.characters`, recording each call under its qualified name and
returning a canned result or raising a canned error (same shape as `_FakeSessions`
/ `_FakeAgents`)."""
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
self._result = result
self._error = error
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
self.me = _FakeMe(self)
self.capabilities = _FakeCapabilities(self)
self.models = _FakeModels(self)
self.characters = _FakeCharacters(self)
async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
self.calls.append((name, args, kwargs))
if self._error is not None:
raise self._error
return self._result
def _wtm(misc: _FakeMisc) -> WorldtreeClient:
"""Cast the slice-5 misc-surface fake (me/capabilities/models/characters) to the
nominal client type the route functions are typed against."""
return cast(WorldtreeClient, misc)
class TestGetMe:
"""slice-5: get_me → SDK me.get(); open-world dict verbatim."""
async def test_happy_returns_dict_verbatim(self) -> None:
me = {"user_id": "alice", "scopes": ["conversations.read"], "tier": "user"}
fake = _FakeMisc(result=me)
out = await get_me(_wtm(fake))
assert out is me
assert fake.calls[-1][0] == "me.get"
async def test_401_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("auth_invalid", "no", status=401))
with pytest.raises(SessionApiFailed) as ei:
await get_me(_wtm(fake))
assert ei.value.status == 401
class TestGetCapabilities:
"""slice-5: get_capabilities → SDK capabilities.get(); open-world verbatim."""
async def test_happy_returns_dict_verbatim(self) -> None:
caps = {"ephemeral_templates": {"echo": {"default_role": "echo"}}}
fake = _FakeMisc(result=caps)
out = await get_capabilities(_wtm(fake))
assert out is caps
assert fake.calls[-1][0] == "capabilities.get"
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("upstream", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await get_capabilities(_wtm(fake))
assert ei.value.status == 500
class TestListCharacterModels:
"""slice-5: list_character_models → SDK models.available_for_characters()."""
async def test_happy_returns_dict_verbatim(self) -> None:
models = {"items": [{"name": "fast", "thinking": False}]}
fake = _FakeMisc(result=models)
out = await list_character_models(_wtm(fake))
assert out is models
assert fake.calls[-1][0] == "models.available_for_characters"
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("auth_scope_denied", "no", status=403))
with pytest.raises(SessionApiFailed) as ei:
await list_character_models(_wtm(fake))
assert ei.value.status == 403
class TestCreateCharacter:
"""slice-5: create_character → SDK characters.create(body); body-building + parity."""
async def test_happy_omits_state_when_none(self) -> None:
# SDK-idiomatic body: {character} only — no redundant explicit state:null.
created = {"character_id": "char_x", "ttl_expires_at": "t"}
fake = _FakeMisc(result=created)
out = await create_character(_wtm(fake), {"schema_version": "1", "name": "H"})
assert out is created
name, args, _ = fake.calls[-1]
assert name == "characters.create"
assert args[0] == {"character": {"schema_version": "1", "name": "H"}}
async def test_includes_state_when_supplied(self) -> None:
fake = _FakeMisc(result={"character_id": "c1"})
await create_character(
_wtm(fake), {"name": "H"}, state={"mood": "calm"}
)
assert fake.calls[-1][1][0] == {
"character": {"name": "H"},
"state": {"mood": "calm"},
}
async def test_empty_character_asserts_no_call(self) -> None:
fake = _FakeMisc(result={})
with pytest.raises(AssertionError):
await create_character(_wtm(fake), {})
assert fake.calls == []
async def test_403_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("auth_scope_denied", "no", status=403))
with pytest.raises(SessionApiFailed) as ei:
await create_character(_wtm(fake), {"name": "H"})
assert ei.value.status == 403
class TestGetCharacterState:
"""slice-5: get_character_state → SDK characters.state(id); open-world verbatim."""
async def test_happy_returns_dict_verbatim(self) -> None:
state = {"schema_version": "1", "pad": [0.4, 0.1, -0.2]}
fake = _FakeMisc(result=state)
out = await get_character_state(_wtm(fake), "char_x")
assert out is state
assert fake.calls[-1] == ("characters.state", ("char_x",), {})
async def test_empty_id_asserts_no_call(self) -> None:
fake = _FakeMisc(result={})
with pytest.raises(AssertionError):
await get_character_state(_wtm(fake), "")
assert fake.calls == []
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("not_found", "no", status=404))
with pytest.raises(SessionApiFailed) as ei:
await get_character_state(_wtm(fake), "char_x")
assert ei.value.status == 404
class TestDeleteCharacter:
"""slice-5: delete_character → SDK characters.delete(id); open ack verbatim."""
async def test_returns_ack_verbatim(self) -> None:
# Worldtree returns an open ack body here (not 204) — passed through, NOT
# normalized to None (parity posture).
ack = {"deleted": True, "character_id": "char_x"}
fake = _FakeMisc(result=ack)
out = await delete_character(_wtm(fake), "char_x")
assert out is ack
assert fake.calls[-1] == ("characters.delete", ("char_x",), {})
async def test_none_on_204(self) -> None:
# A 204 no-content yields None from the SDK — passed through unchanged.
fake = _FakeMisc(result=None)
assert await delete_character(_wtm(fake), "char_x") is None
async def test_empty_id_asserts_no_call(self) -> None:
fake = _FakeMisc(result=None)
with pytest.raises(AssertionError):
await delete_character(_wtm(fake), "")
assert fake.calls == []
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("upstream", "oops", status=500))
with pytest.raises(SessionApiFailed) as ei:
await delete_character(_wtm(fake), "char_x")
assert ei.value.status == 500
# ── slice-6: admin (bifrost inspection + admin-events stream) adapter routes ──
class _SdkAdminEvent:
"""Minimal stand-in for the SDK's `AdminEvent` — the adapter reads
`admin_id`/`type`/`timestamp`/`data`. `admin_id` may be `nan` (id-less);
`type`/`data` may be None (open-world)."""
def __init__(self, admin_id: Any, type: Any, timestamp: Any = None, data: Any = None) -> None:
self.admin_id = admin_id
self.type = type
self.timestamp = timestamp
self.data = data
class _FakeAdminSessions:
def __init__(self, admin: _FakeAdmin) -> None:
self._admin = admin
async def bifrost(self, *a: Any, **k: Any) -> Any:
return await self._admin._bifrost(*a, **k)
class _FakeAdmin:
"""Stand-in for `client.admin` — `.sessions.bifrost(id)` (canned result/error) +
`.stream_events(...)` (canned events / terminal error). Same shape as `_FakeSessions`."""
def __init__(
self,
*,
result: Any = None,
error: BaseException | None = None,
events: list[Any] | None = None,
stream_error: BaseException | None = None,
) -> None:
self._result = result
self._error = error
self._events = events or []
self._stream_error = stream_error
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
self.sessions = _FakeAdminSessions(self)
async def _bifrost(self, *a: Any, **k: Any) -> Any:
self.calls.append(("bifrost", a, k))
if self._error is not None:
raise self._error
return self._result
def stream_events(self, *a: Any, **k: Any) -> Any:
self.calls.append(("stream_events", a, k))
return self._astream()
async def _astream(self) -> Any:
for ev in self._events:
yield ev
if self._stream_error is not None:
raise self._stream_error
class _FakeAdminClient:
def __init__(self, admin: _FakeAdmin) -> None:
self.admin = admin
def _wtad(admin: _FakeAdmin) -> WorldtreeClient:
"""Cast the admin-surface fake (`.admin.sessions.bifrost` + `.admin.stream_events`)
to the nominal client type the slice-6 route functions are typed against."""
return cast(WorldtreeClient, _FakeAdminClient(admin))
class TestGetSessionBifrostWt:
"""slice-6: get_session_bifrost → SDK admin.sessions.bifrost(id); open-world verbatim."""
async def test_happy_returns_dict_verbatim(self) -> None:
binding = {"endpoint_url": "https://b/mcp", "connected": True, "tools": []}
fake = _FakeAdmin(result=binding)
out = await get_session_bifrost(_wtad(fake), "s1")
assert out is binding
assert fake.calls[-1] == ("bifrost", ("s1",), {})
async def test_empty_id_asserts_no_call(self) -> None:
fake = _FakeAdmin(result={})
with pytest.raises(AssertionError):
await get_session_bifrost(_wtad(fake), "")
assert fake.calls == []
async def test_403_maps_to_session_api_failed(self) -> None:
fake = _FakeAdmin(error=ApiError("auth_scope_denied", "no", status=403))
with pytest.raises(SessionApiFailed) as ei:
await get_session_bifrost(_wtad(fake), "s1")
assert ei.value.status == 403
async def test_404_not_bound_maps_to_session_api_failed(self) -> None:
fake = _FakeAdmin(error=ApiError("session_not_bifrost_bound", "no", status=404))
with pytest.raises(SessionApiFailed) as ei:
await get_session_bifrost(_wtad(fake), "s1")
assert ei.value.status == 404
class TestStreamAdminEventsWt:
"""slice-6: stream_admin_events → SDK admin.stream_events; re-wrap SDK AdminEvent →
ratatoskr AdminEvent (nan/None degraded), terminal errors Sse* types."""
async def test_rewraps_events_to_ratatoskr_shape(self) -> None:
sdk_evs = [
_SdkAdminEvent(5, "session.created", "t0", {"session_id": "s1"}),
_SdkAdminEvent(6, "turn.completed", "t1", {"session_id": "s1", "turn_id": 2}),
]
fake = _FakeAdmin(events=sdk_evs)
out = await _drain(stream_admin_events(_wtad(fake)))
assert all(isinstance(e, AdminEvent) for e in out)
assert (out[0].id, out[0].type, out[0].timestamp) == (5, "session.created", "t0")
assert out[0].data == {"session_id": "s1"}
assert out[1].id == 6
async def test_nan_admin_id_degrades_to_zero(self) -> None:
fake = _FakeAdmin(events=[_SdkAdminEvent(float("nan"), "system.heartbeat", None, None)])
out = await _drain(stream_admin_events(_wtad(fake)))
assert out[0].id == 0 # id-less envelope → 0, not nan
async def test_none_type_and_data_degrade(self) -> None:
# A partial wire: type=None (would crash `.startswith` in the web filter) and
# data=None (would crash `.get`) → "" and {} at the adapter boundary.
fake = _FakeAdmin(events=[_SdkAdminEvent(1, None, None, None)])
out = await _drain(stream_admin_events(_wtad(fake)))
assert out[0].type == ""
assert out[0].data == {}
async def test_passes_last_event_id(self) -> None:
fake = _FakeAdmin(events=[])
await _drain(stream_admin_events(_wtad(fake), last_event_id=42))
assert fake.calls[-1] == ("stream_events", (), {"last_event_id": 42})
async def test_non_200_apierror_maps_to_sse_connect_failed(self) -> None:
# A non-200 open raises ApiError("admin_stream_failed", status=…) → SseConnectFailed.
fake = _FakeAdmin(stream_error=ApiError("admin_stream_failed", "no", status=502))
with pytest.raises(SseConnectFailed) as ei:
await _drain(stream_admin_events(_wtad(fake)))
assert ei.value.status == 502
async def test_connect_failed_maps_to_sse_connect_failed(self) -> None:
# A connect-time transport / auth-resolution failure surfaces as ConnectFailed
# (the SDK's general floor) → SseConnectFailed, mirroring stream_turn — else it
# escapes the web gen's Sse* handler and aborts the SSE (heid bug-hunt slice-6).
fake = _FakeAdmin(stream_error=wtsdk.ConnectFailed("connect_failed", "refused", status=0))
with pytest.raises(SseConnectFailed) as ei:
await _drain(stream_admin_events(_wtad(fake)))
assert ei.value.status == 0
async def test_nonstr_type_degrades_to_empty(self) -> None:
# A truthy NON-str `type` (a partial/wrong open-world wire) must degrade to ""
# so the web filter's `.startswith` never AttributeErrors — `or ""` (falsy-only)
# would let it through; the isinstance guard catches it (heid bug-hunt slice-6).
fake = _FakeAdmin(events=[_SdkAdminEvent(1, 123, "t", {"session_id": "s"})])
out = await _drain(stream_admin_events(_wtad(fake)))
assert out[0].type == ""
async def test_connection_dropped_carries_cursor(self) -> None:
# A mid-stream drop / resumable EOF carries the resume cursor.
fake = _FakeAdmin(stream_error=wtsdk.ConnectionDropped("42"))
with pytest.raises(SseConnectionDropped) as ei:
await _drain(stream_admin_events(_wtad(fake)))
assert ei.value.last_seen_sse_id == "42"
async def test_connection_dropped_none_cursor_connect_time(self) -> None:
# A connect-time transport failure surfaces as ConnectionDropped(None) →
# SseConnectionDropped(last_seen_sse_id=None) (the map's other cursor shape;
# heid-code-review slice-6 test-gap).
fake = _FakeAdmin(stream_error=wtsdk.ConnectionDropped(None))
with pytest.raises(SseConnectionDropped) as ei:
await _drain(stream_admin_events(_wtad(fake)))
assert ei.value.last_seen_sse_id is None
Generated
+1 -12
View File
@@ -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.10"
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" },