Compare commits

..

14 Commits

Author SHA1 Message Date
vh deab7627eb feat(#20): characters + me/capabilities/models onto the wt adapter (slice-5)
Slice-5 of the worldtree-sdk cutover: migrate the remaining consumer READS +
transient-character CRUD off the hand-rolled httpx wrappers onto the
`ratatoskr.wt` adapter over the SDK, and delete the retired path.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Suite 466 green; mypy no new errors (baseline 22 → 20 in the touched modules);
ruff clean. INV-CUT-1..5 held. Bifrost provider planes untouched.
2026-07-19 08:53:45 -07:00
vh 0b23334c68 memory: /snapshot — worldtree-sdk cutover slice-1+2 complete + pushed (aba1730)
Slice-2 (sessions/turn) done end-to-end through the House Code Discipline; both heid
gates triaged+fixed. Captures the KEY ADAPTER FACTS foot-guns for slices 3-7 (open-world
dicts, body-derived turn_id, ConnectFailed(0) transport normalization, consumer_key
bound-only, nested-detail error_code parsing) + the two-lens gate value proof. Slice-3
(persona/authored-history) next.
2026-07-19 08:23:23 -07:00
vh aba17304bd fix(#20): heid-bug-hunt fixups — cutover edge-path robustness (slice-2)
Triaged the heid-bug-hunt panel (Gróa 8 / Hulda 6 / Regin 6; Heid source-checked +
refuted 2 Regin FPs). The lens pulled real weight — confirmed bugs the conformance
review structurally could not see.

Confirmed bugs fixed:
- SessionRetired (410) stream-open maps to wt.SessionApiFailed, but neither cli
  _run_turn nor web gen() caught it → crash / dropped SSE stream. Both presenters now
  catch it (cli → exit 20; web → labeled `event: error`). (Gróa#2) + cli regression test.
- cli forwarded consumer_key unconditionally; an UNBOUND create with the env key set
  would auth as the Bifrost consumer, not the default bearer. Guarded in the adapter
  (consumer_key only when bifrost is set). (Gróa#4 + Regin#4) + test.
- cli _turn_id_from_sse_id crashed on a None/non-str sse_id (web guarded, cli didn't)
  → now tolerant. (Gróa#1 + Hulda#2) + test.
- _cancel_and_log broadened to `except Exception` — after the code-review's ApiError
  default, a cancel could raise SessionApiFailed it didn't catch, breaking INV-009
  (never-raise). (Gróa#3, Heid-endorsed over Regin's refuted mechanism).

Open-world degrade-not-crash (contract posture): render hardened — float duration_ms
(_format_duration_safe), non-mapping usage/snapshot guards, unknown event type
degrades instead of asserting (Gróa#5/#6 + Hulda#3); web _event_to_browser_payload
guards a non-mapping `raw` (Hulda#4); web _wt_client bearer extraction is now
case-insensitive + whitespace-robust (Hulda#5 + Regin#5). + render-degrade test.

Rejected (verified): Regin#1 (httpx IS caught), Regin#2 (wtsdk IS worldtree_sdk),
Regin#3 (sse_client.AgentNotAvailable IS caught by SseConnectFailed) — all FPs;
Hulda#1 (deleted funcs "break callers") — grep-verified zero callers pre-deletion.
Accepted-known-risk: lenient sse_id parse, CancelFailed status=0, async-gen aclose
(pre-existing pattern, not a cutover regression).

Suite 497 green; wt/cli/web ruff + wt mypy clean. Patch.
2026-07-19 07:09:26 -07:00
vh 74d41eb559 fix(#20): heid-code-review fixups — INV-CUT-2 completeness on cancel/stream (slice-2)
Triaged the heid-code-review panel (Gróa + Hulda substantive, Regin zero=weak).

Adopted (genuine adds):
- cancel_turn + stream_turn gain a defensive `except ApiError -> SessionApiFailed`
  default after their discriminated branches. INV-CUT-2 ("every ApiError is mapped;
  default SessionApiFailed") now holds STRUCTURALLY on those routes, not by coupling
  to the SDK's internal guarantee that it maps them to discriminated types. + tests.
- get_session_tools error-path test (symmetric with messages).
- Contract § Error map amended: added the stream ProtocolError rows
  (Malformed*/TurnIdFlip -> ratatoskr same-named), clarified the cancel row (the SDK
  RAISES the typed races -> ratatoskr exceptions, only a 200/cancelled=False is a
  CancelResult; caller surface stays exception-based per DEC-2), and noted the
  ApiError default holds on stream+cancel too.

Rejected (category-5, wrong-grounding) — 2/3 arms flagged create's bound-502 as
"should gate on error_code like list's 422+cursor_invalid". Verified against the SDK
parser (not in the arms' file set): the bound-502 body is
{"error_code":"bifrost_handshake_failed","detail":{"bifrost_error":...}}, and the
SDK's envelope parser PREFERS the nested detail (which lacks error_code), so
ApiError.error_code resolves to "unknown" — gating would REGRESS handshake detection
(the cli/web integration tests caught it). INV-002 also makes the handshake the sole
bound-502 cause. Kept the any-bound-502 mapping; documented WHY in code + contract.

Accepted-as-is: create_session -> Mapping annotation (intentional open-world
passthrough, already documented in the route-map note; category 3).

Suite 493 green; wt.py mypy + ruff clean. Patch.
2026-07-19 06:57:52 -07:00
vh 59602fe3ff refactor(#20): delete the orphaned hand-rolled turn-stream paths (slice-2, part 2b-iii)
DEC-4 live smoke PASSED first (personal :8081, b127/b128): create → streamed turn
that rendered (worker_phase/text/text_boundary/done with usage) → SIGINT cancel that
round-tripped to a cancelled terminal. With both CLI + web on the adapter, the
hand-rolled turn-stream family is fully orphaned — deleting it now.

- sse_client.py (714 → 224): removed stream_turn / reconnect_turn /
  stream_turn_resilient / cancel_turn + the Event dataclasses (Text/Done/…/Event
  union) + CancelResult + the SSE parse helpers (_iter_events / _envelope_for_type /
  _parse_sse_id / _eager_failure_fields / _INT_RE). KEPT: the caller-semantic
  exceptions (the adapter raises them, DEC-2), SseId, AdminEvent, stream_admin_events
  (slice-6 admin surface).
- sessions.py (677 → 608): removed list_sessions + get_session_tools (no surface
  users) + SessionPage. KEPT: create_session / get_session_messages (the
  --seed-first-message probe still uses them, slice-3) + all exceptions + SessionInfo.
- tests: test_sse_client pruned to TestStreamAdminEvents; test_sessions dropped the
  list_sessions + get_session_tools classes. The deleted turn-stream behavior is now
  covered by test_wt.py + the CLI/web integration tests + the live smoke.

Suite 490 green (570 − 80 deleted turn-stream tests); ruff clean on all touched
files; no new mypy errors. Patch (internal cleanup; behavior preserved).
2026-07-19 06:35:04 -07:00
vh 5c595b862d feat(#20): rewire the web turn surface onto the wt adapter (slice-2, part 2b-ii)
The Starlette endpoints (create / stream / cancel / tools / messages) now go through
ratatoskr.wt over the worldtree-sdk; the browser contract is preserved. This is the
last consumer of the hand-rolled turn-stream family — after this, stream_turn* /
cancel_turn are orphaned and get deleted in part 2b-iii (with the live smoke).

- _wt_client wraps a client_factory transport as the adapter's WorldtreeClient
  (INV-CUT-1), reading base_url + bearer off the transport (a no-auth test transport
  falls back to a placeholder key). The hand-rolled endpoints (persona / agents /
  admin / bifrost) keep using the raw transport until their slices.
- _event_to_browser_payload derives the browser payload from the SDK's `raw` (the
  wire body) minus the redundant `type`, plus the composite `sse_id` string — the
  SAME shape the old dataclasses produced, so the presentation fixture + browser JS
  are unchanged; the browser event_type is the wire `type`, not the SDK class name.
- The stream endpoint captures the upstream cancel target from the composite sse_id
  (the SDK's top-level turn_id is body-derived, absent on text frames); create reads
  the SDK's open create dict; cancel reads CancelResult.cancelled and surfaces a
  generic 502 for CancelFailed (the SDK abstracts the upstream cancel HTTP status).
- test_web_presentation_contract builds SDK events via build_event; two cancel tests
  adopt the SDK's (status, error_code) race pairs + the 502.

Suite 570 green; web/server.py + presentation test ruff-clean, mypy unchanged
(same pre-existing errors). Patch (internal; browser contract preserved).
2026-07-19 06:22:13 -07:00
vh e3a10ad80e feat(#20): rewire the CLI turn path onto the wt adapter (slice-2, part 2b-i)
The --send turn path (_amain create + _run_turn stream + _cancel_and_log) now goes
through ratatoskr.wt over the worldtree-sdk; external CLI behavior (output, exit
codes) is preserved. No hand-rolled path is deleted yet — web/server.py still uses
them (part 2b-ii), so the deletions + live smoke come after web is rewired.

- _amain builds one WorldtreeClient via wt.build_client over a ratatoskr-owned
  transport (INV-CUT-1); create → wt.create_session (reads the SDK's open create
  dict); the transport keeps the default bearer so the not-yet-migrated hand-rolled
  seed_preset_first_message (slice-3) still authenticates.
- _run_turn drives wt.stream_turn and consumes SDK TurnEvents; the mid-stream cancel
  target is parsed from the composite sse_id ("{turn}:{seq}") — the SDK's top-level
  turn_id is the body field and is absent on text/thinking frames.
- CliPresenterState.render consumes the SDK TurnEvent union with None-hardening on
  the now-optional fields (usage degrades to "(n/a)" rather than crashing).
- The SDK normalizes a pre-response transport failure to ConnectFailed(status=0);
  _amain (network → exit 21) and _cancel_and_log (swallow, INV-009) catch it.
- build_client gains max_reconnects (SDK default 5; tests pass 0 to surface drops
  immediately). test_cli: SDK-event factories keep the render-test bodies intact;
  client constructions wrap in build_client; cancel-race mocks carry the SDK's
  (status, error_code) pair.

Suite 570 green; cli.py + wt.py mypy + ruff clean (the pre-existing send_content
arg-type note is unchanged). Patch (internal; external CLI behavior preserved).
2026-07-19 06:07:50 -07:00
28 changed files with 2541 additions and 4741 deletions
+8
View File
@@ -142,6 +142,14 @@ _Archived 2026-07-18._
_Archived 2026-07-18._
`[2026-06-17]` **#296 triage sent to worldtree-dev** (`01KVBBH0…`): extraction SUBJECT-INVERSION (promotes assistant prose, drops the user's fact) + META-DESCRIPTION-not-content; verbose-persona aggravator. WAD-vs-bug resolved to BUG (extraction quality), not idle-gating.
_Archived 2026-07-19._
`[2026-06-18]` **Tier-3 memory PROVEN end-to-end live**`ratatoskr:terse-probe` recalled a seeded user fact in a COLD history-free session (scope_any → 1 hit @ cosine 0.6994). Closes the opening "how far from Tier-3 memory" question for normal agents.
_Archived 2026-07-19._
## Tried and abandoned (archived)
The 2026-05-20 → 2026-05-28 cluster: original-build-era foot-guns. Archived 2026-06-18.
+21 -20
View File
@@ -13,11 +13,12 @@ scope: >
replacement for a system-prompt "startup" instruction. Two entry points:
`preset_for` (lookup) and `seed_preset_first_message` (best-effort seed).
Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server
(the POST /api/sessions endpoint). Depends on ratatoskr.sessions
(write_authored_history + its exceptions); no core.* / worldtree.* imports.
(the POST /api/sessions endpoint). Depends on ratatoskr.wt
(`wt.write_authored_history` + AuthoredHistoryUnavailable) over a WorldtreeClient
(worldtree-sdk cutover slice-3, #20); no core.* / worldtree.* SOURCE imports.
depends_on:
- "httpx"
- "ratatoskr.sessions"
- "worldtree_sdk"
- "ratatoskr.wt"
used_by:
- "ratatoskr.cli"
- "ratatoskr.web.server"
@@ -26,7 +27,7 @@ complexity: "low"
estimated_loc: 60
confidence: 0.9
assumptions:
- "write_authored_history (contract #2 amendment 2026-07-06) is the seed primitive: 200/201 → ack dict, 404 → AuthoredHistoryUnavailable (hide-existence), other non-2xx → SessionApiFailed."
- "wt.write_authored_history (the SDK-adapter seed primitive, #20) writes over the worldtree-sdk client: success → ack mapping, 404 → AuthoredHistoryUnavailable (hide-existence), other ApiError → wt.SessionApiFailed. Behavior/semantics unchanged from the retired hand-rolled path — only the transport moved to the SDK."
- "The preset registry is a static in-module dict keyed by agent_id; editing it is how an operator tunes an agent's opening. Seeded with ratatoskr:sindra only."
- "Auto-seed is BEST-EFFORT and MUST NOT block session creation: an instance without the session.history.write grant returns the hide-404, which is swallowed (session opens with no seeded greeting)."
---
@@ -47,8 +48,8 @@ every new session for a preset agent opens in-character regardless of surface.
## Data flow
**In:** a live `httpx.AsyncClient` (caller-owned, base_url + bearer set), a fresh
`session_id`, and the bound `agent_id`.
**In:** a `WorldtreeClient` (the wt-adapter client, built over ratatoskr's
caller-owned transport), a fresh `session_id`, and the bound `agent_id`.
**Out:** on a preset agent, one `POST /sessions/{session_id}/history` (author=assistant,
the preset text, per-content idempotency key). Returns the seeded content on
@@ -104,19 +105,19 @@ TESTS:
preset_miss [happy]: preset_for("mimir") is None
empty_agent_id [adversarial]: preset_for("") → AssertionError
FN seed_preset_first_message(client: httpx.AsyncClient, session_id: str, agent_id: str) -> str | None
BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, POST it via write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into three create paths.
FN seed_preset_first_message(client: WorldtreeClient, session_id: str, agent_id: str) -> str | None
BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, write it via wt.write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into the CLI + web create paths.
PRE: [PRE-001 hard] client is not None -- soft-guarded: return None (NOT assert) if violated, so a wiring bug can't crash the create path (INV-001)
PRE: [PRE-002 hard] session_id is a non-empty str -- soft-guarded: return None if violated
PRE: [PRE-003 hard] agent_id is a non-empty str -- soft-guarded: return None if violated (also guards FIRST_MESSAGE_PRESETS.get against a non-hashable/non-str id)
POST: [POST-001 return_value] preset agent + successful write → returns the preset text; no-preset, malformed input, OR any swallowed failure → None
POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO HTTP; a preset agent issues exactly one POST /sessions/{session_id}/history with body author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block
POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO writes; a preset agent issues exactly one authored-history write (POST /sessions/{session_id}/history via the SDK) with entry author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block
ERROR_ROUTING:
asyncio.CancelledError:
local_handling: RE-RAISE (cancellation is not a seed failure; never swallow it — and it is a BaseException, so `except Exception` would miss it anyway)
flow_control: propagate
state_recovery: n/a
any other Exception (hide-404 AuthoredHistoryUnavailable, SessionApiFailed 409/422/etc., httpx.HTTPError, TimeoutError from wait_for, any unexpected error):
any other Exception (hide-404 AuthoredHistoryUnavailable, wt.SessionApiFailed 409/422/etc., SDK ConnectFailed, TimeoutError from wait_for, any unexpected error):
local_handling: swallow; return None
flow_control: continue (never blocks session create)
state_recovery: session opens with no seeded greeting
@@ -125,18 +126,18 @@ STEPS:
2. [sequential, prescriptive] content = FIRST_MESSAGE_PRESETS.get(agent_id); IF content is None: RETURN None (INV-002 — zero HTTP)
3. [sequential, prescriptive] Soft-guard: IF client is None OR session_id is not a non-empty str: RETURN None
4. [sequential, prescriptive] key = "ratatoskr-preset-" + sha256(content utf-8)[:12]
5. [sequential, prescriptive] TRY: await asyncio.wait_for(write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S)
5. [sequential, prescriptive] TRY: await asyncio.wait_for(wt.write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S)
tool: { destructive: false, idempotent: true, read_only: false, open_world: false }
6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None
7. [cleanup, prescriptive] RETURN content
TESTS:
seeds_preset [happy,tracer]: preset agent, mock 201 → returns the preset text; exactly one POST /sessions/{id}/history; body author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12]
no_preset_zero_http [happy]: agent "mimir" → returns None; NO HTTP issued
feature_absent_swallowed [error]: preset agent, mock 404 session_not_found → returns None, no raise
session_api_failed_swallowed [error]: preset agent, mock 409 → returns None, no raise
transport_error_swallowed [error]: preset agent, mock httpx.ConnectError → returns None, no raise
TESTS: (driven through a fake WorldtreeClient whose sessions.write_history returns/raises — the wire is the SDK's to prove via its parity corpus)
seeds_preset [happy,tracer]: preset agent, fake write_history returns an ack → returns the preset text; exactly one write_history call; entry author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12]
no_preset_zero_write [happy]: agent "mimir" → returns None; ZERO write_history call
feature_absent_swallowed [error]: preset agent, fake raises ApiError(404) → adapter maps to AuthoredHistoryUnavailable → returns None, no raise
session_api_failed_swallowed [error]: preset agent, fake raises ApiError(409) → wt.SessionApiFailed → returns None, no raise
transport_error_swallowed [error]: preset agent, fake raises SDK ConnectFailed → returns None, no raise
unexpected_exception_swallowed [error]: preset agent, write raises ValueError → returns None, no raise (INV-001 broad never-raise)
cancellation_propagates [error]: preset agent, write raises asyncio.CancelledError → RE-RAISED (never swallowed)
malformed_agent_id_no_http [adversarial]: agent_id=123 (non-str) OR "" → None; NO HTTP; no raise
empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); NO HTTP; no raise
malformed_agent_id_no_write [adversarial]: agent_id=123 (non-str) OR "" → None; ZERO write; no raise
empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); ZERO write; no raise
```
@@ -158,18 +158,31 @@ others, they get their own row here — the default is NOT a general "any 404
| SDK `AgentNotAvailable` / `TurnLaunchUnavailable` / `SessionRetired` (stream-open) | ratatoskr `AgentNotAvailable` / `TurnLaunchUnavailable` / (retired → `SessionApiFailed`) — same names, passthrough |
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
| SDK `ResumeError` subclasses (in resilient stream) | resilient `stream_turn` absorbs; terminal → `SseConnectFailed` |
| SDK `Cancel*` (cancel_turn) | folded into `CancelResult`; late-cancel race (B-CAN-3) returns `cancelled=False`, never raises |
| SDK `MalformedSseId` / `MalformedSseData` / `TurnIdFlip` (stream `ProtocolError`) | ratatoskr same-named types — same-name rewrap of the discriminated stream protocol errors |
| SDK `Cancel*` (cancel_turn) — the SDK RAISES the typed races | 404 `turn_not_found``CancelTurnNotFound`; 409 `turn_finished``CancelAlreadyCompleted`; other `CancelError``CancelFailed`. A 200 (incl. `cancelled=False`, the B-CAN-3 late-cancel no-op) returns a `CancelResult` — never raises. The caller surface stays exception-based (DEC-2; matches the pre-cutover CLI/web handlers). |
| `ApiError(404)` on `sessions.create` | `AgentNotFound` |
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` |
| `ApiError(502 bifrost_handshake_failed)` on bound `sessions.create` | `BifrostHandshakeFailed` |
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
| `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(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
adapter does NOT invent per-route semantics the contract doesn't list, and does NOT
leave an `ApiError` un-mapped. Each slice adds/confirms its route's rows here before
the old path is deleted.
leave an `ApiError` un-mapped. **This default holds on EVERY route, including the
stream and cancel** (each carries a defensive `except ApiError → SessionApiFailed`
after its discriminated branches — the SDK maps those routes to discriminated types
today, but the default guarantees INV-CUT-2 structurally, not by SDK-internal
coupling). Each slice adds/confirms its route's rows here before the old path is
deleted.
## Slice plan (incremental, DEC-4)
@@ -201,6 +214,70 @@ 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.
- **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.
## Out of scope
- Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched.
+17 -17
View File
@@ -84,26 +84,26 @@ sub-gap).
| Endpoint | Status | Where consumed | Note |
|---|---|---|---|
| `POST /sessions` | ✅ | `sessions.py` `create_session` `cli.py`,`tui.py`,`web/server.py` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed. **v0.21.2 (#19): ephemeral-template (Echo) create**`config` passthrough (`--system-prompt`), `role` not `model` (W-4), `kind`/`config` captured; 422 ephemeral_requires_config now reachable-and-handled. Depth enhancement to an already-covered route — count unchanged |
| `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) |
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history``cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **LIVE-PROVEN 2026-07-06** on personal :8081 (grant applied via a rule-based Heimdall allow, worldtree-dev): create mimir session → seed → **201** (seq=0, phase=seeded, turn_id=1798) → GET /messages reads it back as a plain role=assistant turn (model-invisible provenance confirmed). Hide-404 for ungranted is unit+probe covered |
| `GET /sessions/{id}/messages` (history) | ✅ | `sessions.py:635` `get_session_messages``cli.py:758` `--seed-first-message` read-back | un-deferred as the #347 seed read-back — confirms model-invisible provenance (a seed reads back as a normal `role=assistant` turn) |
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped |
| `GET /agents` | ✅ | `sessions.py:341``tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index |
| `GET /agents/{id}/persona_state` | ✅ | `sessions.py:384``tui.py:1132`,`web/server.py:386` | persona hydrate; 404/403 mapped |
| `POST /agents/define` | ✅ | `tier3.py:175``_run_define` | Tier-3 create |
| `PATCH /agents/{id}` | ✅ | `tier3.py:219``_run_patch` | Tier-3 mutate (system_prompt/model) |
| `DELETE /agents/{id}` | ✅ | `tier3.py:242``_run_delete` | Tier-3 hard-delete |
| `GET /me` | ✅ | `sessions.py:411` `get_me``cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed |
| `GET /capabilities` | ✅ | `sessions.py` `get_capabilities``cli.py` `--whoami` | Echo ephemeral-template discovery. **v0.21.2: `--whoami` renderer reads `allowed_roles`/`default_role`** (was the dead `allowed_models`/`default_model`) + tolerates malformed caps; matches conversation-api-spec **v1.1** (`b4a278c`) |
| `POST /sessions` | ✅ | `wt.py` `create_session` (SDK `sessions.create`) → `cli.py`,`web/server.py` | **wt-adapter re-anchored (slice-2, #20)** — + `end_user_id`, `bifrost` binding (consumer-key via SDK per-request auth), `config` passthrough; 404→AgentNotFound, bound-502→BifrostHandshakeFailed. Ephemeral-template (Echo) create (#19) carried through the adapter. Depth enhancement to an already-covered route — count unchanged |
| `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `wt.py` `stream_turn` (SDK resilient `sessions.stream_turn`, auto-resume) → cli/web | **wt-adapter re-anchored (slice-2, #20)** the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable, drop→SseConnectionDropped, protocol→same-named; absorbs the old `reconnect_turn` |
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `wt.py` `write_authored_history` (SDK `sessions.write_history`)`cli.py` `--seed-first-message`, `first_message.py` `seed_preset_first_message` (create-path seed) | **wt-adapter re-anchored (slice-3, #20)** — SDK owns the entry shape; v1 author=assistant; 404→AuthoredHistoryUnavailable (hide-existence, route is the discriminator, never probe); 409/422→SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): `--seed-first-message` on a sindra session → **201** (seq=0, phase=seeded, turn_id=2294) → read-back verbatim; create-path preset seed observed via `--new`. (Prior 2026-07-06 hand-rolled proof superseded.) |
| `GET /sessions/{id}/messages` (history) | ✅ | `wt.py` `get_session_messages` (SDK `sessions.messages`)`cli.py` `--seed-first-message` read-back, `web/server.py` messages proxy | **wt-adapter re-anchored (slice-3, #20)** — the #347 seed read-back; open-world passthrough. **LIVE-SMOKE 2026-07-19**: read-back rendered the seeded seq-0 turn as a plain role=assistant message (model-invisible provenance confirmed) |
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `wt.py` `cancel_turn` (SDK `sessions.cancel_turn`) → cli/web | **wt-adapter re-anchored (slice-2, #20)** — two-stage Ctrl-C; 404→CancelTurnNotFound, 409→CancelAlreadyCompleted, late-cancel 200 (`cancelled=False`) is a benign result, not an error |
| `GET /agents` | ✅ | `wt.py` `list_agents` (SDK `agents.list`) → `web/server.py` `_agents_endpoint` | **wt-adapter re-anchored (slice-4, #20)** — open-world array verbatim (no AgentInfo normalization), merged with the local tier3 index (remote-wins); error→SessionApiFailed default, transport→ConnectFailed→502. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): 6 agents returned (forseti/lofn/mask/mimir/vili/…) |
| `GET /agents/{id}/persona_state` | ✅ | `wt.py` `get_persona_state` (SDK `agents.persona_state`) → `web/server.py` `_persona_state_endpoint` | **wt-adapter re-anchored (slice-4, #20)** — open-world snapshot; dual-key (status,error_code) map: 404 persona_not_configured→PersonaNotConfigured, 404 agent_not_available→AgentNotAvailable, 403 auth_scope_denied→AuthScopeDenied, else default. **LIVE-SMOKE 2026-07-19**: a tier3 agent → correctly mapped `PersonaNotConfigured` (route+code adapter proven) |
| `POST /agents/define` | ✅ | `wt.py` `define_agent` (SDK `agents.define`) → `tier3.py` `_run_define` | **wt-adapter re-anchored (slice-4, #20)** — sends AgentDefineInput `{agent_name,role,system_prompt}`, returns open-world `DefinedAgent` (echoes `role`, b128); slug pre-validated; 429→Tier3QuotaExceeded(retry_after=0, header-less floor), 403→Tier3UserIdUnsupported, 422 layer_deferred→Tier3LayerDeferred. **LIVE-SMOKE 2026-07-19**: `define --role thoughtful-character``defined ratatoskr:slice4-smoke (thoughtful-character)` |
| `PATCH /agents/{id}` | ✅ | `wt.py` `patch_agent` (SDK `agents.patch`) → `tier3.py` `_run_patch` | **wt-adapter re-anchored (slice-4, #20)** — Tier-3 mutate (system_prompt/**role**, model→role folded in); 404→Tier3AgentNotFound, 422 field_not_mutable→Tier3FieldNotMutable. **LIVE-SMOKE 2026-07-19**: `patched ratatoskr:slice4-smoke`; a non-existent id via `python -m``[agent_not_found]` (exit 20, class-identity fix proven) |
| `DELETE /agents/{id}` | ✅ | `wt.py` `delete_agent` (SDK `agents.delete`) → `tier3.py` `_run_delete` | **wt-adapter re-anchored (slice-4, #20)** — 204→None; 404→Tier3AgentNotFound (route-discriminated, NOT hide-existence). **LIVE-SMOKE 2026-07-19**: `deleted ratatoskr:slice4-smoke` + local index → `[]` |
| `GET /me` | ✅ | `wt.py` `get_me` (SDK `me.get`) → `cli.py` `--whoami` | **wt-adapter re-anchored (slice-5, #20)** — open-world identity dict verbatim; any error→SessionApiFailed default (401 on a bad/absent key), transport→ConnectFailed→exit 21. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): identity rendered (user_id ratatoskr, tier user, scopes incl. `character.*`, key_id c990f0be) |
| `GET /capabilities` | ✅ | `wt.py` `get_capabilities` (SDK `capabilities.get`)`cli.py` `--whoami` | **wt-adapter re-anchored (slice-5, #20)** — open-world advertisement verbatim; `_format_whoami` reads `allowed_roles`/`default_role` and degrades on a null/non-mapping template (slice-4 hardening); matches conversation-api-spec **v1.1** (`b4a278c`). **LIVE-SMOKE 2026-07-19**: `ephemeral_template echo: default=echo max_bytes=32768 roles=[echo]` |
| `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools``tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) |
| `GET /admin/sessions/{id}/bifrost` | ✅ | `sessions.py:428` `get_session_bifrost``tui.py` `_hydrate_bifrost_state` | admin-scoped BifrostState pane (#176); admin key (`RATATOSKR_ADMIN_API_KEY`); live-auth-proven |
| `GET /admin/events` (SSE) | ✅ | `sse_client.py` `stream_admin_events``tui.py` `_stream_admin_events` | admin lifecycle SSE stream (#11), session-filtered AdminEvents pane; admin key; live-auth-proven |
| `GET /models/available-for-characters` | ✅ | `sessions.py` `list_character_models` `cli.py` `--characters` | character-capable model profiles (#161) |
| `POST /characters` | ✅ | `sessions.py` `create_character``cli.py` `--characters` | create transient character (#161) |
| `GET /characters/{id}/state` | ✅ | `sessions.py` `get_character_state``cli.py` `--characters` | live character PAD/emotions (#161) |
| `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character``cli.py` `--characters` | remove transient character (#161) |
| `POST /sessions/{id}/persona_state` | ✅ | `sessions.py` `set_persona_state``cli.py` `--set-persona-pad` | persona-state write / affect injection (freeform body — unpinned in the frozen surface) |
| `GET /models/available-for-characters` | ✅ | `wt.py` `list_character_models` (SDK `models.available_for_characters`) → `cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — open-world catalog verbatim; the probe reads `items` null-safe (`or []`); any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19**: `character models: char-rp` |
| `POST /characters` | ✅ | `wt.py` `create_character` (SDK `characters.create`)`cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — body `{character}` (+`state` only when set — SDK-idiomatic, drops the redundant explicit null); open-world create ACK verbatim; the probe degrades on a missing `character_id` (no hard-index). **LIVE-SMOKE 2026-07-19**: `created char_8c00006e…` |
| `GET /characters/{id}/state` | ✅ | `wt.py` `get_character_state` (SDK `characters.state`)`cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — open-world live PAD/emotions verbatim; TTL-refreshing read. **LIVE-SMOKE 2026-07-19**: `state pad=[0.234, -0.136, 0.065]` read back |
| `DELETE /characters/{id}` | ✅ | `wt.py` `delete_character` (SDK `characters.delete`)`cli.py` `--characters` | **wt-adapter re-anchored (slice-5, #20)** — returns the SDK's open ACK verbatim (`-> Mapping|None`, NOT normalized to None; 204→None); any error→SessionApiFailed default. **LIVE-SMOKE 2026-07-19**: `deleted char_8c00006e…` |
| `POST /sessions/{id}/persona_state` | ✅ | `wt.py` `set_persona_state` (SDK `sessions.set_persona_state`, `PadState`)`cli.py` `--set-persona-pad` | **wt-adapter re-anchored (slice-3, #20)** — SDK owns the canonical `{"pad": {...}}` wire (#317); CLI passes the 3 PAD axes (finiteness pre-validated); 204→None, else SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on personal :8081: `--set-persona-pad 0.4,0.1,-0.2`**204** |
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
on the same path is an unwired frontier item — see frontier Tier 1):
@@ -0,0 +1,57 @@
`[2026-07-19]` **worldtree-sdk cutover — SLICE-1 + SLICE-2 COMPLETE + PUSHED (origin `aba1730`, v0.21.10).**
The consumer client layer's biggest, riskiest slice (sessions/turn) is fully migrated onto
`worldtree-sdk (Python) 1.0.0` behind the `ratatoskr.wt` adapter, run end-to-end through the House Code
Discipline. Pushed 2026-07-19 (12-commit arc `b1fbadd``aba1730`, tags v0.21.3.10). Suite **497 green**.
## The arc (all on origin/main)
| Part | Commit | What |
|---|---|---|
| Slice-1 foundation | `12cd864` (v0.21.3) | `build_client` + `translate_error` DEFAULT (ApiError→SessionApiFailed) + passthrough; INV-CUT-1 (injected transport, `_owns_client=False`) test-proven |
| Slice-2 adapter (read/create) | `bb158ae` (v0.21.4) | create/list/messages/tools over `client.sessions.*`, per-route error mapping |
| Slice-2 adapter (stream/cancel) | `b907a7b` (v0.21.5) | resilient `stream_turn` + `cancel_turn`; SDK stream errors re-wrapped → ratatoskr caller-semantic exceptions (DEC-2) |
| Slice-2 CLI rewire | `e3a10ad` (v0.21.6) | `_amain`/`_run_turn`/render onto SDK `TurnEvent`s + open dicts |
| Slice-2 web rewire | `5c595b8` (v0.21.7) | Starlette endpoints + SSE→JSON serialization; browser contract preserved |
| Slice-2 deletion | `59602fe` (v0.21.8) | sse_client 714→224, sessions 677→608; orphaned turn-stream family + Event model removed; **DEC-4 live-smoke PASSED first** |
| heid-code-review fixup | `74d41eb` (v0.21.9) | INV-CUT-2 completeness (cancel/stream ApiError default) + contract error-map amendments |
| heid-bug-hunt fixup | `aba1730` (v0.21.10) | 4 confirmed bugs + open-world render hardening |
## KEY ADAPTER FACTS (foot-guns for slices 3-7)
- **The SDK returns open-world dicts** (`Mapping`, NOT typed objects) for session reads → presenters read
mappings (`info["session_id"]`), never attributes. Ratatoskr's typed `SessionInfo`/`SessionPage` retired.
- **SDK `TurnEvent` shape:** `sse_id: str` (the composite `"{turn}:{seq}"`) + a **body-derived `turn_id`
that is ABSENT (None) on text/thinking frames.** The mid-stream cancel target must parse the turn from
`sse_id`, NOT read `event.turn_id`. (This was a live bug — sigint-cancel saw "no event".)
- **The SDK normalizes transport failures to `ConnectFailed(status=0)`** (not raw httpx errors) → presenters
catch `wtsdk.ConnectFailed` for network paths; a stream `ConnectFailed` maps → `SseConnectFailed`.
- **The adapter re-wraps SDK stream errors → ratatoskr's caller-semantic exceptions** (DEC-2 keeps ratatoskr's
typed exceptions; SessionRetired→SessionApiFailed, ConnectionDropped→SseConnectionDropped, Malformed*/
TurnIdFlip→same-named, ConnectFailed/terminal ResumeError→SseConnectFailed).
- **`consumer_key` is BOUND-create-only** — the adapter nulls it when `bifrost is None`, else the SDK's
credential precedence auths as the consumer instead of the default bearer.
- **The SDK's error-envelope parser PREFERS the nested `detail`** dict when present, so a top-level
`error_code` doesn't surface. That's why create's bound-502 is NOT gated on error_code (unlike list's
422+cursor_invalid, whose flat body surfaces the code) — INV-002 also makes the handshake the sole
bound-502 cause. This is a genuine cross-frontier triage win: 2/3 heid arms flagged the missing gate; it
was correctly REJECTED as category-5 wrong-grounding (the SDK parser wasn't in the arms' file set).
## The two heid gates (the value proof)
- **heid-code-review** (Gróa+Hulda substantive, Regin zero=weak): adopted the cancel/stream ApiError-default
completeness + error-map table amendments; correctly rejected the bound-502-gate finding (above).
- **heid-bug-hunt** (Gróa 8 / Hulda 6 / Regin 6; Heid source-checked, refuted 2 Regin FPs): caught **4 real
confirmed bugs the conformance lens structurally could not see** — SessionRetired(410) uncaught by both
presenters (crash/dropped-stream), cli consumer_key forwarded on unbound create (auth divergence), sse_id
None-crash, cancel never-raise gap — plus open-world render hardening. Rejected 4 as verified FPs (incl.
Hulda's "deleted funcs break callers" — grep-verified zero callers pre-deletion). Both surfaces re-smoked
live after fixup: create+stream+cancel round-trip clean on :8081 (b128).
## What's still hand-rolled (later slices)
`create_session`/`get_session_messages` (the `--seed-first-message` probe uses them — retire in slice-3);
`set_persona_state`/`write_authored_history`/`first_message` (slice-3); agents/tier3 incl. `model``role`
(slice-4, deploy live b128); characters/me/capabilities/models (slice-5); `stream_admin_events`/
`get_session_bifrost` (slice-6). Slice-7 teardown: retire contracts #2/#15, drop `httpx-sse`, minor bump
(DEC-6, operator approval). Full design → auto-memory `project_worldtree_sdk_cutover`.
@@ -0,0 +1,66 @@
# worldtree-sdk cutover — SLICE-3 complete (2026-07-19)
Slice-3 of the #20 consumer-layer cutover: **persona + authored-history +
first-message** migrated onto the `ratatoskr.wt` adapter, and the last
hand-rolled `sessions.py` paths retired. Two commits: `ca9a339` (feat) +
`fc256bb` (heid-bug-hunt fixups), tags v0.21.11 / v0.21.12. Suite **469 green**.
## What moved / what was deleted
- **`wt.set_persona_state`** — passes 3 PAD axes; SDK owns the `{"pad": {…}}` wire
(#317) via `PadState`. No route-specific error row → `SessionApiFailed` default.
Finiteness enforced at the adapter precondition (chokepoint) AND the CLI.
- **`wt.write_authored_history`** — SDK `write_history`; v1 fixes `author="assistant"`
(dropped the unused `author`/`effects`/`claimed_original_at` params — no caller
used them, and a 3-key literal type-checks against the SDK's private
`AuthoredHistoryInput` without importing a non-public symbol). 404 →
`AuthoredHistoryUnavailable` (hide-existence; the ROUTE is the discriminator,
never the body); else the default.
- **`first_message.seed_preset_first_message`** — now takes a `WorldtreeClient`,
routes through `wt.write_authored_history`; best-effort invariants (INV-001..004)
unchanged. Tests rewritten to drive a fake `WorldtreeClient` (the wire is the
SDK's to prove via its parity corpus, not first_message's).
- **CLI `--set-persona-pad` / `--seed-first-message`** + the `_amain` and web
create-path first-message seeds rewired onto the adapter.
- **DELETED from `sessions.py`**: `create_session` + `SessionInfo`,
`set_persona_state`, `write_authored_history`, `get_session_messages`,
`_bifrost_error_from`. (The exceptions + `BifrostBinding` + `AgentInfo` +
slice-4/5/6 functions stay.)
## Live-smoke (INV-CUT-5, personal :8081 b128)
`--seed-first-message` → 201 (seq=0, phase=seeded) → read-back verbatim;
`--set-persona-pad 0.4,0.1,-0.2` → 204; `--new` create-path preset seed observed
routing through the adapter. All three route families proven end-to-end.
## Both heid gates cleared
- **heid-code-review (panel, cutover contract):** ZERO code-vs-contract drift, all
three arms. Regin's 3 secondary items all self-graded `accept` (non-slice-3
routes / web-not-unit-tested / slice-2 stream test) — correctly declined.
- **heid-bug-hunt (panel):** earned its keep — caught **a real regression I shipped
in `ca9a339`**: when the two CLI probes moved off raw httpx onto the adapter,
transport failures changed class — the SDK normalizes ANY pre-response transport
failure to `worldtree_sdk.ConnectFailed` (`request.py:196`), a `WorldtreeError`
(not `ApiError`), so it escaped the adapter unmapped AND the probes' httpx-only
`except` tuples → uncaught traceback instead of `[network_error]` exit 21.
`_amain` (slice-2) already handled it; the probes lagged. **Fix:** add
`ConnectFailed` to both probe tuples (live-verified at a refused host → exit 21).
Second finding (3/3): finite-PAD enforced only at the CLI, not the adapter
chokepoint → **fix:** adapter precondition assert. Declined (correctly): deleted
`sessions.py` exports (intended no-shim cutover, zero un-migrated importers),
`session["session_id"]` index (accept-known-risk, matches `--new`), Regin's
"web hangs forever" (refuted by Heid from source — the seed is `wait_for`-bounded).
## Foot-gun banked
**The SDK wraps ALL transport failures into `ConnectFailed(status=0)`** (not raw
httpx). Every ratatoskr caller that goes through the adapter must `except
ConnectFailed` on its network path — the httpx-only catches are now dead. This bit
the slice-3 probes; watch for it in slice-4+ call-site rewires.
## Next
Slice-4 (agents/Tier-3 — list/get/define/patch/delete/persona_state) which FOLDS
the pending `model``role` cutover ([[project-tier3-agents-model-to-role-pending]]).
See [[2026-07-19-worldtree-sdk-cutover-slice-1-2-complete]] for the slice-1+2 arc.
@@ -0,0 +1,66 @@
`[2026-07-19]` **worldtree-sdk cutover SLICE-4 COMPLETE + committed (`c62b4ee`+`aed9429`+`477d98f`, v0.21.13.15, 475 green).**
Agents/Tier-3 family onto the `ratatoskr.wt` adapter + the `model``role` fold (scope B),
full House Code Discipline end-to-end (contract error-map → TDD → rewire → delete → live
smoke → coverage re-anchor → both heid gates → fixups).
**What moved onto the SDK** (`client.agents.*`, open-world dicts, errors mapped by
route+(status,error_code) per INV-CUT-2): `list_agents` (`agents.list`),
`get_persona_state` (`agents.persona_state`; 404 persona_not_configured /
404 agent_not_available / 403 auth_scope_denied dual-key), `define_agent`
(`agents.define`; 429→Tier3QuotaExceeded(retry_after=0) status-only, 403
tier3_user_id_unsupported, 422 layer_deferred), `patch_agent` (`agents.patch`; bare-404→
Tier3AgentNotFound, 422 field_not_mutable), `delete_agent` (`agents.delete`; bare-404).
Rewired the `python -m ratatoskr.tier3` CLI + the web `_agents_endpoint` /
`_persona_state_endpoint`. DELETED the hand-rolled `sessions.list_agents` /
`get_persona_state` / `AgentInfo` and `tier3.define/patch/delete_agent` /
`Tier3AgentInfo` / parse+extract helpers.
**model→role fold:** define/patch response echoes `role` (spec 1.2 / b128), read off the
open-world dict; `LocalAgentEntry.model``.role`, local-index `_SCHEMA_VERSION` 1→2 (old
index discarded, no-compat). `project_tier3_agents_model_to_role_pending` is RESOLVED by
this — the deferred scope-B work landed here (doing it standalone would have been throwaway).
**KEY SLICE-4 FOOT-GUNS (for slices 5-7 + any tier3 work):**
- **`python -m` double-module exception identity.** Running tier3 as `__main__` while
`wt` imports `ratatoskr.tier3` bound TWO copies of each `Tier3*` class → a raised
`Tier3AgentNotFound` escaped the CLI's `except` as an uncaught traceback (exit 1, not
the mapped exit 20). Unit tests call `main()` in-process (module is `ratatoskr.tier3`,
not `__main__`) so they NEVER hit the split — the LIVE SMOKE caught it. Fix: the `Tier3*`
exceptions live in `sessions.py` (never run as `__main__`) → single class identity, and
it removed the `wt→tier3` import edge. Rule: caller-semantic exceptions the adapter
raises + a `-m` CLI catches must NOT live in the `-m` module.
- **Open-world presenters must degrade, never crash.** The heid-bug-hunt panel (5/5/5, two
3/3 crash sites) caught the CLI (`_run_define`/`_run_patch`) and web (`_agents_endpoint`)
HARD-INDEXING the open-world dicts (`info["agent_id"]`, `{a["agent_id"] for a in
upstream}`) → KeyError/TypeError on a partial/drifted 2xx response (incl. `system_prompt:
null``None.splitlines()` AttributeError). The wt tests + live smoke used FULL server
dicts, so it never surfaced. Fix: `_str_field` (absent/null/non-str → default) in the
CLI; well-formed-mapping filter in the web endpoint; a no-agent_id 2xx → controlled exit
20. The invariant "open-world reads degrade, never crash the presenter" must hold at
EVERY presenter, not just the adapter.
**Both heid gates cleared:**
- **code-review (panel, zero adapter/error-map/model→role drift):** 3 fixups (v0.21.14,
`aed9429`) — persona endpoint now catches `wt.SessionApiFailed` (parity with 3 sibling
endpoints; a latent PRE-cutover gap, NOT a slice-4 regression); dual-key NEGATIVE tests
for define/patch + flat-`field` test; contract documents the `":" in agent_id` PRE.
- **bug-hunt (panel 5/5/5, no false positives):** 3 fixups (v0.21.15, `477d98f`) — the two
open-world presenter crash sites above + `_error_field_from_body` type-checks `field` is
str. HELD (contract-intended, Heid-confirmed): the 429→quota / bare-404→not-found
status-only maps (the arms flagged them spec-free; the § Error map specifies them; the
SDK's ApiError floor drops Retry-After so retry_after=0 is canonical). Dual-keying
define's 429 for full row consistency is an available tightening (contract amendment),
surfaced not applied.
**LIVE-SMOKE on personal :8081 (b128):** valid agent roles are `thoughtful-character` /
`character` / `assistant` (NOT the `/models/available-for-characters` `char-rp` — that's a
character-model, a different vocab; define 422s on it). define→patch→list(6 agents:
forseti/lofn/mask/mimir/vili/…)→persona_state(→PersonaNotConfigured mapped)→delete→index
empty; non-existent-id patch via `-m``[agent_not_found]` exit 20 (double-module fix
proven). Throwaway `ratatoskr:slice4-smoke` deleted, server left clean.
**NEXT = slice-5** (characters + me/capabilities/models — the remaining consumer reads);
then slice-6 (admin: `stream_admin_events` + `get_session_bifrost`, admin_auth), slice-7
(teardown: delete residual hand-rolled, drop `httpx-sse`, retire contracts #2/#15, MINOR
bump per DEC-6 w/ operator approval). Consumer layer ONLY; Bifrost provider planes untouched.
@@ -0,0 +1,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.
+43 -37
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-07-18_
_Last updated: 2026-07-19_
> **Always check for `/tmp/ratatoskr-dev-handoff.md`** — if it exists and its
> `Written:` stamp is under an hour old, read it (it carries the in-flight
@@ -44,34 +44,36 @@ upstream API key stays server-side (INV-003).
## Current state / in-flight
_As of 2026-07-18 (evening):_
_As of 2026-07-19:_
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20), starting slice-1.** Operator ruled ADOPT
(2026-07-18): ratatoskr cuts its CONSUMER client layer over to consume **worldtree-sdk (Python) 1.0.0**
retire the hand-rolled httpx wrappers (`sessions`/`sse_client`/`tier3`) behind a thin `ratatoskr.wt`
adapter over the SDK. Both TS + Python SDK 1.0.0 are GA (**Python live + pip-installable on the gitea
PyPI** — DEC-5 gate cleared). Ratatoskr's own **parity pass shaped the Python spine** (open-world reads,
caller-injected transport, per-wire role/model). Design locked (6 DECs, vor-cross'd with worldtree-codex,
heid-panel-reviewed → error-map table added); contract `docs/contracts/worldtree_sdk_cutover.contract.md`
(committed `e45640c`). **SLICE-1 IN PROGRESS:** ✅ dep integrated + DEC-5 verified + committed (`29c4fda`) — `worldtree-sdk==1.0.0`
installs from the gitea registry (reuses bifrost's index auth, NO new token; core dep + `[tool.uv.sources]`),
`WorldtreeClient` constructs with an injected transport (`_owns_client=False`, INV-CUT-1 confirmed live),
suite 534 green. **NEXT = the adapter** `src/ratatoskr/wt.py` (TDD): `build_client(base_url, *, api_key,
admin_key, transport)``WorldtreeClient(auth=, admin_auth=, transport=)` (DESIGN CARE: SDK does per-request
auth via the providers; our injected `httpx.AsyncClient` carries base_url/UA/timeout, NOT the Authorization
header — read the SDK `client.py` @ `~/development/worldtree-sdk` for the split, INV-CUT-1) + `translate_error`
DEFAULT (SDK `ApiError``SessionApiFailed`, discriminated `WorldtreeError` subclasses passthrough; route-specific
rows come in later slices). Unit-test; NO surface wiring (slice-2). Then slices 2-7 (route-family + deletions,
live-smoke per slice). Scope: consumer layer ONLY;
Bifrost provider planes untouched. Multi-session grind. Full design → auto-memory
`project_worldtree_sdk_cutover`. This SUPERSEDES the #371 "repin rides the later Python milestone" framing
below (that milestone shipped; we're adopting, not just repinning).
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-14 COMPLETE, slice-5 next.**
Operator ruled ADOPT (2026-07-18): ratatoskr cuts its CONSUMER client layer over to **worldtree-sdk (Python)
1.0.0**, retiring the hand-rolled httpx wrappers behind a thin `ratatoskr.wt` adapter. Design locked (6 DECs,
vor-cross'd, heid-panel-reviewed); contract `docs/contracts/worldtree_sdk_cutover.contract.md`. **SLICE-1+2
(foundation + sessions/turn) ✅ PUSHED** origin `aba1730`. **SLICE-3 (persona + authored-history + first-message)
✅ DONE** `ca9a339`+`fc256bb`. **SLICE-4 (agents/Tier-3 + `model`→`role` fold) ✅ DONE**`c62b4ee` (feat) +
`aed9429` (heid-code-review fixups) + `477d98f` (heid-bug-hunt fixups), tags v0.21.13.15; full House Code
Discipline, both heid panels cleared. Suite **475 green**. Slice-4 migrated
list_agents/get_persona_state/define/patch/delete onto `client.agents.*` (open-world dicts, errors mapped by
route+(status,error_code)), folded `model``role` (`LocalAgentEntry.role`, index schema v2), and DELETED the
hand-rolled `sessions.list_agents`/`get_persona_state`/`AgentInfo` + `tier3.define/patch/delete_agent`. Full arc
+ the two slice-4 foot-guns → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-4-complete.md`.
**KEY ADAPTER FACTS (foot-guns, cumulative for slices 5-7):** SDK reads = **open-world dicts**
presenters MUST degrade not crash (slice-4 bug-hunt: hard-indexing `info["x"]` crashes on partial wire; use
`.get`/type-guards; `system_prompt: null``None.splitlines()`); the SDK **normalizes ANY transport failure to
`ConnectFailed(status=0)`** (NOT raw httpx) — every adapter caller must `except ConnectFailed`;
**caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the `-m` module** (the
`python -m ratatoskr.tier3` double-module split gave two `Tier3*` class identities → uncaught traceback; fixed
by homing them in `sessions.py`; unit tests can't catch this, the live smoke did); `TurnEvent` `turn_id` ABSENT
on text/thinking frames; `consumer_key` is BOUND-create-only; envelope parser prefers nested `detail`.
**NEXT = slice-5** (characters + me/capabilities/models — remaining consumer reads); then slice-6 (admin:
`stream_admin_events` + `get_session_bifrost`, admin_auth), 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), on worldtree-dev's deploy flag.** WT renames the
agents-RESPONSE selector `model``role` (spec 1.2, commit `387c67b`, NOT yet deployed). `_parse_tier3_agent_info`
reads `body["model"]` → KeyErrors post-deploy. Operator chose scope B (full tier3 `model``role` incl.
contract #15 + CLI `--model``--role`). Implement ON the deploy flag, not before (breaks the current demo);
folds into cutover slice-4. Auto-memory `project_tier3_agents_model_to_role_pending`.
**✅ RESOLVED — tier3 agents `model``role` (scope B) folded into cutover slice-4** (`c62b4ee`, v0.21.13). The
deferred deploy-gated scope-B work (response `model``role` per spec 1.2 / b128, `LocalAgentEntry`, index schema
v2) landed with the agents-family SDK cutover — no longer pending. See the slice-4 detail file + Recent decisions.
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).**
**No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
@@ -115,13 +117,13 @@ Full record → `persistent-memory.d/2026-07-18-368-silo-test-passed.md`. Siblin
(2) R39 Phase-2 **matched-quartets rebuild** (confirmatory, "whenever"); (3) bifrost **snapshot-cursor
adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
**Substrate / environment:** branch `main` at **v0.21.2**. **origin at `b1fbadd`** (pushed 2026-07-18:
canonical syncs `5d06a27`/`80c8d58`, ephemeral-Echo `c7016f2` #19, coverage-map→SDK-surface `b1fbadd`).
**UNPUSHED local commits** (cutover work — operator hasn't pushed): cutover contract `e45640c` #20, the
`memory:` snapshot, dep-integration `29c4fda` (worldtree-sdk==1.0.0), + this snapshot — **push is the
operator's call**. origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep: `worldtree-sdk==1.0.0`**
(gitea PyPI, `[tool.uv.sources]`). bifrost **`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0,
**conversation-api-spec re-synced to v1.1** (`b4a278c`); **suite 534 green**. Personal WT on **b127**
**Substrate / environment:** branch `main` at **v0.21.15** — slice-4 arc `c62b4ee``477d98f` + this snapshot
**COMMITTED, not-yet-pushed** (tags v0.21.13.15; push is the operator's call); slice-13 (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 475 green** (slice-4 added the agents-family adapter tests + heid-gate fixup tests, ~offset by the
deleted hand-rolled agent/persona 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
@@ -144,8 +146,6 @@ relational-dynamics verify (bind `--bifrost-url :8392`); WT #356 resume-durabili
Chronological log of decisions with `[YYYY-MM-DD]` prefix. One line per
decision. Captures rationale that won't be obvious from code alone.
- `[2026-06-17]` **#296 triage sent to worldtree-dev** (`01KVBBH0…`): extraction SUBJECT-INVERSION (promotes assistant prose, drops the user's fact) + META-DESCRIPTION-not-content; verbose-persona aggravator. WAD-vs-bug resolved to BUG (extraction quality), not idle-gating.
- `[2026-06-18]` **Tier-3 memory PROVEN end-to-end live**`ratatoskr:terse-probe` recalled a seeded user fact in a COLD history-free session (scope_any → 1 hit @ cosine 0.6994). Closes the opening "how far from Tier-3 memory" question for normal agents.
- `[2026-06-19]` **#18 D2 SHIPPED (`v0.17.14`, `39eebd1`) and the full #17+#18 arc PUSHED to origin** → `persistent-memory.d/2026-06-19-18-d2-shipped-v0-17-14-39eebd1-and-the-full-1.md`
- `[2026-06-19]` **bifrost repinned 0.8.0→0.10.0; `affect.fetch` became MANDATORY (strong-or-absent)**`persistent-memory.d/2026-06-19-bifrost-repinned-0-8-0-0-10-0-affect-fetch-be.md`
@@ -271,8 +271,14 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-07-18]` **ephemeral-template (Echo) create SHIPPED (`v0.21.2`, `c7016f2`, #19)**`config` passthrough + `--system-prompt` + `SessionInfo.kind/config` + `--whoami` roles fix. Diagnosed from the ignored `session_api_failed` startup line; role/model drift resolved w/ worldtree-dev (spec re-synced v1.1). TDD + heid contract-review + bug-hunt.
- `[2026-07-18]` **Rata = THE reference consumer of the worldtree-sdk Python spine** — parity pass (12 grounded findings) shaped its contract BEFORE build (open-world reads, caller-injected transport, per-wire role/model — adopted); coverage-map re-anchored to the SDK's 41-op ratified surface (`b1fbadd`); 4 ergonomics items parked post-v1 with wtsdk-dev.
- `[2026-07-18]` **worldtree-sdk cutover DECIDED — adopt the Python SDK for the consumer client layer** (operator, overriding "stay hand-rolled"). Issue #20; contract `docs/contracts/worldtree_sdk_cutover.contract.md` (`e45640c`, vor-cross'd + heid-reviewed); auto-memory `project_worldtree_sdk_cutover`. Consumer layer only, Bifrost provider untouched; slice-1 foundation next. See in-flight.
- `[2026-07-19]` **worldtree-sdk cutover SLICE-1 + SLICE-2 COMPLETE + PUSHED (origin `aba1730`, v0.21.10, 497 green).** The biggest, riskiest cutover slice done end-to-end through the full House Code Discipline (adapter→cli→web→delete→live-smoke→both heid gates); the bug-hunt caught 4 real confirmed bugs the conformance lens couldn't, and a convergent code-review finding was correctly REJECTED as category-5 (SDK envelope-parser behavior). Slice-3 next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-1-2-complete.md`
- `[2026-07-19]` **worldtree-sdk cutover SLICE-3 COMPLETE + pushed (`ca9a339`+`fc256bb`, v0.21.11.12, 469 green).** Persona/authored-history/first-message onto the wt adapter; last hand-rolled `sessions.py` paths deleted; both heid gates cleared — code-review ZERO drift, bug-hunt caught + fixed a real regression I shipped (ConnectFailed escaping both rewired probes → uncaught crash; the SDK normalizes ALL transport failures to `ConnectFailed`, not raw httpx) plus a finite-PAD chokepoint gap. Slice-4 (agents/tier3 + model→role) next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-3-complete.md`
_65 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 Bifrost-provider build / #17+#18 / #295-296 era) archived to archival-memory.md._
- `[2026-07-19]` **worldtree-sdk cutover SLICE-4 COMPLETE (agents/Tier-3 + `model`→`role` fold, `c62b4ee`+`aed9429`+`477d98f`, v0.21.13.15, 475 green).** Full House Code Discipline; the LIVE SMOKE caught a `python -m` double-module exception-class-identity bug unit tests structurally can't; both heid panels cleared (code-review zero-drift + 3 fixups; bug-hunt 5/5/5 → open-world-presenter degrade-not-crash fixes). Resolves the deferred scope-B model→role. Slice-5 next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-4-complete.md`
- `[2026-07-19]` **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
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.21.5"
version = "0.21.16"
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
readme = "README.md"
requires-python = ">=3.12"
+251 -116
View File
@@ -8,15 +8,34 @@ from __future__ import annotations
import argparse
import asyncio
import hashlib
import math
import os
import signal
import sys
from collections.abc import Mapping
from dataclasses import dataclass, field
from importlib.metadata import PackageNotFoundError, version
from typing import Any, TextIO
import httpx
from worldtree_sdk import (
AffectUpdateEvent,
AwaitingLlmFirstTokenEvent,
CancelledEvent,
ConnectFailed,
DoneEvent,
ErrorEvent,
TextBoundaryEvent,
TextEvent,
ThinkingEvent,
ToolResultEvent,
ToolStartEvent,
TurnEvent,
WorkerPhaseEvent,
WorldtreeClient,
)
from ratatoskr import wt
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentNotFound,
@@ -24,42 +43,20 @@ from ratatoskr.sessions import (
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
SessionApiFailed,
create_character,
create_session,
delete_character,
endpoint_for_plane,
get_capabilities,
get_character_state,
get_me,
get_session_messages,
list_character_models,
set_persona_state,
write_authored_history,
)
# The turn path (create / stream / cancel) 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 (
AffectUpdate,
AwaitingLlmFirstToken,
CancelAlreadyCompleted,
CancelFailed,
Cancelled,
CancelTurnNotFound,
Done,
Error,
Event,
MalformedSseData,
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
Text,
TextBoundary,
Thinking,
ToolResult,
ToolStart,
TurnIdFlip,
WorkerPhase,
cancel_turn,
stream_turn_resilient,
)
@@ -337,6 +334,42 @@ def _format_usage(usage: dict[str, int], *, arrow: str) -> str:
return f"{p} in {arrow} {c} out ({t} total, {ci} cached)"
def _format_usage_safe(usage: object) -> str:
"""Tolerant wrapper over `_format_usage` for the SDK's open-world
`DoneEvent.usage`: the canonical four-key mapping formats; anything absent or
malformed (None, a non-mapping like `5`, a partial dict) degrades to `(n/a)`
rather than crashing the presenter (same posture as `_format_whoami`)."""
keys = ("prompt_tokens", "completion_tokens", "total_tokens", "cached_input_tokens")
if isinstance(usage, Mapping) and all(k in usage for k in keys):
return _format_usage(dict(usage), arrow="->")
return "(n/a)"
def _format_duration_safe(ms: object) -> str:
"""Tolerant wrapper over `_format_duration_ms` for the open-world
`DoneEvent.duration_ms`: a finite non-negative number formats (a float wire
value is floored to int); anything else degrades to `n/a` rather than tripping
`_format_duration_ms`'s int assertion."""
if isinstance(ms, (int, float)) and not isinstance(ms, bool) and ms >= 0:
return _format_duration_ms(int(ms))
return "n/a"
def _turn_id_from_sse_id(sse_id: object) -> int | None:
"""The turn component of the SDK's composite sse_id (`"{turn}:{seq}"`). This is
the mid-stream cancel target: it is present on EVERY frame, unlike the SDK's
top-level `turn_id`, which is the body field (absent on text/thinking events).
Tolerant of a malformed/absent sse_id (open-world) — mirrors the web helper."""
if not isinstance(sse_id, str):
return None
head, _, _ = sse_id.partition(":")
try:
turn = int(head)
except ValueError:
return None
return turn if turn > 0 else None
@dataclass(slots=True)
class CliPresenterState:
"""Per-turn presenter state for `--send` mode (issue #12).
@@ -348,24 +381,36 @@ class CliPresenterState:
thinking_open: bool = False
text_written_since_newline: bool = False
def render(self, event: Event, *, stdout: TextIO, stderr: TextIO) -> None:
"""Render one Worldtree SSE event with editorial hierarchy + coalescing."""
assert isinstance(
def render(self, event: TurnEvent, *, stdout: TextIO, stderr: TextIO) -> None:
"""Render one Worldtree SSE event with editorial hierarchy + coalescing.
Consumes the worldtree-sdk `TurnEvent` union. The SDK types the de-facto
fields as OPTIONAL (open-world), so every read is hardened: a
malformed/partial event degrades to a placeholder rather than crashing the
presenter — the same posture as `_format_whoami`.
"""
if not isinstance(
event,
(
WorkerPhase, Thinking, Text, TextBoundary,
ToolStart, ToolResult, Done, Error, Cancelled,
AffectUpdate, AwaitingLlmFirstToken,
WorkerPhaseEvent, ThinkingEvent, TextEvent, TextBoundaryEvent,
ToolStartEvent, ToolResultEvent, DoneEvent, ErrorEvent, CancelledEvent,
AffectUpdateEvent, AwaitingLlmFirstTokenEvent,
),
)
):
# Open-world: an unknown / future SDK event type degrades to a one-line
# note rather than aborting the presenter. (The SDK skips unknown wire
# types today, so this is belt-and-suspenders for a future SDK event set.)
stderr.write(f". unknown_event: {type(event).__name__}\n")
return
# Thinking events accumulate into the open run.
if isinstance(event, Thinking):
if isinstance(event, ThinkingEvent):
content = event.content or ""
if not self.thinking_open:
stderr.write(". thinking: ")
self.thinking_open = True
stderr.write(event.content)
stderr.write(content)
stderr.flush()
self.thinking_buffer.append(event.content)
self.thinking_buffer.append(content)
return
# Non-thinking event: close any open thinking run first.
if self.thinking_open:
@@ -374,62 +419,64 @@ class CliPresenterState:
self.thinking_open = False
self.thinking_buffer.clear()
# Now render the new event.
if isinstance(event, Text):
stdout.write(event.content)
if isinstance(event, TextEvent):
content = event.content or ""
stdout.write(content)
stdout.flush()
# POST-003: only set if cursor is mid-line (no trailing newline).
self.text_written_since_newline = not event.content.endswith("\n")
self.text_written_since_newline = not content.endswith("\n")
return
if isinstance(event, (Done, Error, Cancelled)):
if isinstance(event, (DoneEvent, ErrorEvent, CancelledEvent)):
# INV-005: ensure stdout newline boundary before stderr terminal label.
if self.text_written_since_newline:
stdout.write("\n")
stdout.flush()
self.text_written_since_newline = False
if isinstance(event, Done):
if isinstance(event, DoneEvent):
stderr.write(
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
f"duration={_format_duration_ms(event.duration_ms)} "
f"usage {_format_usage(event.usage, arrow='->')}\n"
f"[done] turn_id={event.turn_id} model={event.model} "
f"duration={_format_duration_safe(event.duration_ms)} "
f"usage {_format_usage_safe(event.usage)}\n"
)
return
if isinstance(event, WorkerPhase):
if isinstance(event, WorkerPhaseEvent):
stderr.write(
f". worker_phase: phase={event.phase} turn_id={event.turn_id}\n"
)
return
if isinstance(event, Error):
if isinstance(event, ErrorEvent):
stderr.write(
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
f"[error] turn_id={event.turn_id} code={event.error_code} "
f"message={event.message!r}\n"
)
return
if isinstance(event, Cancelled):
if isinstance(event, CancelledEvent):
stderr.write(
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
f"partial_message_id={event.partial_message_id}\n"
)
return
if isinstance(event, ToolStart):
if isinstance(event, ToolStartEvent):
stderr.write(
f". tool_start: name={event.name} args={event.arguments!r}\n"
)
return
if isinstance(event, ToolResult):
if isinstance(event, ToolResultEvent):
stderr.write(
f". tool_result: name={event.name} duration_ms={event.duration_ms} "
f"result={event.result!r:.200}\n"
)
return
if isinstance(event, TextBoundary):
if isinstance(event, TextBoundaryEvent):
stderr.write(
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
)
return
if isinstance(event, AffectUpdate):
if isinstance(event, AffectUpdateEvent):
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
# one line to stderr with status + (for current) dominant_emotion.
if event.snapshot is not None:
# isinstance(Mapping) guards an open-world non-mapping snapshot.
if isinstance(event.snapshot, Mapping):
dom = event.snapshot.get("dominant_emotion")
stderr.write(
f". affect_update: status={event.status} turn_id={event.turn_id} "
@@ -440,10 +487,10 @@ class CliPresenterState:
f". affect_update: status={event.status} turn_id={event.turn_id}\n"
)
return
if isinstance(event, AwaitingLlmFirstToken):
if isinstance(event, AwaitingLlmFirstTokenEvent):
# Worldtree #201 / v0.29.0. Heartbeat during BuildingPrompt →
# CallingLLM gap. Stderr surface, one line per heartbeat.
secs = event.elapsed_ms_since_building_prompt / 1000.0
secs = (event.elapsed_ms_since_building_prompt or 0) / 1000.0
stderr.write(
f". awaiting_llm_first_token: turn_id={event.turn_id} elapsed={secs:.1f}s\n"
)
@@ -451,23 +498,29 @@ class CliPresenterState:
async def _cancel_and_log(
client: httpx.AsyncClient,
client: WorldtreeClient,
session_id: str,
turn_id: int,
*,
stderr: TextIO,
) -> None:
"""Spawn-and-forget cancel that never raises (INV-009)."""
"""Spawn-and-forget cancel that never raises (INV-009). The adapter maps the
cancel races onto ratatoskr's typed exceptions; a benign late-cancel (200,
cancelled=False) returns a result and logs nothing."""
assert client is not None
assert isinstance(turn_id, int) and turn_id > 0
try:
await cancel_turn(client, session_id, turn_id)
except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
await wt.cancel_turn(client, session_id, turn_id)
except Exception as exc:
# Any cancel failure (mapped ratatoskr cancel exceptions, an adapter-defaulted
# SessionApiFailed, an SDK ConnectFailed, a transport error, or anything the
# SDK doesn't normalize) is logged and swallowed — the fire-and-forget cancel
# must never propagate into _run_turn's finally.
stderr.write(f"[cancel_failed] {type(exc).__name__}: {exc}\n")
async def _run_turn(
client: httpx.AsyncClient,
client: WorldtreeClient,
session_id: str,
content: str,
sigint_event: asyncio.Event,
@@ -493,7 +546,10 @@ async def _run_turn(
cancelling = False
sigint_task: asyncio.Task[bool] | None = None
cancel_task: asyncio.Task[None] | None = None # strong ref to fire-and-forget cancel
aiter_obj = stream_turn_resilient(client, session_id, content).__aiter__()
# wt.stream_turn is an async generator — it is already its own iterator, so no
# explicit __aiter__(); keeping the concrete type lets __anext__() type as a
# coroutine for asyncio.create_task.
aiter_obj = wt.stream_turn(client, session_id, content)
try:
while True:
@@ -522,6 +578,11 @@ async def _run_turn(
except StopAsyncIteration:
stderr.write("[connection_dropped] last_seen=<none>\n")
return 21
except wt.SessionApiFailed as exc:
# The adapter maps a stream-open SessionRetired (410) here; without
# this the retired-session stream would crash out of _run_turn.
stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
except SseConnectFailed as exc:
stderr.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}\n")
return 20
@@ -537,17 +598,21 @@ async def _run_turn(
except TurnIdFlip as exc:
stderr.write(f"[turn_id_flip] expected={exc.established} got={exc.got}\n")
return 22
last_turn_id = event.sse_id.turn_id
# The cancel target is the turn from the composite sse_id (present on
# every frame); the body's turn_id is absent on text/thinking events.
tid = _turn_id_from_sse_id(event.sse_id)
if tid is not None:
last_turn_id = tid
state.render(event, stdout=stdout, stderr=stderr)
if isinstance(event, Done):
if isinstance(event, DoneEvent):
if sigint_task is not None and not cancelling:
sigint_task.cancel()
return 0
if isinstance(event, Error):
if isinstance(event, ErrorEvent):
if sigint_task is not None and not cancelling:
sigint_task.cancel()
return 2
if isinstance(event, Cancelled):
if isinstance(event, CancelledEvent):
if sigint_task is not None and not cancelling:
sigint_task.cancel()
return 3
@@ -564,6 +629,11 @@ async def _run_turn(
async def _amain(args: ParsedArgs) -> int:
"""Async orchestrator: create-session (if --new) → SIGINT install → _run_turn → cleanup."""
assert isinstance(args, ParsedArgs)
# ratatoskr owns the transport (INV-CUT-1): the SDK is injected with it and
# never closes it. The transport carries base_url / User-Agent / timeout AND the
# default bearer — the SDK overrides Authorization per request (so a bound create
# still uses its consumer_key), while the not-yet-migrated hand-rolled
# `seed_preset_first_message` reuses the transport's default bearer directly.
async with httpx.AsyncClient(
base_url=args.server_url,
headers={
@@ -575,7 +645,8 @@ async def _amain(args: ParsedArgs) -> int:
# connect/write/pool keep modest timeouts so true network failures
# still surface promptly.
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
) as client:
) as transport:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
if args.new:
assert args.agent_id is not None
try:
@@ -586,7 +657,7 @@ async def _amain(args: ParsedArgs) -> int:
if args.system_prompt is not None
else None
)
info = await create_session(
info = await wt.create_session(
client,
args.agent_id,
end_user_id=args.end_user_id,
@@ -616,23 +687,32 @@ async def _amain(args: ParsedArgs) -> int:
"(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
)
return 23
except SessionApiFailed as exc:
except wt.SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} 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
# Issue #12: demoted lifecycle line — written directly here (NOT via
# state.render, which only accepts SSE Event variants per PRE-001).
kind_suffix = f" kind={info.kind}" if info.kind else ""
# state.render, which only accepts SSE Event variants per PRE-001). The
# adapter returns the SDK's open-world create dict; read it as a mapping.
session_id = info["session_id"]
kind = info.get("kind")
kind_suffix = f" kind={kind}" if kind else ""
sys.stderr.write(
f". create_session: session_id={info.session_id} "
f"agent_id={info.agent_id}{kind_suffix}\n"
f". create_session: session_id={session_id} "
f"agent_id={info['agent_id']}{kind_suffix}\n"
)
# #347 authored first-message: seed the agent's preset opening (best-effort).
if await seed_preset_first_message(client, info.session_id, args.agent_id):
# Routed through the wt adapter (slice-3); the seed never blocks create.
if await seed_preset_first_message(client, session_id, args.agent_id):
sys.stderr.write(
f". first_message: seeded preset opening for {info.agent_id}\n"
f". first_message: seeded preset opening for {args.agent_id}\n"
)
# Issue #17 bound-state indicator: plane + endpoint + status, so the
# operator sees WHICH identity/endpoint bound (not a bare boolean).
@@ -642,7 +722,7 @@ async def _amain(args: ParsedArgs) -> int:
f". bifrost: status=bound plane={plane} "
f"endpoint={args.bifrost.endpoint_url}\n"
)
session_id = info.session_id
# session_id was bound above from the create dict.
else:
assert args.session_id is not None
session_id = args.session_id
@@ -665,7 +745,7 @@ 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 _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', '?')}")
@@ -707,18 +787,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))
@@ -739,12 +824,16 @@ 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 read: `items` may be absent OR explicit-null — `or []` degrades
# both to empty (mirrors the slice-4 `_format_whoami` hardening), never a
# `join(None)` crash.
names = ", ".join(m.get("name", "?") for m in (models.get("items") or []))
sys.stdout.write(f"character models: {names or '(none)'}\n")
created = await create_character(
created = await wt.create_character(
client,
{
"schema_version": "1",
@@ -758,16 +847,32 @@ 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 malformed/absent character_id aborts the probe cleanly rather
# than raising a KeyError — the lifecycle needs the id for state + delete.
cid = created.get("character_id")
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)
state = await wt.get_character_state(client, cid)
sys.stdout.write(f"state: pad={state.get('pad')}\n")
await delete_character(client, cid)
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
@@ -792,16 +897,34 @@ async def _set_persona_probe(args: ParsedArgs) -> int:
"(pleasure,arousal,dominance), e.g. '0.4,0.1,-0.2'\n"
)
return 10
# Canonical POST /sessions/{id}/persona_state body (#317): a named-key dict,
# NOT a bare list — {"pad": {"pleasure", "arousal", "dominance"}}.
snapshot = {"pad": {"pleasure": pad[0], "arousal": pad[1], "dominance": pad[2]}}
async with _probe_client(args) as client:
# The SDK rejects a non-finite axis pre-HTTP (a NaN/Infinity would serialize to
# null and corrupt the injection). Reject it here as a usage error so the probe
# surfaces a clean message instead of crashing on the SDK's ConfigurationError.
if not all(math.isfinite(x) for x in pad):
sys.stderr.write(
"[usage_error] --set-persona-pad values must be finite floats (no nan/inf)\n"
)
return 10
async with _probe_client(args) as transport:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
try:
await set_persona_state(client, args.session_id, snapshot)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
# The SDK owns the canonical {"pad": {...}} wire body (#317); ratatoskr
# passes the three PAD axes and no longer hand-builds the snapshot.
await wt.set_persona_state(
client, args.session_id, pleasure=pad[0], arousal=pad[1], dominance=pad[2]
)
except wt.SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
ConnectFailed, # SDK normalizes a pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
sys.stdout.write(
@@ -824,19 +947,23 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int:
"""
assert isinstance(args, ParsedArgs)
assert args.agent_id is not None and args.seed_first_message is not None
async with _probe_client(args) as client:
async with _probe_client(args) as transport:
client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport)
try:
session = await create_session(
# wt.create_session returns the SDK's open-world create dict; read as a
# mapping (no SessionInfo dataclass — the hand-rolled path is retired).
session = await wt.create_session(
client, args.agent_id, end_user_id=args.end_user_id
)
sys.stdout.write(f"session: {session.session_id} (agent {session.agent_id})\n")
session_id = session["session_id"]
sys.stdout.write(f"session: {session_id} (agent {session.get('agent_id')})\n")
key = "ratatoskr-first-message-" + hashlib.sha256(
args.seed_first_message.encode("utf-8")
).hexdigest()[:12]
try:
ack = await write_authored_history(
ack = await wt.write_authored_history(
client,
session.session_id,
session_id,
content=args.seed_first_message,
idempotency_key=key,
)
@@ -851,17 +978,25 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int:
f"seeded: seq={ack.get('seq')} phase={ack.get('phase')} "
f"turn_id={ack.get('turn_id')} content_chars={ack.get('content_chars')}\n"
)
history = await get_session_messages(client, session.session_id)
history = await wt.get_session_messages(client, session_id)
items = history.get("items", [])
sys.stdout.write(f"read-back: {len(items)} message(s)\n")
for m in items:
sys.stdout.write(
f" seq={m.get('seq')} role={m.get('role')} content={m.get('content')!r}\n"
)
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
except wt.SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
ConnectFailed, # SDK normalizes a pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
return 0
+6 -6
View File
@@ -14,12 +14,12 @@ blocked (the session simply opens with no seeded greeting). See
import asyncio
import hashlib
import httpx
from worldtree_sdk import WorldtreeClient
from ratatoskr.sessions import write_authored_history
from ratatoskr import wt
# Cap the best-effort seed write. The CLI/TUI create paths reuse an httpx client
# with NO read timeout (it streams SSE turns), so an accepted-but-never-answered
# Cap the best-effort seed write. The CLI create path reuses a transport with NO
# read timeout (it streams SSE turns), so an accepted-but-never-answered
# POST /history would otherwise block session creation forever — violating INV-001's
# "never block". asyncio.wait_for bounds the seed regardless of the client's timeout.
_SEED_TIMEOUT_S = 10.0
@@ -39,7 +39,7 @@ def preset_for(agent_id: str) -> str | None:
async def seed_preset_first_message(
client: httpx.AsyncClient, session_id: str, agent_id: str
client: WorldtreeClient, session_id: str, agent_id: str
) -> str | None:
"""Best-effort: seed ``agent_id``'s preset opening as a #347 authored
first-message on ``session_id``; return the seeded text, or None.
@@ -64,7 +64,7 @@ async def seed_preset_first_message(
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
try:
await asyncio.wait_for(
write_authored_history(
wt.write_authored_history(
client, session_id, content=content, idempotency_key=key
),
timeout=_SEED_TIMEOUT_S,
+9 -4
View File
@@ -27,7 +27,11 @@ import os
from dataclasses import asdict, dataclass
from pathlib import Path
_SCHEMA_VERSION = 1
# v2 (worldtree-sdk cutover slice-4): `model` → `role`. The field holds what the
# Worldtree wire now calls a role (spec 1.2 / b128, the define response echoes
# `role`); the version bump discards any pre-cutover on-disk index cleanly
# (no-backwards-compat — old `{"model": …}` rows are dropped, re-defined fresh).
_SCHEMA_VERSION = 2
@dataclass(frozen=True)
@@ -37,16 +41,17 @@ class LocalAgentEntry:
Schema:
- ``agent_id``: full "user_id:agent_name" string (Worldtree-owned).
- ``agent_name``: slug from define (display name).
- ``model``: provider model ID at last define/patch.
- ``role``: model-role at last define/patch (e.g. "thoughtful-character";
the define/patch response's ``role`` field, W-4 resolved / b128).
- ``description``: synthetic display string (typically derived from
the system_prompt's first line + a "(tier 3)" prefix; the picker
uses this in its ``{id} · {name}{description}`` rendering).
- ``defined_at``: ISO-8601 timestamp from the Tier3AgentInfo response.
- ``defined_at``: ISO-8601 timestamp from the define/patch response.
"""
agent_id: str
agent_name: str
model: str
role: str
description: str
defined_at: str
+61 -483
View File
@@ -5,67 +5,12 @@ Implements docs/contracts/issues/2.contract.md.
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
import httpx
@dataclass(frozen=True)
class SessionInfo:
"""Worldtree session envelope; shared shape for create + list responses.
INV-001 / INV-002: origin-conditional defaults — `create_session` sets fixed
`name=None`, `archived=False`, `tags=[]`; `list_sessions` populates from the
response item with the same absent/null defaults but `message_count=None`.
Amendment 2026-07-18 (issue #161): `kind` ("ephemeral" | "foundational") and
`config` (the frozen ephemeral config, create-origin only) captured defensively
via `.get()` — both `None` on a pre-cutover server that omits them.
"""
session_id: str
agent_id: str
created_at: str
last_active: str
metadata: dict[str, Any]
message_count: int | None
name: str | None
archived: bool
tags: list[str]
kind: str | None = None
config: dict[str, Any] | None = None
@dataclass(frozen=True)
class SessionPage:
"""One page of GET /sessions results. `next_cursor=None` on the last page."""
items: list[SessionInfo]
next_cursor: str | None
@dataclass(frozen=True)
class AgentInfo:
"""Worldtree agent envelope from GET /agents (issue #8).
INV-005: required fields (`agent_id`, `name`, `description`) take the
response value verbatim. Optional fields default to None / [] / {} when
omitted by the server, mirroring SessionInfo's INV-001/INV-002
origin-conditional defaulting.
"""
agent_id: str
name: str
description: str
version: str | None
capabilities: list[str]
supported_models: list[str]
persona_traits: dict[str, Any]
ui_hints: dict[str, Any]
@dataclass(frozen=True)
class BifrostBinding:
"""Session-create Bifrost binding (Worldtree BifrostBindingRequest, #160).
@@ -204,53 +149,68 @@ class AuthoredHistoryUnavailable(Exception):
self.session_id = session_id
async def list_sessions(
client: httpx.AsyncClient,
*,
include_archived: bool = False,
limit: int = 50,
cursor: str | None = None,
) -> SessionPage:
"""GET /sessions. See contract FN list_sessions."""
assert client is not None
assert 1 <= limit <= 200
assert cursor is None or (isinstance(cursor, str) and cursor)
# ── 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.
params: dict[str, str] = {"limit": str(limit)}
if include_archived:
params["include_archived"] = "true"
if cursor is not None:
params["cursor"] = cursor
resp = await client.get("/sessions", params=params)
if resp.status_code == 422:
try:
err = resp.json()
except ValueError:
err = {}
if err.get("error_code") == "cursor_invalid":
raise InvalidCursor(raw=cursor)
raise SessionApiFailed(status=422, body=resp.content)
if resp.status_code != 200:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
items = [
SessionInfo(
session_id=item["session_id"],
agent_id=item["agent_id"],
created_at=item["created_at"],
last_active=item["last_active"],
metadata=item.get("metadata", {}),
message_count=None,
name=item.get("name"),
archived=item.get("archived") or False,
tags=item.get("tags") or [],
kind=item.get("kind"), # INV-002 amendment (#161): present on list items
config=item.get("config"), # forward-compat passthrough; None today
)
for item in body["items"]
]
return SessionPage(items=items, next_cursor=body.get("next_cursor"))
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:
@@ -266,287 +226,10 @@ def endpoint_for_plane(plane: str, base_host: str) -> str:
"""
ports = {"memory": 8391, "affect": 8390, "combined": 8392}
if plane not in ports:
raise ValueError(
f"unknown plane: {plane!r} "
"(expected 'memory', 'affect', or 'combined')"
)
raise ValueError(f"unknown plane: {plane!r} (expected 'memory', 'affect', or 'combined')")
return f"http://{base_host}:{ports[plane]}"
def _bifrost_error_from(resp: httpx.Response) -> str | None:
"""Pull the spec-level `bifrost_error` from a 502 body.
Tolerates both the FastAPI-nested `{"detail": {"bifrost_error": …}}` shape
(the spec's documented form, §"Optional Bifrost binding") and a flat
top-level `bifrost_error`, per the both-shape unwrap precedent established for
persona_state errors (the real wire returns the detail-nested form).
"""
try:
err = resp.json()
except ValueError:
return None
if not isinstance(err, dict):
return None
bifrost_error = err.get("bifrost_error")
if bifrost_error is None and isinstance(err.get("detail"), dict):
bifrost_error = err["detail"].get("bifrost_error")
return bifrost_error
async def create_session(
client: httpx.AsyncClient,
agent_id: str,
*,
end_user_id: str | None = None,
bifrost: BifrostBinding | None = None,
consumer_key: str | None = None,
config: Mapping[str, Any] | None = None,
) -> SessionInfo:
"""POST /sessions to create a new session. See contract FN create_session.
Per issue #5: pass `end_user_id` for per-end-user agents (lofn etc.).
When None (default), the body shape matches the pre-#5 baseline
`{"agent_id": agent_id}` so existing callers (mimir smoke) are unaffected.
Empty-string `end_user_id` is rejected before HTTP (PRE-003).
Per issue #17: when `bifrost` is set the request carries the binding and
authenticates with `consumer_key` (NOT the client's default canary bearer);
Worldtree handshakes synchronously to our provider before 201.
"""
assert client is not None
assert agent_id and isinstance(agent_id, str)
assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
# PRE-004 (issue #161): config is None or a Mapping.
assert config is None or isinstance(config, Mapping)
# PRE-005 (issue #161): ephemeral config + Bifrost binding are mutually
# exclusive (server would 422 ephemeral_does_not_accept_bifrost). The CLI
# guards this at arg-parse; this assert is defense-in-depth.
assert not (config is not None and bifrost is not None)
# PRE-001 (INV-001): a bifrost binding REQUIRES a non-empty consumer key,
# enforced before any HTTP so a bound create never falls back to the canary.
if bifrost is not None and not (isinstance(consumer_key, str) and consumer_key):
raise BifrostConsumerKeyMissing()
body: dict[str, Any] = {"agent_id": agent_id}
if end_user_id is not None:
body["end_user_id"] = end_user_id
# Issue #161: ephemeral-template config passthrough — verbatim, role/model-
# agnostic. The caller (CLI) builds {"system_prompt": ...}; the wrapper never
# injects a selector. Absent when None (foundational baseline unchanged).
if config is not None:
body["config"] = dict(config)
headers: dict[str, str] = {}
if bifrost is not None:
body["bifrost"] = {
"endpoint_url": bifrost.endpoint_url,
"scope": bifrost.scope,
}
# INV-001: the bound create authenticates with the consumer key,
# overriding the httpx client's default canary bearer per-request.
headers["Authorization"] = f"Bearer {consumer_key}"
resp = await client.post("/sessions", json=body, headers=headers)
if resp.status_code == 404:
raise AgentNotFound(agent_id=agent_id)
# POST-002 (INV-002): a 502 on a BOUND create is the synchronous Bifrost
# handshake failing. Gated on `bifrost is not None` — an unbound create's
# 502 is a generic upstream fault and stays SessionApiFailed.
if bifrost is not None and resp.status_code == 502:
raise BifrostHandshakeFailed(
bifrost_error=_bifrost_error_from(resp), body=resp.content
)
if resp.status_code != 201:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
return SessionInfo(
session_id=body["session_id"],
agent_id=body["agent_id"],
created_at=body["created_at"],
last_active=body["last_active"],
metadata=body.get("metadata", {}),
message_count=body["message_count"],
name=None,
archived=False,
tags=[],
kind=body.get("kind"), # INV-001 amendment (#161): "ephemeral"|"foundational"|None
config=body.get("config"), # frozen ephemeral config; None for foundational
)
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
"""GET /agents — list available agents. See contract FN list_agents (issue #8).
No request params, no pagination. Returns server-ordered list. Optional
fields are defaulted to None / [] / {} per INV-005.
"""
assert client is not None
resp = await client.get("/agents")
if resp.status_code != 200:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
return [
AgentInfo(
agent_id=item["agent_id"],
name=item["name"],
description=item["description"],
version=item.get("version"),
capabilities=item.get("capabilities") or [],
supported_models=item.get("supported_models") or [],
persona_traits=item.get("persona_traits") or {},
ui_hints=item.get("ui_hints") or {},
)
for item in body
]
async def get_persona_state(
client: httpx.AsyncClient, agent_id: str
) -> dict[str, Any]:
"""GET /agents/{agent_id}/persona_state — fetch current persona snapshot.
Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the
`affect_update` SSE event's `status="current"` emission: pad,
dominant_emotion, emotions_active, baseline_pad, mood_drift,
last_updated_at. Bootstrap read for clients that want to populate a
persona pane on session-open without waiting for turn-1's `affect_update`.
Auth: requires Heimdall `persona.read` scope (user-tier default).
Failure modes (mapped to typed exceptions per the spec error_codes):
- 404 `persona_not_configured` → PersonaNotConfigured (persona-disabled
agents: domari / muninn, and all Tier 3 in Phase 2.0)
- 404 `agent_not_available` → AgentNotAvailable (unknown agent_id)
- 403 `auth_scope_denied` → AuthScopeDenied (key lacks persona.read)
- any other non-2xx → SessionApiFailed (preserves the broader-error
precedent from list_agents / list_sessions / create_session)
"""
assert client is not None
assert agent_id and isinstance(agent_id, str)
resp = await client.get(f"/agents/{agent_id}/persona_state")
if resp.status_code == 200:
return resp.json()
# Discriminate the 4xx error_code sub-codes; everything else falls
# through. Worldtree returns errors as either flat `{"error_code": …}`
# OR FastAPI-default `{"detail": {"error_code": …}}` depending on
# which handler raised — unwrap both shapes (real wire observed
# 2026-05-28 returning the detail-nested form for auth_scope_denied
# from /agents/{id}/persona_state).
try:
err = resp.json()
except ValueError:
err = None
error_code: str | None = None
if isinstance(err, dict):
error_code = err.get("error_code")
if error_code is None and isinstance(err.get("detail"), dict):
error_code = err["detail"].get("error_code")
if resp.status_code == 404 and error_code == "persona_not_configured":
raise PersonaNotConfigured(agent_id=agent_id)
if resp.status_code == 404 and error_code == "agent_not_available":
raise AgentNotAvailable(agent_id=agent_id)
if resp.status_code == 403 and error_code == "auth_scope_denied":
raise AuthScopeDenied(scope="persona.read")
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_me(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /me — the authenticated principal's identity + key metadata (spec §GET /me).
Boot-time whoami: verify the key without agent-config side effects. Returns
the parsed dict verbatim (freeform per the frozen OpenAPI; the spec documents
`{user_id, scopes, tier, display_name?, key_id?, key_label?, ...}`, optional
fields omitted-not-null). 401 (bad/absent key when auth is enabled) — like
every other non-200 — surfaces as SessionApiFailed (get_persona_state
precedent). Read-only, rate-exempt, no audit emission.
"""
assert client is not None
resp = await client.get("/me")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def list_character_models(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /models/available-for-characters — character-capable model profiles (#161).
Requires `character.read`. Returns `{items: [{name, description, thinking}]}`.
Parsed dict verbatim; any non-200 → SessionApiFailed.
"""
assert client is not None
resp = await client.get("/models/available-for-characters")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def create_character(
client: httpx.AsyncClient, character: dict[str, Any], *, state: dict[str, Any] | None = None
) -> dict[str, Any]:
"""POST /characters — create a transient character (#161). Requires `character.write`.
Body is `{character, state}` (state optional — a CharacterStateSchema for
mid-conversation rehydration). Returns 201 `{character_id, ttl_expires_at}`;
any non-201 → SessionApiFailed.
"""
assert client is not None
assert isinstance(character, dict) and character
resp = await client.post("/characters", json={"character": character, "state": state})
if resp.status_code == 201:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_character_state(client: httpx.AsyncClient, character_id: str) -> dict[str, Any]:
"""GET /characters/{character_id}/state — live runtime state (#161). Requires `character.read`.
Returns `{schema_version, pad, emotions_active, mood_drift, goal_signal_history}`;
refreshes the character's TTL. Any non-200 → SessionApiFailed.
"""
assert client is not None
assert character_id and isinstance(character_id, str)
resp = await client.get(f"/characters/{character_id}/state")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def delete_character(client: httpx.AsyncClient, character_id: str) -> None:
"""DELETE /characters/{character_id} — remove a transient character (#161).
Requires `character.write`. Bound sessions detach (next turn → 410
character_not_found). 200/204 → None; any other status → SessionApiFailed.
"""
assert client is not None
assert character_id and isinstance(character_id, str)
resp = await client.delete(f"/characters/{character_id}")
if resp.status_code in (200, 204):
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def set_persona_state(
client: httpx.AsyncClient, session_id: str, snapshot: dict[str, Any]
) -> None:
"""POST /sessions/{session_id}/persona_state — set a session's persona state (affect injection).
The request body is FREEFORM on the wire (the OpenAPI declares no request
schema), but worldtree-dev's prose now pins the canonical shape (#317):
`{"pad": {"pleasure": p, "arousal": a, "dominance": d}}` — a named-key dict
(each in [-1, 1]), NOT a bare list; PAD-only, session-scoped, pull-over-push
(#289). The caller supplies the snapshot. 204 No Content → None; any other
status → SessionApiFailed.
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert isinstance(snapshot, dict)
resp = await client.post(f"/sessions/{session_id}/persona_state", json=snapshot)
if resp.status_code == 204:
return None
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_bifrost(
client: httpx.AsyncClient, session_id: str, *, admin_key: str
) -> dict[str, Any]:
@@ -570,108 +253,3 @@ async def get_session_bifrost(
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_tools(client: httpx.AsyncClient, session_id: str) -> dict[str, Any]:
"""GET /sessions/{session_id}/tools — owner-scoped tool inventory (spec #183).
Returns the merged tool list the LLM saw at turn-fire: `{agent_id,
builtin_tools: [...], bifrost_tools: [{name, description, parameters}, ...]}`.
Owner-scoped (`ctx.user_id == session.user_id`) — reachable with the consumer
key, NO admin scope. Cross-owner access returns 404 `session_not_found`
(existence-hiding); a revoked session returns 401 `auth_revoked`. Parsed dict
verbatim; any non-200 → SessionApiFailed (mirrors get_persona_state).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
resp = await client.get(f"/sessions/{session_id}/tools")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
"""GET /capabilities — server capability discovery (spec §Ephemeral Templates).
Returns `{ephemeral_templates: {echo: {allowed_models, default_model,
system_prompt_max_bytes}}}` — what the server offers before a client decides
to instantiate. Any authenticated caller may read it (no scope). Parsed dict
verbatim; any non-200 → SessionApiFailed.
"""
assert client is not None
resp = await client.get("/capabilities")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def write_authored_history(
client: httpx.AsyncClient,
session_id: str,
*,
content: str,
idempotency_key: str,
author: str = "assistant",
effects: str | None = None,
claimed_original_at: str | None = None,
) -> dict[str, Any]:
"""POST /sessions/{session_id}/history — the #347 authored-history-write primitive.
Write one model-visible turn into the session's ledger AS the bound agent,
WITHOUT a generation and WITHOUT lived-turn side effects (the SillyTavern
"first message"). v1: `author="assistant"`, `effects` omitted (== "none"),
`idempotency_key` REQUIRED (per-session dedup). The server pins the body
(`AuthoredWriteRequest`, `extra="forbid"`), so `effects` /
`claimed_original_at` are sent only when non-None — never as null keys.
Success is 201 (fresh) or 200 (idempotent replay, byte-identical body); both
return the `AuthoredTurnResponse` dict verbatim (`{author, content_chars,
injected_at, phase, seq, session_id, turn_id}` — provenance is audit-only,
never on this body).
404 → `AuthoredHistoryUnavailable` (hide-existence: feature-absent /
ungranted / session-absent are indistinguishable by design; the caller falls
back and NEVER capability-probes — server INV-347-1). Any other non-2xx →
`SessionApiFailed` (notably 409 `generation_active`, 422 `content_too_long` /
`validation_failed`).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert idempotency_key and isinstance(idempotency_key, str)
assert author and isinstance(author, str)
body: dict[str, Any] = {
"author": author,
"content": content,
"idempotency_key": idempotency_key,
}
if effects is not None:
body["effects"] = effects
if claimed_original_at is not None:
body["claimed_original_at"] = claimed_original_at
resp = await client.post(f"/sessions/{session_id}/history", json=body)
if resp.status_code in (200, 201):
return resp.json()
if resp.status_code == 404:
raise AuthoredHistoryUnavailable(session_id=session_id)
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def get_session_messages(
client: httpx.AsyncClient, session_id: str
) -> dict[str, Any]:
"""GET /sessions/{session_id}/messages — the session's message history.
Un-deferred as the #347 seed read-back: a seeded turn renders as a normal
`role=assistant` message (model-invisible provenance — indistinguishable
from a lived turn on read). Returns `{session_id, items: [{seq, role,
content, ...}], next_cursor}` verbatim; owner-scoped; any non-200 →
`SessionApiFailed`. v1 reads the server default page (no pagination params —
add limit/cursor when a caller needs scrollback).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
resp = await client.get(f"/sessions/{session_id}/messages")
if resp.status_code == 200:
return resp.json()
raise SessionApiFailed(status=resp.status_code, body=resp.content)
-491
View File
@@ -6,7 +6,6 @@ Implements docs/contracts/issues/1.contract.md.
from __future__ import annotations
import json
import re
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any, NamedTuple
@@ -14,8 +13,6 @@ from typing import Any, NamedTuple
import httpx
import httpx_sse
_INT_RE = re.compile(r"^-?\d+$")
class SseId(NamedTuple):
"""Parsed composite SSE wire `id:` per spec §SSE id format."""
@@ -24,155 +21,6 @@ class SseId(NamedTuple):
seq: int
@dataclass(frozen=True)
class WorkerPhase:
"""SSE event `worker_phase`: agent entered a new processing phase."""
sse_id: SseId
phase: str
turn_id: int
@dataclass(frozen=True)
class Thinking:
"""SSE event `thinking`: incremental thinking content from thinking-enabled models."""
sse_id: SseId
content: str
@dataclass(frozen=True)
class Text:
"""SSE event `text`: an incremental response-text delta."""
sse_id: SseId
content: str
@dataclass(frozen=True)
class TextBoundary:
"""SSE event `text_boundary`: speakable breakpoint after a `text` event."""
sse_id: SseId
kind: str
char_offset: int
ts: str
@dataclass(frozen=True)
class ToolStart:
"""SSE event `tool_start`: agent is about to execute a tool."""
sse_id: SseId
name: str
arguments: dict[str, Any]
@dataclass(frozen=True)
class ToolResult:
"""SSE event `tool_result`: a tool call completed."""
sse_id: SseId
name: str
result: Any
duration_ms: int
@dataclass(frozen=True)
class Done:
"""Terminal SSE event `done`: turn succeeded."""
sse_id: SseId
phase: str
response: str
model: str
duration_ms: int
usage: dict[str, int]
@dataclass(frozen=True)
class Error:
"""Terminal SSE event `error`: turn failed."""
sse_id: SseId
phase: str
message: str
error_code: str | None
@dataclass(frozen=True)
class Cancelled:
"""Terminal SSE event `cancelled`: turn was cancelled server-side."""
sse_id: SseId
phase: str
turn_id: int
reason: str | None
partial_message_id: int | None
@dataclass(frozen=True)
class AwaitingLlmFirstToken:
"""SSE event `awaiting_llm_first_token`: heartbeat during slow first-token.
Fires at the configured interval (default 5s) during the gap between
`worker_phase` phase=BuildingPrompt and phase=CallingLLM. Lets clients
render a live "thinking for Ns…" indicator instead of a frozen line
during legitimate-slow first-token latency. Stops the moment CallingLLM
fires (defense-in-depth at three sites); no heartbeat after Cancelled
or stalled terminal events. Tool round-trip re-entries do NOT re-fire
heartbeats — INV-201-5 scopes the mechanism to the FIRST gap only.
`elapsed_ms_since_building_prompt` is server-authoritative
`time.monotonic()`-based — independent of network latency or clock
skew, monotonically increasing across the heartbeat sequence.
See docs/conversation-api-spec.md § awaiting_llm_first_token
(Worldtree #201, v0.29.0).
"""
sse_id: SseId
turn_id: int
elapsed_ms_since_building_prompt: float
@dataclass(frozen=True)
class AffectUpdate:
"""SSE event `affect_update`: persona-state observability snapshot.
Two emissions per qualifying turn (persona-enabled agent on non-
ephemeral session): `status="current"` at turn start carrying the full
snapshot, `status="scheduled"` after post-turn appraisal kicks off
(lightweight — `snapshot` is None). Suppressed entirely for persona-
disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined
agents (Phase 2.0), and ephemeral sessions.
Bootstrap reads available via `GET /agents/{agent_id}/persona_state`
(same `snapshot` shape, requires `persona.read` scope).
See docs/conversation-api-spec.md § affect_update (Worldtree #204,
v0.28.0).
"""
sse_id: SseId
status: str # "current" | "scheduled"
turn_id: int
snapshot: dict[str, Any] | None # None when status="scheduled"
Event = (
WorkerPhase
| Thinking
| Text
| TextBoundary
| ToolStart
| ToolResult
| Done
| Error
| Cancelled
| AffectUpdate
| AwaitingLlmFirstToken
)
@dataclass(frozen=True)
@@ -258,31 +106,6 @@ class TurnLaunchUnavailable(SseConnectFailed):
self.message = message
# Canonical error_codes (Worldtree #331 / v1.0.0b2): 409 -> agent_not_available,
# 503 -> not_ready (retryable; re-pinned from internal_error). Used only as a
# fallback default when the body omits error_code — the real code is surfaced
# verbatim from the {detail:{error_code,message}} envelope.
_EAGER_TURN_FAILURE_CODE = {409: "agent_not_available", 503: "not_ready"}
def _eager_failure_fields(body: bytes, status: int) -> tuple[str, str]:
"""Extract (error_code, message) from an eager turn-launch failure body
(#331). Accepts the Worldtree `{"detail": {...}}` envelope OR a flat
`{error_code, message}`; falls back to a status-derived default code and a
generic message when the body is absent / non-JSON / malformed."""
try:
parsed: Any = json.loads(body)
except (json.JSONDecodeError, ValueError):
parsed = None
src: dict[str, Any] = {}
if isinstance(parsed, dict):
detail = parsed.get("detail")
src = detail if isinstance(detail, dict) else parsed
code = src.get("error_code") or _EAGER_TURN_FAILURE_CODE[status]
message = src.get("message")
if not isinstance(message, str):
message = f"turn launch failed (HTTP {status})"
return str(code), message
class SseConnectionDropped(Exception):
@@ -352,275 +175,6 @@ class CancelFailed(Exception):
self.body = body
@dataclass(frozen=True)
class CancelResult:
"""Response envelope from POST /sessions/{id}/turns/{turn_id}/cancel."""
turn_id: int
cancelled: bool
reason: str | None
partial_message_id: int | None
def _envelope_for_type(body: dict[str, Any], sse_id: SseId) -> Event:
"""Dispatch a parsed JSON body to its typed Event variant."""
t = body["type"]
if t == "text":
return Text(sse_id=sse_id, content=body["content"])
if t == "worker_phase":
return WorkerPhase(sse_id=sse_id, phase=body["phase"], turn_id=body["turn_id"])
if t == "thinking":
return Thinking(sse_id=sse_id, content=body["content"])
if t == "text_boundary":
return TextBoundary(
sse_id=sse_id,
kind=body["kind"],
char_offset=body["char_offset"],
ts=body["ts"],
)
if t == "tool_start":
return ToolStart(sse_id=sse_id, name=body["name"], arguments=body["arguments"])
if t == "tool_result":
return ToolResult(
sse_id=sse_id,
name=body["name"],
result=body["result"],
duration_ms=body["duration_ms"],
)
if t == "done":
return Done(
sse_id=sse_id,
phase=body["phase"],
response=body["response"],
model=body["model"],
duration_ms=body["duration_ms"],
usage=body["usage"],
)
if t == "error":
return Error(
sse_id=sse_id,
phase=body.get("phase", "failed"),
message=body.get("message", ""),
error_code=body.get("error_code"),
)
if t == "cancelled":
return Cancelled(
sse_id=sse_id,
phase=body["phase"],
turn_id=body["turn_id"],
reason=body.get("reason"),
partial_message_id=body.get("partial_message_id"),
)
if t == "awaiting_llm_first_token":
# Worldtree #201 / v0.29.0: top-level heartbeat during BuildingPrompt
# → CallingLLM gap. Lets clients render live elapsed-time indicators
# instead of frozen lines on legitimate-slow first-token latency.
return AwaitingLlmFirstToken(
sse_id=sse_id,
turn_id=body["turn_id"],
elapsed_ms_since_building_prompt=body["elapsed_ms_since_building_prompt"],
)
if t == "affect_update":
# Worldtree #204 / v0.28.0: persona-state observability event.
# status="current" carries full snapshot at turn start;
# status="scheduled" omits snapshot (lightweight post-appraisal-
# kickoff notification).
return AffectUpdate(
sse_id=sse_id,
status=body["status"],
turn_id=body["turn_id"],
snapshot=body.get("snapshot"),
)
raise ValueError(f"unknown SSE event type: {t!r}")
async def _iter_events(
event_source: httpx_sse.EventSource,
*,
expected_turn_id: int | None,
) -> AsyncIterator[Event]:
"""Apply INV-002 (sse_id present + in range) and INV-003 (turn_id stable) per event.
`expected_turn_id=None` means "establish from the first event" (stream_turn semantics).
`expected_turn_id=N` means "every event must match N" (reconnect_turn semantics — the
first event is already a flip-candidate per INV-003).
"""
established = expected_turn_id
last_sse_id: SseId | None = None
terminal_seen = False
try:
async for sse in event_source.aiter_sse():
# Issue #7 INV-001: empty-data frames are keepalives — skip silently.
# ORDERING: this branch fires BEFORE _parse_sse_id; an empty-data event
# with a malformed id is silently swallowed (intentional — a keepalive
# with a bad id is still a keepalive). Don't reorder.
if sse.data == "":
continue
# v0.8.1: empty-id frames are also treated as keepalives. Worldtree
# SOMETIMES emits events without an `id:` line (observed mid-stream
# on the qwen3.6-35-a3b-heretic provider, 2026-05-25). Per the SSE
# RFC, events without ids are legitimate (they just don't update
# Last-Event-ID); the previous strict behavior crashed every turn
# on the offending agent. Treat same as empty-data: skip silently.
if sse.id == "":
continue
try:
sse_id = _parse_sse_id(sse.id)
except ValueError as exc:
raise MalformedSseId(raw=sse.id) from exc
if established is None:
established = sse_id.turn_id
elif sse_id.turn_id != established:
raise TurnIdFlip(established=established, got=sse_id.turn_id)
try:
body = json.loads(sse.data)
except json.JSONDecodeError as exc:
raise MalformedSseData(raw=sse.data) from exc
event = _envelope_for_type(body, sse_id=sse_id)
yield event
last_sse_id = sse_id
if isinstance(event, (Done, Error, Cancelled)):
terminal_seen = True
return
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ReadTimeout) as exc:
# ReadTimeout covers idle gaps that exceed httpx's read timeout — the SSE
# stream went quiet long enough for httpx to give up. Treat the same as a
# raw read error: surface as SseConnectionDropped so the caller can decide
# whether to reconnect_turn. (Callers SHOULD configure a long-or-disabled
# read timeout on their AsyncClient for SSE; this is defense in depth.)
raise SseConnectionDropped(last_seen_sse_id=last_sse_id) from exc
if not terminal_seen:
# Clean EOF before terminal event — INV-001 says stream MUST NOT end
# without exactly one Done/Error/Cancelled. Surface as connection drop;
# caller may reconnect_turn if it holds last_sse_id.
raise SseConnectionDropped(last_seen_sse_id=last_sse_id)
async def stream_turn(
client: httpx.AsyncClient, session_id: str, content: str
) -> AsyncIterator[Event]:
"""POST a message and yield typed Events. See contract FN stream_turn."""
assert client is not None
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
async with httpx_sse.aconnect_sse(
client,
"POST",
f"/sessions/{session_id}/messages",
json={"content": content},
) as event_source:
# Worldtree v1.0.0b1 (#331): turn-launch failures arrive EAGERLY as a
# status before any stream — 409 agent_not_available (pre-b1 this was a
# 200 + in-stream `error` event), 503 a transient retryable launch
# failure. Surface them as typed SseConnectFailed subclasses carrying
# error_code; request-level non-2xx (404 session_not_found, etc.) stay
# generic SseConnectFailed.
status = event_source.response.status_code
if status in (409, 503):
body = await event_source.response.aread()
code, message = _eager_failure_fields(body, status)
if status == 409:
raise AgentNotAvailable(body=body, error_code=code, message=message)
raise TurnLaunchUnavailable(body=body, error_code=code, message=message)
try:
event_source.response.raise_for_status()
except httpx.HTTPStatusError as exc:
body = await exc.response.aread()
raise SseConnectFailed(status=exc.response.status_code, body=body) from exc
async for event in _iter_events(event_source, expected_turn_id=None):
yield event
async def reconnect_turn(
client: httpx.AsyncClient,
session_id: str,
content: str,
last_event_id: str,
) -> AsyncIterator[Event]:
"""Re-POST with Last-Event-ID to resume. See contract FN reconnect_turn."""
assert client is not None
assert session_id and isinstance(session_id, str)
assert isinstance(content, str)
expected = _parse_sse_id(last_event_id)
async with httpx_sse.aconnect_sse(
client,
"POST",
f"/sessions/{session_id}/messages",
json={"content": content},
headers={"Last-Event-ID": last_event_id},
) as event_source:
status = event_source.response.status_code
if status != 200:
body_bytes = await event_source.response.aread()
try:
body = json.loads(body_bytes)
except json.JSONDecodeError:
body = {}
if status == 400:
raise InvalidLastEventId(raw=last_event_id)
if status == 410:
raise ResumeTurnFinished(turn_id=body.get("turn_id", expected.turn_id))
if status == 412:
raise ResumeBufferExpired(
turn_id=body.get("turn_id", expected.turn_id),
buffered_from_seq=body.get("buffered_from_seq", 0),
)
raise SseConnectFailed(status=status, body=body_bytes)
async for event in _iter_events(event_source, expected_turn_id=expected.turn_id):
yield event
async def stream_turn_resilient(
client: httpx.AsyncClient,
session_id: str,
content: str,
*,
max_reconnects: int = 5,
) -> AsyncIterator[Event]:
"""Resume-orchestration wrapper over stream_turn + reconnect_turn.
Yields ONE continuous Event stream; on `SseConnectionDropped` (mid-stream
drop or clean EOF before a terminal), resumes from the last-seen `sse_id`
via `reconnect_turn`, up to `max_reconnects` times, until a terminal
Done/Error/Cancelled arrives. The single shared surface presenters consume
for resilient streaming (design-brief §8b: "share the consumer, branch the
presenter"). Cross-process resume stays deferred to v2 (§8d): `last_seen`
lives only in this generator's frame. See contract FN stream_turn_resilient
(amendment 2026-06-30).
"""
assert client is not None
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert isinstance(max_reconnects, int) and max_reconnects >= 0
last_seen: SseId | None = None
reconnects = 0
gen = stream_turn(client, session_id, content)
while True:
try:
async for event in gen:
last_seen = event.sse_id
yield event
return # generator completed cleanly → terminal event reached (INV-001)
except SseConnectionDropped as drop:
# Prefer the id we tracked from a yielded event; fall back to the one
# the drop carries (covers a drop on the very first frame). Non-drop
# reconnect failures (412/410/400/flip) are NOT caught here — they
# propagate per the contract's "surface, not recover" policy.
seen = last_seen or drop.last_seen_sse_id
if seen is None or reconnects >= max_reconnects:
raise
reconnects += 1
gen = reconnect_turn(
client,
session_id,
content,
# A str cursor is already the composite id; an SseId is formatted.
last_event_id=seen if isinstance(seen, str) else f"{seen.turn_id}:{seen.seq}",
)
async def stream_admin_events(
client: httpx.AsyncClient,
@@ -667,48 +221,3 @@ async def stream_admin_events(
raise SseConnectionDropped(last_seen_sse_id=None) from exc
def _parse_sse_id(raw: str) -> SseId:
"""Parse the SSE wire `id:` as composite `{turn_id}:{seq}`. See contract FN _parse_sse_id."""
assert isinstance(raw, str)
parts = raw.split(":")
if len(parts) != 2:
raise ValueError(f"expected '{{turn_id}}:{{seq}}', got: {raw[:64]!r}")
turn_id_str, seq_str = parts
if not _INT_RE.match(turn_id_str) or not _INT_RE.match(seq_str):
raise ValueError(f"expected '{{turn_id}}:{{seq}}' with decimal ints, got: {raw[:64]!r}")
turn_id = int(turn_id_str)
seq = int(seq_str)
if turn_id < 1 or seq < 1:
raise ValueError(f"expected both ints >= 1 per spec, got: {raw[:64]!r}")
return SseId(turn_id=turn_id, seq=seq)
async def cancel_turn(
client: httpx.AsyncClient,
session_id: str,
turn_id: int,
*,
persist_partial: bool = False,
) -> CancelResult:
"""POST /sessions/{id}/turns/{turn_id}/cancel. See contract FN cancel_turn."""
assert client is not None
assert session_id and isinstance(session_id, str)
assert isinstance(turn_id, int) and turn_id > 0
params = {"persist_partial": "true"} if persist_partial else None
resp = await client.post(
f"/sessions/{session_id}/turns/{turn_id}/cancel", params=params
)
if resp.status_code == 404:
raise CancelTurnNotFound(turn_id=turn_id)
if resp.status_code == 409:
raise CancelAlreadyCompleted(turn_id=turn_id)
if resp.status_code != 200:
raise CancelFailed(status=resp.status_code, body=resp.content)
body = resp.json()
return CancelResult(
turn_id=body["turn_id"],
cancelled=body["cancelled"],
reason=body.get("reason"),
partial_message_id=body.get("partial_message_id"),
)
+110 -292
View File
@@ -1,259 +1,55 @@
"""Worldtree Tier 3 (consumer-defined) agent lifecycle client.
"""Worldtree Tier 3 (consumer-defined) agent CLI + caller-semantic exceptions.
Implements docs/contracts/issues/15.contract.md. Caller-owned httpx.AsyncClient
posture (same as ratatoskr.sessions). Exposes three lifecycle operations:
The define / patch / delete wire calls route through the worldtree-sdk adapter
(``ratatoskr.wt.define_agent`` / ``patch_agent`` / ``delete_agent``); this module owns
the ``python -m ratatoskr.tier3`` CLI and the Tier-3 caller-semantic exception
taxonomy the adapter re-raises (quota / user-id / field-not-mutable / layer-deferred /
not-found). The picker already handles colon-containing agent_ids generically
(issue #8).
- ``define_agent`` — POST /agents/define
- ``patch_agent`` — PATCH /agents/<id>
- ``delete_agent`` — DELETE /agents/<id>
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
already handles colon-containing agent_ids generically (issue #8); session
creation works unchanged via ``ratatoskr.sessions.create_session``.
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
The old hand-rolled httpx wrappers + the ``Tier3AgentInfo`` dataclass were deleted in
the worldtree-sdk cutover (issue #20, slice-4); the adapter returns the SDK's
open-world define/patch dicts (echoing ``role`` post-b128, spec 1.2), read here as
mappings. ``wt`` imports this module's exceptions at module level; this module imports
``wt`` only lazily inside the CLI handlers, so there is no import cycle.
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass
import sys
from collections.abc import Mapping
from typing import Any
import httpx
from ratatoskr.sessions import SessionApiFailed
# Per spec §2627: agent_name + user_id slugs are `[a-z][a-z0-9-]{2,63}`.
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
# The Tier-3 caller-semantic exceptions live in `sessions` (never run as `__main__`)
# so the adapter's raise and this CLI's `except` reference the SAME class objects —
# see the header note in `sessions.py`. `main()` catches these; `wt` raises them.
from ratatoskr.sessions import (
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
)
@dataclass(frozen=True)
class Tier3AgentInfo:
"""Worldtree Tier 3 agent envelope returned by define / patch.
INV-001: ``agent_id`` is always shape ``"<user_id>:<agent_name>"`` —
constructed server-side from the auth's user_id + the supplied agent_name.
"""
agent_id: str
user_id: str
agent_name: str
system_prompt: str
model: str
created_at: str
updated_at: str
class Tier3QuotaExceeded(Exception):
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
on the Heimdall key. ``retry_after`` captures the Retry-After header
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero)."""
def __init__(self, *, retry_after: int) -> None:
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
self.retry_after = retry_after
class Tier3UserIdUnsupported(Exception):
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
is not slug-safe per Phase 2.0 gate (spec §2626)."""
def __init__(self) -> None:
super().__init__("tier3 caller user_id is not slug-safe")
class Tier3FieldNotMutable(Exception):
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
carried a key that's immutable post-define (``agent_name``, ``user_id``,
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
def __init__(self, *, field: str | None) -> None:
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
self.field = field
class Tier3LayerDeferred(Exception):
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
Note: ``define_agent`` never sends layer fields, so this exception is
defense-against-server-side-changes / forward-compat. INV-001 in the
request body construction is the first line of defense.
"""
def __init__(self, *, field: str | None) -> None:
super().__init__(f"tier3 layer field deferred: {field!r}")
self.field = field
class Tier3AgentNotFound(Exception):
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
(spec §2634 + §2641)."""
def __init__(self, *, agent_id: str) -> None:
super().__init__(f"tier3 agent not found: {agent_id!r}")
self.agent_id = agent_id
def _extract_error_code(resp: httpx.Response) -> str | None:
"""Pluck the ``detail.error_code`` from a Worldtree error envelope.
Worldtree wraps API errors in ``{"detail": {"error_code": "...", ...}}``
per the spec. Returns None on shape mismatch (so callers fall through
to the generic ``SessionApiFailed`` branch).
"""
try:
body = resp.json()
except ValueError:
return None
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, dict):
code = detail.get("error_code")
if isinstance(code, str):
return code
return None
def _extract_error_field(resp: httpx.Response) -> str | None:
"""Pluck ``detail.field`` from a Worldtree error envelope (used for
``field_not_mutable`` and ``layer_deferred`` to surface which field
triggered the rejection). Returns None on shape mismatch.
"""
try:
body = resp.json()
except ValueError:
return None
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, dict):
field = detail.get("field")
if isinstance(field, str):
return field
return None
def _parse_tier3_agent_info(body: dict) -> Tier3AgentInfo:
"""Parse a Worldtree Tier 3 agent JSON body into the frozen dataclass."""
return Tier3AgentInfo(
agent_id=body["agent_id"],
user_id=body["user_id"],
agent_name=body["agent_name"],
system_prompt=body["system_prompt"],
model=body["model"],
created_at=body["created_at"],
updated_at=body["updated_at"],
)
async def define_agent(
client: httpx.AsyncClient,
*,
agent_name: str,
system_prompt: str,
role: str,
) -> Tier3AgentInfo:
"""POST /agents/define — create a Tier 3 agent.
See contract FN define_agent. Validates the agent_name slug client-side
before the network round-trip; server-side validation is the safety net.
Returns a fully populated Tier3AgentInfo on 201. Routes documented error
codes to typed exceptions; unknown non-2xx → SessionApiFailed.
"""
assert client is not None
assert _SLUG_RE.match(agent_name), (
f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
)
assert system_prompt, "system_prompt must be non-empty"
assert role, "role must be non-empty"
# b125 drift: /agents/define takes `role` (a model-role, e.g. "thoughtful-character")
# in the request; the response echoes it back as `model`. See #15 follow-up.
body = {
"agent_name": agent_name,
"system_prompt": system_prompt,
"role": role,
}
resp = await client.post("/agents/define", json=body)
if resp.status_code == 201:
return _parse_tier3_agent_info(resp.json())
if resp.status_code == 429:
# Spec §2675: 51st define → 429 with Retry-After: 0.
try:
retry_after = int(resp.headers.get("Retry-After", "0"))
except (TypeError, ValueError):
retry_after = 0
raise Tier3QuotaExceeded(retry_after=retry_after)
if resp.status_code == 403:
if _extract_error_code(resp) == "tier3_user_id_unsupported":
raise Tier3UserIdUnsupported()
if resp.status_code == 422:
code = _extract_error_code(resp)
if code == "layer_deferred":
raise Tier3LayerDeferred(field=_extract_error_field(resp))
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def patch_agent(
client: httpx.AsyncClient,
agent_id: str,
*,
system_prompt: str | None = None,
role: str | None = None,
) -> Tier3AgentInfo:
"""PATCH /agents/<id> — mutate system_prompt and/or model.
See contract FN patch_agent. Per spec §2641: only system_prompt + model
are mutable in Phase 2.0; any other key returns 422 field_not_mutable.
"""
assert client is not None
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
assert system_prompt is not None or role is not None, (
"patch requires at least one of system_prompt or role"
)
body: dict[str, str] = {}
if system_prompt is not None:
body["system_prompt"] = system_prompt
if role is not None:
body["role"] = role
resp = await client.patch(f"/agents/{agent_id}", json=body)
if resp.status_code == 200:
return _parse_tier3_agent_info(resp.json())
if resp.status_code == 404:
raise Tier3AgentNotFound(agent_id=agent_id)
if resp.status_code == 422:
code = _extract_error_code(resp)
if code == "field_not_mutable":
raise Tier3FieldNotMutable(field=_extract_error_field(resp))
raise SessionApiFailed(status=resp.status_code, body=resp.content)
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
"""DELETE /agents/<id> — owner hard-delete (cancels active sessions
server-side per spec §2636).
See contract FN delete_agent. 204 on success; 404 if the agent_id
doesn't exist; other non-2xx → SessionApiFailed.
"""
assert client is not None
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
resp = await client.delete(f"/agents/{agent_id}")
if resp.status_code == 204:
return
if resp.status_code == 404:
raise Tier3AgentNotFound(agent_id=agent_id)
raise SessionApiFailed(status=resp.status_code, body=resp.content)
def _str_field(info: Mapping[str, Any], key: str, *, default: str = "") -> str:
"""A string field off an open-world response dict, or `default` when the key is
absent / null / non-string — so a partial or drifted 2xx define/patch response
degrades rather than KeyError/AttributeError-crashing the CLI presenter (the
"open-world reads degrade, never crash the presenter" invariant; heid-bug-hunt)."""
value = info.get(key)
return value if isinstance(value, str) else default
# ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------
#
# Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes
# mirror ratatoskr.cli: 0 happy / 10 usage / 11 auth / 20 api-failure /
# 21 network. Outbound requests carry the same User-Agent string.
# 21 network. Outbound requests carry the same User-Agent string. The wire calls
# route through the worldtree-sdk adapter (ratatoskr.wt) over a ratatoskr-owned
# injected transport (INV-CUT-1: the SDK never closes it).
class _Tier3UsageError(Exception):
@@ -284,7 +80,7 @@ def _build_parser() -> argparse.ArgumentParser:
help="Model role, e.g. thoughtful-character (see GET /models/available-for-characters).",
)
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or model.")
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or role.")
p_patch.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
p_patch.add_argument("--system-prompt", dest="system_prompt", default=None)
p_patch.add_argument("--role", default=None)
@@ -308,46 +104,63 @@ def _resolve_auth(ns: argparse.Namespace) -> tuple[str, str]:
return api_key, server_url
def _transport(server_url: str, api_key: str) -> httpx.AsyncClient:
"""The ratatoskr-owned httpx transport the adapter's WorldtreeClient is built
over. Carries the User-Agent + a generous read timeout for agent CRUD; the SDK
re-applies auth per request (the default bearer here just mirrors it)."""
from ratatoskr.cli import USER_AGENT
return httpx.AsyncClient(
base_url=server_url,
headers={"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
)
async def _run_define(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr import wt
from ratatoskr.local_agents import (
LocalAgentEntry,
add_local_agent,
make_description,
)
async with httpx.AsyncClient(
base_url=server_url,
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": USER_AGENT,
},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
) as client:
info = await define_agent(
async with _transport(server_url, api_key) as transport:
client = wt.build_client(server_url, api_key=api_key, transport=transport)
info = await wt.define_agent(
client,
agent_name=ns.name,
system_prompt=ns.system_prompt,
role=ns.role,
)
# v0.8.0: persist to local index so the picker can show it.
add_local_agent(
LocalAgentEntry(
agent_id=info.agent_id,
agent_name=info.agent_name,
model=info.model,
description=make_description(info.system_prompt),
defined_at=info.created_at,
# Open-world define dict — read defensively (echoes `role` post-b128, not
# `model`). A partial/drifted 2xx must not crash the presenter.
agent_id = _str_field(info, "agent_id")
if not agent_id: # a 2xx with no usable agent_id is a malformed success
sys.stderr.write(f"[api_failed] define returned no usable agent_id: {info!r}\n")
return 20
role = _str_field(info, "role", default="?")
agent_name = _str_field(info, "agent_name")
# v0.8.0: persist to local index so the picker can show it — only for a
# well-formed identity (agent_id + agent_name); else skip the write, still print.
if agent_name:
add_local_agent(
LocalAgentEntry(
agent_id=agent_id,
agent_name=agent_name,
role=role,
description=make_description(_str_field(info, "system_prompt")),
defined_at=_str_field(info, "created_at"),
)
)
)
print(f"defined {info.agent_id} ({info.model})")
print(f"defined {agent_id} ({role})")
return 0
async def _run_patch(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr import wt
from ratatoskr.local_agents import (
LocalAgentEntry,
make_description,
@@ -358,48 +171,43 @@ async def _run_patch(ns: argparse.Namespace) -> int:
raise _Tier3UsageError(
"patch requires at least one of --system-prompt or --role"
)
async with httpx.AsyncClient(
base_url=server_url,
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": USER_AGENT,
},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
) as client:
info = await patch_agent(
async with _transport(server_url, api_key) as transport:
client = wt.build_client(server_url, api_key=api_key, transport=transport)
info = await wt.patch_agent(
client,
ns.agent_id,
system_prompt=ns.system_prompt,
role=ns.role,
)
# v0.8.0: refresh local index with the post-patch state.
update_local_agent(
LocalAgentEntry(
agent_id=info.agent_id,
agent_name=info.agent_name,
model=info.model,
description=make_description(info.system_prompt),
defined_at=info.updated_at,
# Open-world patch dict — read defensively (same degrade-not-crash posture).
agent_id = _str_field(info, "agent_id")
if not agent_id: # a 2xx with no usable agent_id is a malformed success
sys.stderr.write(f"[api_failed] patch returned no usable agent_id: {info!r}\n")
return 20
agent_name = _str_field(info, "agent_name")
# v0.8.0: refresh local index with the post-patch state (well-formed identity only).
if agent_name:
update_local_agent(
LocalAgentEntry(
agent_id=agent_id,
agent_name=agent_name,
role=_str_field(info, "role", default="?"),
description=make_description(_str_field(info, "system_prompt")),
defined_at=_str_field(info, "updated_at"),
)
)
)
print(f"patched {info.agent_id}")
print(f"patched {agent_id}")
return 0
async def _run_delete(ns: argparse.Namespace) -> int:
api_key, server_url = _resolve_auth(ns)
from ratatoskr.cli import USER_AGENT
from ratatoskr import wt
from ratatoskr.local_agents import remove_local_agent
async with httpx.AsyncClient(
base_url=server_url,
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": USER_AGENT,
},
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
) as client:
await delete_agent(client, ns.agent_id)
async with _transport(server_url, api_key) as transport:
client = wt.build_client(server_url, api_key=api_key, transport=transport)
await wt.delete_agent(client, ns.agent_id)
# v0.8.0: drop from local index so the picker stops listing it.
remove_local_agent(ns.agent_id)
print(f"deleted {ns.agent_id}")
@@ -419,6 +227,10 @@ def main(argv: list[str] | None = None) -> int:
import asyncio
import sys
import worldtree_sdk as wtsdk
from ratatoskr.wt import SessionApiFailed
parser = _build_parser()
try:
ns = parser.parse_args(argv)
@@ -459,10 +271,16 @@ def main(argv: list[str] | None = None) -> int:
return 20
except SessionApiFailed as exc:
sys.stderr.write(
f"[api_failed] status={exc.status} body={exc.body!r}\n"
f"[api_failed] status={exc.status} "
f"error_code={exc.error_code!r} body={exc.body!r}\n"
)
return 20
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.TransportError,
wtsdk.ConnectFailed, # SDK normalizes any pre-response transport failure here
) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
+133 -66
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import asyncio
import itertools
import json
from collections.abc import AsyncIterator, Callable
from collections.abc import AsyncIterator, Callable, Mapping
from dataclasses import asdict, dataclass, is_dataclass
from importlib.metadata import version as _pkg_version
@@ -26,8 +26,16 @@ from starlette.responses import (
)
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from worldtree_sdk import (
CancelledEvent,
ConnectFailed,
DoneEvent,
ErrorEvent,
WorldtreeClient,
)
from ratatoskr import local_agents as _local_agents
from ratatoskr import wt
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.sessions import (
AgentNotAvailable,
@@ -38,33 +46,45 @@ from ratatoskr.sessions import (
BifrostHandshakeFailed,
PersonaNotConfigured,
SessionApiFailed,
create_session,
endpoint_for_plane,
get_persona_state,
get_session_bifrost,
get_session_messages,
get_session_tools,
list_agents,
)
# The turn path (create / stream / cancel / tools / messages) AND the agents /
# persona-state reads are served by the worldtree-sdk adapter (`wt.*`), which raises
# ratatoskr's caller-semantic exceptions (DEC-2). The remaining hand-rolled endpoint
# (admin bifrost) stays on the `sessions` / `sse_client` wrappers until slice 6.
from ratatoskr.sse_client import (
AdminEvent,
CancelAlreadyCompleted,
CancelFailed,
Cancelled,
CancelTurnNotFound,
Done,
Error,
MalformedSseData,
MalformedSseId,
SseConnectFailed,
SseConnectionDropped,
TurnIdFlip,
cancel_turn,
stream_admin_events,
stream_turn_resilient,
)
def _wt_client(client: httpx.AsyncClient, *, 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)."""
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
# not silently dropped to the placeholder key (which would misauthenticate).
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
)
def _static_dir() -> str:
"""Locate the bundled static/ directory inside the installed package.
@@ -108,20 +128,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)
@@ -163,16 +194,17 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
try:
async with client_factory() as client:
info = await create_session(
client,
wt_client = _wt_client(client)
info = await wt.create_session(
wt_client,
agent_id,
end_user_id=end_user_id,
bifrost=bifrost,
consumer_key=consumer_key if bifrost else None,
)
# #347 authored first-message: seed the agent's preset opening
# (best-effort; never blocks create — see first_message INV-001).
await seed_preset_first_message(client, info.session_id, agent_id)
# #347 authored first-message: seed the agent's preset opening (best-effort;
# never blocks create). Routed through the wt adapter (slice-3).
await seed_preset_first_message(wt_client, info["session_id"], agent_id)
except AgentNotFound:
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
except BifrostConsumerKeyMissing:
@@ -188,12 +220,13 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
},
status_code=502,
)
except SessionApiFailed as exc:
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_api_failed", "status": exc.status},
status_code=exc.status,
)
payload = _as_dict(info)
# The adapter returns the SDK's open-world create dict; the browser reads it as-is.
payload = dict(info)
if bifrost is not None:
# Bound-state for the UI indicator — plane + endpoint only, never the key.
payload["bifrost"] = {
@@ -251,25 +284,22 @@ async def _submit_turn_endpoint(request: Request) -> JSONResponse:
def _event_to_browser_payload(event: object) -> tuple[str, dict]:
"""Serialize an upstream Event dataclass to (browser_event_type, json_dict).
"""Serialize an SDK `TurnEvent` to (browser_event_type, json_dict).
Per INV-008 + FN stream_turn_endpoint STEP 3. The dict shape is
locked by tests/fixtures/presentation_contract.json — one entry per
Event type. Implementation: snake_case class name as event_type;
asdict(event) with sse_id flattened to "T:S" string.
Per INV-008 + FN stream_turn_endpoint STEP 3. The browser contract
(tests/fixtures/presentation_contract.json) is preserved: the SDK's `raw` is
the wire body — the same per-type field set the old dataclasses carried — so the
payload is `raw` minus the redundant `type`, plus the composite `sse_id` string
(already "T:S"). The browser event_type is the wire `type` ("text" / "done" /
…), NOT the SDK class name. Open-world: additive server fields pass through.
"""
type_name = type(event).__name__
# CamelCase → snake_case
browser_type = "".join(
("_" + c.lower() if c.isupper() and i else c.lower())
for i, c in enumerate(type_name)
)
data = asdict(event) # type: ignore[arg-type]
sse_id = data.get("sse_id")
if isinstance(sse_id, (list, tuple)) and len(sse_id) == 2:
data["sse_id"] = f"{sse_id[0]}:{sse_id[1]}"
elif isinstance(sse_id, dict) and "turn_id" in sse_id and "seq" in sse_id:
data["sse_id"] = f"{sse_id['turn_id']}:{sse_id['seq']}"
browser_type = getattr(event, "type", "") or ""
raw = getattr(event, "raw", None)
# Open-world: degrade a non-mapping `raw` to an empty payload rather than letting
# dict(raw) raise (which would abort the SSE stream mid-response).
src = raw if isinstance(raw, Mapping) else {}
data = {k: v for k, v in src.items() if k != "type"}
data["sse_id"] = getattr(event, "sse_id", None)
return browser_type, data
@@ -281,6 +311,20 @@ def _format_sse(event_type: str, data: dict) -> bytes:
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode()
def _turn_id_from_sse_id(sse_id: object) -> int | None:
"""The turn component of the SDK's composite sse_id (`"{turn}:{seq}"`) — the
upstream cancel target, present on every frame (the SDK's top-level `turn_id` is
the body field, absent on text/thinking events)."""
if not isinstance(sse_id, str):
return None
head, _, _ = sse_id.partition(":")
try:
turn = int(head)
except ValueError:
return None
return turn if turn > 0 else None
async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
"""GET /api/turns/{session_id}/stream?turn_id=N → proxy upstream SSE.
@@ -302,24 +346,28 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
async def gen() -> AsyncIterator[bytes]:
client = client_factory()
wt_client = _wt_client(client)
try:
handle.status = "streaming"
try:
async for event in stream_turn_resilient(client, session_id, handle.content):
# v0.16.0: capture the upstream (Worldtree-assigned)
# turn_id from the first event so cancel paths target
# the real upstream turn, not our local counter.
async for event in wt.stream_turn(wt_client, session_id, handle.content):
# v0.16.0: capture the upstream (Worldtree-assigned) turn_id from
# the first event so cancel paths target the real upstream turn,
# not our local counter — parsed from the composite sse_id.
if handle.upstream_turn_id is None:
sse_id = getattr(event, "sse_id", None)
if sse_id is not None:
handle.upstream_turn_id = sse_id.turn_id
handle.upstream_turn_id = _turn_id_from_sse_id(
getattr(event, "sse_id", None)
)
event_type, data = _event_to_browser_payload(event)
yield _format_sse(event_type, data)
if isinstance(event, (Done, Error, Cancelled)):
handle.status = type(event).__name__.lower()
if isinstance(event, (DoneEvent, ErrorEvent, CancelledEvent)):
handle.status = event.type or "done"
break
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
MalformedSseData, TurnIdFlip) as exc:
except (wt.SessionApiFailed, SseConnectFailed, SseConnectionDropped,
MalformedSseId, MalformedSseData, TurnIdFlip) as exc:
# wt.SessionApiFailed covers the adapter's SessionRetired (410) mapping;
# without it a retired-session stream would escape gen() after partial
# frames as an uncaught 500, not a labeled `event: error`.
yield _format_sse(
"error",
{"exception": type(exc).__name__, "message": str(exc)},
@@ -330,7 +378,7 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
# turn (if it started) — never the local turn_id.
if handle.status == "streaming" and handle.upstream_turn_id is not None:
try:
await cancel_turn(client, session_id, handle.upstream_turn_id)
await wt.cancel_turn(wt_client, session_id, handle.upstream_turn_id)
except (CancelAlreadyCompleted, CancelTurnNotFound):
pass # cooperative race — turn already terminal upstream
except Exception as exc:
@@ -377,15 +425,18 @@ async def _cancel_turn_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
await cancel_turn(client, session_id, handle.upstream_turn_id)
body = {"cancelled": True}
result = await wt.cancel_turn(
_wt_client(client), session_id, handle.upstream_turn_id
)
body = {"cancelled": bool(result.cancelled)}
except (CancelAlreadyCompleted, CancelTurnNotFound):
body = {"cancelled": False, "reason": "race_or_completed"}
except CancelFailed as exc:
except CancelFailed:
# The SDK abstracts the upstream cancel HTTP status; surface a generic 502.
registry.pop((session_id, turn_id), None)
return JSONResponse(
{"error_code": "cancel_failed", "status": exc.status},
status_code=exc.status,
{"error_code": "cancel_failed"},
status_code=502,
)
registry.pop((session_id, turn_id), None)
return JSONResponse(body, status_code=200)
@@ -397,13 +448,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)
@@ -465,13 +531,13 @@ async def _session_tools_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
info = await get_session_tools(client, session_id)
except SessionApiFailed as exc:
info = await wt.get_session_tools(_wt_client(client), session_id)
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_tools_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(info, status_code=200)
return JSONResponse(dict(info), status_code=200)
async def _session_messages_endpoint(request: Request) -> JSONResponse:
@@ -485,13 +551,13 @@ async def _session_messages_endpoint(request: Request) -> JSONResponse:
client_factory = request.app.state.client_factory
try:
async with client_factory() as client:
data = await get_session_messages(client, session_id)
except SessionApiFailed as exc:
data = await wt.get_session_messages(_wt_client(client), session_id)
except wt.SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_messages_unavailable", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(data, status_code=200)
return JSONResponse(dict(data), status_code=200)
async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
@@ -609,14 +675,15 @@ def create_app(
]
if in_flight:
client = client_factory()
wt_client = _wt_client(client)
try:
task_to_handle = {
asyncio.create_task(
cancel_turn(client, h.session_id, h.upstream_turn_id)
wt.cancel_turn(wt_client, h.session_id, h.upstream_turn_id)
): h
for h in in_flight
}
done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
_done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
# Per-pending session/turn detail (INV-006 logging fidelity).
for task in pending:
h = task_to_handle[task]
+374 -9
View File
@@ -28,23 +28,36 @@ carries the SDK's parsed `error_code`).
from __future__ import annotations
import json
from collections.abc import AsyncIterator, Mapping
import math
import re
from collections.abc import AsyncGenerator, Mapping, Sequence
from typing import Any
import httpx
import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, AuthProvider, CancelResult, WorldtreeClient
from worldtree_sdk import ApiError, AuthProvider, CancelResult, PadState, WorldtreeClient
# Transitional (slice-2): the caller-semantic exceptions + the BifrostBinding input
# Transitional (slice-2/3): the caller-semantic exceptions + the BifrostBinding input
# type still live in the retiring `sessions` / `sse_client` modules; they relocate
# into this adapter as their call-sites are rewired in later slice-2 commits. wt →
# into this adapter as their call-sites are rewired in later slices. wt →
# sessions / sse_client is one-way (neither imports wt), so there is no cycle.
from .sessions import (
AgentNotAvailable as PersonaAgentNotAvailable,
)
from .sessions import (
AgentNotFound,
AuthoredHistoryUnavailable,
AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
InvalidCursor,
PersonaNotConfigured,
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
)
from .sse_client import (
AgentNotAvailable,
@@ -81,6 +94,7 @@ def build_client(
api_key: AuthProvider,
admin_key: AuthProvider | None = None,
transport: httpx.AsyncClient,
max_reconnects: int = 5,
) -> WorldtreeClient:
"""Construct the adapter's `WorldtreeClient` over a ratatoskr-owned transport.
@@ -88,15 +102,18 @@ def build_client(
`_owns_client=False`, so `WorldtreeClient.aclose()` never closes it — ratatoskr
owns the lifecycle exactly as today (INV-CUT-1). ratatoskr's `api_key` /
`admin_key` map to the SDK's per-request `auth` / `admin_auth` providers; the
injected transport carries ratatoskr's User-Agent / timeout (wired by the
caller in slice-2), NOT the Authorization header — the SDK adds auth per
request.
injected transport carries ratatoskr's User-Agent / timeout, and (transitionally)
the default bearer — the SDK adds auth per request, overriding it.
`max_reconnects` is the resilient turn-stream's reconnect budget (SDK default 5);
pass 0 to surface a transport drop immediately without auto-resume.
"""
return WorldtreeClient(
base_url,
auth=api_key,
admin_auth=admin_key,
transport=transport,
max_reconnects=max_reconnects,
)
@@ -189,10 +206,22 @@ async def create_session(
body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}
try:
return await client.sessions.create(body, consumer_key=consumer_key)
# consumer_key is a BOUND-create credential only — never forward it on an
# unbound create, or the SDK's credential precedence (consumer_key > default)
# would authenticate as the Bifrost consumer instead of the default bearer.
# Centralized here so both surfaces are guarded (the web endpoint already is).
return await client.sessions.create(
body, consumer_key=consumer_key if bifrost is not None else None
)
except ApiError as exc:
if exc.status == 404:
raise AgentNotFound(agent_id=agent_id) from exc
# NOT gated on error_code (unlike list's 422+cursor_invalid): INV-002 — a 502
# on a BOUND create IS the synchronous Bifrost handshake failing, the sole
# bound-502 cause; and the SDK does not surface a distinguishing top-level
# error_code here (its envelope parser prefers the nested `detail`, which
# carries `bifrost_error`, not `error_code`). The nested bifrost_error is
# extracted for the exception; the route+status is the discriminator.
if bifrost is not None and exc.status == 502:
raise BifrostHandshakeFailed(
bifrost_error=_bifrost_error_from_body(exc.body),
@@ -249,7 +278,7 @@ async def get_session_tools(
async def stream_turn(
client: WorldtreeClient, session_id: str, content: str
) -> AsyncIterator[wtsdk.TurnEvent]:
) -> AsyncGenerator[wtsdk.TurnEvent, None]:
"""Drive the resilient turn stream (auto-resume; absorbs the old `reconnect_turn`)
and yield the SDK's `TurnEvent`s, re-wrapping the stream's TERMINAL SDK errors
into ratatoskr's caller-semantic exceptions (INV-CUT-2 / DEC-2 — the presenter
@@ -295,6 +324,10 @@ async def stream_turn(
raise MalformedSseData(raw=exc.raw) from exc
except wtsdk.TurnIdFlip as exc:
raise TurnIdFlip(established=exc.established, got=exc.got) from exc
except ApiError as exc:
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream →
# SessionApiFailed (the discriminated stream errors are handled above).
raise translate_error(exc) from exc
async def cancel_turn(
@@ -319,3 +352,335 @@ async def cancel_turn(
raise CancelFailed(
status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
) from exc
except ApiError as exc:
# INV-CUT-2 default: an undiscriminated ApiError on this route → SessionApiFailed.
raise translate_error(exc) from exc
# ── slice-3: persona + authored-history adapter routes ───────────────────────
# The session-scoped affect write (set_persona_state) and the #347 authored-history
# write (write_authored_history). The SDK owns the wire shapes — the canonical
# `{"pad": {...}}` persona body via `PadState`, and the authored-write entry — so
# ratatoskr no longer hand-builds either. Error map (INV-CUT-2): persona has no row
# beyond the default; authored-history's 404 is the sole hide-existence route
# (AuthoredHistoryUnavailable), everything else the SessionApiFailed default.
async def set_persona_state(
client: WorldtreeClient,
session_id: str,
*,
pleasure: float,
arousal: float,
dominance: float,
) -> None:
"""Set a session's PAD persona state (POST /sessions/{id}/persona_state, W-7).
The adapter builds the canonical `PadState`; the SDK owns the wire wrapper
(`{"pad": {pleasure, arousal, dominance}}`, prose-pinned #317) — ratatoskr no
longer hand-assembles it. Resolves on 204 (→ None). Error map (INV-CUT-2): no
route-specific row → the `SessionApiFailed` default.
Finiteness is enforced HERE at the chokepoint (not only at the CLI): a
non-finite axis would serialize to `null` and corrupt the injection, and the SDK
raises `ConfigurationError` pre-HTTP — the precondition asserts it so any caller
gets a clean ratatoskr-side rejection, never a leaked SDK error. (The CLI surface
additionally pre-validates for a friendly usage error.)
"""
assert session_id and isinstance(session_id, str)
assert all(math.isfinite(v) for v in (pleasure, arousal, dominance))
try:
await client.sessions.set_persona_state(
session_id, PadState(pleasure=pleasure, arousal=arousal, dominance=dominance)
)
except ApiError as exc:
raise translate_error(exc) from exc
async def write_authored_history(
client: WorldtreeClient,
session_id: str,
*,
content: str,
idempotency_key: str,
) -> Mapping[str, Any]:
"""Write one authored assistant turn into the session ledger (POST
/sessions/{id}/history, #347) — the durable first-message primitive.
v1 accepts only `author="assistant"` (INV-347-4), so the adapter fixes it; the
caller supplies `content` + the per-content `idempotency_key` (REQUIRED, never
SDK-generated — a replay with the same (session, key) is an idempotent 200).
Returns the open-world `AuthoredTurn` ack verbatim. Error map (INV-CUT-2): a 404
→ `AuthoredHistoryUnavailable` (the ROUTE is the discriminator — hide-existence,
never body-sniffed: feature-absent / ungranted / session-absent are one 404 by
design, server INV-347-1); every other `ApiError` (notably 409 generation_active,
422 validation) → the `SessionApiFailed` default.
"""
assert session_id and isinstance(session_id, str)
assert content and isinstance(content, str)
assert idempotency_key and isinstance(idempotency_key, str)
try:
return await client.sessions.write_history(
session_id,
{"author": "assistant", "content": content, "idempotency_key": idempotency_key},
)
except ApiError as exc:
if exc.status == 404:
raise AuthoredHistoryUnavailable(session_id=session_id) from exc
raise translate_error(exc) from exc
# ── slice-4: agents (Tier-3) adapter routes ──────────────────────────────────
# Ratatoskr-semantic surfaces over `WorldtreeClient.agents.*` (list/persona_state/
# define/patch/delete). The SDK returns open-world dicts (parity posture — read as
# mappings, never normalized into a frozen dataclass) and surfaces the tier3/persona
# failure modes on its undiscriminated `ApiError` floor; the adapter maps them by the
# ROUTE + (status, error_code) per the § Error map (INV-CUT-2). The `model`→`role`
# cutover folds in here: the define/patch responses echo `role` (spec 1.2 / b128), so
# callers read `info["role"]` off the open-world dict — no `Tier3AgentInfo` survives.
# Consumer-agent slug (spec §2627): agent_name is `[a-z][a-z0-9-]{2,63}`.
_AGENT_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
def _error_field_from_body(body: str | None) -> str | None:
"""Pull the envelope's `field` from an SDK `ApiError` body string.
The SDK's `ApiError` carries the parsed `error_code` but NOT the envelope's
`field`, so the field-bearing tier3 rejections (`layer_deferred`,
`field_not_mutable`) body-parse it here — same both-shape unwrap as
`_bifrost_error_from_body`, tolerant of `{"detail": {"field": …}}` and a flat
top-level `field`. Returns None on any parse failure (the exception still carries
a None field, exactly as the hand-rolled path did on shape mismatch).
"""
if not body:
return None
try:
err = json.loads(body)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(err, dict):
return None
field = err.get("field")
if field is None and isinstance(err.get("detail"), dict):
field = err["detail"].get("field")
# The exception surface declares `field: str | None` (and the CLI prints it), so a
# non-string envelope value (`{"field": {…}}` / `{"field": 1}`) collapses to None —
# same guard the retired hand-rolled `_extract_error_field` applied (heid-bug-hunt).
return field if isinstance(field, str) else None
async def list_agents(client: WorldtreeClient) -> Sequence[Mapping[str, Any]]:
"""List the caller's agents (GET /agents) as the SDK's open-world array, verbatim
(parity: each item read as a mapping, tolerant of wire drift — no `AgentInfo`
normalization). Any error → the `SessionApiFailed` default."""
try:
return await client.agents.list()
except ApiError as exc:
raise translate_error(exc) from exc
async def get_persona_state(client: WorldtreeClient, agent_id: str) -> Mapping[str, Any]:
"""Fetch an agent's persona snapshot (GET /agents/{id}/persona_state, WT #204),
open-world dict verbatim. Error map (INV-CUT-2 — dual-key status+error_code): 404
`persona_not_configured` → `PersonaNotConfigured`; 404 `agent_not_available` →
`AgentNotAvailable` (the persona-surface variant); 403 `auth_scope_denied` →
`AuthScopeDenied`; every other error → the `SessionApiFailed` default. A 404 with
an unrecognized code stays generic — the error_code is the discriminator, never a
bare 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
+226 -36
View File
@@ -7,8 +7,10 @@ import json
import httpx
import pytest
import respx
from worldtree_sdk.events import build_event
from ratatoskr import cli as cli_mod
from ratatoskr import wt
from ratatoskr.cli import (
ParsedArgs,
UsageError,
@@ -20,18 +22,7 @@ from ratatoskr.cli import (
main,
)
from ratatoskr.sessions import BifrostBinding
from ratatoskr.sse_client import (
Cancelled,
Done,
Error,
SseId,
Text,
TextBoundary,
Thinking,
ToolResult,
ToolStart,
WorkerPhase,
)
from ratatoskr.sse_client import SseId
class _FlushCountingIO(io.StringIO):
@@ -333,6 +324,83 @@ SID = SseId(42, 5)
SID42 = SseId(42, 1)
# ── SDK-event factories ──────────────────────────────────────────────────────
# The presenter now consumes worldtree-sdk `TurnEvent`s. These build them exactly
# as the SDK's parser does (via `build_event` from the raw envelope), preserving
# the old dataclass call shapes so the render-test bodies stay unchanged. `sse_id`
# is a parsed `SseId` here purely to keep the terse SID42 idiom; the SDK carries the
# composite id as a string and turn_id top-level.
def _sid_str(sse_id: SseId) -> str:
return f"{sse_id.turn_id}:{sse_id.seq}"
def Thinking(*, sse_id: SseId, content: str) -> object:
return build_event("thinking", _sid_str(sse_id), sse_id.turn_id, {"content": content})
def Text(*, sse_id: SseId, content: str) -> object:
return build_event("text", _sid_str(sse_id), sse_id.turn_id, {"content": content})
def WorkerPhase(*, sse_id: SseId, phase: str, turn_id: int) -> object:
return build_event("worker_phase", _sid_str(sse_id), turn_id, {"phase": phase})
def TextBoundary(*, sse_id: SseId, kind: str, char_offset: int, ts: str) -> object:
return build_event(
"text_boundary", _sid_str(sse_id), sse_id.turn_id,
{"kind": kind, "char_offset": char_offset, "ts": ts},
)
def ToolStart(*, sse_id: SseId, name: str, arguments: object) -> object:
return build_event(
"tool_start", _sid_str(sse_id), sse_id.turn_id, {"name": name, "arguments": arguments}
)
def ToolResult(*, sse_id: SseId, name: str, result: object, duration_ms: int) -> object:
return build_event(
"tool_result", _sid_str(sse_id), sse_id.turn_id,
{"name": name, "result": result, "duration_ms": duration_ms},
)
def Done(
*, sse_id: SseId, phase: str, response: str, model: str, duration_ms: int, usage: object
) -> object:
return build_event(
"done", _sid_str(sse_id), sse_id.turn_id,
{"phase": phase, "response": response, "model": model,
"duration_ms": duration_ms, "usage": usage},
)
def Error(*, sse_id: SseId, phase: str, message: str, error_code: str) -> object:
return build_event(
"error", _sid_str(sse_id), sse_id.turn_id,
{"phase": phase, "message": message, "error_code": error_code},
)
def Cancelled(
*, sse_id: SseId, phase: str, turn_id: int, reason: object, partial_message_id: object
) -> object:
return build_event(
"cancelled", _sid_str(sse_id), turn_id,
{"phase": phase, "reason": reason, "partial_message_id": partial_message_id},
)
def _wtc(transport: httpx.AsyncClient) -> object:
"""The adapter's WorldtreeClient over a respx-mocked transport. Reconnects are
disabled (max_reconnects=0) so a transport drop surfaces immediately instead of
burning the resilient retry budget with real backoff sleeps."""
return wt.build_client(
"https://w.example", api_key="k", transport=transport, max_reconnects=0
)
class TestCliPresenterState:
"""Tests for the new CliPresenterState — per issue #12 contract."""
@@ -579,6 +647,39 @@ class TestCliPresenterState:
state.render(_make_done(duration_ms=72000), stdout=io.StringIO(), stderr=stderr)
assert "duration=1.2m" in stderr.getvalue()
def test_render_degrades_on_malformed_open_world_fields(self) -> None:
"""Open-world hardening (heid-bug-hunt Gróa#5 / Hulda#3): a DoneEvent with a
float duration_ms + a non-mapping usage, and an AffectUpdate with a non-mapping
snapshot, DEGRADE rather than crash the presenter."""
from ratatoskr.cli import CliPresenterState
stderr = io.StringIO()
state = CliPresenterState()
done = build_event(
"done", "42:9", 42,
{"type": "done", "duration_ms": 1234.0, "usage": 5, "model": "m"},
)
state.render(done, stdout=io.StringIO(), stderr=stderr) # must not raise
out = stderr.getvalue()
# float duration floored to int (1234ms → "1.2s"); non-mapping usage → "(n/a)".
assert "[done]" in out and "duration=1.2s" in out and "usage (n/a)" in out
# AffectUpdate with a list snapshot → no AttributeError on .get.
affect = build_event(
"affect_update", "42:1", 42,
{"type": "affect_update", "status": "current", "snapshot": []},
)
CliPresenterState().render(affect, stdout=io.StringIO(), stderr=io.StringIO())
def test_turn_id_from_sse_id_tolerates_non_str(self) -> None:
"""Open-world hardening (heid-bug-hunt Gróa#1 / Hulda#2): a None/non-str sse_id
yields None instead of crashing on .partition."""
from ratatoskr.cli import _turn_id_from_sse_id
assert _turn_id_from_sse_id(None) is None
assert _turn_id_from_sse_id(42) is None
assert _turn_id_from_sse_id("42:1") == 42
assert _turn_id_from_sse_id("0:1") is None
def test_usage_format_ascii_arrow(self) -> None:
"""usage_format_ascii_arrow [trace]: stderr label contains the natural-language
usage shape with ASCII arrow (-> not →) for CLI scriptability.
@@ -687,7 +788,7 @@ _USAGE_ZERO: dict[str, int] = {
}
def _make_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done:
def _make_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> object:
return Done(
sse_id=SID42,
phase="succeeded",
@@ -709,7 +810,8 @@ class TestCancelAndLog:
)
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
result = await _cancel_and_log(client, "s-1", 42, stderr=stderr)
assert result is None
assert stderr.getvalue() == ""
@@ -721,7 +823,8 @@ class TestCancelAndLog:
return_value=httpx.Response(500, content=b"boom")
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
assert "[cancel_failed]" in out
@@ -730,11 +833,14 @@ class TestCancelAndLog:
@respx.mock
async def test_cancel_already_completed(self) -> None:
"""cancel_already_completed [scenario]: …"""
# SDK gates the race on the (status, error_code) PAIR (B-CAN-3): 409 alone is
# a generic CancelFailed; 409 + turn_finished is the double-cancel race.
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(409)
return_value=httpx.Response(409, json={"error_code": "turn_finished"})
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
assert "[cancel_failed]" in out
@@ -743,11 +849,13 @@ class TestCancelAndLog:
@respx.mock
async def test_cancel_turn_not_found(self) -> None:
"""cancel_turn_not_found [scenario]: 404 → returns None; stderr CancelTurnNotFound."""
# 404 + turn_not_found is the benign "finished before cancel arrived" race.
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(404)
return_value=httpx.Response(404, json={"error_code": "turn_not_found"})
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
assert "[cancel_failed]" in out
@@ -760,11 +868,14 @@ class TestCancelAndLog:
side_effect=httpx.ConnectError("network down")
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
# The SDK normalizes a transport drop to ConnectFailed(status=0); _cancel_and_log
# swallows it (INV-009) and logs the normalized type.
assert "[cancel_failed]" in out
assert "ConnectError" in out
assert "ConnectFailed" in out
class _GatedStream(httpx.AsyncByteStream):
@@ -797,7 +908,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout = io.StringIO()
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 0
assert stdout.getvalue() == "hello\n"
@@ -820,7 +932,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 2
assert "[error]" in stderr.getvalue()
@@ -836,7 +949,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 3
assert "[cancelled]" in stderr.getvalue()
@@ -849,7 +963,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(
client, "missing", "hi", sigint, stdout=stdout, stderr=stderr
)
@@ -858,6 +973,21 @@ class TestRunTurn:
assert "[sse_connect_failed]" in out
assert "status=404" in out
@respx.mock
async def test_session_retired_410_maps_to_session_api_failed(self) -> None:
"""session_retired [error]: 410 stream-open → SessionRetired → SessionApiFailed
→ exit 20. Without the presenter catch this crashed _run_turn (heid-bug-hunt Gróa#2)."""
respx.post("https://w.example/sessions/s-1/messages").mock(
return_value=httpx.Response(410, json={"error_code": "session_retired"})
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 20
assert "[session_api_failed]" in stderr.getvalue()
@respx.mock
async def test_connection_dropped(self) -> None:
"""connection_dropped [error]: RemoteProtocolError mid-stream → exit 21."""
@@ -882,7 +1012,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 21
assert "[connection_dropped]" in stderr.getvalue()
@@ -896,7 +1027,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
assert "[malformed_sse_id]" in stderr.getvalue()
@@ -913,7 +1045,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
out = stderr.getvalue()
@@ -933,7 +1066,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
out = stderr.getvalue()
@@ -955,7 +1089,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
out = stderr.getvalue()
@@ -978,7 +1113,8 @@ class TestRunTurn:
sigint = asyncio.Event()
sigint.set() # SIGINT before _run_turn even starts
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await asyncio.wait_for(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr), timeout=2.0
)
@@ -1007,7 +1143,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1045,7 +1182,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1091,7 +1229,8 @@ class TestRunTurn:
monkeypatch.setattr(sigint, "wait", counting_wait)
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1129,7 +1268,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1176,7 +1316,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 0
assert call_count == 3
@@ -1782,6 +1923,27 @@ class TestTier2Probes:
assert "deleted: char_z" in out
assert del_route.call_count == 1 # lifecycle cleaned up
@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_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None:
"""set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204."""
@@ -1809,6 +1971,20 @@ class TestTier2Probes:
)
assert rc == 10
@respx.mock
def test_set_persona_probe_connect_failed(self, capsys: pytest.CaptureFixture[str]) -> None:
"""connect_failed [error-path]: a transport failure the SDK normalizes to
ConnectFailed → graceful [network_error], exit 21 (not an uncaught crash)."""
respx.post("https://w.example/sessions/s1/persona_state").mock(
side_effect=httpx.ConnectError("refused")
)
rc = main(
["--set-persona-pad", "0.4,0.1,-0.2", "--session", "s1",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 21
assert "[network_error]" in capsys.readouterr().err
class TestSeedFirstMessageProbe:
"""--seed-first-message one-shot (#347 authored-history-write reference-consumer probe)."""
@@ -1925,3 +2101,17 @@ class TestSeedFirstMessageProbe:
assert rc == 0
assert "feature-absent" in capsys.readouterr().out
assert msgs_route.call_count == 0 # never capability-probes past the 404
@respx.mock
def test_seed_probe_connect_failed(self, capsys: pytest.CaptureFixture[str]) -> None:
"""connect_failed [error-path]: a transport failure on create that the SDK
normalizes to ConnectFailed → graceful [network_error], exit 21."""
respx.post("https://w.example/sessions").mock(
side_effect=httpx.ConnectError("refused")
)
rc = main(
["--seed-first-message", "hello", "--agent", "mimir",
"--api-key", "k", "--server", "https://w.example"]
)
assert rc == 21
assert "[network_error]" in capsys.readouterr().err
+77 -91
View File
@@ -1,12 +1,21 @@
"""Tests for ratatoskr.first_message per docs/contracts/first_message.contract.md."""
"""Tests for ratatoskr.first_message per docs/contracts/first_message.contract.md.
Slice-3 (worldtree-sdk cutover): `seed_preset_first_message` routes through the
`ratatoskr.wt` adapter over a `WorldtreeClient`, no longer the hand-rolled httpx
wrapper. These tests drive it through a fake client whose `sessions.write_history`
returns or raises the SDK's real types — exercising the adapter's error mapping AND
first_message's best-effort swallow in one pass. The wire format itself is the SDK's
to prove (the parity corpus); first_message's contract is behavioral: never block,
never raise (except CancelledError), and exactly one write on a preset hit.
"""
import asyncio
import hashlib
import json
from typing import Any, cast
import httpx
import pytest
import respx
import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, WorldtreeClient
from ratatoskr.first_message import (
FIRST_MESSAGE_PRESETS,
@@ -15,6 +24,33 @@ from ratatoskr.first_message import (
)
class _FakeSessions:
"""Stand-in for `WorldtreeClient.sessions` — records each `write_history` call
and returns a canned ack or raises a canned error (the SDK's real exceptions)."""
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
self._result = result if result is not None else {}
self._error = error
self.calls: list[tuple[str, Any]] = []
async def write_history(self, session_id: str, entry: Any) -> Any:
self.calls.append((session_id, entry))
if self._error is not None:
raise self._error
return self._result
class _FakeClient:
def __init__(self, sessions: _FakeSessions) -> None:
self.sessions = sessions
def _wt(sessions: _FakeSessions) -> WorldtreeClient:
"""Cast the structural fake to the nominal client type (no network; the seed
path only touches `client.sessions.write_history`, which the fake provides)."""
return cast(WorldtreeClient, _FakeClient(sessions))
class TestPresetFor:
"""first_message contract — preset_for (dict lookup)."""
@@ -36,120 +72,70 @@ class TestPresetFor:
class TestSeedPresetFirstMessage:
"""first_message contract — seed_preset_first_message (best-effort #347 seed)."""
@respx.mock
async def test_seeds_preset(self) -> None:
"""seeds_preset [happy,tracer]: preset agent → one history POST, correct body."""
"""seeds_preset [happy,tracer]: preset agent → one write_history, correct entry."""
content = FIRST_MESSAGE_PRESETS["ratatoskr:sindra"]
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(
201,
json={
"author": "assistant",
"seq": 0,
"phase": "seeded",
"turn_id": "t1",
"session_id": "s1",
"content_chars": len(content),
"injected_at": "2026-07-06T00:00:00+00:00",
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
fake = _FakeSessions(result={"seq": 0, "phase": "seeded"})
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
assert result == content
assert route.call_count == 1 # POST-002: exactly one history POST
assert json.loads(route.calls[0].request.content) == {
assert len(fake.calls) == 1 # POST-002: exactly one history write
session_id, entry = fake.calls[0]
assert session_id == "s1"
assert entry == {
"author": "assistant",
"content": content,
"idempotency_key": key,
}
@respx.mock
async def test_no_preset_zero_http(self) -> None:
"""no_preset_zero_http [happy]: no-preset agent → None, ZERO HTTP (INV-002)."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "mimir")
async def test_no_preset_zero_write(self) -> None:
"""no_preset_zero_write [happy]: no-preset agent → None, ZERO write (INV-002)."""
fake = _FakeSessions()
result = await seed_preset_first_message(_wt(fake), "s1", "mimir")
assert result is None
assert not route.called
assert fake.calls == []
@respx.mock
async def test_feature_absent_swallowed(self) -> None:
"""feature_absent_swallowed [error]: 404 hide-existence → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
"""feature_absent_swallowed [error]: 404 → AuthoredHistoryUnavailable → None (INV-001)."""
fake = _FakeSessions(error=ApiError("session_not_found", "no", status=404))
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
assert result is None
assert len(fake.calls) == 1 # the write was attempted, then swallowed
@respx.mock
async def test_session_api_failed_swallowed(self) -> None:
"""session_api_failed_swallowed [error]: 409 → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(409, json={"error_code": "generation_active"})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
"""session_api_failed_swallowed [error]: 409 → SessionApiFailed → None (INV-001)."""
fake = _FakeSessions(error=ApiError("generation_active", "busy", status=409))
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
assert result is None
@respx.mock
async def test_transport_error_swallowed(self) -> None:
"""transport_error_swallowed [error]: httpx.ConnectError → None, no raise (INV-001)."""
respx.post("https://w.example/sessions/s1/history").mock(
side_effect=httpx.ConnectError("boom")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
"""transport_error_swallowed [error]: SDK ConnectFailed → None, no raise (INV-001)."""
fake = _FakeSessions(error=wtsdk.ConnectFailed("connect_failed", "boom", status=0))
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
assert result is None
@respx.mock
async def test_unexpected_exception_swallowed(self) -> None:
"""unexpected_exception [error]: write raises ValueError → None (broad never-raise)."""
respx.post("https://w.example/sessions/s1/history").mock(
side_effect=ValueError("unexpected")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
fake = _FakeSessions(error=ValueError("unexpected"))
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
assert result is None
async def test_cancellation_propagates(self) -> None:
"""cancellation_propagates [error]: CancelledError from the write is RE-RAISED."""
import ratatoskr.first_message as fm
fake = _FakeSessions(error=asyncio.CancelledError())
with pytest.raises(asyncio.CancelledError):
await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
async def _cancel(*_a: object, **_k: object) -> None:
raise asyncio.CancelledError
async def test_malformed_agent_id_no_write(self) -> None:
"""malformed_agent_id [adversarial]: non-str or empty agent_id → None; no write; no raise."""
fake = _FakeSessions()
assert await seed_preset_first_message(_wt(fake), "s1", 123) is None # type: ignore[arg-type]
assert await seed_preset_first_message(_wt(fake), "s1", "") is None
assert fake.calls == []
orig = fm.write_authored_history
fm.write_authored_history = _cancel # type: ignore[assignment]
try:
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(asyncio.CancelledError):
await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
finally:
fm.write_authored_history = orig # type: ignore[assignment]
@respx.mock
async def test_malformed_agent_id_no_http(self) -> None:
"""malformed_agent_id [adversarial]: non-str or empty agent_id → None; no HTTP; no raise."""
route = respx.post(url__regex=r".*/history$").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
assert await seed_preset_first_message(client, "s1", 123) is None # type: ignore[arg-type]
assert await seed_preset_first_message(client, "s1", "") is None
assert not route.called
@respx.mock
async def test_empty_session_id(self) -> None:
"""empty_session_id [adversarial]: "" → None (soft guard); no HTTP; no raise."""
route = respx.post("https://w.example/sessions/s1/history").mock(
return_value=httpx.Response(201, json={})
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await seed_preset_first_message(client, "", "ratatoskr:sindra")
"""empty_session_id [adversarial]: "" → None (soft guard); no write; no raise."""
fake = _FakeSessions()
result = await seed_preset_first_message(_wt(fake), "", "ratatoskr:sindra")
assert result is None
assert not route.called
assert fake.calls == []
+10 -10
View File
@@ -33,14 +33,14 @@ def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
def _entry(
agent_id: str = "ratatoskr:wizard",
agent_name: str = "wizard",
model: str = "qwen3.6-35-a3b",
role: str = "qwen3.6-35-a3b",
description: str = "(tier 3) test agent",
defined_at: str = "2026-05-25T00:00:00+00:00",
) -> LocalAgentEntry:
return LocalAgentEntry(
agent_id=agent_id,
agent_name=agent_name,
model=model,
role=role,
description=description,
defined_at=defined_at,
)
@@ -91,13 +91,13 @@ class TestLoadEmpty:
local_path.write_text(
json.dumps(
{
"version": 1,
"version": 2,
"agents": [
{"agent_id": "incomplete"}, # missing required fields
{
"agent_id": "ratatoskr:good",
"agent_name": "good",
"model": "m",
"role": "m",
"description": "d",
"defined_at": "t",
},
@@ -124,11 +124,11 @@ class TestAdd:
assert ids == {"ratatoskr:a", "ratatoskr:b"}
def test_add_replaces_same_id(self, local_path: Path) -> None:
add_local_agent(_entry(model="old-model"))
add_local_agent(_entry(model="new-model"))
add_local_agent(_entry(role="old-role"))
add_local_agent(_entry(role="new-role"))
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].model == "new-model"
assert entries[0].role == "new-role"
def test_creates_parent_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -141,11 +141,11 @@ class TestAdd:
class TestUpdate:
def test_update_changes_existing(self, local_path: Path) -> None:
add_local_agent(_entry(model="v1"))
update_local_agent(_entry(model="v2"))
add_local_agent(_entry(role="v1"))
update_local_agent(_entry(role="v2"))
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].model == "v2"
assert entries[0].role == "v2"
class TestRemove:
+3 -1431
View File
File diff suppressed because it is too large Load Diff
+8 -1249
View File
File diff suppressed because it is too large Load Diff
+114 -319
View File
@@ -1,4 +1,14 @@
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
"""CLI integration tests for `python -m ratatoskr.tier3`.
The define/patch/delete wire calls route through the worldtree-sdk adapter
(`ratatoskr.wt`); the adapter's body-building + error-mapping are unit-tested in
`test_wt.py` (against a fake `client.agents`). These tests exercise the CLI
end-to-end argv the real SDK over a respx-mocked HTTP layer exit code +
stdout + the local tier3 index side effects.
The define/patch response echoes `role` (Worldtree spec 1.2 / b128), read off the
SDK's open-world dict; `LocalAgentEntry.role` is the v2-schema field.
"""
from pathlib import Path
@@ -6,317 +16,20 @@ import httpx
import pytest
import respx
from ratatoskr.sessions import SessionApiFailed
from ratatoskr.tier3 import (
Tier3AgentInfo,
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
define_agent,
delete_agent,
main,
patch_agent,
)
from ratatoskr.tier3 import main
# The consumer-agent response echoes `role` (b128), not the former `model`.
_FULL_AGENT_RESP = {
"agent_id": "ratatoskr:wizard",
"user_id": "ratatoskr",
"agent_name": "wizard",
"system_prompt": "You are a wizard.",
"model": "qwen3.6-35-a3b",
"role": "qwen3.6-35-a3b",
"created_at": "2026-05-25T03:20:09.703601+00:00",
"updated_at": "2026-05-25T03:20:09.703601+00:00",
}
class TestDefineAgent:
@respx.mock
async def test_happy_define(self) -> None:
"""happy_define [happy,tracer]: 201 → fully populated Tier3AgentInfo."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await define_agent(
client,
agent_name="wizard",
system_prompt="You are a wizard.",
role="qwen3.6-35-a3b",
)
assert isinstance(info, Tier3AgentInfo)
assert info.agent_id == "ratatoskr:wizard"
assert info.user_id == "ratatoskr"
assert info.agent_name == "wizard"
assert info.model == "qwen3.6-35-a3b"
@respx.mock
async def test_request_body_shape(self) -> None:
"""request_body_shape [trace]: outbound JSON is exactly the three keys."""
import json as _json
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await define_agent(
client,
agent_name="wizard",
system_prompt="You are a wizard.",
role="qwen3.6-35-a3b",
)
body = _json.loads(route.calls[0].request.content)
# INV-001: exactly these three keys — no layer fields, no metadata.
assert body == {
"agent_name": "wizard",
"system_prompt": "You are a wizard.",
"role": "qwen3.6-35-a3b",
}
@respx.mock
async def test_quota_exceeded(self) -> None:
"""quota_exceeded [error]: 429 + Retry-After → Tier3QuotaExceeded."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(
429,
headers={"Retry-After": "0"},
json={"detail": {"error_code": "agent_quota_exceeded"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3QuotaExceeded) as exc:
await define_agent(
client,
agent_name="overflow",
system_prompt="x",
role="m",
)
assert exc.value.retry_after == 0
@respx.mock
async def test_user_id_unsupported(self) -> None:
"""user_id_unsupported [error]: 403 + error_code → Tier3UserIdUnsupported."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(
403, json={"detail": {"error_code": "tier3_user_id_unsupported"}}
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3UserIdUnsupported):
await define_agent(
client, agent_name="wizard", system_prompt="x", role="m"
)
@respx.mock
async def test_layer_deferred(self) -> None:
"""layer_deferred [error]: 422 + layer_deferred → Tier3LayerDeferred(field)."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(
422,
json={"detail": {"error_code": "layer_deferred", "field": "persona"}},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3LayerDeferred) as exc:
await define_agent(
client, agent_name="wizard", system_prompt="x", role="m"
)
assert exc.value.field == "persona"
@respx.mock
async def test_bad_slug_assert(self) -> None:
"""bad_slug_assert [adversarial]: agent_name with uppercase → AssertionError, no HTTP."""
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await define_agent(
client, agent_name="Wizard", system_prompt="x", role="m"
)
assert route.call_count == 0
@respx.mock
async def test_short_slug_assert(self) -> None:
"""short_slug_assert [adversarial]: agent_name len < 3 → AssertionError."""
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await define_agent(
client, agent_name="ab", system_prompt="x", role="m"
)
assert route.call_count == 0
@respx.mock
async def test_empty_prompt_assert(self) -> None:
"""empty_prompt_assert [adversarial]: empty system_prompt → AssertionError."""
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await define_agent(
client, agent_name="wizard", system_prompt="", role="m"
)
assert route.call_count == 0
@respx.mock
async def test_other_5xx(self) -> None:
"""other_5xx [error]: 503 → SessionApiFailed(status=503)."""
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(503, content=b"upstream out")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await define_agent(
client, agent_name="wizard", system_prompt="x", role="m"
)
assert exc.value.status == 503
class TestPatchAgent:
@respx.mock
async def test_happy_patch_both_fields(self) -> None:
"""happy_patch_both_fields: both fields set → request body has both."""
import json as _json
updated = {
**_FULL_AGENT_RESP,
"system_prompt": "new prompt",
"model": "different-model",
}
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(200, json=updated)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
info = await patch_agent(
client,
"ratatoskr:wizard",
system_prompt="new prompt",
role="different-model",
)
body = _json.loads(route.calls[0].request.content)
assert body == {"system_prompt": "new prompt", "role": "different-model"}
assert info.system_prompt == "new prompt"
assert info.model == "different-model"
@respx.mock
async def test_happy_patch_single_field(self) -> None:
"""happy_patch_single_field: omit role → body has system_prompt only."""
import json as _json
updated = {**_FULL_AGENT_RESP, "system_prompt": "only this"}
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(200, json=updated)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
await patch_agent(client, "ratatoskr:wizard", system_prompt="only this")
body = _json.loads(route.calls[0].request.content)
# INV-002: body omits the None-valued field entirely
assert body == {"system_prompt": "only this"}
@respx.mock
async def test_field_not_mutable(self) -> None:
"""field_not_mutable [error]: 422 + error_code → Tier3FieldNotMutable(field)."""
respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(
422,
json={
"detail": {"error_code": "field_not_mutable", "field": "agent_name"}
},
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3FieldNotMutable) as exc:
await patch_agent(
client, "ratatoskr:wizard", system_prompt="x"
)
assert exc.value.field == "agent_name"
@respx.mock
async def test_404(self) -> None:
"""404 [error]: PATCH on non-existent agent → Tier3AgentNotFound."""
respx.patch("https://w.example/agents/ratatoskr:ghost").mock(
return_value=httpx.Response(404, content=b"")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3AgentNotFound) as exc:
await patch_agent(
client, "ratatoskr:ghost", system_prompt="x"
)
assert exc.value.agent_id == "ratatoskr:ghost"
@respx.mock
async def test_no_fields_assert(self) -> None:
"""no_fields_assert [adversarial]: both None → AssertionError, no HTTP."""
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await patch_agent(client, "ratatoskr:wizard")
assert route.call_count == 0
@respx.mock
async def test_non_tier3_id_assert(self) -> None:
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
route = respx.patch("https://w.example/agents/mimir").mock(
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await patch_agent(client, "mimir", system_prompt="x")
assert route.call_count == 0
class TestDeleteAgent:
@respx.mock
async def test_happy_delete(self) -> None:
"""happy_delete [happy,tracer]: 204 → returns None."""
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
result = await delete_agent(client, "ratatoskr:wizard")
assert result is None
@respx.mock
async def test_404(self) -> None:
"""404 [error]: DELETE on non-existent agent → Tier3AgentNotFound."""
respx.delete("https://w.example/agents/ratatoskr:ghost").mock(
return_value=httpx.Response(404)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(Tier3AgentNotFound) as exc:
await delete_agent(client, "ratatoskr:ghost")
assert exc.value.agent_id == "ratatoskr:ghost"
@respx.mock
async def test_non_tier3_id_assert(self) -> None:
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
route = respx.delete("https://w.example/agents/mimir").mock(
return_value=httpx.Response(204)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(AssertionError):
await delete_agent(client, "mimir")
assert route.call_count == 0
@respx.mock
async def test_other_5xx(self) -> None:
"""other_5xx [error]: 500 → SessionApiFailed."""
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
return_value=httpx.Response(500, content=b"oops")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc:
await delete_agent(client, "ratatoskr:wizard")
assert exc.value.status == 500
@pytest.fixture
def _isolated_local_agents(
tmp_path: "Path", monkeypatch: pytest.MonkeyPatch
@@ -335,8 +48,8 @@ class TestCli:
monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path",
) -> None:
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation;
local index updated with the new entry (v0.8.0 hook).
"""cli_define_happy [happy,tracer]: argv → 201 mock → stdout confirmation;
local index updated with the new entry (role echoed).
"""
from ratatoskr.local_agents import load_local_agents
@@ -354,11 +67,34 @@ class TestCli:
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "defined ratatoskr:wizard (qwen3.6-35-a3b)"
# v0.8.0: local index now has the new entry.
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
assert entries[0].model == "qwen3.6-35-a3b"
assert entries[0].role == "qwen3.6-35-a3b"
@respx.mock
def test_cli_define_body_shape(
self, monkeypatch: pytest.MonkeyPatch, _isolated_local_agents: "Path"
) -> None:
"""cli_define_body_shape [trace]: outbound JSON is exactly the three keys
(the adapter sends AgentDefineInput, no layer fields)."""
import json as _json
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
route = respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
)
main([
"define", "--name", "wizard",
"--system-prompt", "You are a wizard.", "--role", "qwen3.6-35-a3b",
])
body = _json.loads(route.calls[0].request.content)
assert body == {
"agent_name": "wizard",
"role": "qwen3.6-35-a3b",
"system_prompt": "You are a wizard.",
}
@respx.mock
def test_cli_patch_happy(
@@ -384,6 +120,7 @@ class TestCli:
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
assert entries[0].role == "qwen3.6-35-a3b"
@respx.mock
def test_cli_delete_happy(
@@ -401,11 +138,11 @@ class TestCli:
load_local_agents,
)
# Pre-populate so we can verify removal.
# Pre-populate so we can verify removal (v2 schema: role, not model).
add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:wizard",
agent_name="wizard",
model="m",
role="m",
description="d",
defined_at="t",
))
@@ -426,10 +163,7 @@ class TestCli:
"""cli_missing_auth [error]: no api-key → stderr [auth_error] + exit 11."""
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
rc = main([
"define",
"--name", "wizard",
"--system-prompt", "x",
"--role", "m",
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
err = capsys.readouterr().err
assert rc == 11
@@ -446,10 +180,7 @@ class TestCli:
return_value=httpx.Response(500, content=b"upstream out")
)
rc = main([
"define",
"--name", "wizard",
"--system-prompt", "x",
"--role", "m",
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
err = capsys.readouterr().err
assert rc == 20
@@ -470,15 +201,30 @@ class TestCli:
)
)
rc = main([
"define",
"--name", "wizard",
"--system-prompt", "x",
"--role", "m",
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
err = capsys.readouterr().err
assert rc == 20
assert "[quota_exceeded]" in err
@respx.mock
def test_cli_network_error(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_network_error [error]: a transport failure surfaces as the SDK's
ConnectFailed (not a raw httpx error) stderr [network_error] + exit 21."""
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
side_effect=httpx.ConnectError("refused")
)
rc = main([
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
err = capsys.readouterr().err
assert rc == 21
assert "[network_error]" in err
def test_cli_patch_no_fields(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -489,3 +235,52 @@ class TestCli:
err = capsys.readouterr().err
assert rc == 10
assert "[usage_error]" in err
@respx.mock
def test_cli_define_partial_response_degrades(
self,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
_isolated_local_agents: "Path",
) -> None:
"""cli_define_partial [error]: a 201 missing `role` + a NULL system_prompt
degrades (role '?', description-safe) and exits 0 never a KeyError/
AttributeError traceback (heid-bug-hunt open-world invariant)."""
from ratatoskr.local_agents import load_local_agents
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json={
"agent_id": "ratatoskr:wizard", "agent_name": "wizard",
"system_prompt": None, # present-but-null: .get(...,'') would NOT default
})
)
rc = main([
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
out = capsys.readouterr()
assert rc == 0
assert out.out.strip() == "defined ratatoskr:wizard (?)"
# Well-formed identity → still indexed (role degraded to '?').
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].role == "?"
@respx.mock
def test_cli_define_no_agent_id_is_api_failure(
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""cli_define_no_agent_id [error]: a 2xx with no usable agent_id → [api_failed]
+ exit 20 (controlled), not an uncaught traceback."""
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
respx.post("https://w.example/agents/define").mock(
return_value=httpx.Response(201, json={"role": "m"}) # no agent_id
)
rc = main([
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
])
err = capsys.readouterr().err
assert rc == 20
assert "[api_failed]" in err
+32 -49
View File
@@ -7,6 +7,12 @@ type. Server-side serialization (`_event_to_browser_payload`) is
unit-tested against the fixture. JS-side rendering in
`src/ratatoskr/web/static/index.html` consumes the same shape if
this fixture changes, both sides update in lockstep.
Post worldtree-sdk cutover (#20): the presenter consumes SDK `TurnEvent`s.
`_event_to_browser_payload` derives the browser payload from the SDK's `raw`
(the wire body) plus the composite `sse_id` string the SAME shape the old
dataclasses produced, so the fixture is unchanged. These events are built via
the SDK's own `build_event` from the wire body.
"""
from __future__ import annotations
@@ -14,20 +20,8 @@ from __future__ import annotations
import json
from pathlib import Path
from ratatoskr.sse_client import (
AffectUpdate,
AwaitingLlmFirstToken,
Cancelled,
Done,
Error,
SseId,
Text,
TextBoundary,
Thinking,
ToolResult,
ToolStart,
WorkerPhase,
)
from worldtree_sdk.events import build_event
from ratatoskr.web.server import _event_to_browser_payload
@@ -36,6 +30,13 @@ def _load_fixture() -> dict:
return json.loads(path.read_text())
def _ev(ev_type: str, sse_id: str, **fields: object) -> object:
"""Build an SDK TurnEvent from its wire body (raw includes `type`); turn_id is
the turn component of the composite sse_id."""
turn = int(sse_id.split(":", 1)[0])
return build_event(ev_type, sse_id, turn, {"type": ev_type, **fields})
def _check(name: str, event: object) -> None:
"""Assert (event_type, data) for `event` matches the fixture entry."""
fixture = _load_fixture()
@@ -51,58 +52,40 @@ def _check(name: str, event: object) -> None:
def test_worker_phase_matches_fixture() -> None:
_check(
"worker_phase",
WorkerPhase(sse_id=SseId(42, 3), phase="BuildingPrompt", turn_id=42),
)
_check("worker_phase", _ev("worker_phase", "42:3", phase="BuildingPrompt", turn_id=42))
def test_thinking_matches_fixture() -> None:
_check(
"thinking",
Thinking(sse_id=SseId(42, 5), content="Let me think..."),
)
_check("thinking", _ev("thinking", "42:5", content="Let me think..."))
def test_text_matches_fixture() -> None:
_check(
"text",
Text(sse_id=SseId(42, 7), content="Hello there"),
)
_check("text", _ev("text", "42:7", content="Hello there"))
def test_text_boundary_matches_fixture() -> None:
_check(
"text_boundary",
TextBoundary(
sse_id=SseId(42, 8), kind="sentence",
char_offset=11, ts="2026-05-28T00:00:00Z",
),
_ev("text_boundary", "42:8", kind="sentence", char_offset=11, ts="2026-05-28T00:00:00Z"),
)
def test_tool_start_matches_fixture() -> None:
_check(
"tool_start",
ToolStart(sse_id=SseId(42, 9), name="search", arguments={"q": "ratatoskr"}),
)
_check("tool_start", _ev("tool_start", "42:9", name="search", arguments={"q": "ratatoskr"}))
def test_tool_result_matches_fixture() -> None:
_check(
"tool_result",
ToolResult(
sse_id=SseId(42, 10), name="search",
result={"n": 1}, duration_ms=12,
),
_ev("tool_result", "42:10", name="search", result={"n": 1}, duration_ms=12),
)
def test_done_matches_fixture() -> None:
_check(
"done",
Done(
sse_id=SseId(42, 11), phase="succeeded", response="Hello there",
_ev(
"done", "42:11", phase="succeeded", response="Hello there",
model="qwen3.6-35-a3b", duration_ms=1234,
usage={
"prompt_tokens": 100, "completion_tokens": 50,
@@ -115,8 +98,8 @@ def test_done_matches_fixture() -> None:
def test_error_matches_fixture() -> None:
_check(
"error",
Error(
sse_id=SseId(42, 11), phase="failed",
_ev(
"error", "42:11", phase="failed",
message="llm output invalid", error_code="llm_output_invalid",
),
)
@@ -125,8 +108,8 @@ def test_error_matches_fixture() -> None:
def test_cancelled_matches_fixture() -> None:
_check(
"cancelled",
Cancelled(
sse_id=SseId(42, 11), phase="cancelled", turn_id=42,
_ev(
"cancelled", "42:11", phase="cancelled", turn_id=42,
reason="user_cancel", partial_message_id=None,
),
)
@@ -135,8 +118,8 @@ def test_cancelled_matches_fixture() -> None:
def test_affect_update_matches_fixture() -> None:
_check(
"affect_update",
AffectUpdate(
sse_id=SseId(42, 1), status="current", turn_id=42,
_ev(
"affect_update", "42:1", status="current", turn_id=42,
snapshot={
"agent_id": "mimir",
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
@@ -155,8 +138,8 @@ def test_affect_update_matches_fixture() -> None:
def test_awaiting_llm_first_token_matches_fixture() -> None:
_check(
"awaiting_llm_first_token",
AwaitingLlmFirstToken(
sse_id=SseId(42, 2), turn_id=42,
elapsed_ms_since_building_prompt=5012.3,
_ev(
"awaiting_llm_first_token", "42:2",
turn_id=42, elapsed_ms_since_building_prompt=5012.3,
),
)
+76 -6
View File
@@ -66,7 +66,7 @@ class TestAgentsEndpoint:
LocalAgentEntry(
agent_id="ratatoskr:sindra",
agent_name="sindra",
model="artemis-31b-v1i",
role="artemis-31b-v1i",
description="(tier 3) IDENTITY",
defined_at="2026-05-28T00:00:00+00:00",
)
@@ -118,7 +118,7 @@ class TestAgentsEndpoint:
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
add_local_agent(
LocalAgentEntry(
agent_id="ratatoskr:sindra", agent_name="sindra", model="m",
agent_id="ratatoskr:sindra", agent_name="sindra", role="m",
description="local-tier3", defined_at="2026-05-28T00:00:00+00:00",
)
)
@@ -132,6 +132,57 @@ class TestAgentsEndpoint:
# Upstream entry wins (it's first in the merge); local is deduped
assert body[0]["name"] == "Sindra-from-server"
@respx.mock
def test_malformed_upstream_items_degrade_not_500(self, monkeypatch, tmp_path) -> None:
"""malformed_upstream [error]: a non-mapping / agent_id-less upstream item is
dropped, not crashed on the endpoint degrades to the well-formed + local
merge (heid-bug-hunt: open-world reads degrade, never crash)."""
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{"agent_id": "mimir", "name": "Mimir", "description": "k"},
{}, # no agent_id — dropped
{"name": "Ghost"}, # no agent_id — dropped
"not-a-mapping", # non-mapping — dropped
{"agent_id": 123}, # non-str agent_id — dropped
],
)
)
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:local", agent_name="local", role="m",
description="d", defined_at="t",
))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents")
assert resp.status_code == 200
ids = {a["agent_id"] for a in resp.json()}
# Only the one well-formed upstream item + the local entry survive.
assert ids == {"mimir", "ratatoskr:local"}
@respx.mock
def test_non_list_upstream_falls_back_to_local(self, monkeypatch, tmp_path) -> None:
"""non_list_upstream [error]: an envelope (non-list) upstream body degrades to
the local-only list rather than iterating dict keys into a crash."""
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json"))
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json={"items": [{"agent_id": "mimir"}]})
)
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:local", agent_name="local", role="m",
description="d", defined_at="t",
))
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents")
assert resp.status_code == 200
ids = {a["agent_id"] for a in resp.json()}
assert ids == {"ratatoskr:local"}
_CREATE_OK = {
"session_id": "s-1",
@@ -290,6 +341,20 @@ class TestPersonaStateEndpoint:
assert resp.status_code == 403
assert resp.json()["error_code"] == "auth_scope_denied"
@respx.mock
def test_unmatched_error_maps_to_session_api_failed(self) -> None:
"""unmatched_error [error]: an upstream 500 (unmapped ApiError) → the
session_api_failed envelope carrying the upstream status, for parity with
_agents_endpoint (heid-code-review slice-4 fixup was a raw 500 escape)."""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(500, content=b"upstream out")
)
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
resp = TestClient(app).get("/api/agents/mimir/persona_state")
assert resp.status_code == 500
assert resp.json()["error_code"] == "session_api_failed"
class TestSubmitTurnEndpoint:
"""submit_turn_endpoint FN — allocate turn_id, register in turn_registry."""
@@ -456,15 +521,16 @@ class TestCancelTurnEndpoint:
@respx.mock
def test_already_completed_race(self) -> None:
"""already_completed [race]: upstream 409 → 200 reason=race_or_completed."""
"""already_completed [race]: upstream 409 turn_finished → 200 reason=race_or_completed."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
app.state.turn_registry[("s-1", turn_id)].status = "streaming"
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
# SDK gates the race on the (status, error_code) pair (B-CAN-3).
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(409)
return_value=httpx.Response(409, json={"error_code": "turn_finished"})
)
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
assert resp.status_code == 200
@@ -473,7 +539,11 @@ class TestCancelTurnEndpoint:
@respx.mock
def test_cancel_failed_500(self) -> None:
"""cancel_failed [error]: upstream 500 → 500 with cancel_failed envelope."""
"""cancel_failed [error]: upstream 500 → 502 cancel_failed envelope.
Post-cutover: the SDK abstracts the upstream cancel HTTP status behind a
typed CancelFailed, so the endpoint surfaces a generic 502 (bad gateway)
rather than echoing the upstream 500."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
c = TestClient(app)
@@ -484,7 +554,7 @@ class TestCancelTurnEndpoint:
return_value=httpx.Response(500, content=b"boom")
)
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
assert resp.status_code == 500
assert resp.status_code == 502
assert resp.json()["error_code"] == "cancel_failed"
assert ("s-1", turn_id) not in app.state.turn_registry
+657 -1
View File
@@ -17,14 +17,25 @@ from typing import Any, cast
import httpx
import pytest
import worldtree_sdk as wtsdk
from worldtree_sdk import ApiError, CancelResult, WorldtreeClient
from worldtree_sdk import ApiError, CancelResult, PadState, WorldtreeClient
from ratatoskr.sessions import (
AgentNotAvailable as PersonaAgentNotAvailable,
)
from ratatoskr.sessions import (
AgentNotFound,
AuthoredHistoryUnavailable,
AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
InvalidCursor,
PersonaNotConfigured,
Tier3AgentNotFound,
Tier3FieldNotMutable,
Tier3LayerDeferred,
Tier3QuotaExceeded,
Tier3UserIdUnsupported,
)
from ratatoskr.sse_client import (
AgentNotAvailable,
@@ -42,12 +53,25 @@ 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_messages,
get_session_tools,
list_agents,
list_character_models,
list_sessions,
patch_agent,
set_persona_state,
stream_turn,
translate_error,
write_authored_history,
)
@@ -101,6 +125,12 @@ class _FakeSessions:
async def cancel_turn(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("cancel_turn", *args, **kwargs)
async def set_persona_state(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("set_persona_state", *args, **kwargs)
async def write_history(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("write_history", *args, **kwargs)
class _FakeClient:
def __init__(self, sessions: _FakeSessions) -> None:
@@ -218,6 +248,14 @@ class TestCreateSession:
# INV-CUT: the consumer key rides the SDK's per-request auth, NOT a header.
assert kwargs["consumer_key"] == "ck-real"
async def test_unbound_create_drops_consumer_key(self) -> None:
# A consumer_key must NOT reach the SDK on an UNBOUND create — the SDK's
# credential precedence would otherwise auth as the Bifrost consumer instead
# of the default bearer (heid-bug-hunt Gróa#4 / Regin#4).
fake = _FakeSessions(result={"session_id": "s"})
await create_session(_wt(fake), "mimir", consumer_key="ck-should-be-dropped")
assert fake.calls[-1][2]["consumer_key"] is None
async def test_bifrost_without_consumer_key_rejected_pre_http(self) -> None:
fake = _FakeSessions(result={"session_id": "s"})
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
@@ -299,6 +337,12 @@ class TestReadPassthroughs:
await get_session_messages(_wt(fake), "s")
assert ei.value.status == 401
async def test_tools_error_maps_to_session_api_failed(self) -> None:
fake = _FakeSessions(error=ApiError("auth_revoked", "no", status=401))
with pytest.raises(SessionApiFailed) as ei:
await get_session_tools(_wt(fake), "s")
assert ei.value.status == 401
async def _drain(aiter: Any) -> list[Any]:
out: list[Any] = []
@@ -376,6 +420,14 @@ class TestStreamTurn:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert (ei.value.established, ei.value.got) == (5, 7)
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream
# (not a discriminated stream error) → SessionApiFailed.
fake = _FakeSessions(stream_error=ApiError("weird", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await _drain(stream_turn(_wt(fake), "s", "hi"))
assert ei.value.status == 500
class TestCancelTurn:
async def test_happy_returns_cancel_result(self) -> None:
@@ -410,3 +462,607 @@ class TestCancelTurn:
fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed"))
with pytest.raises(CancelFailed):
await cancel_turn(_wt(fake), "s", 42)
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
# INV-CUT-2 default: an undiscriminated ApiError on the cancel route (not a
# typed Cancel* race) → SessionApiFailed, never leaked as a bare ApiError.
fake = _FakeSessions(error=ApiError("weird", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await cancel_turn(_wt(fake), "s", 42)
assert ei.value.status == 500
class TestSetPersonaState:
"""slice-3: set_persona_state → SDK sessions.set_persona_state(PadState). The
adapter builds the canonical PadState (the SDK owns the {"pad": {...}} wire
shape); no error row beyond the § Error map default (SessionApiFailed)."""
async def test_happy_builds_padstate_and_returns_none(self) -> None:
fake = _FakeSessions(result=None) # SDK resolves the 204 to None
out = await set_persona_state(
_wt(fake), "s-1", pleasure=0.4, arousal=0.1, dominance=-0.2
)
assert out is None
name, args, _kwargs = fake.calls[-1]
assert name == "set_persona_state"
assert args[0] == "s-1"
pad = args[1]
assert isinstance(pad, PadState)
assert (pad.pleasure, pad.arousal, pad.dominance) == (0.4, 0.1, -0.2)
async def test_falsy_zero_pad_preserved(self) -> None:
# A 0.0 axis must survive verbatim (not be dropped as falsy).
fake = _FakeSessions(result=None)
await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0)
pad = fake.calls[-1][1][1]
assert (pad.pleasure, pad.arousal, pad.dominance) == (0.0, 0.0, 0.0)
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeSessions(error=ApiError("upstream", "boom", status=500, body="x"))
with pytest.raises(SessionApiFailed) as ei:
await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0)
assert ei.value.status == 500
async def test_non_finite_pad_rejected_at_the_chokepoint(self) -> None:
# The finite-PAD invariant is enforced at the adapter (not only the CLI): a
# non-finite axis would serialize to null and corrupt the injection, so a
# direct caller is rejected pre-SDK — never a leaked SDK ConfigurationError.
fake = _FakeSessions(result=None)
for bad in (float("nan"), float("inf"), float("-inf")):
with pytest.raises(AssertionError):
await set_persona_state(_wt(fake), "s", pleasure=bad, arousal=0.0, dominance=0.0)
assert fake.calls == [] # never reached the SDK
class TestWriteAuthoredHistory:
"""slice-3: write_authored_history → SDK sessions.write_history. Builds the
v1 authored-write entry (author="assistant", the only accepted author);
404 AuthoredHistoryUnavailable (hide-existence); else the default."""
async def test_happy_builds_entry_and_returns_dict(self) -> None:
ack = {"seq": 0, "phase": "seeded", "turn_id": "t1", "content_chars": 3}
fake = _FakeSessions(result=ack)
out = await write_authored_history(
_wt(fake), "s-1", content="hi!", idempotency_key="k1"
)
assert out is ack # open-world passthrough
name, args, _kwargs = fake.calls[-1]
assert name == "write_history"
assert args[0] == "s-1"
assert args[1] == {
"author": "assistant",
"content": "hi!",
"idempotency_key": "k1",
}
async def test_404_maps_to_authored_history_unavailable(self) -> None:
# Hide-existence: the ROUTE is the discriminator (never the body) — any 404
# on write_history → AuthoredHistoryUnavailable, no capability-probe.
fake = _FakeSessions(error=ApiError("session_not_found", "no", status=404))
with pytest.raises(AuthoredHistoryUnavailable) as ei:
await write_authored_history(_wt(fake), "s-1", content="hi", idempotency_key="k")
assert ei.value.session_id == "s-1"
async def test_other_error_maps_to_session_api_failed(self) -> None:
# 409 generation_active (retryable) is NOT a hide-existence 404 → default.
fake = _FakeSessions(error=ApiError("generation_active", "busy", status=409))
with pytest.raises(SessionApiFailed) as ei:
await write_authored_history(_wt(fake), "s", content="hi", idempotency_key="k")
assert ei.value.status == 409
# ── slice-4: agents (Tier-3) adapter routes ──────────────────────────────────
class _FakeAgents:
"""Stand-in for `WorldtreeClient.agents` — records the last call and returns a
canned result or raises a canned error. Same shape as `_FakeSessions`."""
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
self._result = result
self._error = error
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
self.calls.append((name, args, kwargs))
if self._error is not None:
raise self._error
return self._result
async def list(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("list", *args, **kwargs)
async def persona_state(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("persona_state", *args, **kwargs)
async def define(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("define", *args, **kwargs)
async def patch(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("patch", *args, **kwargs)
async def delete(self, *args: Any, **kwargs: Any) -> Any:
return await self._dispatch("delete", *args, **kwargs)
class _FakeAgentsClient:
def __init__(self, agents: _FakeAgents) -> None:
self.agents = agents
def _wta(agents: _FakeAgents) -> WorldtreeClient:
"""Cast the structural agents-fake to the nominal client type (the agent route
functions only touch `client.agents.*`)."""
return cast(WorldtreeClient, _FakeAgentsClient(agents))
class TestListAgents:
"""slice-4: list_agents → SDK agents.list(); open-world array verbatim."""
async def test_happy_returns_array_verbatim(self) -> None:
data = [{"agent_id": "mimir", "name": "Mimir", "description": "k"}]
fake = _FakeAgents(result=data)
out = await list_agents(_wta(fake))
assert out is data # open-world passthrough, no AgentInfo normalization
assert fake.calls[-1][0] == "list"
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeAgents(error=ApiError("upstream", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await list_agents(_wta(fake))
assert ei.value.status == 500
class TestGetPersonaState:
"""slice-4: get_persona_state → SDK agents.persona_state(id); dict verbatim.
404 sub-codes + 403 auth_scope_denied map by (status, error_code)."""
async def test_happy_returns_dict_verbatim(self) -> None:
snap = {"pad": {}, "dominant_emotion": "curiosity"}
fake = _FakeAgents(result=snap)
out = await get_persona_state(_wta(fake), "mimir")
assert out is snap
assert fake.calls[-1] == ("persona_state", ("mimir",), {})
async def test_persona_not_configured(self) -> None:
fake = _FakeAgents(error=ApiError("persona_not_configured", "no", status=404))
with pytest.raises(PersonaNotConfigured) as ei:
await get_persona_state(_wta(fake), "domari")
assert ei.value.agent_id == "domari"
async def test_agent_not_available(self) -> None:
fake = _FakeAgents(error=ApiError("agent_not_available", "no", status=404))
with pytest.raises(PersonaAgentNotAvailable) as ei:
await get_persona_state(_wta(fake), "bogus")
assert ei.value.agent_id == "bogus"
async def test_auth_scope_denied(self) -> None:
fake = _FakeAgents(error=ApiError("auth_scope_denied", "no", status=403))
with pytest.raises(AuthScopeDenied) as ei:
await get_persona_state(_wta(fake), "mimir")
assert ei.value.scope == "persona.read"
async def test_other_404_without_code_maps_to_default(self) -> None:
# A 404 whose error_code is neither persona sub-code → generic default,
# NOT a spurious PersonaNotConfigured (the code is the discriminator).
fake = _FakeAgents(error=ApiError("weird", "no", status=404))
with pytest.raises(SessionApiFailed) as ei:
await get_persona_state(_wta(fake), "mimir")
assert ei.value.status == 404
async def test_empty_agent_id_asserts(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await get_persona_state(_wta(fake), "")
assert fake.calls == []
class TestDefineAgent:
"""slice-4: define_agent → SDK agents.define(); open-world DefinedAgent dict
(echoes `role` post-b128). Slug validated client-side; tier3 error rows."""
async def test_happy_builds_body_and_returns_dict(self) -> None:
resp = {"agent_id": "ratatoskr:wizard", "role": "thoughtful-character"}
fake = _FakeAgents(result=resp)
out = await define_agent(
_wta(fake), agent_name="wizard", system_prompt="You are a wizard.",
role="thoughtful-character",
)
assert out is resp # open-world passthrough (no Tier3AgentInfo)
name, args, _kwargs = fake.calls[-1]
assert name == "define"
# AgentDefineInput body: exactly the three keys, no layer fields.
assert args[0] == {
"agent_name": "wizard",
"role": "thoughtful-character",
"system_prompt": "You are a wizard.",
}
async def test_quota_exceeded_defaults_retry_after_zero(self) -> None:
# The SDK's ApiError floor drops the Retry-After header; spec §2675 pins it
# to 0, so the adapter defaults retry_after=0.
fake = _FakeAgents(error=ApiError("agent_quota_exceeded", "full", status=429))
with pytest.raises(Tier3QuotaExceeded) as ei:
await define_agent(_wta(fake), agent_name="overflow", system_prompt="x", role="m")
assert ei.value.retry_after == 0
async def test_user_id_unsupported(self) -> None:
fake = _FakeAgents(error=ApiError("tier3_user_id_unsupported", "no", status=403))
with pytest.raises(Tier3UserIdUnsupported):
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
async def test_layer_deferred_field_parsed_from_body(self) -> None:
# `field` is not on ApiError — the adapter body-parses detail.field.
fake = _FakeAgents(error=ApiError(
"layer_deferred", "no", status=422,
body='{"detail": {"error_code": "layer_deferred", "field": "persona"}}',
))
with pytest.raises(Tier3LayerDeferred) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.field == "persona"
async def test_layer_deferred_flat_field_body(self) -> None:
# _error_field_from_body also handles a flat top-level `field` (both-shape
# unwrap) — locks the contract's "detail.field / flat field" claim.
fake = _FakeAgents(error=ApiError(
"layer_deferred", "no", status=422, body='{"field": "valence"}',
))
with pytest.raises(Tier3LayerDeferred) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.field == "valence"
async def test_layer_deferred_non_string_field_is_none(self) -> None:
# A non-string `field` value collapses to None — the exception surface is
# `field: str | None` and the CLI prints it (heid-bug-hunt hardening).
fake = _FakeAgents(error=ApiError(
"layer_deferred", "no", status=422, body='{"detail": {"field": {"x": 1}}}',
))
with pytest.raises(Tier3LayerDeferred) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.field is None
async def test_403_wrong_code_maps_to_default(self) -> None:
# Dual-key negative: a 403 whose code is NOT tier3_user_id_unsupported →
# generic default, not a spurious Tier3UserIdUnsupported (INV-CUT-2).
fake = _FakeAgents(error=ApiError("auth_revoked", "no", status=403))
with pytest.raises(SessionApiFailed) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.status == 403
async def test_422_wrong_code_maps_to_default(self) -> None:
# Dual-key negative: a 422 whose code is NOT layer_deferred → default.
fake = _FakeAgents(error=ApiError("validation_failed", "no", status=422))
with pytest.raises(SessionApiFailed) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.status == 422
async def test_bad_slug_asserts_no_call(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await define_agent(_wta(fake), agent_name="Wizard", system_prompt="x", role="m")
assert fake.calls == []
async def test_short_slug_asserts_no_call(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await define_agent(_wta(fake), agent_name="ab", system_prompt="x", role="m")
assert fake.calls == []
async def test_empty_prompt_asserts(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await define_agent(_wta(fake), agent_name="wizard", system_prompt="", role="m")
assert fake.calls == []
async def test_empty_role_asserts(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="")
assert fake.calls == []
async def test_other_5xx_maps_to_default(self) -> None:
fake = _FakeAgents(error=ApiError("upstream", "out", status=503))
with pytest.raises(SessionApiFailed) as ei:
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
assert ei.value.status == 503
class TestPatchAgent:
"""slice-4: patch_agent → SDK agents.patch(id, changes); open PatchedAgent dict."""
async def test_happy_both_fields(self) -> None:
resp = {"agent_id": "ratatoskr:wizard", "role": "different"}
fake = _FakeAgents(result=resp)
out = await patch_agent(
_wta(fake), "ratatoskr:wizard", system_prompt="new", role="different"
)
assert out is resp
name, args, _kwargs = fake.calls[-1]
assert name == "patch"
assert args[0] == "ratatoskr:wizard"
assert args[1] == {"system_prompt": "new", "role": "different"}
async def test_happy_single_field_omits_none(self) -> None:
fake = _FakeAgents(result={})
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="only this")
assert fake.calls[-1][1][1] == {"system_prompt": "only this"}
async def test_404_maps_to_agent_not_found(self) -> None:
fake = _FakeAgents(error=ApiError("not_found", "no", status=404))
with pytest.raises(Tier3AgentNotFound) as ei:
await patch_agent(_wta(fake), "ratatoskr:ghost", system_prompt="x")
assert ei.value.agent_id == "ratatoskr:ghost"
async def test_field_not_mutable_field_parsed(self) -> None:
fake = _FakeAgents(error=ApiError(
"field_not_mutable", "no", status=422,
body='{"detail": {"error_code": "field_not_mutable", "field": "agent_name"}}',
))
with pytest.raises(Tier3FieldNotMutable) as ei:
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
assert ei.value.field == "agent_name"
async def test_422_wrong_code_maps_to_default(self) -> None:
# Dual-key negative: a 422 whose code is NOT field_not_mutable → default,
# not a spurious Tier3FieldNotMutable (INV-CUT-2).
fake = _FakeAgents(error=ApiError("validation_failed", "no", status=422))
with pytest.raises(SessionApiFailed) as ei:
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
assert ei.value.status == 422
async def test_no_fields_asserts_no_call(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await patch_agent(_wta(fake), "ratatoskr:wizard")
assert fake.calls == []
async def test_non_tier3_id_asserts(self) -> None:
fake = _FakeAgents(result={})
with pytest.raises(AssertionError):
await patch_agent(_wta(fake), "mimir", system_prompt="x")
assert fake.calls == []
async def test_other_error_maps_to_default(self) -> None:
fake = _FakeAgents(error=ApiError("upstream", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
assert ei.value.status == 500
class TestDeleteAgent:
"""slice-4: delete_agent → SDK agents.delete(id); None on 204; 404 → not-found."""
async def test_happy_returns_none(self) -> None:
fake = _FakeAgents(result=None)
out = await delete_agent(_wta(fake), "ratatoskr:wizard")
assert out is None
assert fake.calls[-1] == ("delete", ("ratatoskr:wizard",), {})
async def test_404_maps_to_agent_not_found(self) -> None:
fake = _FakeAgents(error=ApiError("not_found", "no", status=404))
with pytest.raises(Tier3AgentNotFound) as ei:
await delete_agent(_wta(fake), "ratatoskr:ghost")
assert ei.value.agent_id == "ratatoskr:ghost"
async def test_non_tier3_id_asserts(self) -> None:
fake = _FakeAgents(result=None)
with pytest.raises(AssertionError):
await delete_agent(_wta(fake), "mimir")
assert fake.calls == []
async def test_other_error_maps_to_default(self) -> None:
fake = _FakeAgents(error=ApiError("upstream", "oops", status=500))
with pytest.raises(SessionApiFailed) as ei:
await delete_agent(_wta(fake), "ratatoskr:wizard")
assert ei.value.status == 500
# ── slice-5: characters + me/capabilities/models adapter routes ───────────────
# One canned result / error per fake (each slice-5 adapter fn touches exactly one
# sub-resource method), recorded by qualified name so the test can assert the route.
class _FakeMe:
def __init__(self, rec: _FakeMisc) -> None:
self._rec = rec
async def get(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("me.get", *a, **k)
class _FakeCapabilities:
def __init__(self, rec: _FakeMisc) -> None:
self._rec = rec
async def get(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("capabilities.get", *a, **k)
class _FakeModels:
def __init__(self, rec: _FakeMisc) -> None:
self._rec = rec
async def available_for_characters(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("models.available_for_characters", *a, **k)
class _FakeCharacters:
def __init__(self, rec: _FakeMisc) -> None:
self._rec = rec
async def create(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("characters.create", *a, **k)
async def state(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("characters.state", *a, **k)
async def delete(self, *a: Any, **k: Any) -> Any:
return await self._rec._dispatch("characters.delete", *a, **k)
class _FakeMisc:
"""Stand-in for the slice-5 client surface — exposes `.me` / `.capabilities` /
`.models` / `.characters`, recording each call under its qualified name and
returning a canned result or raising a canned error (same shape as `_FakeSessions`
/ `_FakeAgents`)."""
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
self._result = result
self._error = error
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
self.me = _FakeMe(self)
self.capabilities = _FakeCapabilities(self)
self.models = _FakeModels(self)
self.characters = _FakeCharacters(self)
async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
self.calls.append((name, args, kwargs))
if self._error is not None:
raise self._error
return self._result
def _wtm(misc: _FakeMisc) -> WorldtreeClient:
"""Cast the slice-5 misc-surface fake (me/capabilities/models/characters) to the
nominal client type the route functions are typed against."""
return cast(WorldtreeClient, misc)
class TestGetMe:
"""slice-5: get_me → SDK me.get(); open-world dict verbatim."""
async def test_happy_returns_dict_verbatim(self) -> None:
me = {"user_id": "alice", "scopes": ["conversations.read"], "tier": "user"}
fake = _FakeMisc(result=me)
out = await get_me(_wtm(fake))
assert out is me
assert fake.calls[-1][0] == "me.get"
async def test_401_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("auth_invalid", "no", status=401))
with pytest.raises(SessionApiFailed) as ei:
await get_me(_wtm(fake))
assert ei.value.status == 401
class TestGetCapabilities:
"""slice-5: get_capabilities → SDK capabilities.get(); open-world verbatim."""
async def test_happy_returns_dict_verbatim(self) -> None:
caps = {"ephemeral_templates": {"echo": {"default_role": "echo"}}}
fake = _FakeMisc(result=caps)
out = await get_capabilities(_wtm(fake))
assert out is caps
assert fake.calls[-1][0] == "capabilities.get"
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("upstream", "boom", status=500))
with pytest.raises(SessionApiFailed) as ei:
await get_capabilities(_wtm(fake))
assert ei.value.status == 500
class TestListCharacterModels:
"""slice-5: list_character_models → SDK models.available_for_characters()."""
async def test_happy_returns_dict_verbatim(self) -> None:
models = {"items": [{"name": "fast", "thinking": False}]}
fake = _FakeMisc(result=models)
out = await list_character_models(_wtm(fake))
assert out is models
assert fake.calls[-1][0] == "models.available_for_characters"
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("auth_scope_denied", "no", status=403))
with pytest.raises(SessionApiFailed) as ei:
await list_character_models(_wtm(fake))
assert ei.value.status == 403
class TestCreateCharacter:
"""slice-5: create_character → SDK characters.create(body); body-building + parity."""
async def test_happy_omits_state_when_none(self) -> None:
# SDK-idiomatic body: {character} only — no redundant explicit state:null.
created = {"character_id": "char_x", "ttl_expires_at": "t"}
fake = _FakeMisc(result=created)
out = await create_character(_wtm(fake), {"schema_version": "1", "name": "H"})
assert out is created
name, args, _ = fake.calls[-1]
assert name == "characters.create"
assert args[0] == {"character": {"schema_version": "1", "name": "H"}}
async def test_includes_state_when_supplied(self) -> None:
fake = _FakeMisc(result={"character_id": "c1"})
await create_character(
_wtm(fake), {"name": "H"}, state={"mood": "calm"}
)
assert fake.calls[-1][1][0] == {
"character": {"name": "H"},
"state": {"mood": "calm"},
}
async def test_empty_character_asserts_no_call(self) -> None:
fake = _FakeMisc(result={})
with pytest.raises(AssertionError):
await create_character(_wtm(fake), {})
assert fake.calls == []
async def test_403_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("auth_scope_denied", "no", status=403))
with pytest.raises(SessionApiFailed) as ei:
await create_character(_wtm(fake), {"name": "H"})
assert ei.value.status == 403
class TestGetCharacterState:
"""slice-5: get_character_state → SDK characters.state(id); open-world verbatim."""
async def test_happy_returns_dict_verbatim(self) -> None:
state = {"schema_version": "1", "pad": [0.4, 0.1, -0.2]}
fake = _FakeMisc(result=state)
out = await get_character_state(_wtm(fake), "char_x")
assert out is state
assert fake.calls[-1] == ("characters.state", ("char_x",), {})
async def test_empty_id_asserts_no_call(self) -> None:
fake = _FakeMisc(result={})
with pytest.raises(AssertionError):
await get_character_state(_wtm(fake), "")
assert fake.calls == []
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("not_found", "no", status=404))
with pytest.raises(SessionApiFailed) as ei:
await get_character_state(_wtm(fake), "char_x")
assert ei.value.status == 404
class TestDeleteCharacter:
"""slice-5: delete_character → SDK characters.delete(id); open ack verbatim."""
async def test_returns_ack_verbatim(self) -> None:
# Worldtree returns an open ack body here (not 204) — passed through, NOT
# normalized to None (parity posture).
ack = {"deleted": True, "character_id": "char_x"}
fake = _FakeMisc(result=ack)
out = await delete_character(_wtm(fake), "char_x")
assert out is ack
assert fake.calls[-1] == ("characters.delete", ("char_x",), {})
async def test_none_on_204(self) -> None:
# A 204 no-content yields None from the SDK — passed through unchanged.
fake = _FakeMisc(result=None)
assert await delete_character(_wtm(fake), "char_x") is None
async def test_empty_id_asserts_no_call(self) -> None:
fake = _FakeMisc(result=None)
with pytest.raises(AssertionError):
await delete_character(_wtm(fake), "")
assert fake.calls == []
async def test_error_maps_to_session_api_failed(self) -> None:
fake = _FakeMisc(error=ApiError("upstream", "oops", status=500))
with pytest.raises(SessionApiFailed) as ei:
await delete_character(_wtm(fake), "char_x")
assert ei.value.status == 500
Generated
+1 -1
View File
@@ -472,7 +472,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.21.5"
version = "0.21.16"
source = { editable = "." }
dependencies = [
{ name = "httpx" },