Compare commits

...

13 Commits

Author SHA1 Message Date
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
24 changed files with 2295 additions and 1627 deletions
+8
View File
@@ -142,6 +142,14 @@ _Archived 2026-07-18._
_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) ## Tried and abandoned (archived)
The 2026-05-20 → 2026-05-28 cluster: original-build-era foot-guns. Archived 2026-06-18. The 2026-05-20 → 2026-05-28 cluster: original-build-era foot-guns. Archived 2026-06-18.
@@ -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(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(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(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 The default row is load-bearing: any `ApiError` not matched above surfaces as the
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
@@ -206,6 +214,127 @@ re-anchor its coverage-map rows.
SSE parsing); retire contracts #2/#15; final coverage-map re-anchor; minor bump SSE parsing); retire contracts #2/#15; final coverage-map re-anchor; minor bump
(DEC-6, operator approval). (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).
## Out of scope ## Out of scope
- Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched. - Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched.
+13 -13
View File
@@ -89,20 +89,20 @@ sub-gap).
| `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.) | | `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) | | `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 | | `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` | ✅ | `sessions.py:341``tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index | | `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` | ✅ | `sessions.py:384``tui.py:1132`,`web/server.py:386` | persona hydrate; 404/403 mapped | | `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` | ✅ | `tier3.py:175``_run_define` | Tier-3 create | | `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}` | ✅ | `tier3.py:219``_run_patch` | Tier-3 mutate (system_prompt/model) | | `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}` | ✅ | `tier3.py:242``_run_delete` | Tier-3 hard-delete | | `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` | ✅ | `sessions.py:411` `get_me``cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed | | `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` | ✅ | `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 /capabilities` | ✅ | `wt.py` `get_capabilities` (SDK `capabilities.get`)`cli.py` `--whoami` | **wt-adapter re-anchored (slice-5, #20)** — open-world advertisement verbatim; `_format_whoami` reads `allowed_roles`/`default_role` and degrades on a null/non-mapping template (slice-4 hardening); matches conversation-api-spec **v1.1** (`b4a278c`). **LIVE-SMOKE 2026-07-19**: `ephemeral_template echo: default=echo max_bytes=32768 roles=[echo]` |
| `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools``tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) | | `GET /sessions/{id}/tools` | ✅ | `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/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) | ✅ | `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 /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` | ✅ | `sessions.py` `list_character_models` `cli.py` `--characters` | character-capable model profiles (#161) | | `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` | ✅ | `sessions.py` `create_character``cli.py` `--characters` | create transient character (#161) | | `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` | ✅ | `sessions.py` `get_character_state``cli.py` `--characters` | live character PAD/emotions (#161) | | `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}` | ✅ | `sessions.py` `delete_character``cli.py` `--characters` | remove transient character (#161) | | `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** | | `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 **Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
@@ -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,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.
+46 -36
View File
@@ -46,36 +46,39 @@ upstream API key stays server-side (INV-003).
_As of 2026-07-19:_ _As of 2026-07-19:_
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-1 + SLICE-2 COMPLETE + PUSHED, slice-3 next.** **🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-15 COMPLETE, slice-6 (admin) next.**
Operator ruled ADOPT (2026-07-18): ratatoskr cuts its CONSUMER client layer over to **worldtree-sdk (Python) 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, 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 vor-cross'd, heid-panel-reviewed); contract `docs/contracts/worldtree_sdk_cutover.contract.md`. **SLICE-1+2
(adapter foundation) ✅ + SLICE-2 (sessions/turn) ✅ DONE + PUSHED** origin at `aba1730` (12-commit arc (foundation + sessions/turn) ✅ PUSHED** origin `aba1730`. **SLICE-3 (persona + authored-history + first-message)
`b1fbadd``aba1730`, tags v0.21.3.10, pushed 2026-07-19): adapter (`b907a7b`, all 6 route families ✅ DONE** `ca9a339`+`fc256bb`. **SLICE-4 (agents/Tier-3 + `model`→`role` fold) ✅ DONE** `c62b4ee``477d98f`
create/list/messages/tools/stream/cancel + the § Error-map mapping) → CLI rewire (`e3a10ad`) → web rewire (v0.21.13.15). **SLICE-5 (characters + me/capabilities/models) ✅ DONE**`deab762` (feat) + `d86d6df`
(`5c595b8`) → orphan deletion (`59602fe`, sse_client 714→224 + sessions 677→608; DEC-4 LIVE-SMOKE PASSED on (heid-code-review fixups) + `4e20030` (heid-bug-hunt fixups), tags v0.21.16.18; full House Code Discipline,
:8081 create+stream+cancel FIRST) → heid-code-review fixup (`74d41eb`) → heid-bug-hunt fixup (`aba1730`). both heid panels cleared. Suite **488 green**; **LIVE SMOKE on :8081/b128** drove `--whoami` (identity+caps) +
Suite **497 green**. **KEY ADAPTER FACTS (foot-guns for slices 3-7):** the SDK returns **open-world dicts** `--characters` (models→create→PAD read-back→delete) end-to-end. Slice-5 migrated
(not typed objects) for session reads → presenters read mappings, `info["session_id"]`; SDK events `get_me`/`get_capabilities`/`list_character_models`/`create_character`/`get_character_state`/`delete_character`
(`TurnEvent`) carry `sse_id: str` + a **body-derived `turn_id` that's ABSENT on text/thinking frames** → parse onto `client.me`/`.capabilities`/`.models`/`.characters.*` (all open-world reads → `SessionApiFailed` default,
the cancel target's turn from the composite `sse_id`, NOT `event.turn_id`; the SDK **normalizes transport **NO new Error-map rows**), rewired `--whoami`/`--characters` (**CLI-only; no web caller**), and DELETED the 6
failures to `ConnectFailed(status=0)`** (not raw httpx) → presenters catch it; the adapter re-wraps SDK stream hand-rolled `sessions.py` wrappers (`endpoint_for_plane`+`get_session_bifrost` [slice-6]+exceptions stay). Full
errors → ratatoskr caller-semantic exceptions (DEC-2, keep ratatoskr's typed exceptions); `consumer_key` is arc → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-5-complete.md`.
BOUND-create-only (adapter nulls it when unbound, else the SDK auths as the consumer); the SDK's error-envelope **KEY ADAPTER FACTS (foot-guns, cumulative for slices 6-7):** SDK reads = **open-world dicts** — presenters
parser **prefers the nested `detail`** so a top-level `error_code` doesn't surface (why create's bound-502 is MUST degrade not crash, guarded at THREE levels (slice-5 needed all three): **container-type** (a scalar `123`
NOT gated on error_code, unlike list's 422). **NEXT = slice-3** (persona `set_persona_state` + authored-history is non-iterable → `for x in 123` TypeError; the `or []` idiom catches null/absent but NOT a truthy non-iterable
`write_history` + first_message presets — retires the still-hand-rolled `create_session`/`get_session_messages` — the heid CODE-REVIEW caught null/element, the cold BUG-HUNT caught the container layer below it, run BOTH),
that the `--seed-first-message` probe uses); then slice-4 (agents/tier3, FOLDS the `model``role` cutover), **element-type** (`isinstance(m, dict)`), **top-level-mapping** (`isinstance(_, Mapping)` before any `.get`; a
slice-5 (characters/me/caps), slice-6 (admin — `stream_admin_events`+`get_session_bifrost` still hand-rolled), non-mapping passthrough → AttributeError); never hard-index `info["x"]`. The SDK **normalizes ANY transport
slice-7 (teardown: retire contracts #2/#15, drop `httpx-sse`, minor bump per DEC-6 w/ operator approval). failure to `ConnectFailed(status=0)`** (NOT raw httpx) — every adapter caller `except ConnectFailed`.
Scope: consumer layer ONLY; Bifrost provider planes untouched. Full design → auto-memory **caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the `-m` module** (double-
`project_worldtree_sdk_cutover`. 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-6** (admin: `admin.sessions.bifrost` + `admin.stream_events`, admin_auth — `get_session_bifrost`
in `sessions.py` + `stream_admin_events` in `sse_client.py`); then slice-7 (teardown: retire contracts #2/#15,
drop `httpx-sse`, MINOR bump per DEC-6 w/ operator approval). Scope: consumer layer ONLY; Bifrost provider
untouched. Full design → auto-memory `project_worldtree_sdk_cutover`.
**⏸️ DEFERRED — tier3 agents `model``role` (scope B), folds into cutover slice-4.** WT renamed the **✅ RESOLVED — tier3 agents `model``role` (scope B) folded into cutover slice-4** (`c62b4ee`, v0.21.13). The
agents-RESPONSE selector `model``role` (spec 1.2). **DEPLOY NOW LIVE** on :8080/:8081 (v1.0.0b128, deferred deploy-gated scope-B work (response `model``role` per spec 1.2 / b128, `LocalAgentEntry`, index schema
worldtree-dev confirmed 2026-07-19 — was the deploy-flag gate; acked). Operator chose scope B (full tier3 v2) landed with the agents-family SDK cutover — no longer pending. See the slice-4 detail file + Recent decisions.
`model``role` incl. contract #15 + CLI `--model``--role`); lands in cutover slice-4 where tier3.py routes
through the SDK (doing it standalone now = throwaway). Auto-memory `project_tier3_agents_model_to_role_pending`.
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).** **✅ 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 **No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
@@ -119,12 +122,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 (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`). adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
**Substrate / environment:** branch `main` at **v0.21.10**, **fully PUSHED to origin at `aba1730`** (slice-2 **Substrate / environment:** branch `main` at **v0.21.18** — slice-4 arc `c62b4ee``477d98f` + slice-5 arc
cutover arc `b1fbadd``aba1730` + tags v0.21.3.10 pushed 2026-07-19). origin `deab762``4e20030` + this snapshot **COMMITTED, not-yet-pushed** (tags v0.21.13.18; push is the operator's
`git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep: `worldtree-sdk==1.0.0`** (gitea PyPI, call); slice-13 (v0.21.3.12, `b1fbadd``4f92a21`) PUSHED to origin earlier. origin
`[tool.uv.sources]`; `httpx-sse` retires at slice-7). bifrost **`==1.1.4`** / wire v0.7; WT openapi vendored `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep:
2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`); **suite 497 green** (was 534 pre-cutover; net `worldtree-sdk==1.0.0`** (gitea PyPI, `[tool.uv.sources]`; `httpx-sse` retires at slice-7). bifrost
delta = adapter/rewire tests added, ~80 deleted hand-rolled turn-stream tests). Personal WT on **b128** **`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`);
**suite 488 green** (slice-5 added the characters/me/caps adapter tests + heid code-review/bug-hunt fixup
tests, ~offset by the deleted hand-rolled character/me/caps tests). Personal WT on **b128**
(`http://10.250.50.152:8081`; #368 silo + #364 promotion-hygiene live both instances). The combined (`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 — **: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 restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets
@@ -147,8 +152,6 @@ relational-dynamics verify (bind `--bifrost-url :8392`); WT #356 resume-durabili
Chronological log of decisions with `[YYYY-MM-DD]` prefix. One line per Chronological log of decisions with `[YYYY-MM-DD]` prefix. One line per
decision. Captures rationale that won't be obvious from code alone. 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]` **#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` - `[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`
@@ -275,8 +278,15 @@ decision. Captures rationale that won't be obvious from code alone.
- `[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]` **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-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-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]` **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._ _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._
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.21.11" version = "0.21.20"
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+97 -43
View File
@@ -43,21 +43,14 @@ from ratatoskr.sessions import (
BifrostBinding, BifrostBinding,
BifrostConsumerKeyMissing, BifrostConsumerKeyMissing,
BifrostHandshakeFailed, BifrostHandshakeFailed,
SessionApiFailed,
create_character,
delete_character,
endpoint_for_plane, endpoint_for_plane,
get_capabilities,
get_character_state,
get_me,
list_character_models,
) )
# The turn path (create / stream / cancel) is served by the worldtree-sdk adapter # The turn path (create / stream / cancel) and all consumer reads are served by the
# (`wt.*`); these caller-semantic exceptions are what the adapter raises, so the # worldtree-sdk adapter (`wt.*`); these caller-semantic exceptions are what the adapter
# presenter keeps catching ratatoskr's own types (DEC-2). The hand-rolled probes # raises, so the presenter keeps catching ratatoskr's own types (DEC-2). Only the
# (--whoami / --characters / --set-persona / --seed-first-message) stay on the # Bifrost-binding inputs + `endpoint_for_plane` remain hand-rolled here (the provider
# `sessions` wrappers until their own slices. # planes are consumer-orthogonal); `get_session_bifrost`'s admin surface lands in slice-6.
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
MalformedSseData, MalformedSseData,
MalformedSseId, MalformedSseId,
@@ -752,12 +745,29 @@ async def _amain(args: ParsedArgs) -> int:
loop.remove_signal_handler(signal.SIGINT) 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.""" """Render the --whoami report: identity (GET /me) + server capabilities."""
lines = ["identity:"] lines = ["identity:"]
lines.append(f" user_id: {me.get('user_id', '?')}") lines.append(f" user_id: {me.get('user_id', '?')}")
lines.append(f" tier: {me.get('tier', '?')}") 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"): for k in ("display_name", "key_id", "key_label"):
if k in me: if k in me:
lines.append(f" {k}: {me[k]}") lines.append(f" {k}: {me[k]}")
@@ -766,16 +776,17 @@ def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
if isinstance(templates, dict) and templates: if isinstance(templates, dict) and templates:
for name, spec in templates.items(): for name, spec in templates.items():
# A diagnostic renderer must tolerate a malformed / partially-cutover # A diagnostic renderer must tolerate a malformed / partially-cutover
# server (heid bug-hunt Gróa#1/#2): a non-mapping template value, or an # server: a non-mapping template value, or an `allowed_roles` that is null
# explicit-null `allowed_roles` (`.get(k, [])` returns None on null, not # / a scalar / carries non-strings, must degrade — not abort the whole
# the default), must degrade — not abort the whole --whoami report. # --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): if not isinstance(spec, dict):
lines.append(f" ephemeral_template {name}: (malformed)") lines.append(f" ephemeral_template {name}: (malformed)")
continue continue
# Canonical post-cutover shape (worldtree-dev althing 2026-07-18, # Canonical post-cutover shape (worldtree-dev althing 2026-07-18,
# ADR-0012): roles, not models. `config.role` selects; `config.model` # ADR-0012): roles, not models. `config.role` selects; `config.model`
# is now rejected server-side. # 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( lines.append(
f" ephemeral_template {name}: default={spec.get('default_role', '?')} " f" ephemeral_template {name}: default={spec.get('default_role', '?')} "
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} roles=[{roles}]" f"max_bytes={spec.get('system_prompt_max_bytes', '?')} roles=[{roles}]"
@@ -794,18 +805,23 @@ async def _whoami(args: ParsedArgs) -> int:
vocab + exit codes as the other modes. vocab + exit codes as the other modes.
""" """
assert isinstance(args, ParsedArgs) assert isinstance(args, ParsedArgs)
async with httpx.AsyncClient( async with _probe_client(args) as transport:
base_url=args.server_url, client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
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:
try: try:
me = await get_me(client) me = await wt.get_me(client)
caps = await get_capabilities(client) caps = await wt.get_capabilities(client)
except SessionApiFailed as exc: except wt.SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") sys.stderr.write(
f"[session_api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20 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") sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21 return 21
sys.stdout.write(_format_whoami(me, caps)) sys.stdout.write(_format_whoami(me, caps))
@@ -826,12 +842,21 @@ async def _characters_probe(args: ParsedArgs) -> int:
(models → create → get-state → delete), print a report, exit. A reference- (models → create → get-state → delete), print a report, exit. A reference-
consumer smoke of the #161 character surface (needs character.read/write).""" consumer smoke of the #161 character surface (needs character.read/write)."""
assert isinstance(args, ParsedArgs) 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: try:
models = await list_character_models(client) models = await wt.list_character_models(client)
names = ", ".join(m.get("name", "?") for m in models.get("items", [])) # 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") sys.stdout.write(f"character models: {names or '(none)'}\n")
created = await create_character( created = await wt.create_character(
client, client,
{ {
"schema_version": "1", "schema_version": "1",
@@ -845,16 +870,35 @@ async def _characters_probe(args: ParsedArgs) -> int:
"voice_profile_block": "plain", "voice_profile_block": "plain",
}, },
) )
cid = created["character_id"] # Open-world create ACK: degrade, don't hard-index (cumulative cutover
sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n") # foot-gun). A non-mapping ACK or an absent/blank character_id aborts the
state = await get_character_state(client, cid) # probe cleanly (exit 20) rather than raising AttributeError/KeyError — the
sys.stdout.write(f"state: pad={state.get('pad')}\n") # lifecycle needs the id for state + delete (heid bug-hunt slice-5). Past the
await delete_character(client, cid) # guard, `created`/`state` are known mappings.
sys.stdout.write(f"deleted: {cid}\n") cid = created.get("character_id") if isinstance(created, Mapping) else None
except SessionApiFailed as exc: if not (isinstance(cid, str) and cid):
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n") sys.stderr.write(
f"[session_api_failed] create returned no character_id: {created!r}\n"
)
return 20 return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n")
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 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,
ConnectFailed, # SDK normalizes a pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21 return 21
return 0 return 0
@@ -901,7 +945,12 @@ async def _set_persona_probe(args: ParsedArgs) -> int:
f"error_code={exc.error_code!r} body={exc.body!r}\n" f"error_code={exc.error_code!r} body={exc.body!r}\n"
) )
return 20 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") sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21 return 21
sys.stdout.write( sys.stdout.write(
@@ -968,7 +1017,12 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int:
f"error_code={exc.error_code!r} body={exc.body!r}\n" f"error_code={exc.error_code!r} body={exc.body!r}\n"
) )
return 20 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") sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21 return 21
return 0 return 0
+9 -4
View File
@@ -27,7 +27,11 @@ import os
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path 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) @dataclass(frozen=True)
@@ -37,16 +41,17 @@ class LocalAgentEntry:
Schema: Schema:
- ``agent_id``: full "user_id:agent_name" string (Worldtree-owned). - ``agent_id``: full "user_id:agent_name" string (Worldtree-owned).
- ``agent_name``: slug from define (display name). - ``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 - ``description``: synthetic display string (typically derived from
the system_prompt's first line + a "(tier 3)" prefix; the picker the system_prompt's first line + a "(tier 3)" prefix; the picker
uses this in its ``{id} · {name}{description}`` rendering). 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_id: str
agent_name: str agent_name: str
model: str role: str
description: str description: str
defined_at: str defined_at: str
+64 -214
View File
@@ -6,29 +6,6 @@ Implements docs/contracts/issues/2.contract.md.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
import httpx
@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) @dataclass(frozen=True)
@@ -169,6 +146,70 @@ class AuthoredHistoryUnavailable(Exception):
self.session_id = session_id 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: def endpoint_for_plane(plane: str, base_host: str) -> str:
"""Map a provider plane name to its Worldtree-VISIBLE base URL. """Map a provider plane name to its Worldtree-VISIBLE base URL.
@@ -184,194 +225,3 @@ def endpoint_for_plane(plane: str, base_host: str) -> str:
if plane not in ports: 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]}" return f"http://{base_host}:{ports[plane]}"
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 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)
-53
View File
@@ -5,14 +5,9 @@ Implements docs/contracts/issues/1.contract.md.
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import AsyncIterator
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, NamedTuple from typing import Any, NamedTuple
import httpx
import httpx_sse
class SseId(NamedTuple): class SseId(NamedTuple):
"""Parsed composite SSE wire `id:` per spec §SSE id format.""" """Parsed composite SSE wire `id:` per spec §SSE id format."""
@@ -173,51 +168,3 @@ class CancelFailed(Exception):
super().__init__(f"cancel failed: status={status}, body={body[:128]!r}") super().__init__(f"cancel failed: status={status}, body={body[:128]!r}")
self.status = status self.status = status
self.body = body 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
+104 -286
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 The define / patch / delete wire calls route through the worldtree-sdk adapter
posture (same as ratatoskr.sessions). Exposes three lifecycle operations: (``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 The old hand-rolled httpx wrappers + the ``Tier3AgentInfo`` dataclass were deleted in
- ``patch_agent`` — PATCH /agents/<id> the worldtree-sdk cutover (issue #20, slice-4); the adapter returns the SDK's
- ``delete_agent`` — DELETE /agents/<id> 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
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker ``wt`` only lazily inside the CLI handlers, so there is no import cycle.
already handles colon-containing agent_ids generically (issue #8); session
creation works unchanged via ``ratatoskr.wt.create_session`` (worldtree-sdk cutover).
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import re import sys
from dataclasses import dataclass from collections.abc import Mapping
from typing import Any
import httpx import httpx
from ratatoskr.sessions import SessionApiFailed # 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 —
# Per spec §2627: agent_name + user_id slugs are `[a-z][a-z0-9-]{2,63}`. # see the header note in `sessions.py`. `main()` catches these; `wt` raises them.
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$") from ratatoskr.sessions import (
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
)
@dataclass(frozen=True) def _str_field(info: Mapping[str, Any], key: str, *, default: str = "") -> str:
class Tier3AgentInfo: """A string field off an open-world response dict, or `default` when the key is
"""Worldtree Tier 3 agent envelope returned by define / patch. absent / null / non-string — so a partial or drifted 2xx define/patch response
degrades rather than KeyError/AttributeError-crashing the CLI presenter (the
INV-001: ``agent_id`` is always shape ``"<user_id>:<agent_name>"`` — "open-world reads degrade, never crash the presenter" invariant; heid-bug-hunt)."""
constructed server-side from the auth's user_id + the supplied agent_name. value = info.get(key)
""" return value if isinstance(value, str) else default
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)
# ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------ # ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------
# #
# Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes # Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes
# mirror ratatoskr.cli: 0 happy / 10 usage / 11 auth / 20 api-failure / # 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): 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).", 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("agent_id", help='Full "<user_id>:<agent_name>" form.')
p_patch.add_argument("--system-prompt", dest="system_prompt", default=None) p_patch.add_argument("--system-prompt", dest="system_prompt", default=None)
p_patch.add_argument("--role", 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 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: async def _run_define(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns) api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT from ratatoskr import wt
from ratatoskr.local_agents import ( from ratatoskr.local_agents import (
LocalAgentEntry, LocalAgentEntry,
add_local_agent, add_local_agent,
make_description, make_description,
) )
async with httpx.AsyncClient( async with _transport(server_url, api_key) as transport:
base_url=server_url, client = wt.build_client(server_url, api_key=api_key, transport=transport)
headers={ info = await wt.define_agent(
"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(
client, client,
agent_name=ns.name, agent_name=ns.name,
system_prompt=ns.system_prompt, system_prompt=ns.system_prompt,
role=ns.role, role=ns.role,
) )
# v0.8.0: persist to local index so the picker can show it. # 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( add_local_agent(
LocalAgentEntry( LocalAgentEntry(
agent_id=info.agent_id, agent_id=agent_id,
agent_name=info.agent_name, agent_name=agent_name,
model=info.model, role=role,
description=make_description(info.system_prompt), description=make_description(_str_field(info, "system_prompt")),
defined_at=info.created_at, defined_at=_str_field(info, "created_at"),
) )
) )
print(f"defined {info.agent_id} ({info.model})") print(f"defined {agent_id} ({role})")
return 0 return 0
async def _run_patch(ns: argparse.Namespace) -> int: async def _run_patch(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns) api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT from ratatoskr import wt
from ratatoskr.local_agents import ( from ratatoskr.local_agents import (
LocalAgentEntry, LocalAgentEntry,
make_description, make_description,
@@ -358,48 +171,43 @@ async def _run_patch(ns: argparse.Namespace) -> int:
raise _Tier3UsageError( raise _Tier3UsageError(
"patch requires at least one of --system-prompt or --role" "patch requires at least one of --system-prompt or --role"
) )
async with httpx.AsyncClient( async with _transport(server_url, api_key) as transport:
base_url=server_url, client = wt.build_client(server_url, api_key=api_key, transport=transport)
headers={ info = await wt.patch_agent(
"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(
client, client,
ns.agent_id, ns.agent_id,
system_prompt=ns.system_prompt, system_prompt=ns.system_prompt,
role=ns.role, role=ns.role,
) )
# v0.8.0: refresh local index with the post-patch state. # 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( update_local_agent(
LocalAgentEntry( LocalAgentEntry(
agent_id=info.agent_id, agent_id=agent_id,
agent_name=info.agent_name, agent_name=agent_name,
model=info.model, role=_str_field(info, "role", default="?"),
description=make_description(info.system_prompt), description=make_description(_str_field(info, "system_prompt")),
defined_at=info.updated_at, defined_at=_str_field(info, "updated_at"),
) )
) )
print(f"patched {info.agent_id}") print(f"patched {agent_id}")
return 0 return 0
async def _run_delete(ns: argparse.Namespace) -> int: async def _run_delete(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns) 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 from ratatoskr.local_agents import remove_local_agent
async with httpx.AsyncClient( async with _transport(server_url, api_key) as transport:
base_url=server_url, client = wt.build_client(server_url, api_key=api_key, transport=transport)
headers={ await wt.delete_agent(client, ns.agent_id)
"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)
# v0.8.0: drop from local index so the picker stops listing it. # v0.8.0: drop from local index so the picker stops listing it.
remove_local_agent(ns.agent_id) remove_local_agent(ns.agent_id)
print(f"deleted {ns.agent_id}") print(f"deleted {ns.agent_id}")
@@ -419,6 +227,10 @@ def main(argv: list[str] | None = None) -> int:
import asyncio import asyncio
import sys import sys
import worldtree_sdk as wtsdk
from ratatoskr.wt import SessionApiFailed
parser = _build_parser() parser = _build_parser()
try: try:
ns = parser.parse_args(argv) ns = parser.parse_args(argv)
@@ -459,10 +271,16 @@ def main(argv: list[str] | None = None) -> int:
return 20 return 20
except SessionApiFailed as exc: except SessionApiFailed as exc:
sys.stderr.write( 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 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") sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21 return 21
+88 -29
View File
@@ -26,7 +26,13 @@ from starlette.responses import (
) )
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles 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 local_agents as _local_agents
from ratatoskr import wt from ratatoskr import wt
@@ -39,17 +45,14 @@ from ratatoskr.sessions import (
BifrostConsumerKeyMissing, BifrostConsumerKeyMissing,
BifrostHandshakeFailed, BifrostHandshakeFailed,
PersonaNotConfigured, PersonaNotConfigured,
SessionApiFailed,
endpoint_for_plane, endpoint_for_plane,
get_persona_state,
get_session_bifrost,
list_agents,
) )
# The turn path (create / stream / cancel / tools / messages) is served by the # The turn path (create / stream / cancel / tools / messages), the agents /
# worldtree-sdk adapter (`wt.*`), which raises ratatoskr's caller-semantic # persona-state reads, AND the admin surface (bifrost inspection + admin-events stream)
# exceptions (DEC-2). The hand-rolled endpoints (persona / agents / admin / # are all served by the worldtree-sdk adapter (`wt.*`), which raises ratatoskr's
# bifrost) stay on the `sessions` / `sse_client` wrappers until their own slices. # 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 ( from ratatoskr.sse_client import (
AdminEvent, AdminEvent,
CancelAlreadyCompleted, CancelAlreadyCompleted,
@@ -60,16 +63,21 @@ from ratatoskr.sse_client import (
SseConnectFailed, SseConnectFailed,
SseConnectionDropped, SseConnectionDropped,
TurnIdFlip, 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: """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 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 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 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" base_url = str(client.base_url) or "http://localhost"
header = client.headers.get("Authorization", "") header = client.headers.get("Authorization", "")
# Case-insensitive scheme + tolerant of extra whitespace, so a valid bearer is # 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) parts = header.split(None, 1)
api_key = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else "" api_key = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else ""
return wt.build_client( 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 client_factory = request.app.state.client_factory
try: try:
async with client_factory() as client: async with client_factory() as client:
upstream = await list_agents(client) upstream = await wt.list_agents(_wt_client(client))
except SessionApiFailed as exc: except wt.SessionApiFailed as exc:
return JSONResponse( return JSONResponse(
{"error_code": "session_api_failed", "status": exc.status}, {"error_code": "session_api_failed", "status": exc.status},
status_code=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( return JSONResponse(
{"error_code": "network_error", "message": str(exc)}, {"error_code": "network_error", "message": str(exc)},
status_code=502, 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() 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 _as_dict(le) for le in local if le.agent_id not in upstream_ids
] ]
return JSONResponse(merged, status_code=200) return JSONResponse(merged, status_code=200)
@@ -433,13 +456,28 @@ async def _persona_state_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory client_factory = request.app.state.client_factory
try: try:
async with client_factory() as client: 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: except PersonaNotConfigured:
return JSONResponse({"error_code": "persona_not_configured"}, status_code=404) return JSONResponse({"error_code": "persona_not_configured"}, status_code=404)
except AgentNotAvailable: except AgentNotAvailable:
return JSONResponse({"error_code": "agent_not_available"}, status_code=404) return JSONResponse({"error_code": "agent_not_available"}, status_code=404)
except AuthScopeDenied: except AuthScopeDenied:
return JSONResponse({"error_code": "auth_scope_denied"}, status_code=403) 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) 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). """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 The admin key is SERVER-HELD (app.state.admin_key) and never reaches the
browser (INV-003 precedent — upstream credentials stay server-side); the browser (INV-003 precedent — upstream credentials stay server-side); it rides on
wrapper overrides the Authorization header with it. Fail-visible when the the wt client's `admin_auth` (`_wt_client(admin_key=…)`), which the SDK uses for
admin key isn't configured (never a silent empty pane).""" 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"] session_id = request.path_params["session_id"]
admin_key = request.app.state.admin_key admin_key = request.app.state.admin_key
if not admin_key: # PRE-001: fail-visible, never silent if not admin_key: # PRE-001: fail-visible, never silent
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400) return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
client_factory = request.app.state.client_factory client_factory = request.app.state.client_factory
try: try:
async with client_factory() as client: async with client_factory() as transport:
bstate = await get_session_bifrost(client, session_id, admin_key=admin_key) # slice-6: the SDK's admin.* routes use the client's admin_auth (built with
except SessionApiFailed as exc: # 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( return JSONResponse(
{"error_code": "bifrost_state_unavailable", "status": exc.status}, {"error_code": "bifrost_state_unavailable", "status": exc.status},
status_code=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: 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 client_factory = request.app.state.client_factory
async def gen() -> AsyncIterator[bytes]: async def gen() -> AsyncIterator[bytes]:
client = client_factory() transport = client_factory()
try: 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): if not _admin_event_matches_web(ev, session_id):
continue continue
# Fixed SSE event name so the browser renders EVERY admin type # 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: except asyncio.CancelledError:
raise # browser disconnect — let the generator unwind raise # browser disconnect — let the generator unwind
finally: 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") return StreamingResponse(gen(), media_type="text/event-stream")
+349 -4
View File
@@ -28,7 +28,9 @@ carries the SDK's parsed `error_code`).
from __future__ import annotations from __future__ import annotations
import json import json
from collections.abc import AsyncGenerator, Mapping import math
import re
from collections.abc import AsyncGenerator, Mapping, Sequence
from typing import Any from typing import Any
import httpx import httpx
@@ -39,15 +41,26 @@ from worldtree_sdk import ApiError, AuthProvider, CancelResult, PadState, Worldt
# type still live in the retiring `sessions` / `sse_client` modules; they relocate # type still live in the retiring `sessions` / `sse_client` modules; they relocate
# into this adapter as their call-sites are rewired in later slices. 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. # sessions / sse_client is one-way (neither imports wt), so there is no cycle.
from .sessions import (
AgentNotAvailable as PersonaAgentNotAvailable,
)
from .sessions import ( from .sessions import (
AgentNotFound, AgentNotFound,
AuthoredHistoryUnavailable, AuthoredHistoryUnavailable,
AuthScopeDenied,
BifrostBinding, BifrostBinding,
BifrostConsumerKeyMissing, BifrostConsumerKeyMissing,
BifrostHandshakeFailed, BifrostHandshakeFailed,
InvalidCursor, InvalidCursor,
PersonaNotConfigured,
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
) )
from .sse_client import ( from .sse_client import (
AdminEvent,
AgentNotAvailable, AgentNotAvailable,
CancelAlreadyCompleted, CancelAlreadyCompleted,
CancelFailed, CancelFailed,
@@ -367,11 +380,16 @@ async def set_persona_state(
The adapter builds the canonical `PadState`; the SDK owns the wire wrapper The adapter builds the canonical `PadState`; the SDK owns the wire wrapper
(`{"pad": {pleasure, arousal, dominance}}`, prose-pinned #317) — ratatoskr no (`{"pad": {pleasure, arousal, dominance}}`, prose-pinned #317) — ratatoskr no
longer hand-assembles it. Resolves on 204 (→ None). Error map (INV-CUT-2): no longer hand-assembles it. Resolves on 204 (→ None). Error map (INV-CUT-2): no
route-specific row → the `SessionApiFailed` default. (A non-finite axis is the route-specific row → the `SessionApiFailed` default.
caller's to reject; the SDK raises `ConfigurationError` pre-HTTP and the CLI
surface pre-validates finiteness before calling.) 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 session_id and isinstance(session_id, str)
assert all(math.isfinite(v) for v in (pleasure, arousal, dominance))
try: try:
await client.sessions.set_persona_state( await client.sessions.set_persona_state(
session_id, PadState(pleasure=pleasure, arousal=arousal, dominance=dominance) session_id, PadState(pleasure=pleasure, arousal=arousal, dominance=dominance)
@@ -411,3 +429,330 @@ async def write_authored_history(
if exc.status == 404: if exc.status == 404:
raise AuthoredHistoryUnavailable(session_id=session_id) from exc raise AuthoredHistoryUnavailable(session_id=session_id) from exc
raise translate_error(exc) 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 404→hidden (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 rc == 20
assert "[session_api_failed]" in capsys.readouterr().err 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: class TestTier2Probes:
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write).""" """--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 "deleted: char_z" in out
assert del_route.call_count == 1 # lifecycle cleaned up 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 @respx.mock
def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None: def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None:
"""set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204.""" """set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204."""
@@ -1950,6 +2089,20 @@ class TestTier2Probes:
) )
assert rc == 10 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: class TestSeedFirstMessageProbe:
"""--seed-first-message one-shot (#347 authored-history-write reference-consumer probe).""" """--seed-first-message one-shot (#347 authored-history-write reference-consumer probe)."""
@@ -2066,3 +2219,17 @@ class TestSeedFirstMessageProbe:
assert rc == 0 assert rc == 0
assert "feature-absent" in capsys.readouterr().out assert "feature-absent" in capsys.readouterr().out
assert msgs_route.call_count == 0 # never capability-probes past the 404 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
+10 -10
View File
@@ -33,14 +33,14 @@ def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
def _entry( def _entry(
agent_id: str = "ratatoskr:wizard", agent_id: str = "ratatoskr:wizard",
agent_name: str = "wizard", agent_name: str = "wizard",
model: str = "qwen3.6-35-a3b", role: str = "qwen3.6-35-a3b",
description: str = "(tier 3) test agent", description: str = "(tier 3) test agent",
defined_at: str = "2026-05-25T00:00:00+00:00", defined_at: str = "2026-05-25T00:00:00+00:00",
) -> LocalAgentEntry: ) -> LocalAgentEntry:
return LocalAgentEntry( return LocalAgentEntry(
agent_id=agent_id, agent_id=agent_id,
agent_name=agent_name, agent_name=agent_name,
model=model, role=role,
description=description, description=description,
defined_at=defined_at, defined_at=defined_at,
) )
@@ -91,13 +91,13 @@ class TestLoadEmpty:
local_path.write_text( local_path.write_text(
json.dumps( json.dumps(
{ {
"version": 1, "version": 2,
"agents": [ "agents": [
{"agent_id": "incomplete"}, # missing required fields {"agent_id": "incomplete"}, # missing required fields
{ {
"agent_id": "ratatoskr:good", "agent_id": "ratatoskr:good",
"agent_name": "good", "agent_name": "good",
"model": "m", "role": "m",
"description": "d", "description": "d",
"defined_at": "t", "defined_at": "t",
}, },
@@ -124,11 +124,11 @@ class TestAdd:
assert ids == {"ratatoskr:a", "ratatoskr:b"} assert ids == {"ratatoskr:a", "ratatoskr:b"}
def test_add_replaces_same_id(self, local_path: Path) -> None: def test_add_replaces_same_id(self, local_path: Path) -> None:
add_local_agent(_entry(model="old-model")) add_local_agent(_entry(role="old-role"))
add_local_agent(_entry(model="new-model")) add_local_agent(_entry(role="new-role"))
entries = load_local_agents() entries = load_local_agents()
assert len(entries) == 1 assert len(entries) == 1
assert entries[0].model == "new-model" assert entries[0].role == "new-role"
def test_creates_parent_dirs( def test_creates_parent_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -141,11 +141,11 @@ class TestAdd:
class TestUpdate: class TestUpdate:
def test_update_changes_existing(self, local_path: Path) -> None: def test_update_changes_existing(self, local_path: Path) -> None:
add_local_agent(_entry(model="v1")) add_local_agent(_entry(role="v1"))
update_local_agent(_entry(model="v2")) update_local_agent(_entry(role="v2"))
entries = load_local_agents() entries = load_local_agents()
assert len(entries) == 1 assert len(entries) == 1
assert entries[0].model == "v2" assert entries[0].role == "v2"
class TestRemove: class TestRemove:
+7 -513
View File
@@ -1,26 +1,13 @@
"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md.""" """Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md.
Post worldtree-sdk cutover the `sessions` module is down to `endpoint_for_plane`
(the Bifrost provider-plane helper) + the caller-semantic exception classes the
`ratatoskr.wt` adapter raises; every wire wrapper has retired onto the SDK adapter
(the wrappers' tests live in `test_wt.py`)."""
import httpx
import pytest import pytest
import respx
from ratatoskr.sessions import ( from ratatoskr.sessions import endpoint_for_plane
AgentInfo,
AgentNotAvailable,
AuthScopeDenied,
PersonaNotConfigured,
SessionApiFailed,
create_character,
delete_character,
endpoint_for_plane,
get_capabilities,
get_character_state,
get_me,
get_persona_state,
get_session_bifrost,
list_agents,
list_character_models,
)
class TestEndpointForPlane: class TestEndpointForPlane:
@@ -42,496 +29,3 @@ class TestEndpointForPlane:
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001).""" """unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
with pytest.raises(ValueError): with pytest.raises(ValueError):
endpoint_for_plane("persona", "10.100.10.50") endpoint_for_plane("persona", "10.100.10.50")
class TestListAgents:
@respx.mock
async def test_happy_full_shape(self) -> None:
"""happy_full_shape [happy,tracer]: spec full-shape mimir example → all fields."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "mimir",
"name": "Mimir",
"description": "Keeper of the Well of Knowledge.",
"version": "0.2.0",
"capabilities": ["knowledge_base", "semantic_search"],
"supported_models": ["default", "heavy"],
"persona_traits": {
"ocean": {
"openness": 0.7,
"conscientiousness": 0.9,
"extraversion": 0.1,
"agreeableness": 0.5,
"neuroticism": 0.3,
},
"vibe": "contemplative",
},
"ui_hints": {"icon": "well", "color_hint": "#5b8aa3"},
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert len(agents) == 1
a = agents[0]
assert isinstance(a, AgentInfo)
assert a.agent_id == "mimir"
assert a.name == "Mimir"
assert a.description == "Keeper of the Well of Knowledge."
assert a.version == "0.2.0"
assert a.capabilities == ["knowledge_base", "semantic_search"]
assert a.supported_models == ["default", "heavy"]
assert a.persona_traits["vibe"] == "contemplative"
assert a.ui_hints["icon"] == "well"
@respx.mock
async def test_happy_minimum_shape(self) -> None:
"""happy_minimum_shape: required-only agent → optional fields default."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "minimal",
"name": "Minimal Agent",
"description": "Just a sketch.",
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
a = agents[0]
assert a.agent_id == "minimal"
assert a.version is None
assert a.capabilities == []
assert a.supported_models == []
assert a.persona_traits == {}
assert a.ui_hints == {}
@respx.mock
async def test_happy_multi_agent(self) -> None:
"""happy_multi_agent: 3 agents preserve order."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{"agent_id": "a", "name": "A", "description": "x"},
{"agent_id": "b", "name": "B", "description": "y"},
{"agent_id": "c", "name": "C", "description": "z"},
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert [a.agent_id for a in agents] == ["a", "b", "c"]
@respx.mock
async def test_happy_empty(self) -> None:
"""happy_empty: 200 with [] returns empty list (no error)."""
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents == []
@respx.mock
async def test_omit_capabilities_empty_list(self) -> None:
"""omit_capabilities_empty: explicit [] from server still defaults to []."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "a",
"name": "A",
"description": "x",
"capabilities": [],
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents[0].capabilities == []
@respx.mock
async def test_500_raises_session_api_failed(self) -> None:
"""500 → SessionApiFailed with status=500."""
respx.get("https://w.example/agents").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 excinfo:
await list_agents(client)
assert excinfo.value.status == 500
@respx.mock
async def test_401_raises_session_api_failed(self) -> None:
"""401 → SessionApiFailed with status=401."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(401, content=b'{"error":"unauthorized"}')
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as excinfo:
await list_agents(client)
assert excinfo.value.status == 401
class TestGetPersonaState:
"""Worldtree #204 / v0.28.0 — GET /agents/{agent_id}/persona_state.
Bootstrap read for the persona snapshot same shape as `affect_update`'s
`current` snapshot. Auth via `persona.read` scope (user-tier default).
"""
@respx.mock
async def test_happy_full_snapshot(self) -> None:
"""happy_full_snapshot [happy,tracer]: 200 → snapshot dict with pad +
dominant_emotion + emotions_active + baseline_pad + mood_drift.
"""
snapshot = {
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
"dominant_emotion": "curiosity",
"emotions_active": [
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
],
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
"last_updated_at": "2026-05-25T22:30:18+00:00",
}
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(200, json=snapshot)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await get_persona_state(client, "mimir")
assert result == snapshot
@respx.mock
async def test_persona_not_configured_404(self) -> None:
"""persona_not_configured_404 [error]: 404 with error_code
persona_not_configured PersonaNotConfigured. Agent exists but has
no persona surface (e.g. domari, muninn, Tier 3).
"""
respx.get("https://w.example/agents/domari/persona_state").mock(
return_value=httpx.Response(
404, json={"error_code": "persona_not_configured", "message": "no persona"}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(PersonaNotConfigured) as exc_info:
await get_persona_state(client, "domari")
assert exc_info.value.agent_id == "domari"
@respx.mock
async def test_agent_not_available_404(self) -> None:
"""agent_not_available_404 [error]: 404 with error_code
agent_not_available AgentNotAvailable. Distinct from
persona_not_configured the agent_id itself is unknown.
"""
respx.get("https://w.example/agents/bogus/persona_state").mock(
return_value=httpx.Response(
404, json={"error_code": "agent_not_available", "message": "unknown agent"}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AgentNotAvailable) as exc_info:
await get_persona_state(client, "bogus")
assert exc_info.value.agent_id == "bogus"
@respx.mock
async def test_auth_scope_denied_403(self) -> None:
"""auth_scope_denied_403 [error]: 403 with error_code auth_scope_denied
AuthScopeDenied. Key lacks `persona.read` scope.
"""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(
403,
json={"error_code": "auth_scope_denied", "message": "missing persona.read"},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AuthScopeDenied) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.scope == "persona.read"
@respx.mock
async def test_404_unknown_error_code_falls_through(self) -> None:
"""404_unknown_error_code_falls_through [adversarial]: 404 without the
two known error codes SessionApiFailed (don't swallow novel failure
modes as something more specific than they are).
"""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(404, json={"error_code": "novel_404"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.status == 404
@respx.mock
async def test_500_unexpected_status(self) -> None:
"""500_unexpected_status [error]: 5xx → SessionApiFailed (matches the
list_agents / list_sessions / create_session precedent)."""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(500, content=b"boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.status == 500
@respx.mock
async def test_auth_scope_denied_detail_envelope(self) -> None:
"""auth_scope_denied_detail_envelope [regression]: real Worldtree
returns `{"detail": {"error_code": "auth_scope_denied", }}`
(FastAPI default), not flat `{"error_code": }`. Smoke against
personal:8081 2026-05-28 surfaced this pre-fix the response
fell through to SessionApiFailed(403) instead of AuthScopeDenied.
"""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(
403,
json={
"detail": {
"error_code": "auth_scope_denied",
"message": "Missing required scope: persona.read",
}
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AuthScopeDenied) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.scope == "persona.read"
@respx.mock
async def test_persona_not_configured_detail_envelope(self) -> None:
"""persona_not_configured_detail_envelope [regression]: same
envelope-shape unwrap on 404 + persona_not_configured.
"""
respx.get("https://w.example/agents/domari/persona_state").mock(
return_value=httpx.Response(
404,
json={"detail": {"error_code": "persona_not_configured"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(PersonaNotConfigured) as exc_info:
await get_persona_state(client, "domari")
assert exc_info.value.agent_id == "domari"
class TestGetMe:
"""docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me)."""
@respx.mock
async def test_happy_authenticated(self) -> None:
"""happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "alice",
"scopes": ["conversations.read", "conversations.write"],
"tier": "user",
"key_id": "a1b2c3d4",
"key_label": "alice phone",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
me = await get_me(client)
assert me["user_id"] == "alice"
assert me["tier"] == "user"
assert me["key_id"] == "a1b2c3d4"
assert me["scopes"] == ["conversations.read", "conversations.write"]
@respx.mock
async def test_anonymous_dev_mode(self) -> None:
"""anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(
200,
json={
"user_id": "anonymous",
"scopes": ["conversations.read"],
"tier": "anonymous",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
me = await get_me(client)
assert me["tier"] == "anonymous"
assert "key_id" not in me # optional fields omitted, not null
@respx.mock
async def test_401_raises_session_api_failed(self) -> None:
"""401_raises [error]: bad/absent key → SessionApiFailed(status=401)."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(401, json={"detail": "auth_invalid"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_me(client)
assert exc.value.status == 401
class TestGetCapabilities:
"""docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me)."""
@respx.mock
async def test_happy(self) -> None:
"""happy [happy]: 200 → ephemeral_templates dict verbatim."""
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(
200,
json={
"ephemeral_templates": {
"echo": {
"allowed_models": ["glm5-turbo", "glm4.7"],
"default_model": "glm5-turbo",
"system_prompt_max_bytes": 32768,
}
}
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
caps = await get_capabilities(client)
echo = caps["ephemeral_templates"]["echo"]
assert echo["default_model"] == "glm5-turbo"
assert echo["system_prompt_max_bytes"] == 32768
@respx.mock
async def test_non_200_raises(self) -> None:
"""non_200_raises [error]: 500 → SessionApiFailed(status=500)."""
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(500, content=b"boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_capabilities(client)
assert exc.value.status == 500
class TestGetSessionBifrost:
"""#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176)."""
@respx.mock
async def test_happy_uses_admin_bearer(self) -> None:
"""happy [happy,tracer]: 200 → binding dict; request carries the ADMIN bearer (override)."""
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(
200,
json={
"endpoint_url": "https://bifrost.example/mcp",
"consumer_id": "alice",
"connected": True,
"capabilities_granted": ["tools:call", "tools:read"],
"tools": [{"name": "bifrost.alice.echo", "description": "echo"}],
},
)
)
async with httpx.AsyncClient(
base_url="https://w.example",
headers={"Authorization": "Bearer consumer-key"},
) as client:
state = await get_session_bifrost(client, "s1", admin_key="admin-xyz")
assert state["connected"] is True
assert state["tools"][0]["name"] == "bifrost.alice.echo"
# the request overrode the client's default consumer bearer with the admin key
assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz"
@respx.mock
async def test_403_scope_denied(self) -> None:
"""403 [error]: admin key lacks admin.sessions.read → SessionApiFailed(403)."""
respx.get("https://w.example/admin/sessions/s1/bifrost").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(SessionApiFailed) as exc:
await get_session_bifrost(client, "s1", admin_key="k")
assert exc.value.status == 403
@respx.mock
async def test_404_not_bound(self) -> None:
"""404 [error]: session_not_bifrost_bound → SessionApiFailed(404)."""
respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_bifrost_bound"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await get_session_bifrost(client, "s1", admin_key="k")
assert exc.value.status == 404
@respx.mock
async def test_empty_admin_key_asserts(self) -> None:
"""empty_admin_key [adversarial]: '' → AssertionError; no HTTP issued."""
route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock(
return_value=httpx.Response(200, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await get_session_bifrost(client, "s1", admin_key="")
assert route.call_count == 0
class TestTransientCharacters:
"""docs/contracts/issues/2.contract.md — transient-character wrappers (#161)."""
@respx.mock
async def test_list_models(self) -> None:
"""list_models [happy,tracer]: 200 → {items:[...]} verbatim."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": [{"name": "fast", "thinking": False}]})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
models = await list_character_models(client)
assert models["items"][0]["name"] == "fast"
@respx.mock
async def test_create_body_and_response(self) -> None:
"""create [happy]: body is {character, state}; 201 → {character_id, ttl_expires_at}."""
import json as _json
route = respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "char_x", "ttl_expires_at": "t"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
out = await create_character(client, {"schema_version": "1", "name": "H"})
assert out["character_id"] == "char_x"
body = _json.loads(route.calls[0].request.content)
assert body == {"character": {"schema_version": "1", "name": "H"}, "state": None}
@respx.mock
async def test_get_state(self) -> None:
"""get_state [happy]: 200 → live PAD/emotions snapshot."""
respx.get("https://w.example/characters/char_x/state").mock(
return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.4, 0.1, -0.2]})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
state = await get_character_state(client, "char_x")
assert state["pad"] == [0.4, 0.1, -0.2]
@respx.mock
async def test_delete_204(self) -> None:
"""delete [happy]: 204 → None."""
respx.delete("https://w.example/characters/char_x").mock(return_value=httpx.Response(204))
async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await delete_character(client, "char_x") is None
@respx.mock
async def test_create_403_scope(self) -> None:
"""create_403 [error]: key lacks character.write → SessionApiFailed(403)."""
respx.post("https://w.example/characters").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(SessionApiFailed) as exc:
await create_character(client, {"name": "H"})
assert exc.value.status == 403
-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 from pathlib import Path
@@ -6,317 +16,20 @@ import httpx
import pytest import pytest
import respx import respx
from ratatoskr.sessions import SessionApiFailed from ratatoskr.tier3 import main
from ratatoskr.tier3 import (
Tier3AgentInfo,
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
define_agent,
delete_agent,
main,
patch_agent,
)
# The consumer-agent response echoes `role` (b128), not the former `model`.
_FULL_AGENT_RESP = { _FULL_AGENT_RESP = {
"agent_id": "ratatoskr:wizard", "agent_id": "ratatoskr:wizard",
"user_id": "ratatoskr", "user_id": "ratatoskr",
"agent_name": "wizard", "agent_name": "wizard",
"system_prompt": "You are a 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", "created_at": "2026-05-25T03:20:09.703601+00:00",
"updated_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 @pytest.fixture
def _isolated_local_agents( def _isolated_local_agents(
tmp_path: "Path", monkeypatch: pytest.MonkeyPatch tmp_path: "Path", monkeypatch: pytest.MonkeyPatch
@@ -335,8 +48,8 @@ class TestCli:
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path", _isolated_local_agents: "Path",
) -> None: ) -> None:
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation; """cli_define_happy [happy,tracer]: argv → 201 mock → stdout confirmation;
local index updated with the new entry (v0.8.0 hook). local index updated with the new entry (role echoed).
""" """
from ratatoskr.local_agents import load_local_agents from ratatoskr.local_agents import load_local_agents
@@ -354,11 +67,34 @@ class TestCli:
out = capsys.readouterr() out = capsys.readouterr()
assert rc == 0 assert rc == 0
assert out.out.strip() == "defined ratatoskr:wizard (qwen3.6-35-a3b)" 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() entries = load_local_agents()
assert len(entries) == 1 assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard" 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 @respx.mock
def test_cli_patch_happy( def test_cli_patch_happy(
@@ -384,6 +120,7 @@ class TestCli:
entries = load_local_agents() entries = load_local_agents()
assert len(entries) == 1 assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard" assert entries[0].agent_id == "ratatoskr:wizard"
assert entries[0].role == "qwen3.6-35-a3b"
@respx.mock @respx.mock
def test_cli_delete_happy( def test_cli_delete_happy(
@@ -401,11 +138,11 @@ class TestCli:
load_local_agents, 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( add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:wizard", agent_id="ratatoskr:wizard",
agent_name="wizard", agent_name="wizard",
model="m", role="m",
description="d", description="d",
defined_at="t", defined_at="t",
)) ))
@@ -426,10 +163,7 @@ class TestCli:
"""cli_missing_auth [error]: no api-key → stderr [auth_error] + exit 11.""" """cli_missing_auth [error]: no api-key → stderr [auth_error] + exit 11."""
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False) monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
rc = main([ rc = main([
"define", "define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
"--name", "wizard",
"--system-prompt", "x",
"--role", "m",
]) ])
err = capsys.readouterr().err err = capsys.readouterr().err
assert rc == 11 assert rc == 11
@@ -446,10 +180,7 @@ class TestCli:
return_value=httpx.Response(500, content=b"upstream out") return_value=httpx.Response(500, content=b"upstream out")
) )
rc = main([ rc = main([
"define", "define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
"--name", "wizard",
"--system-prompt", "x",
"--role", "m",
]) ])
err = capsys.readouterr().err err = capsys.readouterr().err
assert rc == 20 assert rc == 20
@@ -470,15 +201,30 @@ class TestCli:
) )
) )
rc = main([ rc = main([
"define", "define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
"--name", "wizard",
"--system-prompt", "x",
"--role", "m",
]) ])
err = capsys.readouterr().err err = capsys.readouterr().err
assert rc == 20 assert rc == 20
assert "[quota_exceeded]" in err 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( def test_cli_patch_no_fields(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
@@ -489,3 +235,52 @@ class TestCli:
err = capsys.readouterr().err err = capsys.readouterr().err
assert rc == 10 assert rc == 10
assert "[usage_error]" in err 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( LocalAgentEntry(
agent_id="ratatoskr:sindra", agent_id="ratatoskr:sindra",
agent_name="sindra", agent_name="sindra",
model="artemis-31b-v1i", role="artemis-31b-v1i",
description="(tier 3) IDENTITY", description="(tier 3) IDENTITY",
defined_at="2026-05-28T00:00:00+00:00", defined_at="2026-05-28T00:00:00+00:00",
) )
@@ -118,7 +118,7 @@ class TestAgentsEndpoint:
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
add_local_agent( add_local_agent(
LocalAgentEntry( 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", 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 # Upstream entry wins (it's first in the merge); local is deduped
assert body[0]["name"] == "Sindra-from-server" 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 = { _CREATE_OK = {
"session_id": "s-1", "session_id": "s-1",
@@ -290,6 +341,20 @@ class TestPersonaStateEndpoint:
assert resp.status_code == 403 assert resp.status_code == 403
assert resp.json()["error_code"] == "auth_scope_denied" 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: class TestSubmitTurnEndpoint:
"""submit_turn_endpoint FN — allocate turn_id, register in turn_registry.""" """submit_turn_endpoint FN — allocate turn_id, register in turn_registry."""
@@ -1211,6 +1276,19 @@ class TestSessionBifrostEndpoint:
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.json()["error_code"] == "bifrost_state_unavailable" 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: class TestAdminEventsEndpoint:
"""admin_events_endpoint — SSE proxy of GET /admin/events, session-filtered (#11).""" """admin_events_endpoint — SSE proxy of GET /admin/events, session-filtered (#11)."""
+725
View File
@@ -19,15 +19,26 @@ import pytest
import worldtree_sdk as wtsdk import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, CancelResult, PadState, WorldtreeClient from worldtree_sdk import ApiError, CancelResult, PadState, WorldtreeClient
from ratatoskr.sessions import (
AgentNotAvailable as PersonaAgentNotAvailable,
)
from ratatoskr.sessions import ( from ratatoskr.sessions import (
AgentNotFound, AgentNotFound,
AuthoredHistoryUnavailable, AuthoredHistoryUnavailable,
AuthScopeDenied,
BifrostBinding, BifrostBinding,
BifrostConsumerKeyMissing, BifrostConsumerKeyMissing,
BifrostHandshakeFailed, BifrostHandshakeFailed,
InvalidCursor, InvalidCursor,
PersonaNotConfigured,
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
) )
from ratatoskr.sse_client import ( from ratatoskr.sse_client import (
AdminEvent,
AgentNotAvailable, AgentNotAvailable,
CancelAlreadyCompleted, CancelAlreadyCompleted,
CancelFailed, CancelFailed,
@@ -43,11 +54,24 @@ from ratatoskr.wt import (
SessionApiFailed, SessionApiFailed,
build_client, build_client,
cancel_turn, cancel_turn,
create_character,
create_session, 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_messages,
get_session_tools, get_session_tools,
list_agents,
list_character_models,
list_sessions, list_sessions,
patch_agent,
set_persona_state, set_persona_state,
stream_admin_events,
stream_turn, stream_turn,
translate_error, translate_error,
write_authored_history, write_authored_history,
@@ -482,6 +506,16 @@ class TestSetPersonaState:
await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0) await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0)
assert ei.value.status == 500 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: class TestWriteAuthoredHistory:
"""slice-3: write_authored_history → SDK sessions.write_history. Builds the """slice-3: write_authored_history → SDK sessions.write_history. Builds the
@@ -518,3 +552,694 @@ class TestWriteAuthoredHistory:
with pytest.raises(SessionApiFailed) as ei: with pytest.raises(SessionApiFailed) as ei:
await write_authored_history(_wt(fake), "s", content="hi", idempotency_key="k") await write_authored_history(_wt(fake), "s", content="hi", idempotency_key="k")
assert ei.value.status == 409 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 -1
View File
@@ -472,7 +472,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.21.11" version = "0.21.20"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },