Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de9a5baf45 | |||
| 5bc39a092e | |||
| 4e20030229 | |||
| d86d6df147 | |||
| deab7627eb | |||
| 4f74645a00 | |||
| 477d98f52e | |||
| aed942972f | |||
| c62b4eecb3 | |||
| 4f92a21cb9 |
@@ -142,6 +142,14 @@ _Archived 2026-07-18._
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-17]` **#296 triage sent to worldtree-dev** (`01KVBBH0…`): extraction SUBJECT-INVERSION (promotes assistant prose, drops the user's fact) + META-DESCRIPTION-not-content; verbose-persona aggravator. WAD-vs-bug resolved to BUG (extraction quality), not idle-gating.
|
||||
|
||||
_Archived 2026-07-19._
|
||||
|
||||
`[2026-06-18]` **Tier-3 memory PROVEN end-to-end live** — `ratatoskr:terse-probe` recalled a seeded user fact in a COLD history-free session (scope_any → 1 hit @ cosine 0.6994). Closes the opening "how far from Tier-3 memory" question for normal agents.
|
||||
|
||||
_Archived 2026-07-19._
|
||||
|
||||
## Tried and abandoned (archived)
|
||||
|
||||
The 2026-05-20 → 2026-05-28 cluster: original-build-era foot-guns. Archived 2026-06-18.
|
||||
|
||||
@@ -164,7 +164,15 @@ others, they get their own row here — the default is NOT a general "any 404
|
||||
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
|
||||
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` (dual-key: status 422 AND error_code; the flat cursor body surfaces the code) |
|
||||
| `ApiError(502)` on bound `sessions.create` | `BifrostHandshakeFailed` — NOT gated on error_code (unlike list's 422): INV-002, the synchronous handshake is the SOLE bound-502 cause; and the SDK's envelope parser prefers the nested `detail` (which carries `bifrost_error`, not `error_code`), so no distinguishing top-level `error_code` surfaces. The route+status IS the discriminator. |
|
||||
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
|
||||
| `ApiError(429)` on `agents.define` (slice-4) | `Tier3QuotaExceeded(retry_after=0)` — the SDK's `ApiError` floor carries no response headers, so the `Retry-After` header the hand-rolled path read is unavailable; spec §2675 pins Phase-2.0 quota to `Retry-After: 0`, so the adapter defaults to 0. A non-zero forward-compat value is unrecoverable until the SDK surfaces headers (INFORM wtsdk-dev; reference-impl posture). |
|
||||
| `ApiError(403 tier3_user_id_unsupported)` on `agents.define` (slice-4) | `Tier3UserIdUnsupported` (dual-key: status 403 AND error_code) |
|
||||
| `ApiError(422 layer_deferred)` on `agents.define` (slice-4) | `Tier3LayerDeferred(field)` — `field` parsed from the body (`detail.field` / flat `field`); the SDK carries `error_code` but not `field`, so the adapter body-parses it (same posture as bound-502's `bifrost_error`) |
|
||||
| `ApiError(404)` on `agents.patch` / `agents.delete` (slice-4) | `Tier3AgentNotFound` (route-discriminated; agents CRUD is NOT a hide-existence route — a 404 there IS "no such agent") |
|
||||
| `ApiError(422 field_not_mutable)` on `agents.patch` (slice-4) | `Tier3FieldNotMutable(field)` (dual-key status+error_code; `field` body-parsed) |
|
||||
| `ApiError(404 persona_not_configured)` on `agents.persona_state` (slice-4) | `PersonaNotConfigured` (dual-key) |
|
||||
| `ApiError(404 agent_not_available)` on `agents.persona_state` (slice-4) | `AgentNotAvailable` (the persona-surface `sessions.AgentNotAvailable`, distinct from the eager-turn `sse_client.AgentNotAvailable`; dual-key) |
|
||||
| `ApiError(403 auth_scope_denied)` on `agents.persona_state` (slice-4) | `AuthScopeDenied(scope="persona.read")` (dual-key) |
|
||||
| **`ApiError` (any other status/route, incl. `agents.list` and any unmatched agent-route code) — the default** | `SessionApiFailed(status, error_code, body)` |
|
||||
|
||||
The default row is load-bearing: any `ApiError` not matched above surfaces as the
|
||||
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
|
||||
@@ -206,6 +214,127 @@ re-anchor its coverage-map rows.
|
||||
SSE parsing); retire contracts #2/#15; final coverage-map re-anchor; minor bump
|
||||
(DEC-6, operator approval).
|
||||
|
||||
### Slice-4 notes (Agents/Tier-3 + `model`→`role` fold, decided at TDD)
|
||||
|
||||
- **`model`→`role` cutover folds in here (scope B).** Worldtree spec 1.2 (`v1.0.0b128`,
|
||||
live on :8080/:8081) made the `/agents/define` response echo `role`, closing the
|
||||
old W-4 `model` echo. The adapter returns the SDK's OPEN-WORLD `DefinedAgent` /
|
||||
`PatchedAgent` dicts verbatim (parity posture); callers read `info["role"]`. The
|
||||
frozen `Tier3AgentInfo` dataclass (which read `body["model"]` and would KeyError
|
||||
post-b128) is DELETED — no dataclass normalization layer survives.
|
||||
- **`ratatoskr.local_agents` schema bump.** `LocalAgentEntry.model` → `.role` (the
|
||||
field stores what the wire now calls a role); `_SCHEMA_VERSION` 1→2 so any
|
||||
pre-cutover on-disk index is discarded cleanly (no-backwards-compat, DEC-3).
|
||||
- **`AgentNotAvailable` name collision.** `sessions.AgentNotAvailable` (persona-state
|
||||
404 `agent_not_available`) and `sse_client.AgentNotAvailable` (eager-turn 409) are
|
||||
distinct types that share a name; `wt` already imports the sse_client one for the
|
||||
stream, so it imports the persona one ALIASED (`PersonaAgentNotAvailable`) and
|
||||
raises it from `get_persona_state`. The web endpoint keeps importing the persona
|
||||
`AgentNotAvailable` from `sessions` (same class), so its `except` is unchanged.
|
||||
- **`ConnectFailed` at every rewired caller (slice-3 foot-gun).** The SDK normalizes
|
||||
ANY transport failure to `ConnectFailed(status=0)` (`request.py`), not a raw httpx
|
||||
error. The rewired tier3 CLI and both web endpoints (`_agents_endpoint`,
|
||||
`_persona_state_endpoint`) catch `wtsdk.ConnectFailed` → their existing
|
||||
network-error surface (CLI exit 21 / web 502). The web `test_network_error_returns_502`
|
||||
(respx `httpx.ConnectError` side-effect) is the RED that proves this.
|
||||
- **`agents.get(agent_id)`** (SDK `GET /agents/{id}`) is NOT wrapped — ratatoskr has no
|
||||
`get_agent` consumer; only list/persona_state/define/patch/delete are in coverage.
|
||||
- **Client-side Tier-3-id PRE on `patch_agent` / `delete_agent`.** Both assert
|
||||
`":" in agent_id` pre-HTTP (a Tier-3 id is always `<user_id>:<agent_name>`, ADR-0019),
|
||||
so a non-colon id fails fast with an `AssertionError` rather than reaching the SDK's
|
||||
route-discriminated 404 → `Tier3AgentNotFound`. Intentional fail-fast on a
|
||||
wrong-shaped id (carried over from the retired hand-rolled wrappers); documented here
|
||||
per the heid-code-review slice-4 precision flag (the § Error map 404 rows assume a
|
||||
well-formed Tier-3 id reaches the route).
|
||||
|
||||
### Slice-5 notes (Characters + me/capabilities/models, decided at TDD)
|
||||
|
||||
- **No new § Error map rows.** All six routes (`me.get`, `capabilities.get`,
|
||||
`models.available_for_characters`, `characters.create` / `.state` / `.delete`) are
|
||||
open-world reads/acks (B-OPEN-2) whose SDK ops carry NO discriminated error (no
|
||||
`map_error`), so every `ApiError` maps to the default `SessionApiFailed` — exact
|
||||
parity with the retiring hand-rolled path, which likewise raised only its generic
|
||||
`SessionApiFailed` on any non-2xx (never discriminating a status/code on these
|
||||
routes). The route-map table above already lists all six.
|
||||
- **`create_character` body — omit `state` when None.** The adapter sends
|
||||
`{"character": …}` plus `"state"` only when the caller supplies a non-None state
|
||||
(the SDK forwards the body dict as-is via httpx `json=`). This drops the hand-rolled
|
||||
path's redundant explicit `"state": null` — server-equivalent (Worldtree's
|
||||
`CreateCharacterRequest.state` defaults None whether omitted or explicit-null),
|
||||
SDK-idiomatic (matches the SDK's `CreateCharacterInput` `NotRequired` shape), and
|
||||
invisible at the sole call-site (`--characters` never passes a state). Adopt-
|
||||
canonical over byte-for-byte wire parity.
|
||||
- **`delete_character` returns the SDK's open ack verbatim (`-> Mapping | None`).**
|
||||
The SDK route returns an open-world ack body (not 204 — `CharacterDeleteResult`), so
|
||||
the adapter passes it through rather than normalizing to the hand-rolled `None`
|
||||
(parity posture: no None-normalization of an open-world read). On a 204 no-content
|
||||
the SDK yields `None`, so the return type is `Mapping | None`; the sole call-site
|
||||
(`--characters`) ignores the value, so the change is unobservable.
|
||||
- **Open-world presenter degrade-not-crash (cumulative foot-gun).** `_format_whoami`
|
||||
is already hardened (slice-4 heid bug-hunt). The rewired `_characters_probe` extracts
|
||||
the created id defensively (`created.get("character_id")` + type-guard → clean abort,
|
||||
never a hard-index KeyError) since the create ACK is now an open-world SDK read.
|
||||
- **Container-type hardening (heid code-review + bug-hunt slice-5).** The degrade-not-
|
||||
crash floor is guarded at THREE levels for the CLI presenters, not just one: (a) the
|
||||
list-typed fields `scopes` / `allowed_roles` / model `items` degrade a non-list scalar
|
||||
(`123`) or a bare string to empty via `_display_seq` / an `isinstance(_, list)` guard —
|
||||
the older `or []` idiom only caught null/absent and would `for x in 123` `TypeError`;
|
||||
(b) each element is type-guarded (`isinstance(m, dict)`); (c) the top-level open-world
|
||||
reads `created` / `models` / `state` are `isinstance(_, Mapping)`-guarded before any
|
||||
`.get` (a non-mapping passthrough would otherwise `AttributeError`). All three feed
|
||||
`--whoami` / `--characters` only.
|
||||
- **Accepted (not fixed): the `--characters` probe leaks its transient character on a
|
||||
mid-lifecycle failure.** create → get-state → delete runs linearly with no `finally`,
|
||||
so a state/delete failure after a successful create orphans the probe character until
|
||||
its TTL. This is PRE-EXISTING (the retired hand-rolled probe had the identical
|
||||
structure — the cutover did not worsen it), TTL-bounded, and `--characters` is a
|
||||
one-shot diagnostic smoke; a `try/finally` cleanup would also swallow a happy-path
|
||||
delete-failure (delete is both the teardown AND a tested lifecycle step). Accepted as
|
||||
known-risk per the heid bug-hunt (Gróa + Heid concur accept is defensible).
|
||||
- **CLI-only rewire.** `me` / `capabilities` / `characters` / `models` have NO
|
||||
web-server caller — only the `--whoami` and `--characters` CLI one-shot probes. The
|
||||
web surface is untouched this slice.
|
||||
|
||||
### Slice-6 notes (Admin: bifrost inspection + admin-events stream, decided at TDD)
|
||||
|
||||
- **Admin auth moves from a per-call header override to the client's `admin_auth`.**
|
||||
The SDK's `admin.*` methods authenticate with the client's `admin_auth` provider
|
||||
(set via `build_client(admin_key=...)`), NOT a per-request `Authorization` header. So
|
||||
the two web admin endpoints build their wt client WITH `admin_key` (`_wt_client(client,
|
||||
admin_key=...)`, extended this slice); the hand-rolled per-call `admin_key=` +
|
||||
header-override is retired. The web already guards `if not admin_key: 400` before the
|
||||
call, so the SDK's pre-HTTP `ConfigurationError` (missing admin_auth, W-5) is
|
||||
unreachable from the web surface. **CLI has no admin caller** — both routes are
|
||||
web-only (the coverage-map's `tui.py` rows were stale; corrected to `web/server.py`).
|
||||
- **`get_session_bifrost` — no new § Error map row.** `client.admin.sessions.bifrost`
|
||||
returns the open-world `BifrostInspection` dict verbatim; any `ApiError` (notably 403
|
||||
`auth_scope_denied`, 404 `session_not_bifrost_bound`) → the `SessionApiFailed` default
|
||||
— exact parity with the retired path (which mapped every non-200 → `SessionApiFailed`).
|
||||
- **`stream_admin_events` re-wraps the SDK's `AdminEvent` → ratatoskr's `AdminEvent`
|
||||
(chosen over yield-through).** The SDK's `AdminEvent` diverges from ratatoskr's:
|
||||
`admin_id: int|float` (`nan` for an id-less envelope) vs ratatoskr's `id: int` (0
|
||||
default), and the SDK's `type`/`data` are None-able where ratatoskr's are a dotted-str
|
||||
/ a `{}`-default dict. The web filter + SSE formatter read `ev.id`/`ev.type`/`ev.data`.
|
||||
The adapter re-wraps at the boundary — `id = admin_id if int else 0` (nan→0),
|
||||
`type = type or ""` (None→"" so `.startswith` never crashes), `data = data or {}` —
|
||||
degrading the SDK's open-world None/nan ONCE at the adapter, keeping the web endpoint +
|
||||
`_admin_event_matches_web` + the ratatoskr `AdminEvent` domain type UNCHANGED (preserves
|
||||
the web surface per § Out of scope). **Rejected alternative:** yield SDK `AdminEvent`s
|
||||
through and rewire the web filter for `admin_id`/None/nan (the slice-2 turn-stream
|
||||
precedent) — heavier web churn + scatters the None/nan hardening through the filter;
|
||||
re-wrap localizes it. The ratatoskr `AdminEvent` dataclass stays in `sse_client.py` this
|
||||
slice (imported by `wt` + the web); its home moves in slice-7 teardown if `sse_client.py`
|
||||
is retired.
|
||||
- **Admin-stream error mapping (reuses the § Error map stream rows).** The SDK admin
|
||||
stream raises `ApiError("admin_stream_failed", status=…)` on a NON-200 open (NOT
|
||||
`ConnectFailed` — a gotcha the web integration test caught that the unit fake could not)
|
||||
→ `SseConnectFailed`; and `ConnectionDropped` on a connect-time transport failure
|
||||
(cursor None) OR a mid-stream drop / the long-lived stream's resumable EOF (cursor set)
|
||||
→ `SseConnectionDropped`. The SDK admin stream is best-effort (skips malformed frames —
|
||||
no `Malformed*`), as was the retired hand-rolled path; the web endpoint's existing
|
||||
`except (…, MalformedSseId, MalformedSseData)` stays a harmless defensive superset
|
||||
(pre-existing, not introduced here).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched.
|
||||
|
||||
+13
-13
@@ -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.) |
|
||||
| `GET /sessions/{id}/messages` (history) | ✅ | `wt.py` `get_session_messages` (SDK `sessions.messages`) → `cli.py` `--seed-first-message` read-back, `web/server.py` messages proxy | **wt-adapter re-anchored (slice-3, #20)** — the #347 seed read-back; open-world passthrough. **LIVE-SMOKE 2026-07-19**: read-back rendered the seeded seq-0 turn as a plain role=assistant message (model-invisible provenance confirmed) |
|
||||
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `wt.py` `cancel_turn` (SDK `sessions.cancel_turn`) → cli/web | **wt-adapter re-anchored (slice-2, #20)** — two-stage Ctrl-C; 404→CancelTurnNotFound, 409→CancelAlreadyCompleted, late-cancel 200 (`cancelled=False`) is a benign result, not an error |
|
||||
| `GET /agents` | ✅ | `sessions.py:341` → `tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index |
|
||||
| `GET /agents/{id}/persona_state` | ✅ | `sessions.py:384` → `tui.py:1132`,`web/server.py:386` | persona hydrate; 404/403 mapped |
|
||||
| `POST /agents/define` | ✅ | `tier3.py:175` → `_run_define` | Tier-3 create |
|
||||
| `PATCH /agents/{id}` | ✅ | `tier3.py:219` → `_run_patch` | Tier-3 mutate (system_prompt/model) |
|
||||
| `DELETE /agents/{id}` | ✅ | `tier3.py:242` → `_run_delete` | Tier-3 hard-delete |
|
||||
| `GET /me` | ✅ | `sessions.py:411` `get_me` → `cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed |
|
||||
| `GET /capabilities` | ✅ | `sessions.py` `get_capabilities` → `cli.py` `--whoami` | Echo ephemeral-template discovery. **v0.21.2: `--whoami` renderer reads `allowed_roles`/`default_role`** (was the dead `allowed_models`/`default_model`) + tolerates malformed caps; matches conversation-api-spec **v1.1** (`b4a278c`) |
|
||||
| `GET /agents` | ✅ | `wt.py` `list_agents` (SDK `agents.list`) → `web/server.py` `_agents_endpoint` | **wt-adapter re-anchored (slice-4, #20)** — open-world array verbatim (no AgentInfo normalization), merged with the local tier3 index (remote-wins); error→SessionApiFailed default, transport→ConnectFailed→502. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): 6 agents returned (forseti/lofn/mask/mimir/vili/…) |
|
||||
| `GET /agents/{id}/persona_state` | ✅ | `wt.py` `get_persona_state` (SDK `agents.persona_state`) → `web/server.py` `_persona_state_endpoint` | **wt-adapter re-anchored (slice-4, #20)** — open-world snapshot; dual-key (status,error_code) map: 404 persona_not_configured→PersonaNotConfigured, 404 agent_not_available→AgentNotAvailable, 403 auth_scope_denied→AuthScopeDenied, else default. **LIVE-SMOKE 2026-07-19**: a tier3 agent → correctly mapped `PersonaNotConfigured` (route+code adapter proven) |
|
||||
| `POST /agents/define` | ✅ | `wt.py` `define_agent` (SDK `agents.define`) → `tier3.py` `_run_define` | **wt-adapter re-anchored (slice-4, #20)** — sends AgentDefineInput `{agent_name,role,system_prompt}`, returns open-world `DefinedAgent` (echoes `role`, b128); slug pre-validated; 429→Tier3QuotaExceeded(retry_after=0, header-less floor), 403→Tier3UserIdUnsupported, 422 layer_deferred→Tier3LayerDeferred. **LIVE-SMOKE 2026-07-19**: `define --role thoughtful-character` → `defined ratatoskr:slice4-smoke (thoughtful-character)` |
|
||||
| `PATCH /agents/{id}` | ✅ | `wt.py` `patch_agent` (SDK `agents.patch`) → `tier3.py` `_run_patch` | **wt-adapter re-anchored (slice-4, #20)** — Tier-3 mutate (system_prompt/**role**, model→role folded in); 404→Tier3AgentNotFound, 422 field_not_mutable→Tier3FieldNotMutable. **LIVE-SMOKE 2026-07-19**: `patched ratatoskr:slice4-smoke`; a non-existent id via `python -m` → `[agent_not_found]` (exit 20, class-identity fix proven) |
|
||||
| `DELETE /agents/{id}` | ✅ | `wt.py` `delete_agent` (SDK `agents.delete`) → `tier3.py` `_run_delete` | **wt-adapter re-anchored (slice-4, #20)** — 204→None; 404→Tier3AgentNotFound (route-discriminated, NOT hide-existence). **LIVE-SMOKE 2026-07-19**: `deleted ratatoskr:slice4-smoke` + local index → `[]` |
|
||||
| `GET /me` | ✅ | `wt.py` `get_me` (SDK `me.get`) → `cli.py` `--whoami` | **wt-adapter re-anchored (slice-5, #20)** — open-world identity dict verbatim; any error→SessionApiFailed default (401 on a bad/absent key), transport→ConnectFailed→exit 21. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): identity rendered (user_id ratatoskr, tier user, scopes incl. `character.*`, key_id c990f0be) |
|
||||
| `GET /capabilities` | ✅ | `wt.py` `get_capabilities` (SDK `capabilities.get`) → `cli.py` `--whoami` | **wt-adapter re-anchored (slice-5, #20)** — open-world advertisement verbatim; `_format_whoami` reads `allowed_roles`/`default_role` and degrades on a null/non-mapping template (slice-4 hardening); matches conversation-api-spec **v1.1** (`b4a278c`). **LIVE-SMOKE 2026-07-19**: `ephemeral_template echo: default=echo max_bytes=32768 roles=[echo]` |
|
||||
| `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools` → `tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) |
|
||||
| `GET /admin/sessions/{id}/bifrost` | ✅ | `sessions.py:428` `get_session_bifrost` → `tui.py` `_hydrate_bifrost_state` | admin-scoped BifrostState pane (#176); admin key (`RATATOSKR_ADMIN_API_KEY`); live-auth-proven |
|
||||
| `GET /admin/events` (SSE) | ✅ | `sse_client.py` `stream_admin_events` → `tui.py` `_stream_admin_events` | admin lifecycle SSE stream (#11), session-filtered AdminEvents pane; admin key; live-auth-proven |
|
||||
| `GET /models/available-for-characters` | ✅ | `sessions.py` `list_character_models` → `cli.py` `--characters` | character-capable model profiles (#161) |
|
||||
| `POST /characters` | ✅ | `sessions.py` `create_character` → `cli.py` `--characters` | create transient character (#161) |
|
||||
| `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state` → `cli.py` `--characters` | live character PAD/emotions (#161) |
|
||||
| `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character` → `cli.py` `--characters` | remove transient character (#161) |
|
||||
| `GET /admin/sessions/{id}/bifrost` | ✅ | `wt.py` `get_session_bifrost` (SDK `admin.sessions.bifrost`) → `web/server.py` `_session_bifrost_endpoint` | **wt-adapter re-anchored (slice-6, #20)** — admin-scoped BifrostState (#176); admin_auth rides on the wt client (`_wt_client(admin_key=…)`), NOT a per-call header; open-world dict verbatim, any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on :8081 (readonly-admin key): admin-authed end-to-end (404 `session_not_bifrost_bound` clean envelope — auth + route + mapping proven). (Consumer is `web/server.py`, not `tui.py` — the old row was stale.) |
|
||||
| `GET /admin/events` (SSE) | ✅ | `wt.py` `stream_admin_events` (SDK `admin.stream_events`) → `web/server.py` `_admin_events_endpoint` | **wt-adapter re-anchored (slice-6, #20)** — admin lifecycle SSE (#11), session-filtered; admin_auth on the wt client; the adapter re-wraps the SDK's `AdminEvent`→ratatoskr's (nan `admin_id`→id 0, None type/data→`""`/`{}`), non-200 open `ApiError`→SseConnectFailed, `ConnectionDropped`→SseConnectionDropped. **LIVE-SMOKE 2026-07-19**: a real `session.created` event (id=32) re-wrapped cleanly on live wire. (Consumer is `web/server.py`, not `tui.py` — stale row corrected.) |
|
||||
| `GET /models/available-for-characters` | ✅ | `wt.py` `list_character_models` (SDK `models.available_for_characters`) → `cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — open-world catalog verbatim; the probe reads `items` null-safe (`or []`); any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19**: `character models: char-rp` |
|
||||
| `POST /characters` | ✅ | `wt.py` `create_character` (SDK `characters.create`) → `cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — body `{character}` (+`state` only when set — SDK-idiomatic, drops the redundant explicit null); open-world create ACK verbatim; the probe degrades on a missing `character_id` (no hard-index). **LIVE-SMOKE 2026-07-19**: `created char_8c00006e…` |
|
||||
| `GET /characters/{id}/state` | ✅ | `wt.py` `get_character_state` (SDK `characters.state`) → `cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — open-world live PAD/emotions verbatim; TTL-refreshing read. **LIVE-SMOKE 2026-07-19**: `state pad=[0.234, -0.136, 0.065]` read back |
|
||||
| `DELETE /characters/{id}` | ✅ | `wt.py` `delete_character` (SDK `characters.delete`) → `cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — returns the SDK's open ACK verbatim (`-> Mapping|None`, NOT normalized to None; 204→None); any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19**: `deleted char_8c00006e…` |
|
||||
| `POST /sessions/{id}/persona_state` | ✅ | `wt.py` `set_persona_state` (SDK `sessions.set_persona_state`, `PadState`) → `cli.py` `--set-persona-pad` | **wt-adapter re-anchored (slice-3, #20)** — SDK owns the canonical `{"pad": {...}}` wire (#317); CLI passes the 3 PAD axes (finiteness pre-validated); 204→None, else SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on personal :8081: `--set-persona-pad 0.4,0.1,-0.2` → **204** |
|
||||
|
||||
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
|
||||
|
||||
@@ -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
@@ -46,36 +46,39 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
_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-1–5 COMPLETE, slice-6 (admin) next.**
|
||||
Operator ruled ADOPT (2026-07-18): ratatoskr cuts its CONSUMER client layer over to **worldtree-sdk (Python)
|
||||
1.0.0**, retiring the hand-rolled httpx wrappers behind a thin `ratatoskr.wt` adapter. Design locked (6 DECs,
|
||||
vor-cross'd, heid-panel-reviewed); contract `docs/contracts/worldtree_sdk_cutover.contract.md`. **SLICE-1
|
||||
(adapter foundation) ✅ + SLICE-2 (sessions/turn) ✅ DONE + PUSHED** — origin at `aba1730` (12-commit arc
|
||||
`b1fbadd`→`aba1730`, tags v0.21.3–.10, pushed 2026-07-19): adapter (`b907a7b`, all 6 route families
|
||||
create/list/messages/tools/stream/cancel + the § Error-map mapping) → CLI rewire (`e3a10ad`) → web rewire
|
||||
(`5c595b8`) → orphan deletion (`59602fe`, sse_client 714→224 + sessions 677→608; DEC-4 LIVE-SMOKE PASSED on
|
||||
:8081 create+stream+cancel FIRST) → heid-code-review fixup (`74d41eb`) → heid-bug-hunt fixup (`aba1730`).
|
||||
Suite **497 green**. **KEY ADAPTER FACTS (foot-guns for slices 3-7):** the SDK returns **open-world dicts**
|
||||
(not typed objects) for session reads → presenters read mappings, `info["session_id"]`; SDK events
|
||||
(`TurnEvent`) carry `sse_id: str` + a **body-derived `turn_id` that's ABSENT on text/thinking frames** → parse
|
||||
the cancel target's turn from the composite `sse_id`, NOT `event.turn_id`; the SDK **normalizes transport
|
||||
failures to `ConnectFailed(status=0)`** (not raw httpx) → presenters catch it; the adapter re-wraps SDK stream
|
||||
errors → ratatoskr caller-semantic exceptions (DEC-2, keep ratatoskr's typed exceptions); `consumer_key` is
|
||||
BOUND-create-only (adapter nulls it when unbound, else the SDK auths as the consumer); the SDK's error-envelope
|
||||
parser **prefers the nested `detail`** so a top-level `error_code` doesn't surface (why create's bound-502 is
|
||||
NOT gated on error_code, unlike list's 422). **NEXT = slice-3** (persona `set_persona_state` + authored-history
|
||||
`write_history` + first_message presets — retires the still-hand-rolled `create_session`/`get_session_messages`
|
||||
that the `--seed-first-message` probe uses); then slice-4 (agents/tier3, FOLDS the `model`→`role` cutover),
|
||||
slice-5 (characters/me/caps), slice-6 (admin — `stream_admin_events`+`get_session_bifrost` still hand-rolled),
|
||||
slice-7 (teardown: retire contracts #2/#15, drop `httpx-sse`, minor bump per DEC-6 w/ operator approval).
|
||||
Scope: consumer layer ONLY; Bifrost provider planes untouched. Full design → auto-memory
|
||||
`project_worldtree_sdk_cutover`.
|
||||
vor-cross'd, heid-panel-reviewed); contract `docs/contracts/worldtree_sdk_cutover.contract.md`. **SLICE-1+2
|
||||
(foundation + sessions/turn) ✅ PUSHED** origin `aba1730`. **SLICE-3 (persona + authored-history + first-message)
|
||||
✅ DONE** `ca9a339`+`fc256bb`. **SLICE-4 (agents/Tier-3 + `model`→`role` fold) ✅ DONE** `c62b4ee`→`477d98f`
|
||||
(v0.21.13–.15). **SLICE-5 (characters + me/capabilities/models) ✅ DONE** — `deab762` (feat) + `d86d6df`
|
||||
(heid-code-review fixups) + `4e20030` (heid-bug-hunt fixups), tags v0.21.16–.18; full House Code Discipline,
|
||||
both heid panels cleared. Suite **488 green**; **LIVE SMOKE on :8081/b128** drove `--whoami` (identity+caps) +
|
||||
`--characters` (models→create→PAD read-back→delete) end-to-end. Slice-5 migrated
|
||||
`get_me`/`get_capabilities`/`list_character_models`/`create_character`/`get_character_state`/`delete_character`
|
||||
onto `client.me`/`.capabilities`/`.models`/`.characters.*` (all open-world reads → `SessionApiFailed` default,
|
||||
**NO new Error-map rows**), rewired `--whoami`/`--characters` (**CLI-only; no web caller**), and DELETED the 6
|
||||
hand-rolled `sessions.py` wrappers (`endpoint_for_plane`+`get_session_bifrost` [slice-6]+exceptions stay). Full
|
||||
arc → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-5-complete.md`.
|
||||
**KEY ADAPTER FACTS (foot-guns, cumulative for slices 6-7):** SDK reads = **open-world dicts** — presenters
|
||||
MUST degrade not crash, guarded at THREE levels (slice-5 needed all three): **container-type** (a scalar `123`
|
||||
is non-iterable → `for x in 123` TypeError; the `or []` idiom catches null/absent but NOT a truthy non-iterable
|
||||
— the heid CODE-REVIEW caught null/element, the cold BUG-HUNT caught the container layer below it, run BOTH),
|
||||
**element-type** (`isinstance(m, dict)`), **top-level-mapping** (`isinstance(_, Mapping)` before any `.get`; a
|
||||
non-mapping passthrough → AttributeError); never hard-index `info["x"]`. The SDK **normalizes ANY transport
|
||||
failure to `ConnectFailed(status=0)`** (NOT raw httpx) — every adapter caller `except ConnectFailed`.
|
||||
**caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the `-m` module** (double-
|
||||
module class-identity split → uncaught traceback; live smoke catches it, unit tests can't); `TurnEvent` `turn_id`
|
||||
ABSENT on text/thinking frames; `consumer_key` is BOUND-create-only; envelope parser prefers nested `detail`.
|
||||
**NEXT = slice-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
|
||||
agents-RESPONSE selector `model`→`role` (spec 1.2). **DEPLOY NOW LIVE** on :8080/:8081 (v1.0.0b128,
|
||||
worldtree-dev confirmed 2026-07-19 — was the deploy-flag gate; acked). Operator chose scope B (full tier3
|
||||
`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 — tier3 agents `model`→`role` (scope B) folded into cutover slice-4** (`c62b4ee`, v0.21.13). The
|
||||
deferred deploy-gated scope-B work (response `model`→`role` per spec 1.2 / b128, `LocalAgentEntry`, index schema
|
||||
v2) landed with the agents-family SDK cutover — no longer pending. See the slice-4 detail file + Recent decisions.
|
||||
|
||||
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).**
|
||||
**No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
|
||||
@@ -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
|
||||
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
|
||||
cutover arc `b1fbadd`→`aba1730` + tags v0.21.3–.10 pushed 2026-07-19). origin
|
||||
`git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep: `worldtree-sdk==1.0.0`** (gitea PyPI,
|
||||
`[tool.uv.sources]`; `httpx-sse` retires at slice-7). bifrost **`==1.1.4`** / wire v0.7; WT openapi vendored
|
||||
2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`); **suite 497 green** (was 534 pre-cutover; net
|
||||
delta = adapter/rewire tests added, ~80 deleted hand-rolled turn-stream tests). Personal WT on **b128**
|
||||
**Substrate / environment:** branch `main` at **v0.21.18** — slice-4 arc `c62b4ee`→`477d98f` + slice-5 arc
|
||||
`deab762`→`4e20030` + this snapshot **COMMITTED, not-yet-pushed** (tags v0.21.13–.18; push is the operator's
|
||||
call); slice-1–3 (v0.21.3–.12, `b1fbadd`→`4f92a21`) PUSHED to origin earlier. origin
|
||||
`git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep:
|
||||
`worldtree-sdk==1.0.0`** (gitea PyPI, `[tool.uv.sources]`; `httpx-sse` retires at slice-7). bifrost
|
||||
**`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`);
|
||||
**suite 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
|
||||
**: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
|
||||
@@ -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
|
||||
decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[2026-06-17]` **#296 triage sent to worldtree-dev** (`01KVBBH0…`): extraction SUBJECT-INVERSION (promotes assistant prose, drops the user's fact) + META-DESCRIPTION-not-content; verbose-persona aggravator. WAD-vs-bug resolved to BUG (extraction quality), not idle-gating.
|
||||
- `[2026-06-18]` **Tier-3 memory PROVEN end-to-end live** — `ratatoskr:terse-probe` recalled a seeded user fact in a COLD history-free session (scope_any → 1 hit @ cosine 0.6994). Closes the opening "how far from Tier-3 memory" question for normal agents.
|
||||
- `[2026-06-19]` **#18 D2 SHIPPED (`v0.17.14`, `39eebd1`) and the full #17+#18 arc PUSHED to origin** → `persistent-memory.d/2026-06-19-18-d2-shipped-v0-17-14-39eebd1-and-the-full-1.md`
|
||||
|
||||
- `[2026-06-19]` **bifrost repinned 0.8.0→0.10.0; `affect.fetch` became MANDATORY (strong-or-absent)** → `persistent-memory.d/2026-06-19-bifrost-repinned-0-8-0-0-10-0-affect-fetch-be.md`
|
||||
@@ -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]` **worldtree-sdk cutover DECIDED — adopt the Python SDK for the consumer client layer** (operator, overriding "stay hand-rolled"). Issue #20; contract `docs/contracts/worldtree_sdk_cutover.contract.md` (`e45640c`, vor-cross'd + heid-reviewed); auto-memory `project_worldtree_sdk_cutover`. Consumer layer only, Bifrost provider untouched; slice-1 foundation next. See in-flight.
|
||||
- `[2026-07-19]` **worldtree-sdk cutover SLICE-1 + SLICE-2 COMPLETE + PUSHED (origin `aba1730`, v0.21.10, 497 green).** The biggest, riskiest cutover slice done end-to-end through the full House Code Discipline (adapter→cli→web→delete→live-smoke→both heid gates); the bug-hunt caught 4 real confirmed bugs the conformance lens couldn't, and a convergent code-review finding was correctly REJECTED as category-5 (SDK envelope-parser behavior). Slice-3 next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-1-2-complete.md`
|
||||
- `[2026-07-19]` **worldtree-sdk cutover SLICE-3 COMPLETE + pushed (`ca9a339`+`fc256bb`, v0.21.11–.12, 469 green).** Persona/authored-history/first-message onto the wt adapter; last hand-rolled `sessions.py` paths deleted; both heid gates cleared — code-review ZERO drift, bug-hunt caught + fixed a real regression I shipped (ConnectFailed escaping both rewired probes → uncaught crash; the SDK normalizes ALL transport failures to `ConnectFailed`, not raw httpx) plus a finite-PAD chokepoint gap. Slice-4 (agents/tier3 + model→role) next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-3-complete.md`
|
||||
|
||||
_65 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 Bifrost-provider build / #17+#18 / #295-296 era) archived to archival-memory.md._
|
||||
- `[2026-07-19]` **worldtree-sdk cutover SLICE-4 COMPLETE (agents/Tier-3 + `model`→`role` fold, `c62b4ee`+`aed9429`+`477d98f`, v0.21.13–.15, 475 green).** Full House Code Discipline; the LIVE SMOKE caught a `python -m` double-module exception-class-identity bug unit tests structurally can't; both heid panels cleared (code-review zero-drift + 3 fixups; bug-hunt 5/5/5 → open-world-presenter degrade-not-crash fixes). Resolves the deferred scope-B model→role. Slice-5 next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-4-complete.md`
|
||||
|
||||
- `[2026-07-19]` **worldtree-sdk cutover SLICE-5 COMPLETE (characters + me/capabilities/models, `deab762`+`d86d6df`+`4e20030`, v0.21.16–.18, 488 green).** Last consumer reads + transient-character CRUD onto the wt adapter (CLI-only rewire, NO new Error-map rows — all six routes → SessionApiFailed default); live-smoke-proven on :8081/b128; both heid gates cleared — code-review caught the null/element open-world-presenter degrade holes, the cold bug-hunt caught the **container-type layer below** them (guard container-type + element-type + top-level-mapping). Slice-6 (admin) next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-5-complete.md`
|
||||
|
||||
- `[2026-07-19]` **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._
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.21.12"
|
||||
version = "0.21.19"
|
||||
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+83
-39
@@ -43,21 +43,14 @@ from ratatoskr.sessions import (
|
||||
BifrostBinding,
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
SessionApiFailed,
|
||||
create_character,
|
||||
delete_character,
|
||||
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
|
||||
# (`wt.*`); these caller-semantic exceptions are what the adapter raises, so the
|
||||
# presenter keeps catching ratatoskr's own types (DEC-2). The hand-rolled probes
|
||||
# (--whoami / --characters / --set-persona / --seed-first-message) stay on the
|
||||
# `sessions` wrappers until their own slices.
|
||||
# The turn path (create / stream / cancel) and all consumer reads are served by the
|
||||
# worldtree-sdk adapter (`wt.*`); these caller-semantic exceptions are what the adapter
|
||||
# raises, so the presenter keeps catching ratatoskr's own types (DEC-2). Only the
|
||||
# Bifrost-binding inputs + `endpoint_for_plane` remain hand-rolled here (the provider
|
||||
# planes are consumer-orthogonal); `get_session_bifrost`'s admin surface lands in slice-6.
|
||||
from ratatoskr.sse_client import (
|
||||
MalformedSseData,
|
||||
MalformedSseId,
|
||||
@@ -752,12 +745,29 @@ async def _amain(args: ParsedArgs) -> int:
|
||||
loop.remove_signal_handler(signal.SIGINT)
|
||||
|
||||
|
||||
def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
|
||||
def _display_seq(value: Any) -> list[str]:
|
||||
"""Coerce an open-world wire value to a list of display strings — the degrade-not-
|
||||
crash floor for a list-typed field (`scopes`, `allowed_roles`, model `items`, ...).
|
||||
|
||||
A non-list scalar (absent, null, `123`, or a bare string) → empty rather than a
|
||||
crash: the older `or []` idiom handles absent/null but NOT a truthy non-iterable
|
||||
(`123 or [] == 123` → `for x in 123` `TypeError`) and would char-iterate a bare
|
||||
string. Only a genuine list/tuple is str-mapped (heid bug-hunt slice-5, findings 1-2).
|
||||
"""
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
return [str(x) for x in value]
|
||||
|
||||
|
||||
def _format_whoami(me: Mapping[str, Any], caps: Mapping[str, Any]) -> str:
|
||||
"""Render the --whoami report: identity (GET /me) + server capabilities."""
|
||||
lines = ["identity:"]
|
||||
lines.append(f" user_id: {me.get('user_id', '?')}")
|
||||
lines.append(f" tier: {me.get('tier', '?')}")
|
||||
lines.append(f" scopes: {', '.join(me.get('scopes', [])) or '(none)'}")
|
||||
# Open-world read: `scopes` may be absent, null, a scalar, or carry non-strings —
|
||||
# `_display_seq` degrades every non-list to empty (the contract names this function
|
||||
# the degrade-not-crash exemplar; heid code-review + bug-hunt slice-5).
|
||||
lines.append(f" scopes: {', '.join(_display_seq(me.get('scopes'))) or '(none)'}")
|
||||
for k in ("display_name", "key_id", "key_label"):
|
||||
if k in me:
|
||||
lines.append(f" {k}: {me[k]}")
|
||||
@@ -766,16 +776,17 @@ def _format_whoami(me: dict[str, Any], caps: dict[str, Any]) -> str:
|
||||
if isinstance(templates, dict) and templates:
|
||||
for name, spec in templates.items():
|
||||
# A diagnostic renderer must tolerate a malformed / partially-cutover
|
||||
# server (heid bug-hunt Gróa#1/#2): a non-mapping template value, or an
|
||||
# explicit-null `allowed_roles` (`.get(k, [])` returns None on null, not
|
||||
# the default), must degrade — not abort the whole --whoami report.
|
||||
# server: a non-mapping template value, or an `allowed_roles` that is null
|
||||
# / a scalar / carries non-strings, must degrade — not abort the whole
|
||||
# --whoami report (heid bug-hunt slice-5: `_display_seq` guards the
|
||||
# container type, not just null/element as the prior `or []` did).
|
||||
if not isinstance(spec, dict):
|
||||
lines.append(f" ephemeral_template {name}: (malformed)")
|
||||
continue
|
||||
# Canonical post-cutover shape (worldtree-dev althing 2026-07-18,
|
||||
# ADR-0012): roles, not models. `config.role` selects; `config.model`
|
||||
# is now rejected server-side.
|
||||
roles = ", ".join(str(r) for r in (spec.get("allowed_roles") or []))
|
||||
roles = ", ".join(_display_seq(spec.get("allowed_roles")))
|
||||
lines.append(
|
||||
f" ephemeral_template {name}: default={spec.get('default_role', '?')} "
|
||||
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} roles=[{roles}]"
|
||||
@@ -794,18 +805,23 @@ async def _whoami(args: ParsedArgs) -> int:
|
||||
vocab + exit codes as the other modes.
|
||||
"""
|
||||
assert isinstance(args, ParsedArgs)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=args.server_url,
|
||||
headers={"Authorization": f"Bearer {args.api_key}", "User-Agent": USER_AGENT},
|
||||
timeout=httpx.Timeout(connect=10.0, read=10.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
async with _probe_client(args) as transport:
|
||||
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
|
||||
try:
|
||||
me = await get_me(client)
|
||||
caps = await get_capabilities(client)
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
||||
me = await wt.get_me(client)
|
||||
caps = await wt.get_capabilities(client)
|
||||
except wt.SessionApiFailed as exc:
|
||||
sys.stderr.write(
|
||||
f"[session_api_failed] status={exc.status} "
|
||||
f"error_code={exc.error_code!r} body={exc.body!r}\n"
|
||||
)
|
||||
return 20
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||
except (
|
||||
httpx.ConnectError,
|
||||
httpx.ReadTimeout,
|
||||
httpx.TransportError,
|
||||
ConnectFailed, # SDK normalizes a pre-response transport failure here
|
||||
) as exc:
|
||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||
return 21
|
||||
sys.stdout.write(_format_whoami(me, caps))
|
||||
@@ -826,12 +842,21 @@ async def _characters_probe(args: ParsedArgs) -> int:
|
||||
(models → create → get-state → delete), print a report, exit. A reference-
|
||||
consumer smoke of the #161 character surface (needs character.read/write)."""
|
||||
assert isinstance(args, ParsedArgs)
|
||||
async with _probe_client(args) as client:
|
||||
async with _probe_client(args) as transport:
|
||||
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
|
||||
try:
|
||||
models = await list_character_models(client)
|
||||
names = ", ".join(m.get("name", "?") for m in models.get("items", []))
|
||||
models = await wt.list_character_models(client)
|
||||
# Open-world reads degrade, never crash (heid code-review + bug-hunt slice-5):
|
||||
# guard the top-level `models` is a mapping AND `items` is a list before
|
||||
# iterating (a scalar `items: 123` makes `... or []` yield `123` → `for m in
|
||||
# 123` TypeError), then guard each entry is a dict with a str-coerced `name`.
|
||||
raw_items = models.get("items") if isinstance(models, Mapping) else None
|
||||
items = raw_items if isinstance(raw_items, (list, tuple)) else []
|
||||
names = ", ".join(
|
||||
str(m.get("name", "?")) for m in items if isinstance(m, dict)
|
||||
)
|
||||
sys.stdout.write(f"character models: {names or '(none)'}\n")
|
||||
created = await create_character(
|
||||
created = await wt.create_character(
|
||||
client,
|
||||
{
|
||||
"schema_version": "1",
|
||||
@@ -845,16 +870,35 @@ async def _characters_probe(args: ParsedArgs) -> int:
|
||||
"voice_profile_block": "plain",
|
||||
},
|
||||
)
|
||||
cid = created["character_id"]
|
||||
# Open-world create ACK: degrade, don't hard-index (cumulative cutover
|
||||
# foot-gun). A non-mapping ACK or an absent/blank character_id aborts the
|
||||
# probe cleanly (exit 20) rather than raising AttributeError/KeyError — the
|
||||
# lifecycle needs the id for state + delete (heid bug-hunt slice-5). Past the
|
||||
# guard, `created`/`state` are known mappings.
|
||||
cid = created.get("character_id") if isinstance(created, Mapping) else None
|
||||
if not (isinstance(cid, str) and cid):
|
||||
sys.stderr.write(
|
||||
f"[session_api_failed] create returned no character_id: {created!r}\n"
|
||||
)
|
||||
return 20
|
||||
sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n")
|
||||
state = await get_character_state(client, cid)
|
||||
sys.stdout.write(f"state: pad={state.get('pad')}\n")
|
||||
await delete_character(client, cid)
|
||||
state = await wt.get_character_state(client, cid)
|
||||
pad = state.get("pad") if isinstance(state, Mapping) else None
|
||||
sys.stdout.write(f"state: pad={pad}\n")
|
||||
await wt.delete_character(client, cid)
|
||||
sys.stdout.write(f"deleted: {cid}\n")
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
||||
except wt.SessionApiFailed as exc:
|
||||
sys.stderr.write(
|
||||
f"[session_api_failed] status={exc.status} "
|
||||
f"error_code={exc.error_code!r} body={exc.body!r}\n"
|
||||
)
|
||||
return 20
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||
except (
|
||||
httpx.ConnectError,
|
||||
httpx.ReadTimeout,
|
||||
httpx.TransportError,
|
||||
ConnectFailed, # SDK normalizes a pre-response transport failure here
|
||||
) as exc:
|
||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||
return 21
|
||||
return 0
|
||||
|
||||
@@ -27,7 +27,11 @@ import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
# v2 (worldtree-sdk cutover slice-4): `model` → `role`. The field holds what the
|
||||
# Worldtree wire now calls a role (spec 1.2 / b128, the define response echoes
|
||||
# `role`); the version bump discards any pre-cutover on-disk index cleanly
|
||||
# (no-backwards-compat — old `{"model": …}` rows are dropped, re-defined fresh).
|
||||
_SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -37,16 +41,17 @@ class LocalAgentEntry:
|
||||
Schema:
|
||||
- ``agent_id``: full "user_id:agent_name" string (Worldtree-owned).
|
||||
- ``agent_name``: slug from define (display name).
|
||||
- ``model``: provider model ID at last define/patch.
|
||||
- ``role``: model-role at last define/patch (e.g. "thoughtful-character";
|
||||
the define/patch response's ``role`` field, W-4 resolved / b128).
|
||||
- ``description``: synthetic display string (typically derived from
|
||||
the system_prompt's first line + a "(tier 3)" prefix; the picker
|
||||
uses this in its ``{id} · {name} — {description}`` rendering).
|
||||
- ``defined_at``: ISO-8601 timestamp from the Tier3AgentInfo response.
|
||||
- ``defined_at``: ISO-8601 timestamp from the define/patch response.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
model: str
|
||||
role: str
|
||||
description: str
|
||||
defined_at: str
|
||||
|
||||
|
||||
+64
-214
@@ -6,29 +6,6 @@ Implements docs/contracts/issues/2.contract.md.
|
||||
from __future__ import annotations
|
||||
|
||||
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)
|
||||
@@ -169,6 +146,70 @@ class AuthoredHistoryUnavailable(Exception):
|
||||
self.session_id = session_id
|
||||
|
||||
|
||||
# ── Tier-3 (consumer-defined) agent lifecycle exceptions ─────────────────────
|
||||
# The worldtree-sdk adapter (`ratatoskr.wt`) re-raises these from the agents.define/
|
||||
# patch/delete routes. They live HERE (not in `tier3`) so both `wt` and the
|
||||
# `python -m ratatoskr.tier3` CLI reference the SAME class objects: `tier3` is run as
|
||||
# `__main__`, and if these were defined there, `wt`'s `from .tier3 import …` would bind
|
||||
# a SECOND copy under `ratatoskr.tier3` — so a raised exception would not match the
|
||||
# CLI's `except` (the exception would escape as an uncaught traceback). Homing them in
|
||||
# `sessions` (never run as `__main__`) makes the class identity single.
|
||||
|
||||
|
||||
class Tier3QuotaExceeded(Exception):
|
||||
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
|
||||
on the Heimdall key. ``retry_after`` captures the Retry-After header
|
||||
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero).
|
||||
|
||||
Post-cutover the adapter constructs this with ``retry_after=0`` — the SDK's
|
||||
``ApiError`` floor carries no response headers, and §2675 pins Phase-2.0 quota
|
||||
to ``Retry-After: 0``, so the value is spec-canonical."""
|
||||
|
||||
def __init__(self, *, retry_after: int) -> None:
|
||||
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class Tier3UserIdUnsupported(Exception):
|
||||
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
|
||||
is not slug-safe per Phase 2.0 gate (spec §2626)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("tier3 caller user_id is not slug-safe")
|
||||
|
||||
|
||||
class Tier3FieldNotMutable(Exception):
|
||||
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
|
||||
carried a key that's immutable post-define (``agent_name``, ``user_id``,
|
||||
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3LayerDeferred(Exception):
|
||||
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
|
||||
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
|
||||
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
|
||||
|
||||
Note: ``define_agent`` never sends layer fields, so this exception is
|
||||
defense-against-server-side-changes / forward-compat."""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"tier3 layer field deferred: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
|
||||
(spec §2634 + §2641)."""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"tier3 agent not found: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||
"""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:
|
||||
raise ValueError(f"unknown plane: {plane!r} (expected 'memory', 'affect', or 'combined')")
|
||||
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)
|
||||
|
||||
@@ -5,14 +5,9 @@ Implements docs/contracts/issues/1.contract.md.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import httpx
|
||||
import httpx_sse
|
||||
|
||||
|
||||
class SseId(NamedTuple):
|
||||
"""Parsed composite SSE wire `id:` per spec §SSE id format."""
|
||||
@@ -173,51 +168,3 @@ class CancelFailed(Exception):
|
||||
super().__init__(f"cancel failed: status={status}, body={body[:128]!r}")
|
||||
self.status = status
|
||||
self.body = body
|
||||
|
||||
|
||||
|
||||
async def stream_admin_events(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
admin_key: str,
|
||||
last_event_id: int | None = None,
|
||||
) -> AsyncIterator[AdminEvent]:
|
||||
"""GET /admin/events SSE — the admin-tier lifecycle broadcast stream (INV-046).
|
||||
|
||||
Yields `AdminEvent` envelopes as they arrive. Admin-scoped (admin.events.read):
|
||||
the request OVERRIDES Authorization with `admin_key` (distinct from the
|
||||
client's default consumer bearer). `last_event_id` sets the `Last-Event-ID`
|
||||
header for resume (plain decimal int). Long-lived — iterate until the caller
|
||||
stops or the connection ends. Non-200 → SseConnectFailed; a mid-stream drop
|
||||
→ SseConnectionDropped (caller may reconnect from the last-seen `AdminEvent.id`).
|
||||
Malformed frames are skipped (best-effort stream).
|
||||
"""
|
||||
assert client is not None
|
||||
assert admin_key and isinstance(admin_key, str)
|
||||
headers = {"Authorization": f"Bearer {admin_key}"}
|
||||
if last_event_id is not None:
|
||||
headers["Last-Event-ID"] = str(last_event_id)
|
||||
async with httpx_sse.aconnect_sse(
|
||||
client, "GET", "/admin/events", headers=headers
|
||||
) as event_source:
|
||||
if event_source.response.status_code != 200:
|
||||
body = await event_source.response.aread()
|
||||
raise SseConnectFailed(status=event_source.response.status_code, body=body)
|
||||
try:
|
||||
async for sse in event_source.aiter_sse():
|
||||
if sse.data == "":
|
||||
continue
|
||||
try:
|
||||
env = json.loads(sse.data)
|
||||
except json.JSONDecodeError:
|
||||
continue # skip a malformed admin frame (best-effort)
|
||||
yield AdminEvent(
|
||||
id=env.get("id", 0),
|
||||
type=env["type"],
|
||||
timestamp=env.get("timestamp"),
|
||||
data=env.get("data", {}),
|
||||
)
|
||||
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ReadTimeout) as exc:
|
||||
raise SseConnectionDropped(last_seen_sse_id=None) from exc
|
||||
|
||||
|
||||
|
||||
+110
-292
@@ -1,259 +1,55 @@
|
||||
"""Worldtree Tier 3 (consumer-defined) agent lifecycle client.
|
||||
"""Worldtree Tier 3 (consumer-defined) agent CLI + caller-semantic exceptions.
|
||||
|
||||
Implements docs/contracts/issues/15.contract.md. Caller-owned httpx.AsyncClient
|
||||
posture (same as ratatoskr.sessions). Exposes three lifecycle operations:
|
||||
The define / patch / delete wire calls route through the worldtree-sdk adapter
|
||||
(``ratatoskr.wt.define_agent`` / ``patch_agent`` / ``delete_agent``); this module owns
|
||||
the ``python -m ratatoskr.tier3`` CLI and the Tier-3 caller-semantic exception
|
||||
taxonomy the adapter re-raises (quota / user-id / field-not-mutable / layer-deferred /
|
||||
not-found). The picker already handles colon-containing agent_ids generically
|
||||
(issue #8).
|
||||
|
||||
- ``define_agent`` — POST /agents/define
|
||||
- ``patch_agent`` — PATCH /agents/<id>
|
||||
- ``delete_agent`` — DELETE /agents/<id>
|
||||
|
||||
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
|
||||
already handles colon-containing agent_ids generically (issue #8); session
|
||||
creation works unchanged via ``ratatoskr.wt.create_session`` (worldtree-sdk cutover).
|
||||
|
||||
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
|
||||
The old hand-rolled httpx wrappers + the ``Tier3AgentInfo`` dataclass were deleted in
|
||||
the worldtree-sdk cutover (issue #20, slice-4); the adapter returns the SDK's
|
||||
open-world define/patch dicts (echoing ``role`` post-b128, spec 1.2), read here as
|
||||
mappings. ``wt`` imports this module's exceptions at module level; this module imports
|
||||
``wt`` only lazily inside the CLI handlers, so there is no import cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
|
||||
# Per spec §2627: agent_name + user_id slugs are `[a-z][a-z0-9-]{2,63}`.
|
||||
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
|
||||
# The Tier-3 caller-semantic exceptions live in `sessions` (never run as `__main__`)
|
||||
# so the adapter's raise and this CLI's `except` reference the SAME class objects —
|
||||
# see the header note in `sessions.py`. `main()` catches these; `wt` raises them.
|
||||
from ratatoskr.sessions import (
|
||||
Tier3AgentNotFound,
|
||||
Tier3FieldNotMutable,
|
||||
Tier3LayerDeferred,
|
||||
Tier3QuotaExceeded,
|
||||
Tier3UserIdUnsupported,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tier3AgentInfo:
|
||||
"""Worldtree Tier 3 agent envelope returned by define / patch.
|
||||
|
||||
INV-001: ``agent_id`` is always shape ``"<user_id>:<agent_name>"`` —
|
||||
constructed server-side from the auth's user_id + the supplied agent_name.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
user_id: str
|
||||
agent_name: str
|
||||
system_prompt: str
|
||||
model: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class Tier3QuotaExceeded(Exception):
|
||||
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
|
||||
on the Heimdall key. ``retry_after`` captures the Retry-After header
|
||||
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero)."""
|
||||
|
||||
def __init__(self, *, retry_after: int) -> None:
|
||||
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class Tier3UserIdUnsupported(Exception):
|
||||
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
|
||||
is not slug-safe per Phase 2.0 gate (spec §2626)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("tier3 caller user_id is not slug-safe")
|
||||
|
||||
|
||||
class Tier3FieldNotMutable(Exception):
|
||||
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
|
||||
carried a key that's immutable post-define (``agent_name``, ``user_id``,
|
||||
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3LayerDeferred(Exception):
|
||||
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
|
||||
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
|
||||
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
|
||||
|
||||
Note: ``define_agent`` never sends layer fields, so this exception is
|
||||
defense-against-server-side-changes / forward-compat. INV-001 in the
|
||||
request body construction is the first line of defense.
|
||||
"""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"tier3 layer field deferred: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
|
||||
(spec §2634 + §2641)."""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"tier3 agent not found: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
def _extract_error_code(resp: httpx.Response) -> str | None:
|
||||
"""Pluck the ``detail.error_code`` from a Worldtree error envelope.
|
||||
|
||||
Worldtree wraps API errors in ``{"detail": {"error_code": "...", ...}}``
|
||||
per the spec. Returns None on shape mismatch (so callers fall through
|
||||
to the generic ``SessionApiFailed`` branch).
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
code = detail.get("error_code")
|
||||
if isinstance(code, str):
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
def _extract_error_field(resp: httpx.Response) -> str | None:
|
||||
"""Pluck ``detail.field`` from a Worldtree error envelope (used for
|
||||
``field_not_mutable`` and ``layer_deferred`` to surface which field
|
||||
triggered the rejection). Returns None on shape mismatch.
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
field = detail.get("field")
|
||||
if isinstance(field, str):
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def _parse_tier3_agent_info(body: dict) -> Tier3AgentInfo:
|
||||
"""Parse a Worldtree Tier 3 agent JSON body into the frozen dataclass."""
|
||||
return Tier3AgentInfo(
|
||||
agent_id=body["agent_id"],
|
||||
user_id=body["user_id"],
|
||||
agent_name=body["agent_name"],
|
||||
system_prompt=body["system_prompt"],
|
||||
model=body["model"],
|
||||
created_at=body["created_at"],
|
||||
updated_at=body["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
async def define_agent(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
agent_name: str,
|
||||
system_prompt: str,
|
||||
role: str,
|
||||
) -> Tier3AgentInfo:
|
||||
"""POST /agents/define — create a Tier 3 agent.
|
||||
|
||||
See contract FN define_agent. Validates the agent_name slug client-side
|
||||
before the network round-trip; server-side validation is the safety net.
|
||||
Returns a fully populated Tier3AgentInfo on 201. Routes documented error
|
||||
codes to typed exceptions; unknown non-2xx → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert _SLUG_RE.match(agent_name), (
|
||||
f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
|
||||
)
|
||||
assert system_prompt, "system_prompt must be non-empty"
|
||||
assert role, "role must be non-empty"
|
||||
|
||||
# b125 drift: /agents/define takes `role` (a model-role, e.g. "thoughtful-character")
|
||||
# in the request; the response echoes it back as `model`. See #15 follow-up.
|
||||
body = {
|
||||
"agent_name": agent_name,
|
||||
"system_prompt": system_prompt,
|
||||
"role": role,
|
||||
}
|
||||
resp = await client.post("/agents/define", json=body)
|
||||
|
||||
if resp.status_code == 201:
|
||||
return _parse_tier3_agent_info(resp.json())
|
||||
if resp.status_code == 429:
|
||||
# Spec §2675: 51st define → 429 with Retry-After: 0.
|
||||
try:
|
||||
retry_after = int(resp.headers.get("Retry-After", "0"))
|
||||
except (TypeError, ValueError):
|
||||
retry_after = 0
|
||||
raise Tier3QuotaExceeded(retry_after=retry_after)
|
||||
if resp.status_code == 403:
|
||||
if _extract_error_code(resp) == "tier3_user_id_unsupported":
|
||||
raise Tier3UserIdUnsupported()
|
||||
if resp.status_code == 422:
|
||||
code = _extract_error_code(resp)
|
||||
if code == "layer_deferred":
|
||||
raise Tier3LayerDeferred(field=_extract_error_field(resp))
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def patch_agent(
|
||||
client: httpx.AsyncClient,
|
||||
agent_id: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
role: str | None = None,
|
||||
) -> Tier3AgentInfo:
|
||||
"""PATCH /agents/<id> — mutate system_prompt and/or model.
|
||||
|
||||
See contract FN patch_agent. Per spec §2641: only system_prompt + model
|
||||
are mutable in Phase 2.0; any other key returns 422 field_not_mutable.
|
||||
"""
|
||||
assert client is not None
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
assert system_prompt is not None or role is not None, (
|
||||
"patch requires at least one of system_prompt or role"
|
||||
)
|
||||
|
||||
body: dict[str, str] = {}
|
||||
if system_prompt is not None:
|
||||
body["system_prompt"] = system_prompt
|
||||
if role is not None:
|
||||
body["role"] = role
|
||||
resp = await client.patch(f"/agents/{agent_id}", json=body)
|
||||
|
||||
if resp.status_code == 200:
|
||||
return _parse_tier3_agent_info(resp.json())
|
||||
if resp.status_code == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
if resp.status_code == 422:
|
||||
code = _extract_error_code(resp)
|
||||
if code == "field_not_mutable":
|
||||
raise Tier3FieldNotMutable(field=_extract_error_field(resp))
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
|
||||
"""DELETE /agents/<id> — owner hard-delete (cancels active sessions
|
||||
server-side per spec §2636).
|
||||
|
||||
See contract FN delete_agent. 204 on success; 404 if the agent_id
|
||||
doesn't exist; other non-2xx → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
|
||||
resp = await client.delete(f"/agents/{agent_id}")
|
||||
if resp.status_code == 204:
|
||||
return
|
||||
if resp.status_code == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
def _str_field(info: Mapping[str, Any], key: str, *, default: str = "") -> str:
|
||||
"""A string field off an open-world response dict, or `default` when the key is
|
||||
absent / null / non-string — so a partial or drifted 2xx define/patch response
|
||||
degrades rather than KeyError/AttributeError-crashing the CLI presenter (the
|
||||
"open-world reads degrade, never crash the presenter" invariant; heid-bug-hunt)."""
|
||||
value = info.get(key)
|
||||
return value if isinstance(value, str) else default
|
||||
|
||||
# ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------
|
||||
#
|
||||
# Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes
|
||||
# mirror ratatoskr.cli: 0 happy / 10 usage / 11 auth / 20 api-failure /
|
||||
# 21 network. Outbound requests carry the same User-Agent string.
|
||||
# 21 network. Outbound requests carry the same User-Agent string. The wire calls
|
||||
# route through the worldtree-sdk adapter (ratatoskr.wt) over a ratatoskr-owned
|
||||
# injected transport (INV-CUT-1: the SDK never closes it).
|
||||
|
||||
|
||||
class _Tier3UsageError(Exception):
|
||||
@@ -284,7 +80,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
help="Model role, e.g. thoughtful-character (see GET /models/available-for-characters).",
|
||||
)
|
||||
|
||||
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or model.")
|
||||
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or role.")
|
||||
p_patch.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
|
||||
p_patch.add_argument("--system-prompt", dest="system_prompt", default=None)
|
||||
p_patch.add_argument("--role", default=None)
|
||||
@@ -308,46 +104,63 @@ def _resolve_auth(ns: argparse.Namespace) -> tuple[str, str]:
|
||||
return api_key, server_url
|
||||
|
||||
|
||||
def _transport(server_url: str, api_key: str) -> httpx.AsyncClient:
|
||||
"""The ratatoskr-owned httpx transport the adapter's WorldtreeClient is built
|
||||
over. Carries the User-Agent + a generous read timeout for agent CRUD; the SDK
|
||||
re-applies auth per request (the default bearer here just mirrors it)."""
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
|
||||
return httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
)
|
||||
|
||||
|
||||
async def _run_define(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr import wt
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
add_local_agent,
|
||||
make_description,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
info = await define_agent(
|
||||
async with _transport(server_url, api_key) as transport:
|
||||
client = wt.build_client(server_url, api_key=api_key, transport=transport)
|
||||
info = await wt.define_agent(
|
||||
client,
|
||||
agent_name=ns.name,
|
||||
system_prompt=ns.system_prompt,
|
||||
role=ns.role,
|
||||
)
|
||||
# v0.8.0: persist to local index so the picker can show it.
|
||||
add_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=info.agent_id,
|
||||
agent_name=info.agent_name,
|
||||
model=info.model,
|
||||
description=make_description(info.system_prompt),
|
||||
defined_at=info.created_at,
|
||||
# Open-world define dict — read defensively (echoes `role` post-b128, not
|
||||
# `model`). A partial/drifted 2xx must not crash the presenter.
|
||||
agent_id = _str_field(info, "agent_id")
|
||||
if not agent_id: # a 2xx with no usable agent_id is a malformed success
|
||||
sys.stderr.write(f"[api_failed] define returned no usable agent_id: {info!r}\n")
|
||||
return 20
|
||||
role = _str_field(info, "role", default="?")
|
||||
agent_name = _str_field(info, "agent_name")
|
||||
# v0.8.0: persist to local index so the picker can show it — only for a
|
||||
# well-formed identity (agent_id + agent_name); else skip the write, still print.
|
||||
if agent_name:
|
||||
add_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
role=role,
|
||||
description=make_description(_str_field(info, "system_prompt")),
|
||||
defined_at=_str_field(info, "created_at"),
|
||||
)
|
||||
)
|
||||
)
|
||||
print(f"defined {info.agent_id} ({info.model})")
|
||||
print(f"defined {agent_id} ({role})")
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_patch(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr import wt
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
make_description,
|
||||
@@ -358,48 +171,43 @@ async def _run_patch(ns: argparse.Namespace) -> int:
|
||||
raise _Tier3UsageError(
|
||||
"patch requires at least one of --system-prompt or --role"
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
info = await patch_agent(
|
||||
async with _transport(server_url, api_key) as transport:
|
||||
client = wt.build_client(server_url, api_key=api_key, transport=transport)
|
||||
info = await wt.patch_agent(
|
||||
client,
|
||||
ns.agent_id,
|
||||
system_prompt=ns.system_prompt,
|
||||
role=ns.role,
|
||||
)
|
||||
# v0.8.0: refresh local index with the post-patch state.
|
||||
update_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=info.agent_id,
|
||||
agent_name=info.agent_name,
|
||||
model=info.model,
|
||||
description=make_description(info.system_prompt),
|
||||
defined_at=info.updated_at,
|
||||
# Open-world patch dict — read defensively (same degrade-not-crash posture).
|
||||
agent_id = _str_field(info, "agent_id")
|
||||
if not agent_id: # a 2xx with no usable agent_id is a malformed success
|
||||
sys.stderr.write(f"[api_failed] patch returned no usable agent_id: {info!r}\n")
|
||||
return 20
|
||||
agent_name = _str_field(info, "agent_name")
|
||||
# v0.8.0: refresh local index with the post-patch state (well-formed identity only).
|
||||
if agent_name:
|
||||
update_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
role=_str_field(info, "role", default="?"),
|
||||
description=make_description(_str_field(info, "system_prompt")),
|
||||
defined_at=_str_field(info, "updated_at"),
|
||||
)
|
||||
)
|
||||
)
|
||||
print(f"patched {info.agent_id}")
|
||||
print(f"patched {agent_id}")
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_delete(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr import wt
|
||||
from ratatoskr.local_agents import remove_local_agent
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
await delete_agent(client, ns.agent_id)
|
||||
async with _transport(server_url, api_key) as transport:
|
||||
client = wt.build_client(server_url, api_key=api_key, transport=transport)
|
||||
await wt.delete_agent(client, ns.agent_id)
|
||||
# v0.8.0: drop from local index so the picker stops listing it.
|
||||
remove_local_agent(ns.agent_id)
|
||||
print(f"deleted {ns.agent_id}")
|
||||
@@ -419,6 +227,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import worldtree_sdk as wtsdk
|
||||
|
||||
from ratatoskr.wt import SessionApiFailed
|
||||
|
||||
parser = _build_parser()
|
||||
try:
|
||||
ns = parser.parse_args(argv)
|
||||
@@ -459,10 +271,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 20
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(
|
||||
f"[api_failed] status={exc.status} body={exc.body!r}\n"
|
||||
f"[api_failed] status={exc.status} "
|
||||
f"error_code={exc.error_code!r} body={exc.body!r}\n"
|
||||
)
|
||||
return 20
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||
except (
|
||||
httpx.ConnectError,
|
||||
httpx.ReadTimeout,
|
||||
httpx.TransportError,
|
||||
wtsdk.ConnectFailed, # SDK normalizes any pre-response transport failure here
|
||||
) as exc:
|
||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||
return 21
|
||||
|
||||
|
||||
+80
-26
@@ -26,7 +26,13 @@ from starlette.responses import (
|
||||
)
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from worldtree_sdk import CancelledEvent, DoneEvent, ErrorEvent, WorldtreeClient
|
||||
from worldtree_sdk import (
|
||||
CancelledEvent,
|
||||
ConnectFailed,
|
||||
DoneEvent,
|
||||
ErrorEvent,
|
||||
WorldtreeClient,
|
||||
)
|
||||
|
||||
from ratatoskr import local_agents as _local_agents
|
||||
from ratatoskr import wt
|
||||
@@ -39,17 +45,14 @@ from ratatoskr.sessions import (
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
endpoint_for_plane,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
list_agents,
|
||||
)
|
||||
|
||||
# The turn path (create / stream / cancel / tools / messages) is served by the
|
||||
# worldtree-sdk adapter (`wt.*`), which raises ratatoskr's caller-semantic
|
||||
# exceptions (DEC-2). The hand-rolled endpoints (persona / agents / admin /
|
||||
# bifrost) stay on the `sessions` / `sse_client` wrappers until their own slices.
|
||||
# The turn path (create / stream / cancel / tools / messages), the agents /
|
||||
# persona-state reads, AND the admin surface (bifrost inspection + admin-events stream)
|
||||
# are all served by the worldtree-sdk adapter (`wt.*`), which raises ratatoskr's
|
||||
# caller-semantic exceptions (DEC-2). `AdminEvent` is still ratatoskr's domain event
|
||||
# type the adapter re-wraps into (imported from `sse_client` until slice-7 teardown).
|
||||
from ratatoskr.sse_client import (
|
||||
AdminEvent,
|
||||
CancelAlreadyCompleted,
|
||||
@@ -60,16 +63,21 @@ from ratatoskr.sse_client import (
|
||||
SseConnectFailed,
|
||||
SseConnectionDropped,
|
||||
TurnIdFlip,
|
||||
stream_admin_events,
|
||||
)
|
||||
|
||||
|
||||
def _wt_client(client: httpx.AsyncClient, *, max_reconnects: int = 5) -> WorldtreeClient:
|
||||
def _wt_client(
|
||||
client: httpx.AsyncClient, *, admin_key: str | None = None, max_reconnects: int = 5
|
||||
) -> WorldtreeClient:
|
||||
"""Wrap a client_factory transport as the adapter's WorldtreeClient (INV-CUT-1:
|
||||
the SDK never closes it). base_url + bearer are read off the transport (the
|
||||
factory bakes them in); the SDK re-applies auth per request, so the extracted
|
||||
key just mirrors the transport's default. A no-auth test transport falls back to
|
||||
a placeholder key (respx ignores auth)."""
|
||||
a placeholder key (respx ignores auth).
|
||||
|
||||
`admin_key` is the SERVER-HELD admin credential (slice-6): the SDK's `admin.*`
|
||||
routes authenticate with the client's `admin_auth`, NOT a per-call header, so an
|
||||
admin endpoint passes it here. Omitted for the default-tier reads."""
|
||||
base_url = str(client.base_url) or "http://localhost"
|
||||
header = client.headers.get("Authorization", "")
|
||||
# Case-insensitive scheme + tolerant of extra whitespace, so a valid bearer is
|
||||
@@ -77,7 +85,11 @@ def _wt_client(client: httpx.AsyncClient, *, max_reconnects: int = 5) -> Worldtr
|
||||
parts = header.split(None, 1)
|
||||
api_key = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else ""
|
||||
return wt.build_client(
|
||||
base_url, api_key=api_key or "ratatoskr", transport=client, max_reconnects=max_reconnects
|
||||
base_url,
|
||||
api_key=api_key or "ratatoskr",
|
||||
admin_key=admin_key,
|
||||
transport=client,
|
||||
max_reconnects=max_reconnects,
|
||||
)
|
||||
|
||||
|
||||
@@ -124,20 +136,31 @@ async def _agents_endpoint(request: Request) -> JSONResponse:
|
||||
client_factory = request.app.state.client_factory
|
||||
try:
|
||||
async with client_factory() as client:
|
||||
upstream = await list_agents(client)
|
||||
except SessionApiFailed as exc:
|
||||
upstream = await wt.list_agents(_wt_client(client))
|
||||
except wt.SessionApiFailed as exc:
|
||||
return JSONResponse(
|
||||
{"error_code": "session_api_failed", "status": exc.status},
|
||||
status_code=exc.status,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
except (httpx.RequestError, ConnectFailed) as exc:
|
||||
# The SDK normalizes a transport failure to ConnectFailed(status=0), not a
|
||||
# raw httpx error; both surface the same network envelope (slice-3 foot-gun).
|
||||
return JSONResponse(
|
||||
{"error_code": "network_error", "message": str(exc)},
|
||||
status_code=502,
|
||||
)
|
||||
upstream_ids = {a.agent_id for a in upstream}
|
||||
# Open-world upstream (parity: no AgentInfo normalization). Degrade, never crash:
|
||||
# a non-list envelope OR a malformed item (missing/non-str agent_id, non-mapping)
|
||||
# is dropped rather than KeyError/TypeError'ing the endpoint into a 500 before the
|
||||
# local fallback merges (heid-bug-hunt: the "open-world reads degrade" invariant).
|
||||
upstream_items = upstream if isinstance(upstream, list) else []
|
||||
well_formed = [
|
||||
a for a in upstream_items
|
||||
if isinstance(a, Mapping) and isinstance(a.get("agent_id"), str)
|
||||
]
|
||||
upstream_ids = {a["agent_id"] for a in well_formed}
|
||||
local = _local_agents.load_local_agents()
|
||||
merged = [_as_dict(a) for a in upstream] + [
|
||||
merged = [_as_dict(a) for a in well_formed] + [
|
||||
_as_dict(le) for le in local if le.agent_id not in upstream_ids
|
||||
]
|
||||
return JSONResponse(merged, status_code=200)
|
||||
@@ -433,13 +456,28 @@ async def _persona_state_endpoint(request: Request) -> JSONResponse:
|
||||
client_factory = request.app.state.client_factory
|
||||
try:
|
||||
async with client_factory() as client:
|
||||
snap = await get_persona_state(client, agent_id)
|
||||
snap = await wt.get_persona_state(_wt_client(client), agent_id)
|
||||
except PersonaNotConfigured:
|
||||
return JSONResponse({"error_code": "persona_not_configured"}, status_code=404)
|
||||
except AgentNotAvailable:
|
||||
return JSONResponse({"error_code": "agent_not_available"}, status_code=404)
|
||||
except AuthScopeDenied:
|
||||
return JSONResponse({"error_code": "auth_scope_denied"}, status_code=403)
|
||||
except wt.SessionApiFailed as exc:
|
||||
# An unmatched upstream ApiError (a 500, or a coded-but-unmapped 4xx) →
|
||||
# controlled envelope, for parity with _agents_endpoint / create / admin
|
||||
# (heid-code-review slice-4: the persona endpoint was the lone sibling that
|
||||
# let it escape as a raw 500 — a latent pre-cutover gap, closed here).
|
||||
return JSONResponse(
|
||||
{"error_code": "session_api_failed", "status": exc.status},
|
||||
status_code=exc.status,
|
||||
)
|
||||
except (httpx.RequestError, ConnectFailed) as exc:
|
||||
# SDK normalizes a transport failure to ConnectFailed(status=0) (slice-3
|
||||
# foot-gun); surface the network envelope rather than a 500 crash.
|
||||
return JSONResponse(
|
||||
{"error_code": "network_error", "message": str(exc)}, status_code=502
|
||||
)
|
||||
return JSONResponse(snap, status_code=200)
|
||||
|
||||
|
||||
@@ -543,14 +581,24 @@ async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"error_code": "admin_key_not_configured"}, status_code=400)
|
||||
client_factory = request.app.state.client_factory
|
||||
try:
|
||||
async with client_factory() as client:
|
||||
bstate = await get_session_bifrost(client, session_id, admin_key=admin_key)
|
||||
except SessionApiFailed as exc:
|
||||
async with client_factory() as transport:
|
||||
# slice-6: the SDK's admin.* routes use the client's admin_auth (built with
|
||||
# admin_key), not a per-call header — so it rides on the wt client here.
|
||||
client = _wt_client(transport, admin_key=admin_key)
|
||||
bstate = await wt.get_session_bifrost(client, session_id)
|
||||
except wt.SessionApiFailed as exc:
|
||||
return JSONResponse(
|
||||
{"error_code": "bifrost_state_unavailable", "status": exc.status},
|
||||
status_code=exc.status,
|
||||
)
|
||||
return JSONResponse(bstate, status_code=200)
|
||||
except (httpx.RequestError, ConnectFailed) as exc:
|
||||
# SDK normalizes a transport failure to ConnectFailed(status=0), not a raw
|
||||
# httpx error; both surface the same network envelope (cutover foot-gun).
|
||||
return JSONResponse(
|
||||
{"error_code": "network_error", "message": str(exc)},
|
||||
status_code=502,
|
||||
)
|
||||
return JSONResponse(dict(bstate), status_code=200)
|
||||
|
||||
|
||||
def _admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool:
|
||||
@@ -578,9 +626,13 @@ async def _admin_events_endpoint(request: Request) -> Response:
|
||||
client_factory = request.app.state.client_factory
|
||||
|
||||
async def gen() -> AsyncIterator[bytes]:
|
||||
client = client_factory()
|
||||
transport = client_factory()
|
||||
# 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.
|
||||
client = _wt_client(transport, admin_key=admin_key)
|
||||
try:
|
||||
async for ev in stream_admin_events(client, admin_key=admin_key):
|
||||
async for ev in wt.stream_admin_events(client):
|
||||
if not _admin_event_matches_web(ev, session_id):
|
||||
continue
|
||||
# Fixed SSE event name so the browser renders EVERY admin type
|
||||
@@ -600,7 +652,9 @@ async def _admin_events_endpoint(request: Request) -> Response:
|
||||
except asyncio.CancelledError:
|
||||
raise # browser disconnect — let the generator unwind
|
||||
finally:
|
||||
await client.aclose()
|
||||
# ratatoskr owns the transport lifecycle (INV-CUT-1); close the injected
|
||||
# httpx client, never the wt client (which would no-op the transport anyway).
|
||||
await transport.aclose()
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
+332
-1
@@ -29,7 +29,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -40,15 +41,26 @@ from worldtree_sdk import ApiError, AuthProvider, CancelResult, PadState, Worldt
|
||||
# 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 →
|
||||
# sessions / sse_client is one-way (neither imports wt), so there is no cycle.
|
||||
from .sessions import (
|
||||
AgentNotAvailable as PersonaAgentNotAvailable,
|
||||
)
|
||||
from .sessions import (
|
||||
AgentNotFound,
|
||||
AuthoredHistoryUnavailable,
|
||||
AuthScopeDenied,
|
||||
BifrostBinding,
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
InvalidCursor,
|
||||
PersonaNotConfigured,
|
||||
Tier3AgentNotFound,
|
||||
Tier3FieldNotMutable,
|
||||
Tier3LayerDeferred,
|
||||
Tier3QuotaExceeded,
|
||||
Tier3UserIdUnsupported,
|
||||
)
|
||||
from .sse_client import (
|
||||
AdminEvent,
|
||||
AgentNotAvailable,
|
||||
CancelAlreadyCompleted,
|
||||
CancelFailed,
|
||||
@@ -417,3 +429,322 @@ async def write_authored_history(
|
||||
if exc.status == 404:
|
||||
raise AuthoredHistoryUnavailable(session_id=session_id) from exc
|
||||
raise translate_error(exc) from exc
|
||||
|
||||
|
||||
# ── slice-4: agents (Tier-3) adapter routes ──────────────────────────────────
|
||||
# Ratatoskr-semantic surfaces over `WorldtreeClient.agents.*` (list/persona_state/
|
||||
# define/patch/delete). The SDK returns open-world dicts (parity posture — read as
|
||||
# mappings, never normalized into a frozen dataclass) and surfaces the tier3/persona
|
||||
# failure modes on its undiscriminated `ApiError` floor; the adapter maps them by the
|
||||
# ROUTE + (status, error_code) per the § Error map (INV-CUT-2). The `model`→`role`
|
||||
# cutover folds in here: the define/patch responses echo `role` (spec 1.2 / b128), so
|
||||
# callers read `info["role"]` off the open-world dict — no `Tier3AgentInfo` survives.
|
||||
|
||||
# Consumer-agent slug (spec §2627): agent_name is `[a-z][a-z0-9-]{2,63}`.
|
||||
_AGENT_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
|
||||
|
||||
|
||||
def _error_field_from_body(body: str | None) -> str | None:
|
||||
"""Pull the envelope's `field` from an SDK `ApiError` body string.
|
||||
|
||||
The SDK's `ApiError` carries the parsed `error_code` but NOT the envelope's
|
||||
`field`, so the field-bearing tier3 rejections (`layer_deferred`,
|
||||
`field_not_mutable`) body-parse it here — same both-shape unwrap as
|
||||
`_bifrost_error_from_body`, tolerant of `{"detail": {"field": …}}` and a flat
|
||||
top-level `field`. Returns None on any parse failure (the exception still carries
|
||||
a None field, exactly as the hand-rolled path did on shape mismatch).
|
||||
"""
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
err = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(err, dict):
|
||||
return None
|
||||
field = err.get("field")
|
||||
if field is None and isinstance(err.get("detail"), dict):
|
||||
field = err["detail"].get("field")
|
||||
# The exception surface declares `field: str | None` (and the CLI prints it), so a
|
||||
# non-string envelope value (`{"field": {…}}` / `{"field": 1}`) collapses to None —
|
||||
# same guard the retired hand-rolled `_extract_error_field` applied (heid-bug-hunt).
|
||||
return field if isinstance(field, str) else None
|
||||
|
||||
|
||||
async def list_agents(client: WorldtreeClient) -> Sequence[Mapping[str, Any]]:
|
||||
"""List the caller's agents (GET /agents) as the SDK's open-world array, verbatim
|
||||
(parity: each item read as a mapping, tolerant of wire drift — no `AgentInfo`
|
||||
normalization). Any error → the `SessionApiFailed` default."""
|
||||
try:
|
||||
return await client.agents.list()
|
||||
except ApiError as exc:
|
||||
raise translate_error(exc) from exc
|
||||
|
||||
|
||||
async def get_persona_state(client: WorldtreeClient, agent_id: str) -> Mapping[str, Any]:
|
||||
"""Fetch an agent's persona snapshot (GET /agents/{id}/persona_state, WT #204),
|
||||
open-world dict verbatim. Error map (INV-CUT-2 — dual-key status+error_code): 404
|
||||
`persona_not_configured` → `PersonaNotConfigured`; 404 `agent_not_available` →
|
||||
`AgentNotAvailable` (the persona-surface variant); 403 `auth_scope_denied` →
|
||||
`AuthScopeDenied`; every other error → the `SessionApiFailed` default. A 404 with
|
||||
an unrecognized code stays generic — the error_code is the discriminator, never a
|
||||
bare 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 `ApiError` (a non-200 open — the admin stream raises `admin_stream_failed`,
|
||||
NOT `ConnectFailed`) → `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,
|
||||
type=ev.type or "",
|
||||
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 ApiError as exc:
|
||||
# The admin stream raises ApiError("admin_stream_failed", status=…) on a non-200
|
||||
# open (a connect-time transport failure instead surfaces as ConnectionDropped);
|
||||
# map the non-200 → SseConnectFailed so the web's stream-error handler catches it.
|
||||
raise SseConnectFailed(status=exc.status, body=(exc.body or "").encode()) from exc
|
||||
|
||||
@@ -1879,6 +1879,49 @@ class TestWhoami:
|
||||
assert rc == 20
|
||||
assert "[session_api_failed]" in capsys.readouterr().err
|
||||
|
||||
@respx.mock
|
||||
def test_whoami_tolerates_null_and_nonstring_scopes(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""scopes present-null / non-string → renders '(none)' or str-coerced, never a
|
||||
`join(None)` TypeError (heid-code-review slice-5: `_format_whoami` is the
|
||||
contract's degrade-not-crash exemplar; `allowed_roles` was hardened, `scopes`
|
||||
was not)."""
|
||||
# scopes: null (present, not absent) → `.get('scopes', [])` would return None.
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(200, json={"user_id": "u", "scopes": None, "tier": "user"})
|
||||
)
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(200, json={"ephemeral_templates": {}})
|
||||
)
|
||||
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 0
|
||||
assert "scopes: (none)" in capsys.readouterr().out
|
||||
|
||||
@respx.mock
|
||||
def test_whoami_tolerates_scalar_scopes_and_roles(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Non-iterable (scalar) `scopes` / `allowed_roles` → degrade to empty, never a
|
||||
`for x in 123` TypeError (heid bug-hunt slice-5: `_display_seq` guards the
|
||||
container TYPE, the next layer past the code-review null/element fix)."""
|
||||
respx.get("https://w.example/me").mock(
|
||||
return_value=httpx.Response(200, json={"user_id": "u", "scopes": 123, "tier": "user"})
|
||||
)
|
||||
respx.get("https://w.example/capabilities").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ephemeral_templates": {"echo": {"allowed_roles": 7, "default_role": "echo"}}
|
||||
},
|
||||
)
|
||||
)
|
||||
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "scopes: (none)" in out
|
||||
assert "roles=[]" in out
|
||||
|
||||
|
||||
class TestTier2Probes:
|
||||
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write)."""
|
||||
@@ -1923,6 +1966,102 @@ class TestTier2Probes:
|
||||
assert "deleted: char_z" in out
|
||||
assert del_route.call_count == 1 # lifecycle cleaned up
|
||||
|
||||
@respx.mock
|
||||
def test_characters_probe_tolerates_malformed_models(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""models catalog with non-mapping / non-string-name items → degrades (no
|
||||
AttributeError/TypeError), lifecycle still proceeds (heid-code-review slice-5:
|
||||
element-level completion of the list-level `or []` guard)."""
|
||||
respx.get("https://w.example/models/available-for-characters").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"items": [None, "x", {"name": 123}, {"name": "ok"}]}
|
||||
)
|
||||
)
|
||||
respx.post("https://w.example/characters").mock(
|
||||
return_value=httpx.Response(201, json={"character_id": "c1", "ttl_expires_at": "t"})
|
||||
)
|
||||
respx.get("https://w.example/characters/c1/state").mock(
|
||||
return_value=httpx.Response(200, json={"pad": [0.0, 0.0, 0.0]})
|
||||
)
|
||||
respx.delete("https://w.example/characters/c1").mock(return_value=httpx.Response(204))
|
||||
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
# non-mappings dropped; {"name":123}→"123", {"name":"ok"}→"ok" — no crash.
|
||||
assert "character models: 123, ok" in out
|
||||
assert "created: c1" in out
|
||||
|
||||
@respx.mock
|
||||
def test_characters_probe_tolerates_scalar_items_and_nonmapping_state(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Scalar `items` (`123`) → '(none)' not a `for m in 123` TypeError; a non-mapping
|
||||
`state` → 'pad=None' not an AttributeError. Lifecycle still completes (heid
|
||||
bug-hunt slice-5: container-type + top-level-mapping guards)."""
|
||||
respx.get("https://w.example/models/available-for-characters").mock(
|
||||
return_value=httpx.Response(200, json={"items": 123})
|
||||
)
|
||||
respx.post("https://w.example/characters").mock(
|
||||
return_value=httpx.Response(201, json={"character_id": "c1", "ttl_expires_at": "t"})
|
||||
)
|
||||
# non-mapping state body (open-world passthrough of a JSON array).
|
||||
respx.get("https://w.example/characters/c1/state").mock(
|
||||
return_value=httpx.Response(200, json=["not", "a", "mapping"])
|
||||
)
|
||||
del_route = respx.delete("https://w.example/characters/c1").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "character models: (none)" in out
|
||||
assert "state: pad=None" in out
|
||||
assert "deleted: c1" in out
|
||||
assert del_route.call_count == 1
|
||||
|
||||
@respx.mock
|
||||
def test_characters_probe_create_missing_id_aborts(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""create ACK without character_id → clean abort (exit 20), never a hard-index
|
||||
KeyError (open-world degrade-not-crash; slice-5 cutover foot-gun)."""
|
||||
respx.get("https://w.example/models/available-for-characters").mock(
|
||||
return_value=httpx.Response(200, json={"items": []})
|
||||
)
|
||||
# 201 but the open-world ACK omits character_id — the probe must degrade.
|
||||
respx.post("https://w.example/characters").mock(
|
||||
return_value=httpx.Response(201, json={"ttl_expires_at": "t"})
|
||||
)
|
||||
del_route = respx.delete(url__regex=r"https://w\.example/characters/.+").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 20
|
||||
assert "no character_id" in capsys.readouterr().err
|
||||
assert del_route.call_count == 0 # aborted before state/delete — nothing to clean
|
||||
|
||||
@respx.mock
|
||||
def test_characters_probe_non_mapping_create_aborts(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A non-mapping create ACK (open-world passthrough of a JSON array/scalar) →
|
||||
clean exit-20 abort, never an AttributeError on `created.get(...)` (heid
|
||||
bug-hunt slice-5, finding #3)."""
|
||||
respx.get("https://w.example/models/available-for-characters").mock(
|
||||
return_value=httpx.Response(200, json={"items": []})
|
||||
)
|
||||
respx.post("https://w.example/characters").mock(
|
||||
return_value=httpx.Response(201, json=["not", "a", "mapping"])
|
||||
)
|
||||
del_route = respx.delete(url__regex=r"https://w\.example/characters/.+").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
|
||||
assert rc == 20
|
||||
assert "no character_id" in capsys.readouterr().err
|
||||
assert del_route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204."""
|
||||
|
||||
+10
-10
@@ -33,14 +33,14 @@ def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
def _entry(
|
||||
agent_id: str = "ratatoskr:wizard",
|
||||
agent_name: str = "wizard",
|
||||
model: str = "qwen3.6-35-a3b",
|
||||
role: str = "qwen3.6-35-a3b",
|
||||
description: str = "(tier 3) test agent",
|
||||
defined_at: str = "2026-05-25T00:00:00+00:00",
|
||||
) -> LocalAgentEntry:
|
||||
return LocalAgentEntry(
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
model=model,
|
||||
role=role,
|
||||
description=description,
|
||||
defined_at=defined_at,
|
||||
)
|
||||
@@ -91,13 +91,13 @@ class TestLoadEmpty:
|
||||
local_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"agents": [
|
||||
{"agent_id": "incomplete"}, # missing required fields
|
||||
{
|
||||
"agent_id": "ratatoskr:good",
|
||||
"agent_name": "good",
|
||||
"model": "m",
|
||||
"role": "m",
|
||||
"description": "d",
|
||||
"defined_at": "t",
|
||||
},
|
||||
@@ -124,11 +124,11 @@ class TestAdd:
|
||||
assert ids == {"ratatoskr:a", "ratatoskr:b"}
|
||||
|
||||
def test_add_replaces_same_id(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry(model="old-model"))
|
||||
add_local_agent(_entry(model="new-model"))
|
||||
add_local_agent(_entry(role="old-role"))
|
||||
add_local_agent(_entry(role="new-role"))
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].model == "new-model"
|
||||
assert entries[0].role == "new-role"
|
||||
|
||||
def test_creates_parent_dirs(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -141,11 +141,11 @@ class TestAdd:
|
||||
|
||||
class TestUpdate:
|
||||
def test_update_changes_existing(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry(model="v1"))
|
||||
update_local_agent(_entry(model="v2"))
|
||||
add_local_agent(_entry(role="v1"))
|
||||
update_local_agent(_entry(role="v2"))
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].model == "v2"
|
||||
assert entries[0].role == "v2"
|
||||
|
||||
|
||||
class TestRemove:
|
||||
|
||||
+7
-513
@@ -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 respx
|
||||
|
||||
from ratatoskr.sessions import (
|
||||
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,
|
||||
)
|
||||
from ratatoskr.sessions import endpoint_for_plane
|
||||
|
||||
|
||||
class TestEndpointForPlane:
|
||||
@@ -42,496 +29,3 @@ class TestEndpointForPlane:
|
||||
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
|
||||
with pytest.raises(ValueError):
|
||||
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
|
||||
|
||||
@@ -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
@@ -1,4 +1,14 @@
|
||||
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
|
||||
"""CLI integration tests for `python -m ratatoskr.tier3`.
|
||||
|
||||
The define/patch/delete wire calls route through the worldtree-sdk adapter
|
||||
(`ratatoskr.wt`); the adapter's body-building + error-mapping are unit-tested in
|
||||
`test_wt.py` (against a fake `client.agents`). These tests exercise the CLI
|
||||
end-to-end — argv → the real SDK over a respx-mocked HTTP layer → exit code +
|
||||
stdout + the local tier3 index side effects.
|
||||
|
||||
The define/patch response echoes `role` (Worldtree spec 1.2 / b128), read off the
|
||||
SDK's open-world dict; `LocalAgentEntry.role` is the v2-schema field.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -6,317 +16,20 @@ import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
from ratatoskr.tier3 import (
|
||||
Tier3AgentInfo,
|
||||
Tier3AgentNotFound,
|
||||
Tier3FieldNotMutable,
|
||||
Tier3LayerDeferred,
|
||||
Tier3QuotaExceeded,
|
||||
Tier3UserIdUnsupported,
|
||||
define_agent,
|
||||
delete_agent,
|
||||
main,
|
||||
patch_agent,
|
||||
)
|
||||
from ratatoskr.tier3 import main
|
||||
|
||||
# The consumer-agent response echoes `role` (b128), not the former `model`.
|
||||
_FULL_AGENT_RESP = {
|
||||
"agent_id": "ratatoskr:wizard",
|
||||
"user_id": "ratatoskr",
|
||||
"agent_name": "wizard",
|
||||
"system_prompt": "You are a wizard.",
|
||||
"model": "qwen3.6-35-a3b",
|
||||
"role": "qwen3.6-35-a3b",
|
||||
"created_at": "2026-05-25T03:20:09.703601+00:00",
|
||||
"updated_at": "2026-05-25T03:20:09.703601+00:00",
|
||||
}
|
||||
|
||||
|
||||
class TestDefineAgent:
|
||||
@respx.mock
|
||||
async def test_happy_define(self) -> None:
|
||||
"""happy_define [happy,tracer]: 201 → fully populated Tier3AgentInfo."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
info = await define_agent(
|
||||
client,
|
||||
agent_name="wizard",
|
||||
system_prompt="You are a wizard.",
|
||||
role="qwen3.6-35-a3b",
|
||||
)
|
||||
assert isinstance(info, Tier3AgentInfo)
|
||||
assert info.agent_id == "ratatoskr:wizard"
|
||||
assert info.user_id == "ratatoskr"
|
||||
assert info.agent_name == "wizard"
|
||||
assert info.model == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
async def test_request_body_shape(self) -> None:
|
||||
"""request_body_shape [trace]: outbound JSON is exactly the three keys."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await define_agent(
|
||||
client,
|
||||
agent_name="wizard",
|
||||
system_prompt="You are a wizard.",
|
||||
role="qwen3.6-35-a3b",
|
||||
)
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
# INV-001: exactly these three keys — no layer fields, no metadata.
|
||||
assert body == {
|
||||
"agent_name": "wizard",
|
||||
"system_prompt": "You are a wizard.",
|
||||
"role": "qwen3.6-35-a3b",
|
||||
}
|
||||
|
||||
@respx.mock
|
||||
async def test_quota_exceeded(self) -> None:
|
||||
"""quota_exceeded [error]: 429 + Retry-After → Tier3QuotaExceeded."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(
|
||||
429,
|
||||
headers={"Retry-After": "0"},
|
||||
json={"detail": {"error_code": "agent_quota_exceeded"}},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3QuotaExceeded) as exc:
|
||||
await define_agent(
|
||||
client,
|
||||
agent_name="overflow",
|
||||
system_prompt="x",
|
||||
role="m",
|
||||
)
|
||||
assert exc.value.retry_after == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_user_id_unsupported(self) -> None:
|
||||
"""user_id_unsupported [error]: 403 + error_code → Tier3UserIdUnsupported."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(
|
||||
403, json={"detail": {"error_code": "tier3_user_id_unsupported"}}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3UserIdUnsupported):
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="x", role="m"
|
||||
)
|
||||
|
||||
@respx.mock
|
||||
async def test_layer_deferred(self) -> None:
|
||||
"""layer_deferred [error]: 422 + layer_deferred → Tier3LayerDeferred(field)."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(
|
||||
422,
|
||||
json={"detail": {"error_code": "layer_deferred", "field": "persona"}},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3LayerDeferred) as exc:
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="x", role="m"
|
||||
)
|
||||
assert exc.value.field == "persona"
|
||||
|
||||
@respx.mock
|
||||
async def test_bad_slug_assert(self) -> None:
|
||||
"""bad_slug_assert [adversarial]: agent_name with uppercase → AssertionError, no HTTP."""
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(
|
||||
client, agent_name="Wizard", system_prompt="x", role="m"
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_short_slug_assert(self) -> None:
|
||||
"""short_slug_assert [adversarial]: agent_name len < 3 → AssertionError."""
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(
|
||||
client, agent_name="ab", system_prompt="x", role="m"
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_prompt_assert(self) -> None:
|
||||
"""empty_prompt_assert [adversarial]: empty system_prompt → AssertionError."""
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="", role="m"
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_other_5xx(self) -> None:
|
||||
"""other_5xx [error]: 503 → SessionApiFailed(status=503)."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(503, content=b"upstream out")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="x", role="m"
|
||||
)
|
||||
assert exc.value.status == 503
|
||||
|
||||
|
||||
class TestPatchAgent:
|
||||
@respx.mock
|
||||
async def test_happy_patch_both_fields(self) -> None:
|
||||
"""happy_patch_both_fields: both fields set → request body has both."""
|
||||
import json as _json
|
||||
|
||||
updated = {
|
||||
**_FULL_AGENT_RESP,
|
||||
"system_prompt": "new prompt",
|
||||
"model": "different-model",
|
||||
}
|
||||
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(200, json=updated)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
info = await patch_agent(
|
||||
client,
|
||||
"ratatoskr:wizard",
|
||||
system_prompt="new prompt",
|
||||
role="different-model",
|
||||
)
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body == {"system_prompt": "new prompt", "role": "different-model"}
|
||||
assert info.system_prompt == "new prompt"
|
||||
assert info.model == "different-model"
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_patch_single_field(self) -> None:
|
||||
"""happy_patch_single_field: omit role → body has system_prompt only."""
|
||||
import json as _json
|
||||
|
||||
updated = {**_FULL_AGENT_RESP, "system_prompt": "only this"}
|
||||
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(200, json=updated)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await patch_agent(client, "ratatoskr:wizard", system_prompt="only this")
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
# INV-002: body omits the None-valued field entirely
|
||||
assert body == {"system_prompt": "only this"}
|
||||
|
||||
@respx.mock
|
||||
async def test_field_not_mutable(self) -> None:
|
||||
"""field_not_mutable [error]: 422 + error_code → Tier3FieldNotMutable(field)."""
|
||||
respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(
|
||||
422,
|
||||
json={
|
||||
"detail": {"error_code": "field_not_mutable", "field": "agent_name"}
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3FieldNotMutable) as exc:
|
||||
await patch_agent(
|
||||
client, "ratatoskr:wizard", system_prompt="x"
|
||||
)
|
||||
assert exc.value.field == "agent_name"
|
||||
|
||||
@respx.mock
|
||||
async def test_404(self) -> None:
|
||||
"""404 [error]: PATCH on non-existent agent → Tier3AgentNotFound."""
|
||||
respx.patch("https://w.example/agents/ratatoskr:ghost").mock(
|
||||
return_value=httpx.Response(404, content=b"")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3AgentNotFound) as exc:
|
||||
await patch_agent(
|
||||
client, "ratatoskr:ghost", system_prompt="x"
|
||||
)
|
||||
assert exc.value.agent_id == "ratatoskr:ghost"
|
||||
|
||||
@respx.mock
|
||||
async def test_no_fields_assert(self) -> None:
|
||||
"""no_fields_assert [adversarial]: both None → AssertionError, no HTTP."""
|
||||
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await patch_agent(client, "ratatoskr:wizard")
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_non_tier3_id_assert(self) -> None:
|
||||
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
|
||||
route = respx.patch("https://w.example/agents/mimir").mock(
|
||||
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await patch_agent(client, "mimir", system_prompt="x")
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
class TestDeleteAgent:
|
||||
@respx.mock
|
||||
async def test_happy_delete(self) -> None:
|
||||
"""happy_delete [happy,tracer]: 204 → returns None."""
|
||||
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await delete_agent(client, "ratatoskr:wizard")
|
||||
assert result is None
|
||||
|
||||
@respx.mock
|
||||
async def test_404(self) -> None:
|
||||
"""404 [error]: DELETE on non-existent agent → Tier3AgentNotFound."""
|
||||
respx.delete("https://w.example/agents/ratatoskr:ghost").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3AgentNotFound) as exc:
|
||||
await delete_agent(client, "ratatoskr:ghost")
|
||||
assert exc.value.agent_id == "ratatoskr:ghost"
|
||||
|
||||
@respx.mock
|
||||
async def test_non_tier3_id_assert(self) -> None:
|
||||
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
|
||||
route = respx.delete("https://w.example/agents/mimir").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await delete_agent(client, "mimir")
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_other_5xx(self) -> None:
|
||||
"""other_5xx [error]: 500 → SessionApiFailed."""
|
||||
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(500, content=b"oops")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await delete_agent(client, "ratatoskr:wizard")
|
||||
assert exc.value.status == 500
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _isolated_local_agents(
|
||||
tmp_path: "Path", monkeypatch: pytest.MonkeyPatch
|
||||
@@ -335,8 +48,8 @@ class TestCli:
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolated_local_agents: "Path",
|
||||
) -> None:
|
||||
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation;
|
||||
local index updated with the new entry (v0.8.0 hook).
|
||||
"""cli_define_happy [happy,tracer]: argv → 201 mock → stdout confirmation;
|
||||
local index updated with the new entry (role echoed).
|
||||
"""
|
||||
from ratatoskr.local_agents import load_local_agents
|
||||
|
||||
@@ -354,11 +67,34 @@ class TestCli:
|
||||
out = capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert out.out.strip() == "defined ratatoskr:wizard (qwen3.6-35-a3b)"
|
||||
# v0.8.0: local index now has the new entry.
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
assert entries[0].model == "qwen3.6-35-a3b"
|
||||
assert entries[0].role == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_define_body_shape(
|
||||
self, monkeypatch: pytest.MonkeyPatch, _isolated_local_agents: "Path"
|
||||
) -> None:
|
||||
"""cli_define_body_shape [trace]: outbound JSON is exactly the three keys
|
||||
(the adapter sends AgentDefineInput, no layer fields)."""
|
||||
import json as _json
|
||||
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
main([
|
||||
"define", "--name", "wizard",
|
||||
"--system-prompt", "You are a wizard.", "--role", "qwen3.6-35-a3b",
|
||||
])
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body == {
|
||||
"agent_name": "wizard",
|
||||
"role": "qwen3.6-35-a3b",
|
||||
"system_prompt": "You are a wizard.",
|
||||
}
|
||||
|
||||
@respx.mock
|
||||
def test_cli_patch_happy(
|
||||
@@ -384,6 +120,7 @@ class TestCli:
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
assert entries[0].role == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_delete_happy(
|
||||
@@ -401,11 +138,11 @@ class TestCli:
|
||||
load_local_agents,
|
||||
)
|
||||
|
||||
# Pre-populate so we can verify removal.
|
||||
# Pre-populate so we can verify removal (v2 schema: role, not model).
|
||||
add_local_agent(LocalAgentEntry(
|
||||
agent_id="ratatoskr:wizard",
|
||||
agent_name="wizard",
|
||||
model="m",
|
||||
role="m",
|
||||
description="d",
|
||||
defined_at="t",
|
||||
))
|
||||
@@ -426,10 +163,7 @@ class TestCli:
|
||||
"""cli_missing_auth [error]: no api-key → stderr [auth_error] + exit 11."""
|
||||
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--role", "m",
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 11
|
||||
@@ -446,10 +180,7 @@ class TestCli:
|
||||
return_value=httpx.Response(500, content=b"upstream out")
|
||||
)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--role", "m",
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 20
|
||||
@@ -470,15 +201,30 @@ class TestCli:
|
||||
)
|
||||
)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--role", "m",
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 20
|
||||
assert "[quota_exceeded]" in err
|
||||
|
||||
@respx.mock
|
||||
def test_cli_network_error(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""cli_network_error [error]: a transport failure surfaces as the SDK's
|
||||
ConnectFailed (not a raw httpx error) → stderr [network_error] + exit 21."""
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
side_effect=httpx.ConnectError("refused")
|
||||
)
|
||||
rc = main([
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 21
|
||||
assert "[network_error]" in err
|
||||
|
||||
def test_cli_patch_no_fields(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -489,3 +235,52 @@ class TestCli:
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 10
|
||||
assert "[usage_error]" in err
|
||||
|
||||
@respx.mock
|
||||
def test_cli_define_partial_response_degrades(
|
||||
self,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolated_local_agents: "Path",
|
||||
) -> None:
|
||||
"""cli_define_partial [error]: a 201 missing `role` + a NULL system_prompt
|
||||
degrades (role '?', description-safe) and exits 0 — never a KeyError/
|
||||
AttributeError traceback (heid-bug-hunt open-world invariant)."""
|
||||
from ratatoskr.local_agents import load_local_agents
|
||||
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json={
|
||||
"agent_id": "ratatoskr:wizard", "agent_name": "wizard",
|
||||
"system_prompt": None, # present-but-null: .get(...,'') would NOT default
|
||||
})
|
||||
)
|
||||
rc = main([
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
out = capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert out.out.strip() == "defined ratatoskr:wizard (?)"
|
||||
# Well-formed identity → still indexed (role degraded to '?').
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].role == "?"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_define_no_agent_id_is_api_failure(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""cli_define_no_agent_id [error]: a 2xx with no usable agent_id → [api_failed]
|
||||
+ exit 20 (controlled), not an uncaught traceback."""
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json={"role": "m"}) # no agent_id
|
||||
)
|
||||
rc = main([
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 20
|
||||
assert "[api_failed]" in err
|
||||
|
||||
@@ -66,7 +66,7 @@ class TestAgentsEndpoint:
|
||||
LocalAgentEntry(
|
||||
agent_id="ratatoskr:sindra",
|
||||
agent_name="sindra",
|
||||
model="artemis-31b-v1i",
|
||||
role="artemis-31b-v1i",
|
||||
description="(tier 3) IDENTITY",
|
||||
defined_at="2026-05-28T00:00:00+00:00",
|
||||
)
|
||||
@@ -118,7 +118,7 @@ class TestAgentsEndpoint:
|
||||
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
|
||||
add_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id="ratatoskr:sindra", agent_name="sindra", model="m",
|
||||
agent_id="ratatoskr:sindra", agent_name="sindra", role="m",
|
||||
description="local-tier3", defined_at="2026-05-28T00:00:00+00:00",
|
||||
)
|
||||
)
|
||||
@@ -132,6 +132,57 @@ class TestAgentsEndpoint:
|
||||
# Upstream entry wins (it's first in the merge); local is deduped
|
||||
assert body[0]["name"] == "Sindra-from-server"
|
||||
|
||||
@respx.mock
|
||||
def test_malformed_upstream_items_degrade_not_500(self, monkeypatch, tmp_path) -> None:
|
||||
"""malformed_upstream [error]: a non-mapping / agent_id-less upstream item is
|
||||
dropped, not crashed on — the endpoint degrades to the well-formed + local
|
||||
merge (heid-bug-hunt: open-world reads degrade, never crash)."""
|
||||
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{"agent_id": "mimir", "name": "Mimir", "description": "k"},
|
||||
{}, # no agent_id — dropped
|
||||
{"name": "Ghost"}, # no agent_id — dropped
|
||||
"not-a-mapping", # non-mapping — dropped
|
||||
{"agent_id": 123}, # non-str agent_id — dropped
|
||||
],
|
||||
)
|
||||
)
|
||||
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
|
||||
add_local_agent(LocalAgentEntry(
|
||||
agent_id="ratatoskr:local", agent_name="local", role="m",
|
||||
description="d", defined_at="t",
|
||||
))
|
||||
from ratatoskr.web.server import create_app
|
||||
app = create_app(_mock_client_factory())
|
||||
resp = TestClient(app).get("/api/agents")
|
||||
assert resp.status_code == 200
|
||||
ids = {a["agent_id"] for a in resp.json()}
|
||||
# Only the one well-formed upstream item + the local entry survive.
|
||||
assert ids == {"mimir", "ratatoskr:local"}
|
||||
|
||||
@respx.mock
|
||||
def test_non_list_upstream_falls_back_to_local(self, monkeypatch, tmp_path) -> None:
|
||||
"""non_list_upstream [error]: an envelope (non-list) upstream body degrades to
|
||||
the local-only list rather than iterating dict keys into a crash."""
|
||||
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(200, json={"items": [{"agent_id": "mimir"}]})
|
||||
)
|
||||
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
|
||||
add_local_agent(LocalAgentEntry(
|
||||
agent_id="ratatoskr:local", agent_name="local", role="m",
|
||||
description="d", defined_at="t",
|
||||
))
|
||||
from ratatoskr.web.server import create_app
|
||||
app = create_app(_mock_client_factory())
|
||||
resp = TestClient(app).get("/api/agents")
|
||||
assert resp.status_code == 200
|
||||
ids = {a["agent_id"] for a in resp.json()}
|
||||
assert ids == {"ratatoskr:local"}
|
||||
|
||||
|
||||
_CREATE_OK = {
|
||||
"session_id": "s-1",
|
||||
@@ -290,6 +341,20 @@ class TestPersonaStateEndpoint:
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["error_code"] == "auth_scope_denied"
|
||||
|
||||
@respx.mock
|
||||
def test_unmatched_error_maps_to_session_api_failed(self) -> None:
|
||||
"""unmatched_error [error]: an upstream 500 (unmapped ApiError) → the
|
||||
session_api_failed envelope carrying the upstream status, for parity with
|
||||
_agents_endpoint (heid-code-review slice-4 fixup — was a raw 500 escape)."""
|
||||
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||
return_value=httpx.Response(500, content=b"upstream out")
|
||||
)
|
||||
from ratatoskr.web.server import create_app
|
||||
app = create_app(_mock_client_factory())
|
||||
resp = TestClient(app).get("/api/agents/mimir/persona_state")
|
||||
assert resp.status_code == 500
|
||||
assert resp.json()["error_code"] == "session_api_failed"
|
||||
|
||||
|
||||
class TestSubmitTurnEndpoint:
|
||||
"""submit_turn_endpoint FN — allocate turn_id, register in turn_registry."""
|
||||
|
||||
@@ -19,15 +19,26 @@ import pytest
|
||||
import worldtree_sdk as wtsdk
|
||||
from worldtree_sdk import ApiError, CancelResult, PadState, WorldtreeClient
|
||||
|
||||
from ratatoskr.sessions import (
|
||||
AgentNotAvailable as PersonaAgentNotAvailable,
|
||||
)
|
||||
from ratatoskr.sessions import (
|
||||
AgentNotFound,
|
||||
AuthoredHistoryUnavailable,
|
||||
AuthScopeDenied,
|
||||
BifrostBinding,
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
InvalidCursor,
|
||||
PersonaNotConfigured,
|
||||
Tier3AgentNotFound,
|
||||
Tier3FieldNotMutable,
|
||||
Tier3LayerDeferred,
|
||||
Tier3QuotaExceeded,
|
||||
Tier3UserIdUnsupported,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AdminEvent,
|
||||
AgentNotAvailable,
|
||||
CancelAlreadyCompleted,
|
||||
CancelFailed,
|
||||
@@ -43,11 +54,24 @@ from ratatoskr.wt import (
|
||||
SessionApiFailed,
|
||||
build_client,
|
||||
cancel_turn,
|
||||
create_character,
|
||||
create_session,
|
||||
define_agent,
|
||||
delete_agent,
|
||||
delete_character,
|
||||
get_capabilities,
|
||||
get_character_state,
|
||||
get_me,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
get_session_messages,
|
||||
get_session_tools,
|
||||
list_agents,
|
||||
list_character_models,
|
||||
list_sessions,
|
||||
patch_agent,
|
||||
set_persona_state,
|
||||
stream_admin_events,
|
||||
stream_turn,
|
||||
translate_error,
|
||||
write_authored_history,
|
||||
@@ -528,3 +552,670 @@ class TestWriteAuthoredHistory:
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await write_authored_history(_wt(fake), "s", content="hi", idempotency_key="k")
|
||||
assert ei.value.status == 409
|
||||
|
||||
|
||||
# ── slice-4: agents (Tier-3) adapter routes ──────────────────────────────────
|
||||
|
||||
|
||||
class _FakeAgents:
|
||||
"""Stand-in for `WorldtreeClient.agents` — records the last call and returns a
|
||||
canned result or raises a canned error. Same shape as `_FakeSessions`."""
|
||||
|
||||
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
|
||||
self._result = result
|
||||
self._error = error
|
||||
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
|
||||
|
||||
async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
self.calls.append((name, args, kwargs))
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._result
|
||||
|
||||
async def list(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("list", *args, **kwargs)
|
||||
|
||||
async def persona_state(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("persona_state", *args, **kwargs)
|
||||
|
||||
async def define(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("define", *args, **kwargs)
|
||||
|
||||
async def patch(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("patch", *args, **kwargs)
|
||||
|
||||
async def delete(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("delete", *args, **kwargs)
|
||||
|
||||
|
||||
class _FakeAgentsClient:
|
||||
def __init__(self, agents: _FakeAgents) -> None:
|
||||
self.agents = agents
|
||||
|
||||
|
||||
def _wta(agents: _FakeAgents) -> WorldtreeClient:
|
||||
"""Cast the structural agents-fake to the nominal client type (the agent route
|
||||
functions only touch `client.agents.*`)."""
|
||||
return cast(WorldtreeClient, _FakeAgentsClient(agents))
|
||||
|
||||
|
||||
class TestListAgents:
|
||||
"""slice-4: list_agents → SDK agents.list(); open-world array verbatim."""
|
||||
|
||||
async def test_happy_returns_array_verbatim(self) -> None:
|
||||
data = [{"agent_id": "mimir", "name": "Mimir", "description": "k"}]
|
||||
fake = _FakeAgents(result=data)
|
||||
out = await list_agents(_wta(fake))
|
||||
assert out is data # open-world passthrough, no AgentInfo normalization
|
||||
assert fake.calls[-1][0] == "list"
|
||||
|
||||
async def test_error_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("upstream", "boom", status=500))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await list_agents(_wta(fake))
|
||||
assert ei.value.status == 500
|
||||
|
||||
|
||||
class TestGetPersonaState:
|
||||
"""slice-4: get_persona_state → SDK agents.persona_state(id); dict verbatim.
|
||||
404 sub-codes + 403 auth_scope_denied map by (status, error_code)."""
|
||||
|
||||
async def test_happy_returns_dict_verbatim(self) -> None:
|
||||
snap = {"pad": {}, "dominant_emotion": "curiosity"}
|
||||
fake = _FakeAgents(result=snap)
|
||||
out = await get_persona_state(_wta(fake), "mimir")
|
||||
assert out is snap
|
||||
assert fake.calls[-1] == ("persona_state", ("mimir",), {})
|
||||
|
||||
async def test_persona_not_configured(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("persona_not_configured", "no", status=404))
|
||||
with pytest.raises(PersonaNotConfigured) as ei:
|
||||
await get_persona_state(_wta(fake), "domari")
|
||||
assert ei.value.agent_id == "domari"
|
||||
|
||||
async def test_agent_not_available(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("agent_not_available", "no", status=404))
|
||||
with pytest.raises(PersonaAgentNotAvailable) as ei:
|
||||
await get_persona_state(_wta(fake), "bogus")
|
||||
assert ei.value.agent_id == "bogus"
|
||||
|
||||
async def test_auth_scope_denied(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("auth_scope_denied", "no", status=403))
|
||||
with pytest.raises(AuthScopeDenied) as ei:
|
||||
await get_persona_state(_wta(fake), "mimir")
|
||||
assert ei.value.scope == "persona.read"
|
||||
|
||||
async def test_other_404_without_code_maps_to_default(self) -> None:
|
||||
# A 404 whose error_code is neither persona sub-code → generic default,
|
||||
# NOT a spurious PersonaNotConfigured (the code is the discriminator).
|
||||
fake = _FakeAgents(error=ApiError("weird", "no", status=404))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await get_persona_state(_wta(fake), "mimir")
|
||||
assert ei.value.status == 404
|
||||
|
||||
async def test_empty_agent_id_asserts(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await get_persona_state(_wta(fake), "")
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
class TestDefineAgent:
|
||||
"""slice-4: define_agent → SDK agents.define(); open-world DefinedAgent dict
|
||||
(echoes `role` post-b128). Slug validated client-side; tier3 error rows."""
|
||||
|
||||
async def test_happy_builds_body_and_returns_dict(self) -> None:
|
||||
resp = {"agent_id": "ratatoskr:wizard", "role": "thoughtful-character"}
|
||||
fake = _FakeAgents(result=resp)
|
||||
out = await define_agent(
|
||||
_wta(fake), agent_name="wizard", system_prompt="You are a wizard.",
|
||||
role="thoughtful-character",
|
||||
)
|
||||
assert out is resp # open-world passthrough (no Tier3AgentInfo)
|
||||
name, args, _kwargs = fake.calls[-1]
|
||||
assert name == "define"
|
||||
# AgentDefineInput body: exactly the three keys, no layer fields.
|
||||
assert args[0] == {
|
||||
"agent_name": "wizard",
|
||||
"role": "thoughtful-character",
|
||||
"system_prompt": "You are a wizard.",
|
||||
}
|
||||
|
||||
async def test_quota_exceeded_defaults_retry_after_zero(self) -> None:
|
||||
# The SDK's ApiError floor drops the Retry-After header; spec §2675 pins it
|
||||
# to 0, so the adapter defaults retry_after=0.
|
||||
fake = _FakeAgents(error=ApiError("agent_quota_exceeded", "full", status=429))
|
||||
with pytest.raises(Tier3QuotaExceeded) as ei:
|
||||
await define_agent(_wta(fake), agent_name="overflow", system_prompt="x", role="m")
|
||||
assert ei.value.retry_after == 0
|
||||
|
||||
async def test_user_id_unsupported(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("tier3_user_id_unsupported", "no", status=403))
|
||||
with pytest.raises(Tier3UserIdUnsupported):
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
||||
|
||||
async def test_layer_deferred_field_parsed_from_body(self) -> None:
|
||||
# `field` is not on ApiError — the adapter body-parses detail.field.
|
||||
fake = _FakeAgents(error=ApiError(
|
||||
"layer_deferred", "no", status=422,
|
||||
body='{"detail": {"error_code": "layer_deferred", "field": "persona"}}',
|
||||
))
|
||||
with pytest.raises(Tier3LayerDeferred) as ei:
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
||||
assert ei.value.field == "persona"
|
||||
|
||||
async def test_layer_deferred_flat_field_body(self) -> None:
|
||||
# _error_field_from_body also handles a flat top-level `field` (both-shape
|
||||
# unwrap) — locks the contract's "detail.field / flat field" claim.
|
||||
fake = _FakeAgents(error=ApiError(
|
||||
"layer_deferred", "no", status=422, body='{"field": "valence"}',
|
||||
))
|
||||
with pytest.raises(Tier3LayerDeferred) as ei:
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
||||
assert ei.value.field == "valence"
|
||||
|
||||
async def test_layer_deferred_non_string_field_is_none(self) -> None:
|
||||
# A non-string `field` value collapses to None — the exception surface is
|
||||
# `field: str | None` and the CLI prints it (heid-bug-hunt hardening).
|
||||
fake = _FakeAgents(error=ApiError(
|
||||
"layer_deferred", "no", status=422, body='{"detail": {"field": {"x": 1}}}',
|
||||
))
|
||||
with pytest.raises(Tier3LayerDeferred) as ei:
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
||||
assert ei.value.field is None
|
||||
|
||||
async def test_403_wrong_code_maps_to_default(self) -> None:
|
||||
# Dual-key negative: a 403 whose code is NOT tier3_user_id_unsupported →
|
||||
# generic default, not a spurious Tier3UserIdUnsupported (INV-CUT-2).
|
||||
fake = _FakeAgents(error=ApiError("auth_revoked", "no", status=403))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
||||
assert ei.value.status == 403
|
||||
|
||||
async def test_422_wrong_code_maps_to_default(self) -> None:
|
||||
# Dual-key negative: a 422 whose code is NOT layer_deferred → default.
|
||||
fake = _FakeAgents(error=ApiError("validation_failed", "no", status=422))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
||||
assert ei.value.status == 422
|
||||
|
||||
async def test_bad_slug_asserts_no_call(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(_wta(fake), agent_name="Wizard", system_prompt="x", role="m")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_short_slug_asserts_no_call(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(_wta(fake), agent_name="ab", system_prompt="x", role="m")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_empty_prompt_asserts(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="", role="m")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_empty_role_asserts(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_other_5xx_maps_to_default(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("upstream", "out", status=503))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
||||
assert ei.value.status == 503
|
||||
|
||||
|
||||
class TestPatchAgent:
|
||||
"""slice-4: patch_agent → SDK agents.patch(id, changes); open PatchedAgent dict."""
|
||||
|
||||
async def test_happy_both_fields(self) -> None:
|
||||
resp = {"agent_id": "ratatoskr:wizard", "role": "different"}
|
||||
fake = _FakeAgents(result=resp)
|
||||
out = await patch_agent(
|
||||
_wta(fake), "ratatoskr:wizard", system_prompt="new", role="different"
|
||||
)
|
||||
assert out is resp
|
||||
name, args, _kwargs = fake.calls[-1]
|
||||
assert name == "patch"
|
||||
assert args[0] == "ratatoskr:wizard"
|
||||
assert args[1] == {"system_prompt": "new", "role": "different"}
|
||||
|
||||
async def test_happy_single_field_omits_none(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="only this")
|
||||
assert fake.calls[-1][1][1] == {"system_prompt": "only this"}
|
||||
|
||||
async def test_404_maps_to_agent_not_found(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("not_found", "no", status=404))
|
||||
with pytest.raises(Tier3AgentNotFound) as ei:
|
||||
await patch_agent(_wta(fake), "ratatoskr:ghost", system_prompt="x")
|
||||
assert ei.value.agent_id == "ratatoskr:ghost"
|
||||
|
||||
async def test_field_not_mutable_field_parsed(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError(
|
||||
"field_not_mutable", "no", status=422,
|
||||
body='{"detail": {"error_code": "field_not_mutable", "field": "agent_name"}}',
|
||||
))
|
||||
with pytest.raises(Tier3FieldNotMutable) as ei:
|
||||
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
|
||||
assert ei.value.field == "agent_name"
|
||||
|
||||
async def test_422_wrong_code_maps_to_default(self) -> None:
|
||||
# Dual-key negative: a 422 whose code is NOT field_not_mutable → default,
|
||||
# not a spurious Tier3FieldNotMutable (INV-CUT-2).
|
||||
fake = _FakeAgents(error=ApiError("validation_failed", "no", status=422))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
|
||||
assert ei.value.status == 422
|
||||
|
||||
async def test_no_fields_asserts_no_call(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await patch_agent(_wta(fake), "ratatoskr:wizard")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_non_tier3_id_asserts(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await patch_agent(_wta(fake), "mimir", system_prompt="x")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_other_error_maps_to_default(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("upstream", "boom", status=500))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
|
||||
assert ei.value.status == 500
|
||||
|
||||
|
||||
class TestDeleteAgent:
|
||||
"""slice-4: delete_agent → SDK agents.delete(id); None on 204; 404 → not-found."""
|
||||
|
||||
async def test_happy_returns_none(self) -> None:
|
||||
fake = _FakeAgents(result=None)
|
||||
out = await delete_agent(_wta(fake), "ratatoskr:wizard")
|
||||
assert out is None
|
||||
assert fake.calls[-1] == ("delete", ("ratatoskr:wizard",), {})
|
||||
|
||||
async def test_404_maps_to_agent_not_found(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("not_found", "no", status=404))
|
||||
with pytest.raises(Tier3AgentNotFound) as ei:
|
||||
await delete_agent(_wta(fake), "ratatoskr:ghost")
|
||||
assert ei.value.agent_id == "ratatoskr:ghost"
|
||||
|
||||
async def test_non_tier3_id_asserts(self) -> None:
|
||||
fake = _FakeAgents(result=None)
|
||||
with pytest.raises(AssertionError):
|
||||
await delete_agent(_wta(fake), "mimir")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_other_error_maps_to_default(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("upstream", "oops", status=500))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await delete_agent(_wta(fake), "ratatoskr:wizard")
|
||||
assert ei.value.status == 500
|
||||
|
||||
|
||||
# ── slice-5: characters + me/capabilities/models adapter routes ───────────────
|
||||
# One canned result / error per fake (each slice-5 adapter fn touches exactly one
|
||||
# sub-resource method), recorded by qualified name so the test can assert the route.
|
||||
|
||||
|
||||
class _FakeMe:
|
||||
def __init__(self, rec: _FakeMisc) -> None:
|
||||
self._rec = rec
|
||||
|
||||
async def get(self, *a: Any, **k: Any) -> Any:
|
||||
return await self._rec._dispatch("me.get", *a, **k)
|
||||
|
||||
|
||||
class _FakeCapabilities:
|
||||
def __init__(self, rec: _FakeMisc) -> None:
|
||||
self._rec = rec
|
||||
|
||||
async def get(self, *a: Any, **k: Any) -> Any:
|
||||
return await self._rec._dispatch("capabilities.get", *a, **k)
|
||||
|
||||
|
||||
class _FakeModels:
|
||||
def __init__(self, rec: _FakeMisc) -> None:
|
||||
self._rec = rec
|
||||
|
||||
async def available_for_characters(self, *a: Any, **k: Any) -> Any:
|
||||
return await self._rec._dispatch("models.available_for_characters", *a, **k)
|
||||
|
||||
|
||||
class _FakeCharacters:
|
||||
def __init__(self, rec: _FakeMisc) -> None:
|
||||
self._rec = rec
|
||||
|
||||
async def create(self, *a: Any, **k: Any) -> Any:
|
||||
return await self._rec._dispatch("characters.create", *a, **k)
|
||||
|
||||
async def state(self, *a: Any, **k: Any) -> Any:
|
||||
return await self._rec._dispatch("characters.state", *a, **k)
|
||||
|
||||
async def delete(self, *a: Any, **k: Any) -> Any:
|
||||
return await self._rec._dispatch("characters.delete", *a, **k)
|
||||
|
||||
|
||||
class _FakeMisc:
|
||||
"""Stand-in for the slice-5 client surface — exposes `.me` / `.capabilities` /
|
||||
`.models` / `.characters`, recording each call under its qualified name and
|
||||
returning a canned result or raising a canned error (same shape as `_FakeSessions`
|
||||
/ `_FakeAgents`)."""
|
||||
|
||||
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
|
||||
self._result = result
|
||||
self._error = error
|
||||
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
|
||||
self.me = _FakeMe(self)
|
||||
self.capabilities = _FakeCapabilities(self)
|
||||
self.models = _FakeModels(self)
|
||||
self.characters = _FakeCharacters(self)
|
||||
|
||||
async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
self.calls.append((name, args, kwargs))
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._result
|
||||
|
||||
|
||||
def _wtm(misc: _FakeMisc) -> WorldtreeClient:
|
||||
"""Cast the slice-5 misc-surface fake (me/capabilities/models/characters) to the
|
||||
nominal client type the route functions are typed against."""
|
||||
return cast(WorldtreeClient, misc)
|
||||
|
||||
|
||||
class TestGetMe:
|
||||
"""slice-5: get_me → SDK me.get(); open-world dict verbatim."""
|
||||
|
||||
async def test_happy_returns_dict_verbatim(self) -> None:
|
||||
me = {"user_id": "alice", "scopes": ["conversations.read"], "tier": "user"}
|
||||
fake = _FakeMisc(result=me)
|
||||
out = await get_me(_wtm(fake))
|
||||
assert out is me
|
||||
assert fake.calls[-1][0] == "me.get"
|
||||
|
||||
async def test_401_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeMisc(error=ApiError("auth_invalid", "no", status=401))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await get_me(_wtm(fake))
|
||||
assert ei.value.status == 401
|
||||
|
||||
|
||||
class TestGetCapabilities:
|
||||
"""slice-5: get_capabilities → SDK capabilities.get(); open-world verbatim."""
|
||||
|
||||
async def test_happy_returns_dict_verbatim(self) -> None:
|
||||
caps = {"ephemeral_templates": {"echo": {"default_role": "echo"}}}
|
||||
fake = _FakeMisc(result=caps)
|
||||
out = await get_capabilities(_wtm(fake))
|
||||
assert out is caps
|
||||
assert fake.calls[-1][0] == "capabilities.get"
|
||||
|
||||
async def test_error_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeMisc(error=ApiError("upstream", "boom", status=500))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await get_capabilities(_wtm(fake))
|
||||
assert ei.value.status == 500
|
||||
|
||||
|
||||
class TestListCharacterModels:
|
||||
"""slice-5: list_character_models → SDK models.available_for_characters()."""
|
||||
|
||||
async def test_happy_returns_dict_verbatim(self) -> None:
|
||||
models = {"items": [{"name": "fast", "thinking": False}]}
|
||||
fake = _FakeMisc(result=models)
|
||||
out = await list_character_models(_wtm(fake))
|
||||
assert out is models
|
||||
assert fake.calls[-1][0] == "models.available_for_characters"
|
||||
|
||||
async def test_error_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeMisc(error=ApiError("auth_scope_denied", "no", status=403))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await list_character_models(_wtm(fake))
|
||||
assert ei.value.status == 403
|
||||
|
||||
|
||||
class TestCreateCharacter:
|
||||
"""slice-5: create_character → SDK characters.create(body); body-building + parity."""
|
||||
|
||||
async def test_happy_omits_state_when_none(self) -> None:
|
||||
# SDK-idiomatic body: {character} only — no redundant explicit state:null.
|
||||
created = {"character_id": "char_x", "ttl_expires_at": "t"}
|
||||
fake = _FakeMisc(result=created)
|
||||
out = await create_character(_wtm(fake), {"schema_version": "1", "name": "H"})
|
||||
assert out is created
|
||||
name, args, _ = fake.calls[-1]
|
||||
assert name == "characters.create"
|
||||
assert args[0] == {"character": {"schema_version": "1", "name": "H"}}
|
||||
|
||||
async def test_includes_state_when_supplied(self) -> None:
|
||||
fake = _FakeMisc(result={"character_id": "c1"})
|
||||
await create_character(
|
||||
_wtm(fake), {"name": "H"}, state={"mood": "calm"}
|
||||
)
|
||||
assert fake.calls[-1][1][0] == {
|
||||
"character": {"name": "H"},
|
||||
"state": {"mood": "calm"},
|
||||
}
|
||||
|
||||
async def test_empty_character_asserts_no_call(self) -> None:
|
||||
fake = _FakeMisc(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await create_character(_wtm(fake), {})
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_403_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeMisc(error=ApiError("auth_scope_denied", "no", status=403))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await create_character(_wtm(fake), {"name": "H"})
|
||||
assert ei.value.status == 403
|
||||
|
||||
|
||||
class TestGetCharacterState:
|
||||
"""slice-5: get_character_state → SDK characters.state(id); open-world verbatim."""
|
||||
|
||||
async def test_happy_returns_dict_verbatim(self) -> None:
|
||||
state = {"schema_version": "1", "pad": [0.4, 0.1, -0.2]}
|
||||
fake = _FakeMisc(result=state)
|
||||
out = await get_character_state(_wtm(fake), "char_x")
|
||||
assert out is state
|
||||
assert fake.calls[-1] == ("characters.state", ("char_x",), {})
|
||||
|
||||
async def test_empty_id_asserts_no_call(self) -> None:
|
||||
fake = _FakeMisc(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await get_character_state(_wtm(fake), "")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_error_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeMisc(error=ApiError("not_found", "no", status=404))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await get_character_state(_wtm(fake), "char_x")
|
||||
assert ei.value.status == 404
|
||||
|
||||
|
||||
class TestDeleteCharacter:
|
||||
"""slice-5: delete_character → SDK characters.delete(id); open ack verbatim."""
|
||||
|
||||
async def test_returns_ack_verbatim(self) -> None:
|
||||
# Worldtree returns an open ack body here (not 204) — passed through, NOT
|
||||
# normalized to None (parity posture).
|
||||
ack = {"deleted": True, "character_id": "char_x"}
|
||||
fake = _FakeMisc(result=ack)
|
||||
out = await delete_character(_wtm(fake), "char_x")
|
||||
assert out is ack
|
||||
assert fake.calls[-1] == ("characters.delete", ("char_x",), {})
|
||||
|
||||
async def test_none_on_204(self) -> None:
|
||||
# A 204 no-content yields None from the SDK — passed through unchanged.
|
||||
fake = _FakeMisc(result=None)
|
||||
assert await delete_character(_wtm(fake), "char_x") is None
|
||||
|
||||
async def test_empty_id_asserts_no_call(self) -> None:
|
||||
fake = _FakeMisc(result=None)
|
||||
with pytest.raises(AssertionError):
|
||||
await delete_character(_wtm(fake), "")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_error_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeMisc(error=ApiError("upstream", "oops", status=500))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await delete_character(_wtm(fake), "char_x")
|
||||
assert ei.value.status == 500
|
||||
|
||||
|
||||
# ── slice-6: admin (bifrost inspection + admin-events stream) adapter routes ──
|
||||
|
||||
|
||||
class _SdkAdminEvent:
|
||||
"""Minimal stand-in for the SDK's `AdminEvent` — the adapter reads
|
||||
`admin_id`/`type`/`timestamp`/`data`. `admin_id` may be `nan` (id-less);
|
||||
`type`/`data` may be None (open-world)."""
|
||||
|
||||
def __init__(self, admin_id: Any, type: Any, timestamp: Any = None, data: Any = None) -> None:
|
||||
self.admin_id = admin_id
|
||||
self.type = type
|
||||
self.timestamp = timestamp
|
||||
self.data = data
|
||||
|
||||
|
||||
class _FakeAdminSessions:
|
||||
def __init__(self, admin: _FakeAdmin) -> None:
|
||||
self._admin = admin
|
||||
|
||||
async def bifrost(self, *a: Any, **k: Any) -> Any:
|
||||
return await self._admin._bifrost(*a, **k)
|
||||
|
||||
|
||||
class _FakeAdmin:
|
||||
"""Stand-in for `client.admin` — `.sessions.bifrost(id)` (canned result/error) +
|
||||
`.stream_events(...)` (canned events / terminal error). Same shape as `_FakeSessions`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
result: Any = None,
|
||||
error: BaseException | None = None,
|
||||
events: list[Any] | None = None,
|
||||
stream_error: BaseException | None = None,
|
||||
) -> None:
|
||||
self._result = result
|
||||
self._error = error
|
||||
self._events = events or []
|
||||
self._stream_error = stream_error
|
||||
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
|
||||
self.sessions = _FakeAdminSessions(self)
|
||||
|
||||
async def _bifrost(self, *a: Any, **k: Any) -> Any:
|
||||
self.calls.append(("bifrost", a, k))
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._result
|
||||
|
||||
def stream_events(self, *a: Any, **k: Any) -> Any:
|
||||
self.calls.append(("stream_events", a, k))
|
||||
return self._astream()
|
||||
|
||||
async def _astream(self) -> Any:
|
||||
for ev in self._events:
|
||||
yield ev
|
||||
if self._stream_error is not None:
|
||||
raise self._stream_error
|
||||
|
||||
|
||||
class _FakeAdminClient:
|
||||
def __init__(self, admin: _FakeAdmin) -> None:
|
||||
self.admin = admin
|
||||
|
||||
|
||||
def _wtad(admin: _FakeAdmin) -> WorldtreeClient:
|
||||
"""Cast the admin-surface fake (`.admin.sessions.bifrost` + `.admin.stream_events`)
|
||||
to the nominal client type the slice-6 route functions are typed against."""
|
||||
return cast(WorldtreeClient, _FakeAdminClient(admin))
|
||||
|
||||
|
||||
class TestGetSessionBifrostWt:
|
||||
"""slice-6: get_session_bifrost → SDK admin.sessions.bifrost(id); open-world verbatim."""
|
||||
|
||||
async def test_happy_returns_dict_verbatim(self) -> None:
|
||||
binding = {"endpoint_url": "https://b/mcp", "connected": True, "tools": []}
|
||||
fake = _FakeAdmin(result=binding)
|
||||
out = await get_session_bifrost(_wtad(fake), "s1")
|
||||
assert out is binding
|
||||
assert fake.calls[-1] == ("bifrost", ("s1",), {})
|
||||
|
||||
async def test_empty_id_asserts_no_call(self) -> None:
|
||||
fake = _FakeAdmin(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await get_session_bifrost(_wtad(fake), "")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_403_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeAdmin(error=ApiError("auth_scope_denied", "no", status=403))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await get_session_bifrost(_wtad(fake), "s1")
|
||||
assert ei.value.status == 403
|
||||
|
||||
async def test_404_not_bound_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeAdmin(error=ApiError("session_not_bifrost_bound", "no", status=404))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await get_session_bifrost(_wtad(fake), "s1")
|
||||
assert ei.value.status == 404
|
||||
|
||||
|
||||
class TestStreamAdminEventsWt:
|
||||
"""slice-6: stream_admin_events → SDK admin.stream_events; re-wrap SDK AdminEvent →
|
||||
ratatoskr AdminEvent (nan/None degraded), terminal errors → Sse* types."""
|
||||
|
||||
async def test_rewraps_events_to_ratatoskr_shape(self) -> None:
|
||||
sdk_evs = [
|
||||
_SdkAdminEvent(5, "session.created", "t0", {"session_id": "s1"}),
|
||||
_SdkAdminEvent(6, "turn.completed", "t1", {"session_id": "s1", "turn_id": 2}),
|
||||
]
|
||||
fake = _FakeAdmin(events=sdk_evs)
|
||||
out = await _drain(stream_admin_events(_wtad(fake)))
|
||||
assert all(isinstance(e, AdminEvent) for e in out)
|
||||
assert (out[0].id, out[0].type, out[0].timestamp) == (5, "session.created", "t0")
|
||||
assert out[0].data == {"session_id": "s1"}
|
||||
assert out[1].id == 6
|
||||
|
||||
async def test_nan_admin_id_degrades_to_zero(self) -> None:
|
||||
fake = _FakeAdmin(events=[_SdkAdminEvent(float("nan"), "system.heartbeat", None, None)])
|
||||
out = await _drain(stream_admin_events(_wtad(fake)))
|
||||
assert out[0].id == 0 # id-less envelope → 0, not nan
|
||||
|
||||
async def test_none_type_and_data_degrade(self) -> None:
|
||||
# A partial wire: type=None (would crash `.startswith` in the web filter) and
|
||||
# data=None (would crash `.get`) → "" and {} at the adapter boundary.
|
||||
fake = _FakeAdmin(events=[_SdkAdminEvent(1, None, None, None)])
|
||||
out = await _drain(stream_admin_events(_wtad(fake)))
|
||||
assert out[0].type == ""
|
||||
assert out[0].data == {}
|
||||
|
||||
async def test_passes_last_event_id(self) -> None:
|
||||
fake = _FakeAdmin(events=[])
|
||||
await _drain(stream_admin_events(_wtad(fake), last_event_id=42))
|
||||
assert fake.calls[-1] == ("stream_events", (), {"last_event_id": 42})
|
||||
|
||||
async def test_non_200_apierror_maps_to_sse_connect_failed(self) -> None:
|
||||
# The SDK admin stream raises ApiError("admin_stream_failed", status=…) on a
|
||||
# non-200 open (NOT ConnectFailed) — mapped → SseConnectFailed for the web.
|
||||
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_connection_dropped_maps_and_carries_cursor(self) -> None:
|
||||
# Both a connect-time failure (cursor None) and a mid-stream drop / resumable
|
||||
# EOF (cursor set) surface as ConnectionDropped → SseConnectionDropped.
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user