Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca9a339050 | ||
|
|
0b23334c68 | ||
|
|
aba17304bd | ||
|
|
74d41eb559 | ||
|
|
59602fe3ff | ||
|
|
5c595b862d | ||
|
|
e3a10ad80e |
@@ -13,11 +13,12 @@ scope: >
|
|||||||
replacement for a system-prompt "startup" instruction. Two entry points:
|
replacement for a system-prompt "startup" instruction. Two entry points:
|
||||||
`preset_for` (lookup) and `seed_preset_first_message` (best-effort seed).
|
`preset_for` (lookup) and `seed_preset_first_message` (best-effort seed).
|
||||||
Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server
|
Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server
|
||||||
(the POST /api/sessions endpoint). Depends on ratatoskr.sessions
|
(the POST /api/sessions endpoint). Depends on ratatoskr.wt
|
||||||
(write_authored_history + its exceptions); no core.* / worldtree.* imports.
|
(`wt.write_authored_history` + AuthoredHistoryUnavailable) over a WorldtreeClient
|
||||||
|
(worldtree-sdk cutover slice-3, #20); no core.* / worldtree.* SOURCE imports.
|
||||||
depends_on:
|
depends_on:
|
||||||
- "httpx"
|
- "worldtree_sdk"
|
||||||
- "ratatoskr.sessions"
|
- "ratatoskr.wt"
|
||||||
used_by:
|
used_by:
|
||||||
- "ratatoskr.cli"
|
- "ratatoskr.cli"
|
||||||
- "ratatoskr.web.server"
|
- "ratatoskr.web.server"
|
||||||
@@ -26,7 +27,7 @@ complexity: "low"
|
|||||||
estimated_loc: 60
|
estimated_loc: 60
|
||||||
confidence: 0.9
|
confidence: 0.9
|
||||||
assumptions:
|
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."
|
- "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)."
|
- "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
|
## Data flow
|
||||||
|
|
||||||
**In:** a live `httpx.AsyncClient` (caller-owned, base_url + bearer set), a fresh
|
**In:** a `WorldtreeClient` (the wt-adapter client, built over ratatoskr's
|
||||||
`session_id`, and the bound `agent_id`.
|
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,
|
**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
|
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
|
preset_miss [happy]: preset_for("mimir") is None
|
||||||
empty_agent_id [adversarial]: preset_for("") → AssertionError
|
empty_agent_id [adversarial]: preset_for("") → AssertionError
|
||||||
|
|
||||||
FN seed_preset_first_message(client: httpx.AsyncClient, session_id: str, agent_id: str) -> str | None
|
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, 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.
|
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-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-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)
|
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-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:
|
ERROR_ROUTING:
|
||||||
asyncio.CancelledError:
|
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)
|
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
|
flow_control: propagate
|
||||||
state_recovery: n/a
|
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
|
local_handling: swallow; return None
|
||||||
flow_control: continue (never blocks session create)
|
flow_control: continue (never blocks session create)
|
||||||
state_recovery: session opens with no seeded greeting
|
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)
|
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
|
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]
|
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 }
|
tool: { destructive: false, idempotent: true, read_only: false, open_world: false }
|
||||||
6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None
|
6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None
|
||||||
7. [cleanup, prescriptive] RETURN content
|
7. [cleanup, prescriptive] RETURN content
|
||||||
TESTS:
|
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, mock 201 → returns the preset text; exactly one POST /sessions/{id}/history; body author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12]
|
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_http [happy]: agent "mimir" → returns None; NO HTTP issued
|
no_preset_zero_write [happy]: agent "mimir" → returns None; ZERO write_history call
|
||||||
feature_absent_swallowed [error]: preset agent, mock 404 session_not_found → returns None, no raise
|
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, mock 409 → 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, mock httpx.ConnectError → 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)
|
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)
|
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
|
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); NO HTTP; no raise
|
empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); ZERO write; no raise
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -158,18 +158,23 @@ 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 `AgentNotAvailable` / `TurnLaunchUnavailable` / `SessionRetired` (stream-open) | ratatoskr `AgentNotAvailable` / `TurnLaunchUnavailable` / (retired → `SessionApiFailed`) — same names, passthrough |
|
||||||
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
|
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
|
||||||
| SDK `ResumeError` subclasses (in resilient stream) | resilient `stream_turn` absorbs; terminal → `SseConnectFailed` |
|
| 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.create` | `AgentNotFound` |
|
||||||
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
|
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
|
||||||
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` |
|
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` (dual-key: status 422 AND error_code; the flat cursor body surfaces the code) |
|
||||||
| `ApiError(502 bifrost_handshake_failed)` on bound `sessions.create` | `BifrostHandshakeFailed` |
|
| `ApiError(502)` on bound `sessions.create` | `BifrostHandshakeFailed` — NOT gated on error_code (unlike list's 422): INV-002, the synchronous handshake is the SOLE bound-502 cause; and the SDK's envelope parser prefers the nested `detail` (which carries `bifrost_error`, not `error_code`), so no distinguishing top-level `error_code` surfaces. The route+status IS the discriminator. |
|
||||||
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
|
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
|
||||||
|
|
||||||
The default row is load-bearing: any `ApiError` not matched above surfaces as the
|
The default row is load-bearing: any `ApiError` not matched above surfaces as the
|
||||||
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
|
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
|
||||||
adapter does NOT invent per-route semantics the contract doesn't list, and does NOT
|
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
|
leave an `ApiError` un-mapped. **This default holds on EVERY route, including the
|
||||||
the old path is deleted.
|
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)
|
## Slice plan (incremental, DEC-4)
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,11 @@ sub-gap).
|
|||||||
|
|
||||||
| Endpoint | Status | Where consumed | Note |
|
| 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` | ✅ | `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) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) |
|
| `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) | ✅ | `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 |
|
| `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) | ✅ | `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) |
|
| `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` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped |
|
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `wt.py` `cancel_turn` (SDK `sessions.cancel_turn`) → cli/web | **wt-adapter re-anchored (slice-2, #20)** — two-stage Ctrl-C; 404→CancelTurnNotFound, 409→CancelAlreadyCompleted, late-cancel 200 (`cancelled=False`) is a benign result, not an error |
|
||||||
| `GET /agents` | ✅ | `sessions.py:341` → `tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index |
|
| `GET /agents` | ✅ | `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 |
|
| `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 |
|
| `POST /agents/define` | ✅ | `tier3.py:175` → `_run_define` | Tier-3 create |
|
||||||
@@ -103,7 +103,7 @@ sub-gap).
|
|||||||
| `POST /characters` | ✅ | `sessions.py` `create_character` → `cli.py` `--characters` | create transient character (#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) |
|
| `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) |
|
| `DELETE /characters/{id}` | ✅ | `sessions.py` `delete_character` → `cli.py` `--characters` | remove transient character (#161) |
|
||||||
| `POST /sessions/{id}/persona_state` | ✅ | `sessions.py` `set_persona_state` → `cli.py` `--set-persona-pad` | persona-state write / affect injection (freeform body — unpinned in the frozen surface) |
|
| `POST /sessions/{id}/persona_state` | ✅ | `wt.py` `set_persona_state` (SDK `sessions.set_persona_state`, `PadState`) → `cli.py` `--set-persona-pad` | **wt-adapter re-anchored (slice-3, #20)** — SDK owns the canonical `{"pad": {...}}` wire (#317); CLI passes the 3 PAD axes (finiteness pre-validated); 204→None, else SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on personal :8081: `--set-persona-pad 0.4,0.1,-0.2` → **204** |
|
||||||
|
|
||||||
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
|
**Sub-gaps inside ✅ path groups** (the method we use is live; a sibling method
|
||||||
on the same path is an unwired frontier item — see frontier Tier 1):
|
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`.
|
||||||
+38
-34
@@ -1,6 +1,6 @@
|
|||||||
# Persistent memory — ratatoskr
|
# 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
|
> **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
|
> `Written:` stamp is under an hour old, read it (it carries the in-flight
|
||||||
@@ -44,34 +44,38 @@ upstream API key stays server-side (INV-003).
|
|||||||
|
|
||||||
## Current state / in-flight
|
## 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
|
**🔨 ACTIVE MIGRATION — worldtree-sdk cutover (issue #20): SLICE-1 + SLICE-2 COMPLETE + PUSHED, slice-3 next.**
|
||||||
(2026-07-18): ratatoskr cuts its CONSUMER client layer over to consume **worldtree-sdk (Python) 1.0.0** —
|
Operator ruled ADOPT (2026-07-18): ratatoskr cuts its CONSUMER client layer over to **worldtree-sdk (Python)
|
||||||
retire the hand-rolled httpx wrappers (`sessions`/`sse_client`/`tier3`) behind a thin `ratatoskr.wt`
|
1.0.0**, retiring the hand-rolled httpx wrappers behind a thin `ratatoskr.wt` adapter. Design locked (6 DECs,
|
||||||
adapter over the SDK. Both TS + Python SDK 1.0.0 are GA (**Python live + pip-installable on the gitea
|
vor-cross'd, heid-panel-reviewed); contract `docs/contracts/worldtree_sdk_cutover.contract.md`. **SLICE-1
|
||||||
PyPI** — DEC-5 gate cleared). Ratatoskr's own **parity pass shaped the Python spine** (open-world reads,
|
(adapter foundation) ✅ + SLICE-2 (sessions/turn) ✅ DONE + PUSHED** — origin at `aba1730` (12-commit arc
|
||||||
caller-injected transport, per-wire role/model). Design locked (6 DECs, vor-cross'd with worldtree-codex,
|
`b1fbadd`→`aba1730`, tags v0.21.3–.10, pushed 2026-07-19): adapter (`b907a7b`, all 6 route families
|
||||||
heid-panel-reviewed → error-map table added); contract `docs/contracts/worldtree_sdk_cutover.contract.md`
|
create/list/messages/tools/stream/cancel + the § Error-map mapping) → CLI rewire (`e3a10ad`) → web rewire
|
||||||
(committed `e45640c`). **SLICE-1 IN PROGRESS:** ✅ dep integrated + DEC-5 verified + committed (`29c4fda`) — `worldtree-sdk==1.0.0`
|
(`5c595b8`) → orphan deletion (`59602fe`, sse_client 714→224 + sessions 677→608; DEC-4 LIVE-SMOKE PASSED on
|
||||||
installs from the gitea registry (reuses bifrost's index auth, NO new token; core dep + `[tool.uv.sources]`),
|
:8081 create+stream+cancel FIRST) → heid-code-review fixup (`74d41eb`) → heid-bug-hunt fixup (`aba1730`).
|
||||||
`WorldtreeClient` constructs with an injected transport (`_owns_client=False`, INV-CUT-1 confirmed live),
|
Suite **497 green**. **KEY ADAPTER FACTS (foot-guns for slices 3-7):** the SDK returns **open-world dicts**
|
||||||
suite 534 green. **NEXT = the adapter** `src/ratatoskr/wt.py` (TDD): `build_client(base_url, *, api_key,
|
(not typed objects) for session reads → presenters read mappings, `info["session_id"]`; SDK events
|
||||||
admin_key, transport)` → `WorldtreeClient(auth=, admin_auth=, transport=)` (DESIGN CARE: SDK does per-request
|
(`TurnEvent`) carry `sse_id: str` + a **body-derived `turn_id` that's ABSENT on text/thinking frames** → parse
|
||||||
auth via the providers; our injected `httpx.AsyncClient` carries base_url/UA/timeout, NOT the Authorization
|
the cancel target's turn from the composite `sse_id`, NOT `event.turn_id`; the SDK **normalizes transport
|
||||||
header — read the SDK `client.py` @ `~/development/worldtree-sdk` for the split, INV-CUT-1) + `translate_error`
|
failures to `ConnectFailed(status=0)`** (not raw httpx) → presenters catch it; the adapter re-wraps SDK stream
|
||||||
DEFAULT (SDK `ApiError`→`SessionApiFailed`, discriminated `WorldtreeError` subclasses passthrough; route-specific
|
errors → ratatoskr caller-semantic exceptions (DEC-2, keep ratatoskr's typed exceptions); `consumer_key` is
|
||||||
rows come in later slices). Unit-test; NO surface wiring (slice-2). Then slices 2-7 (route-family + deletions,
|
BOUND-create-only (adapter nulls it when unbound, else the SDK auths as the consumer); the SDK's error-envelope
|
||||||
live-smoke per slice). Scope: consumer layer ONLY;
|
parser **prefers the nested `detail`** so a top-level `error_code` doesn't surface (why create's bound-502 is
|
||||||
Bifrost provider planes untouched. Multi-session grind. Full design → auto-memory
|
NOT gated on error_code, unlike list's 422). **NEXT = slice-3** (persona `set_persona_state` + authored-history
|
||||||
`project_worldtree_sdk_cutover`. This SUPERSEDES the #371 "repin rides the later Python milestone" framing
|
`write_history` + first_message presets — retires the still-hand-rolled `create_session`/`get_session_messages`
|
||||||
below (that milestone shipped; we're adopting, not just repinning).
|
that the `--seed-first-message` probe uses); then slice-4 (agents/tier3, FOLDS the `model`→`role` cutover),
|
||||||
|
slice-5 (characters/me/caps), slice-6 (admin — `stream_admin_events`+`get_session_bifrost` still hand-rolled),
|
||||||
|
slice-7 (teardown: retire contracts #2/#15, drop `httpx-sse`, minor bump per DEC-6 w/ operator approval).
|
||||||
|
Scope: consumer layer ONLY; Bifrost provider planes untouched. Full design → auto-memory
|
||||||
|
`project_worldtree_sdk_cutover`.
|
||||||
|
|
||||||
**⏸️ DEFERRED — tier3 agents `model`→`role` (scope B), on worldtree-dev's deploy flag.** WT renames the
|
**⏸️ DEFERRED — tier3 agents `model`→`role` (scope B), folds into cutover slice-4.** WT renamed the
|
||||||
agents-RESPONSE selector `model`→`role` (spec 1.2, commit `387c67b`, NOT yet deployed). `_parse_tier3_agent_info`
|
agents-RESPONSE selector `model`→`role` (spec 1.2). **DEPLOY NOW LIVE** on :8080/:8081 (v1.0.0b128,
|
||||||
reads `body["model"]` → KeyErrors post-deploy. Operator chose scope B (full tier3 `model`→`role` incl.
|
worldtree-dev confirmed 2026-07-19 — was the deploy-flag gate; acked). Operator chose scope B (full tier3
|
||||||
contract #15 + CLI `--model`→`--role`). Implement ON the deploy flag, not before (breaks the current demo);
|
`model`→`role` incl. contract #15 + CLI `--model`→`--role`); lands in cutover slice-4 where tier3.py routes
|
||||||
folds into cutover slice-4. Auto-memory `project_tier3_agents_model_to_role_pending`.
|
through the SDK (doing it standalone now = throwaway). Auto-memory `project_tier3_agents_model_to_role_pending`.
|
||||||
|
|
||||||
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).**
|
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).**
|
||||||
**No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
|
**No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
|
||||||
@@ -115,13 +119,12 @@ Full record → `persistent-memory.d/2026-07-18-368-silo-test-passed.md`. Siblin
|
|||||||
(2) R39 Phase-2 **matched-quartets rebuild** (confirmatory, "whenever"); (3) bifrost **snapshot-cursor
|
(2) R39 Phase-2 **matched-quartets rebuild** (confirmatory, "whenever"); (3) bifrost **snapshot-cursor
|
||||||
adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
|
adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
|
||||||
|
|
||||||
**Substrate / environment:** branch `main` at **v0.21.2**. **origin at `b1fbadd`** (pushed 2026-07-18:
|
**Substrate / environment:** branch `main` at **v0.21.10**, **fully PUSHED to origin at `aba1730`** (slice-2
|
||||||
canonical syncs `5d06a27`/`80c8d58`, ephemeral-Echo `c7016f2` #19, coverage-map→SDK-surface `b1fbadd`).
|
cutover arc `b1fbadd`→`aba1730` + tags v0.21.3–.10 pushed 2026-07-19). origin
|
||||||
**UNPUSHED local commits** (cutover work — operator hasn't pushed): cutover contract `e45640c` #20, the
|
`git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep: `worldtree-sdk==1.0.0`** (gitea PyPI,
|
||||||
`memory:` snapshot, dep-integration `29c4fda` (worldtree-sdk==1.0.0), + this snapshot — **push is the
|
`[tool.uv.sources]`; `httpx-sse` retires at slice-7). bifrost **`==1.1.4`** / wire v0.7; WT openapi vendored
|
||||||
operator's call**. origin `git@gitea.phasefinal.com:vh/ratatoskr.git`. **NEW core dep: `worldtree-sdk==1.0.0`**
|
2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`); **suite 497 green** (was 534 pre-cutover; net
|
||||||
(gitea PyPI, `[tool.uv.sources]`). bifrost **`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0,
|
delta = adapter/rewire tests added, ~80 deleted hand-rolled turn-stream tests). Personal WT on **b128**
|
||||||
**conversation-api-spec re-synced to v1.1** (`b4a278c`); **suite 534 green**. Personal WT on **b127**
|
|
||||||
(`http://10.250.50.152:8081`; #368 silo + #364 promotion-hygiene live both instances). The combined
|
(`http://10.250.50.152:8081`; #368 silo + #364 promotion-hygiene live both instances). The combined
|
||||||
**:8392** provider (memory+affect) + **:8765** web are THE surfaces, dev-box BACKGROUND SHELLS —
|
**:8392** provider (memory+affect) + **:8765** web are THE surfaces, dev-box BACKGROUND SHELLS —
|
||||||
restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets
|
restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets
|
||||||
@@ -271,6 +274,7 @@ 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]` **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]` **Rata = THE reference consumer of the worldtree-sdk Python spine** — parity pass (12 grounded findings) shaped its contract BEFORE build (open-world reads, caller-injected transport, per-wire role/model — adopted); coverage-map re-anchored to the SDK's 41-op ratified surface (`b1fbadd`); 4 ergonomics items parked post-v1 with wtsdk-dev.
|
||||||
- `[2026-07-18]` **worldtree-sdk cutover DECIDED — adopt the Python SDK for the consumer client layer** (operator, overriding "stay hand-rolled"). Issue #20; contract `docs/contracts/worldtree_sdk_cutover.contract.md` (`e45640c`, vor-cross'd + heid-reviewed); auto-memory `project_worldtree_sdk_cutover`. Consumer layer only, Bifrost provider untouched; slice-1 foundation next. See in-flight.
|
- `[2026-07-18]` **worldtree-sdk cutover DECIDED — adopt the Python SDK for the consumer client layer** (operator, overriding "stay hand-rolled"). Issue #20; contract `docs/contracts/worldtree_sdk_cutover.contract.md` (`e45640c`, vor-cross'd + heid-reviewed); auto-memory `project_worldtree_sdk_cutover`. Consumer layer only, Bifrost provider untouched; slice-1 foundation next. See in-flight.
|
||||||
|
- `[2026-07-19]` **worldtree-sdk cutover SLICE-1 + SLICE-2 COMPLETE + PUSHED (origin `aba1730`, v0.21.10, 497 green).** The biggest, riskiest cutover slice done end-to-end through the full House Code Discipline (adapter→cli→web→delete→live-smoke→both heid gates); the bug-hunt caught 4 real confirmed bugs the conformance lens couldn't, and a convergent code-review finding was correctly REJECTED as category-5 (SDK envelope-parser behavior). Slice-3 next. → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-1-2-complete.md`
|
||||||
|
|
||||||
_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._
|
_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._
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.21.5"
|
version = "0.21.11"
|
||||||
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
+193
-86
@@ -8,15 +8,34 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from importlib.metadata import PackageNotFoundError, version
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
from typing import Any, TextIO
|
from typing import Any, TextIO
|
||||||
|
|
||||||
import httpx
|
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.first_message import seed_preset_first_message
|
||||||
from ratatoskr.sessions import (
|
from ratatoskr.sessions import (
|
||||||
AgentNotFound,
|
AgentNotFound,
|
||||||
@@ -26,40 +45,25 @@ from ratatoskr.sessions import (
|
|||||||
BifrostHandshakeFailed,
|
BifrostHandshakeFailed,
|
||||||
SessionApiFailed,
|
SessionApiFailed,
|
||||||
create_character,
|
create_character,
|
||||||
create_session,
|
|
||||||
delete_character,
|
delete_character,
|
||||||
endpoint_for_plane,
|
endpoint_for_plane,
|
||||||
get_capabilities,
|
get_capabilities,
|
||||||
get_character_state,
|
get_character_state,
|
||||||
get_me,
|
get_me,
|
||||||
get_session_messages,
|
|
||||||
list_character_models,
|
list_character_models,
|
||||||
set_persona_state,
|
|
||||||
write_authored_history,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The turn path (create / stream / cancel) is served by the worldtree-sdk adapter
|
||||||
|
# (`wt.*`); these caller-semantic exceptions are what the adapter raises, so the
|
||||||
|
# presenter keeps catching ratatoskr's own types (DEC-2). The hand-rolled probes
|
||||||
|
# (--whoami / --characters / --set-persona / --seed-first-message) stay on the
|
||||||
|
# `sessions` wrappers until their own slices.
|
||||||
from ratatoskr.sse_client import (
|
from ratatoskr.sse_client import (
|
||||||
AffectUpdate,
|
|
||||||
AwaitingLlmFirstToken,
|
|
||||||
CancelAlreadyCompleted,
|
|
||||||
CancelFailed,
|
|
||||||
Cancelled,
|
|
||||||
CancelTurnNotFound,
|
|
||||||
Done,
|
|
||||||
Error,
|
|
||||||
Event,
|
|
||||||
MalformedSseData,
|
MalformedSseData,
|
||||||
MalformedSseId,
|
MalformedSseId,
|
||||||
SseConnectFailed,
|
SseConnectFailed,
|
||||||
SseConnectionDropped,
|
SseConnectionDropped,
|
||||||
Text,
|
|
||||||
TextBoundary,
|
|
||||||
Thinking,
|
|
||||||
ToolResult,
|
|
||||||
ToolStart,
|
|
||||||
TurnIdFlip,
|
TurnIdFlip,
|
||||||
WorkerPhase,
|
|
||||||
cancel_turn,
|
|
||||||
stream_turn_resilient,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -337,6 +341,42 @@ def _format_usage(usage: dict[str, int], *, arrow: str) -> str:
|
|||||||
return f"{p} in {arrow} {c} out ({t} total, {ci} cached)"
|
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)
|
@dataclass(slots=True)
|
||||||
class CliPresenterState:
|
class CliPresenterState:
|
||||||
"""Per-turn presenter state for `--send` mode (issue #12).
|
"""Per-turn presenter state for `--send` mode (issue #12).
|
||||||
@@ -348,24 +388,36 @@ class CliPresenterState:
|
|||||||
thinking_open: bool = False
|
thinking_open: bool = False
|
||||||
text_written_since_newline: bool = False
|
text_written_since_newline: bool = False
|
||||||
|
|
||||||
def render(self, event: Event, *, stdout: TextIO, stderr: TextIO) -> None:
|
def render(self, event: TurnEvent, *, stdout: TextIO, stderr: TextIO) -> None:
|
||||||
"""Render one Worldtree SSE event with editorial hierarchy + coalescing."""
|
"""Render one Worldtree SSE event with editorial hierarchy + coalescing.
|
||||||
assert isinstance(
|
|
||||||
|
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,
|
event,
|
||||||
(
|
(
|
||||||
WorkerPhase, Thinking, Text, TextBoundary,
|
WorkerPhaseEvent, ThinkingEvent, TextEvent, TextBoundaryEvent,
|
||||||
ToolStart, ToolResult, Done, Error, Cancelled,
|
ToolStartEvent, ToolResultEvent, DoneEvent, ErrorEvent, CancelledEvent,
|
||||||
AffectUpdate, AwaitingLlmFirstToken,
|
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.
|
# Thinking events accumulate into the open run.
|
||||||
if isinstance(event, Thinking):
|
if isinstance(event, ThinkingEvent):
|
||||||
|
content = event.content or ""
|
||||||
if not self.thinking_open:
|
if not self.thinking_open:
|
||||||
stderr.write(". thinking: ")
|
stderr.write(". thinking: ")
|
||||||
self.thinking_open = True
|
self.thinking_open = True
|
||||||
stderr.write(event.content)
|
stderr.write(content)
|
||||||
stderr.flush()
|
stderr.flush()
|
||||||
self.thinking_buffer.append(event.content)
|
self.thinking_buffer.append(content)
|
||||||
return
|
return
|
||||||
# Non-thinking event: close any open thinking run first.
|
# Non-thinking event: close any open thinking run first.
|
||||||
if self.thinking_open:
|
if self.thinking_open:
|
||||||
@@ -374,62 +426,64 @@ class CliPresenterState:
|
|||||||
self.thinking_open = False
|
self.thinking_open = False
|
||||||
self.thinking_buffer.clear()
|
self.thinking_buffer.clear()
|
||||||
# Now render the new event.
|
# Now render the new event.
|
||||||
if isinstance(event, Text):
|
if isinstance(event, TextEvent):
|
||||||
stdout.write(event.content)
|
content = event.content or ""
|
||||||
|
stdout.write(content)
|
||||||
stdout.flush()
|
stdout.flush()
|
||||||
# POST-003: only set if cursor is mid-line (no trailing newline).
|
# 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
|
return
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (DoneEvent, ErrorEvent, CancelledEvent)):
|
||||||
# INV-005: ensure stdout newline boundary before stderr terminal label.
|
# INV-005: ensure stdout newline boundary before stderr terminal label.
|
||||||
if self.text_written_since_newline:
|
if self.text_written_since_newline:
|
||||||
stdout.write("\n")
|
stdout.write("\n")
|
||||||
stdout.flush()
|
stdout.flush()
|
||||||
self.text_written_since_newline = False
|
self.text_written_since_newline = False
|
||||||
if isinstance(event, Done):
|
if isinstance(event, DoneEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
f"[done] turn_id={event.turn_id} model={event.model} "
|
||||||
f"duration={_format_duration_ms(event.duration_ms)} "
|
f"duration={_format_duration_safe(event.duration_ms)} "
|
||||||
f"usage {_format_usage(event.usage, arrow='->')}\n"
|
f"usage {_format_usage_safe(event.usage)}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, WorkerPhase):
|
if isinstance(event, WorkerPhaseEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f". worker_phase: phase={event.phase} turn_id={event.turn_id}\n"
|
f". worker_phase: phase={event.phase} turn_id={event.turn_id}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, Error):
|
if isinstance(event, ErrorEvent):
|
||||||
stderr.write(
|
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"
|
f"message={event.message!r}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, Cancelled):
|
if isinstance(event, CancelledEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
||||||
f"partial_message_id={event.partial_message_id}\n"
|
f"partial_message_id={event.partial_message_id}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, ToolStart):
|
if isinstance(event, ToolStartEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f". tool_start: name={event.name} args={event.arguments!r}\n"
|
f". tool_start: name={event.name} args={event.arguments!r}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, ToolResult):
|
if isinstance(event, ToolResultEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f". tool_result: name={event.name} duration_ms={event.duration_ms} "
|
f". tool_result: name={event.name} duration_ms={event.duration_ms} "
|
||||||
f"result={event.result!r:.200}\n"
|
f"result={event.result!r:.200}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, TextBoundary):
|
if isinstance(event, TextBoundaryEvent):
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
|
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, AffectUpdate):
|
if isinstance(event, AffectUpdateEvent):
|
||||||
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
|
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
|
||||||
# one line to stderr with status + (for current) dominant_emotion.
|
# 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")
|
dom = event.snapshot.get("dominant_emotion")
|
||||||
stderr.write(
|
stderr.write(
|
||||||
f". affect_update: status={event.status} turn_id={event.turn_id} "
|
f". affect_update: status={event.status} turn_id={event.turn_id} "
|
||||||
@@ -440,10 +494,10 @@ class CliPresenterState:
|
|||||||
f". affect_update: status={event.status} turn_id={event.turn_id}\n"
|
f". affect_update: status={event.status} turn_id={event.turn_id}\n"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, AwaitingLlmFirstToken):
|
if isinstance(event, AwaitingLlmFirstTokenEvent):
|
||||||
# Worldtree #201 / v0.29.0. Heartbeat during BuildingPrompt →
|
# Worldtree #201 / v0.29.0. Heartbeat during BuildingPrompt →
|
||||||
# CallingLLM gap. Stderr surface, one line per heartbeat.
|
# 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(
|
stderr.write(
|
||||||
f". awaiting_llm_first_token: turn_id={event.turn_id} elapsed={secs:.1f}s\n"
|
f". awaiting_llm_first_token: turn_id={event.turn_id} elapsed={secs:.1f}s\n"
|
||||||
)
|
)
|
||||||
@@ -451,23 +505,29 @@ class CliPresenterState:
|
|||||||
|
|
||||||
|
|
||||||
async def _cancel_and_log(
|
async def _cancel_and_log(
|
||||||
client: httpx.AsyncClient,
|
client: WorldtreeClient,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
turn_id: int,
|
turn_id: int,
|
||||||
*,
|
*,
|
||||||
stderr: TextIO,
|
stderr: TextIO,
|
||||||
) -> None:
|
) -> 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 client is not None
|
||||||
assert isinstance(turn_id, int) and turn_id > 0
|
assert isinstance(turn_id, int) and turn_id > 0
|
||||||
try:
|
try:
|
||||||
await cancel_turn(client, session_id, turn_id)
|
await wt.cancel_turn(client, session_id, turn_id)
|
||||||
except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
|
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")
|
stderr.write(f"[cancel_failed] {type(exc).__name__}: {exc}\n")
|
||||||
|
|
||||||
|
|
||||||
async def _run_turn(
|
async def _run_turn(
|
||||||
client: httpx.AsyncClient,
|
client: WorldtreeClient,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
content: str,
|
content: str,
|
||||||
sigint_event: asyncio.Event,
|
sigint_event: asyncio.Event,
|
||||||
@@ -493,7 +553,10 @@ async def _run_turn(
|
|||||||
cancelling = False
|
cancelling = False
|
||||||
sigint_task: asyncio.Task[bool] | None = None
|
sigint_task: asyncio.Task[bool] | None = None
|
||||||
cancel_task: asyncio.Task[None] | None = None # strong ref to fire-and-forget cancel
|
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:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -522,6 +585,11 @@ async def _run_turn(
|
|||||||
except StopAsyncIteration:
|
except StopAsyncIteration:
|
||||||
stderr.write("[connection_dropped] last_seen=<none>\n")
|
stderr.write("[connection_dropped] last_seen=<none>\n")
|
||||||
return 21
|
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:
|
except SseConnectFailed as exc:
|
||||||
stderr.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}\n")
|
stderr.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}\n")
|
||||||
return 20
|
return 20
|
||||||
@@ -537,17 +605,21 @@ async def _run_turn(
|
|||||||
except TurnIdFlip as exc:
|
except TurnIdFlip as exc:
|
||||||
stderr.write(f"[turn_id_flip] expected={exc.established} got={exc.got}\n")
|
stderr.write(f"[turn_id_flip] expected={exc.established} got={exc.got}\n")
|
||||||
return 22
|
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)
|
state.render(event, stdout=stdout, stderr=stderr)
|
||||||
if isinstance(event, Done):
|
if isinstance(event, DoneEvent):
|
||||||
if sigint_task is not None and not cancelling:
|
if sigint_task is not None and not cancelling:
|
||||||
sigint_task.cancel()
|
sigint_task.cancel()
|
||||||
return 0
|
return 0
|
||||||
if isinstance(event, Error):
|
if isinstance(event, ErrorEvent):
|
||||||
if sigint_task is not None and not cancelling:
|
if sigint_task is not None and not cancelling:
|
||||||
sigint_task.cancel()
|
sigint_task.cancel()
|
||||||
return 2
|
return 2
|
||||||
if isinstance(event, Cancelled):
|
if isinstance(event, CancelledEvent):
|
||||||
if sigint_task is not None and not cancelling:
|
if sigint_task is not None and not cancelling:
|
||||||
sigint_task.cancel()
|
sigint_task.cancel()
|
||||||
return 3
|
return 3
|
||||||
@@ -564,6 +636,11 @@ async def _run_turn(
|
|||||||
async def _amain(args: ParsedArgs) -> int:
|
async def _amain(args: ParsedArgs) -> int:
|
||||||
"""Async orchestrator: create-session (if --new) → SIGINT install → _run_turn → cleanup."""
|
"""Async orchestrator: create-session (if --new) → SIGINT install → _run_turn → cleanup."""
|
||||||
assert isinstance(args, ParsedArgs)
|
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(
|
async with httpx.AsyncClient(
|
||||||
base_url=args.server_url,
|
base_url=args.server_url,
|
||||||
headers={
|
headers={
|
||||||
@@ -575,7 +652,8 @@ async def _amain(args: ParsedArgs) -> int:
|
|||||||
# connect/write/pool keep modest timeouts so true network failures
|
# connect/write/pool keep modest timeouts so true network failures
|
||||||
# still surface promptly.
|
# still surface promptly.
|
||||||
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
|
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:
|
if args.new:
|
||||||
assert args.agent_id is not None
|
assert args.agent_id is not None
|
||||||
try:
|
try:
|
||||||
@@ -586,7 +664,7 @@ async def _amain(args: ParsedArgs) -> int:
|
|||||||
if args.system_prompt is not None
|
if args.system_prompt is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
info = await create_session(
|
info = await wt.create_session(
|
||||||
client,
|
client,
|
||||||
args.agent_id,
|
args.agent_id,
|
||||||
end_user_id=args.end_user_id,
|
end_user_id=args.end_user_id,
|
||||||
@@ -616,23 +694,32 @@ async def _amain(args: ParsedArgs) -> int:
|
|||||||
"(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
|
"(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
|
||||||
)
|
)
|
||||||
return 23
|
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")
|
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
||||||
return 20
|
return 20
|
||||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
except (
|
||||||
|
httpx.ConnectError,
|
||||||
|
httpx.ReadTimeout,
|
||||||
|
httpx.TransportError,
|
||||||
|
ConnectFailed, # SDK normalizes a pre-response transport failure here
|
||||||
|
) as exc:
|
||||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||||
return 21
|
return 21
|
||||||
# Issue #12: demoted lifecycle line — written directly here (NOT via
|
# Issue #12: demoted lifecycle line — written directly here (NOT via
|
||||||
# state.render, which only accepts SSE Event variants per PRE-001).
|
# state.render, which only accepts SSE Event variants per PRE-001). The
|
||||||
kind_suffix = f" kind={info.kind}" if info.kind else ""
|
# 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(
|
sys.stderr.write(
|
||||||
f". create_session: session_id={info.session_id} "
|
f". create_session: session_id={session_id} "
|
||||||
f"agent_id={info.agent_id}{kind_suffix}\n"
|
f"agent_id={info['agent_id']}{kind_suffix}\n"
|
||||||
)
|
)
|
||||||
# #347 authored first-message: seed the agent's preset opening (best-effort).
|
# #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(
|
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
|
# Issue #17 bound-state indicator: plane + endpoint + status, so the
|
||||||
# operator sees WHICH identity/endpoint bound (not a bare boolean).
|
# operator sees WHICH identity/endpoint bound (not a bare boolean).
|
||||||
@@ -642,7 +729,7 @@ async def _amain(args: ParsedArgs) -> int:
|
|||||||
f". bifrost: status=bound plane={plane} "
|
f". bifrost: status=bound plane={plane} "
|
||||||
f"endpoint={args.bifrost.endpoint_url}\n"
|
f"endpoint={args.bifrost.endpoint_url}\n"
|
||||||
)
|
)
|
||||||
session_id = info.session_id
|
# session_id was bound above from the create dict.
|
||||||
else:
|
else:
|
||||||
assert args.session_id is not None
|
assert args.session_id is not None
|
||||||
session_id = args.session_id
|
session_id = args.session_id
|
||||||
@@ -792,14 +879,27 @@ async def _set_persona_probe(args: ParsedArgs) -> int:
|
|||||||
"(pleasure,arousal,dominance), e.g. '0.4,0.1,-0.2'\n"
|
"(pleasure,arousal,dominance), e.g. '0.4,0.1,-0.2'\n"
|
||||||
)
|
)
|
||||||
return 10
|
return 10
|
||||||
# Canonical POST /sessions/{id}/persona_state body (#317): a named-key dict,
|
# The SDK rejects a non-finite axis pre-HTTP (a NaN/Infinity would serialize to
|
||||||
# NOT a bare list — {"pad": {"pleasure", "arousal", "dominance"}}.
|
# null and corrupt the injection). Reject it here as a usage error so the probe
|
||||||
snapshot = {"pad": {"pleasure": pad[0], "arousal": pad[1], "dominance": pad[2]}}
|
# surfaces a clean message instead of crashing on the SDK's ConfigurationError.
|
||||||
async with _probe_client(args) as client:
|
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:
|
try:
|
||||||
await set_persona_state(client, args.session_id, snapshot)
|
# The SDK owns the canonical {"pad": {...}} wire body (#317); ratatoskr
|
||||||
except SessionApiFailed as exc:
|
# passes the three PAD axes and no longer hand-builds the snapshot.
|
||||||
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
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
|
return 20
|
||||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||||
@@ -824,19 +924,23 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int:
|
|||||||
"""
|
"""
|
||||||
assert isinstance(args, ParsedArgs)
|
assert isinstance(args, ParsedArgs)
|
||||||
assert args.agent_id is not None and args.seed_first_message is not None
|
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:
|
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
|
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(
|
key = "ratatoskr-first-message-" + hashlib.sha256(
|
||||||
args.seed_first_message.encode("utf-8")
|
args.seed_first_message.encode("utf-8")
|
||||||
).hexdigest()[:12]
|
).hexdigest()[:12]
|
||||||
try:
|
try:
|
||||||
ack = await write_authored_history(
|
ack = await wt.write_authored_history(
|
||||||
client,
|
client,
|
||||||
session.session_id,
|
session_id,
|
||||||
content=args.seed_first_message,
|
content=args.seed_first_message,
|
||||||
idempotency_key=key,
|
idempotency_key=key,
|
||||||
)
|
)
|
||||||
@@ -851,15 +955,18 @@ async def _seed_first_message_probe(args: ParsedArgs) -> int:
|
|||||||
f"seeded: seq={ack.get('seq')} phase={ack.get('phase')} "
|
f"seeded: seq={ack.get('seq')} phase={ack.get('phase')} "
|
||||||
f"turn_id={ack.get('turn_id')} content_chars={ack.get('content_chars')}\n"
|
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", [])
|
items = history.get("items", [])
|
||||||
sys.stdout.write(f"read-back: {len(items)} message(s)\n")
|
sys.stdout.write(f"read-back: {len(items)} message(s)\n")
|
||||||
for m in items:
|
for m in items:
|
||||||
sys.stdout.write(
|
sys.stdout.write(
|
||||||
f" seq={m.get('seq')} role={m.get('role')} content={m.get('content')!r}\n"
|
f" seq={m.get('seq')} role={m.get('role')} content={m.get('content')!r}\n"
|
||||||
)
|
)
|
||||||
except SessionApiFailed as exc:
|
except wt.SessionApiFailed as exc:
|
||||||
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
|
sys.stderr.write(
|
||||||
|
f"[session_api_failed] status={exc.status} "
|
||||||
|
f"error_code={exc.error_code!r} body={exc.body!r}\n"
|
||||||
|
)
|
||||||
return 20
|
return 20
|
||||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ blocked (the session simply opens with no seeded greeting). See
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
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
|
# Cap the best-effort seed write. The CLI create path reuses a transport with NO
|
||||||
# with NO read timeout (it streams SSE turns), so an accepted-but-never-answered
|
# read timeout (it streams SSE turns), so an accepted-but-never-answered
|
||||||
# POST /history would otherwise block session creation forever — violating INV-001's
|
# 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.
|
# "never block". asyncio.wait_for bounds the seed regardless of the client's timeout.
|
||||||
_SEED_TIMEOUT_S = 10.0
|
_SEED_TIMEOUT_S = 10.0
|
||||||
@@ -39,7 +39,7 @@ def preset_for(agent_id: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
async def seed_preset_first_message(
|
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:
|
) -> str | None:
|
||||||
"""Best-effort: seed ``agent_id``'s preset opening as a #347 authored
|
"""Best-effort: seed ``agent_id``'s preset opening as a #347 authored
|
||||||
first-message on ``session_id``; return the seeded text, or None.
|
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]
|
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
write_authored_history(
|
wt.write_authored_history(
|
||||||
client, session_id, content=content, idempotency_key=key
|
client, session_id, content=content, idempotency_key=key
|
||||||
),
|
),
|
||||||
timeout=_SEED_TIMEOUT_S,
|
timeout=_SEED_TIMEOUT_S,
|
||||||
|
|||||||
+2
-302
@@ -5,47 +5,12 @@ Implements docs/contracts/issues/2.contract.md.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
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)
|
@dataclass(frozen=True)
|
||||||
class AgentInfo:
|
class AgentInfo:
|
||||||
"""Worldtree agent envelope from GET /agents (issue #8).
|
"""Worldtree agent envelope from GET /agents (issue #8).
|
||||||
@@ -204,55 +169,6 @@ class AuthoredHistoryUnavailable(Exception):
|
|||||||
self.session_id = session_id
|
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)
|
|
||||||
|
|
||||||
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"))
|
|
||||||
|
|
||||||
|
|
||||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||||
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
|
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
|
||||||
|
|
||||||
@@ -266,113 +182,10 @@ def endpoint_for_plane(plane: str, base_host: str) -> str:
|
|||||||
"""
|
"""
|
||||||
ports = {"memory": 8391, "affect": 8390, "combined": 8392}
|
ports = {"memory": 8391, "affect": 8390, "combined": 8392}
|
||||||
if plane not in ports:
|
if plane not in ports:
|
||||||
raise ValueError(
|
raise ValueError(f"unknown plane: {plane!r} (expected 'memory', 'affect', or 'combined')")
|
||||||
f"unknown plane: {plane!r} "
|
|
||||||
"(expected 'memory', 'affect', or 'combined')"
|
|
||||||
)
|
|
||||||
return f"http://{base_host}:{ports[plane]}"
|
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]:
|
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
|
||||||
"""GET /agents — list available agents. See contract FN list_agents (issue #8).
|
"""GET /agents — list available agents. See contract FN list_agents (issue #8).
|
||||||
|
|
||||||
@@ -400,9 +213,7 @@ async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def get_persona_state(
|
async def get_persona_state(client: httpx.AsyncClient, agent_id: str) -> dict[str, Any]:
|
||||||
client: httpx.AsyncClient, agent_id: str
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""GET /agents/{agent_id}/persona_state — fetch current persona snapshot.
|
"""GET /agents/{agent_id}/persona_state — fetch current persona snapshot.
|
||||||
|
|
||||||
Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the
|
Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the
|
||||||
@@ -526,27 +337,6 @@ async def delete_character(client: httpx.AsyncClient, character_id: str) -> None
|
|||||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
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(
|
async def get_session_bifrost(
|
||||||
client: httpx.AsyncClient, session_id: str, *, admin_key: str
|
client: httpx.AsyncClient, session_id: str, *, admin_key: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -572,24 +362,6 @@ async def get_session_bifrost(
|
|||||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
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]:
|
async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||||
"""GET /capabilities — server capability discovery (spec §Ephemeral Templates).
|
"""GET /capabilities — server capability discovery (spec §Ephemeral Templates).
|
||||||
|
|
||||||
@@ -603,75 +375,3 @@ async def get_capabilities(client: httpx.AsyncClient) -> dict[str, Any]:
|
|||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
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)
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Implements docs/contracts/issues/1.contract.md.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
@@ -14,8 +13,6 @@ from typing import Any, NamedTuple
|
|||||||
import httpx
|
import httpx
|
||||||
import httpx_sse
|
import httpx_sse
|
||||||
|
|
||||||
_INT_RE = re.compile(r"^-?\d+$")
|
|
||||||
|
|
||||||
|
|
||||||
class SseId(NamedTuple):
|
class SseId(NamedTuple):
|
||||||
"""Parsed composite SSE wire `id:` per spec §SSE id format."""
|
"""Parsed composite SSE wire `id:` per spec §SSE id format."""
|
||||||
@@ -24,155 +21,6 @@ class SseId(NamedTuple):
|
|||||||
seq: int
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -258,31 +106,6 @@ class TurnLaunchUnavailable(SseConnectFailed):
|
|||||||
self.message = message
|
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):
|
class SseConnectionDropped(Exception):
|
||||||
@@ -352,275 +175,6 @@ class CancelFailed(Exception):
|
|||||||
self.body = body
|
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(
|
async def stream_admin_events(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
@@ -667,48 +221,3 @@ async def stream_admin_events(
|
|||||||
raise SseConnectionDropped(last_seen_sse_id=None) from exc
|
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"),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ posture (same as ratatoskr.sessions). Exposes three lifecycle operations:
|
|||||||
|
|
||||||
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
|
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
|
||||||
already handles colon-containing agent_ids generically (issue #8); session
|
already handles colon-containing agent_ids generically (issue #8); session
|
||||||
creation works unchanged via ``ratatoskr.sessions.create_session``.
|
creation works unchanged via ``ratatoskr.wt.create_session`` (worldtree-sdk cutover).
|
||||||
|
|
||||||
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
|
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
|
||||||
"""
|
"""
|
||||||
|
|||||||
+95
-58
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator, Callable
|
from collections.abc import AsyncIterator, Callable, Mapping
|
||||||
from dataclasses import asdict, dataclass, is_dataclass
|
from dataclasses import asdict, dataclass, is_dataclass
|
||||||
from importlib.metadata import version as _pkg_version
|
from importlib.metadata import version as _pkg_version
|
||||||
|
|
||||||
@@ -26,8 +26,10 @@ from starlette.responses import (
|
|||||||
)
|
)
|
||||||
from starlette.routing import Mount, Route
|
from starlette.routing import Mount, Route
|
||||||
from starlette.staticfiles import StaticFiles
|
from starlette.staticfiles import StaticFiles
|
||||||
|
from worldtree_sdk import CancelledEvent, DoneEvent, ErrorEvent, WorldtreeClient
|
||||||
|
|
||||||
from ratatoskr import local_agents as _local_agents
|
from ratatoskr import local_agents as _local_agents
|
||||||
|
from ratatoskr import wt
|
||||||
from ratatoskr.first_message import seed_preset_first_message
|
from ratatoskr.first_message import seed_preset_first_message
|
||||||
from ratatoskr.sessions import (
|
from ratatoskr.sessions import (
|
||||||
AgentNotAvailable,
|
AgentNotAvailable,
|
||||||
@@ -38,33 +40,47 @@ from ratatoskr.sessions import (
|
|||||||
BifrostHandshakeFailed,
|
BifrostHandshakeFailed,
|
||||||
PersonaNotConfigured,
|
PersonaNotConfigured,
|
||||||
SessionApiFailed,
|
SessionApiFailed,
|
||||||
create_session,
|
|
||||||
endpoint_for_plane,
|
endpoint_for_plane,
|
||||||
get_persona_state,
|
get_persona_state,
|
||||||
get_session_bifrost,
|
get_session_bifrost,
|
||||||
get_session_messages,
|
|
||||||
get_session_tools,
|
|
||||||
list_agents,
|
list_agents,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The turn path (create / stream / cancel / tools / messages) is served by the
|
||||||
|
# worldtree-sdk adapter (`wt.*`), which raises ratatoskr's caller-semantic
|
||||||
|
# exceptions (DEC-2). The hand-rolled endpoints (persona / agents / admin /
|
||||||
|
# bifrost) stay on the `sessions` / `sse_client` wrappers until their own slices.
|
||||||
from ratatoskr.sse_client import (
|
from ratatoskr.sse_client import (
|
||||||
AdminEvent,
|
AdminEvent,
|
||||||
CancelAlreadyCompleted,
|
CancelAlreadyCompleted,
|
||||||
CancelFailed,
|
CancelFailed,
|
||||||
Cancelled,
|
|
||||||
CancelTurnNotFound,
|
CancelTurnNotFound,
|
||||||
Done,
|
|
||||||
Error,
|
|
||||||
MalformedSseData,
|
MalformedSseData,
|
||||||
MalformedSseId,
|
MalformedSseId,
|
||||||
SseConnectFailed,
|
SseConnectFailed,
|
||||||
SseConnectionDropped,
|
SseConnectionDropped,
|
||||||
TurnIdFlip,
|
TurnIdFlip,
|
||||||
cancel_turn,
|
|
||||||
stream_admin_events,
|
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:
|
def _static_dir() -> str:
|
||||||
"""Locate the bundled static/ directory inside the installed package.
|
"""Locate the bundled static/ directory inside the installed package.
|
||||||
|
|
||||||
@@ -163,16 +179,17 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with client_factory() as client:
|
async with client_factory() as client:
|
||||||
info = await create_session(
|
wt_client = _wt_client(client)
|
||||||
client,
|
info = await wt.create_session(
|
||||||
|
wt_client,
|
||||||
agent_id,
|
agent_id,
|
||||||
end_user_id=end_user_id,
|
end_user_id=end_user_id,
|
||||||
bifrost=bifrost,
|
bifrost=bifrost,
|
||||||
consumer_key=consumer_key if bifrost else None,
|
consumer_key=consumer_key if bifrost else None,
|
||||||
)
|
)
|
||||||
# #347 authored first-message: seed the agent's preset opening
|
# #347 authored first-message: seed the agent's preset opening (best-effort;
|
||||||
# (best-effort; never blocks create — see first_message INV-001).
|
# never blocks create). Routed through the wt adapter (slice-3).
|
||||||
await seed_preset_first_message(client, info.session_id, agent_id)
|
await seed_preset_first_message(wt_client, info["session_id"], agent_id)
|
||||||
except AgentNotFound:
|
except AgentNotFound:
|
||||||
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
|
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
|
||||||
except BifrostConsumerKeyMissing:
|
except BifrostConsumerKeyMissing:
|
||||||
@@ -188,12 +205,13 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
|
|||||||
},
|
},
|
||||||
status_code=502,
|
status_code=502,
|
||||||
)
|
)
|
||||||
except SessionApiFailed as exc:
|
except wt.SessionApiFailed as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error_code": "session_api_failed", "status": exc.status},
|
{"error_code": "session_api_failed", "status": exc.status},
|
||||||
status_code=exc.status,
|
status_code=exc.status,
|
||||||
)
|
)
|
||||||
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:
|
if bifrost is not None:
|
||||||
# Bound-state for the UI indicator — plane + endpoint only, never the key.
|
# Bound-state for the UI indicator — plane + endpoint only, never the key.
|
||||||
payload["bifrost"] = {
|
payload["bifrost"] = {
|
||||||
@@ -251,25 +269,22 @@ async def _submit_turn_endpoint(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
|
|
||||||
def _event_to_browser_payload(event: object) -> tuple[str, dict]:
|
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
|
Per INV-008 + FN stream_turn_endpoint STEP 3. The browser contract
|
||||||
locked by tests/fixtures/presentation_contract.json — one entry per
|
(tests/fixtures/presentation_contract.json) is preserved: the SDK's `raw` is
|
||||||
Event type. Implementation: snake_case class name as event_type;
|
the wire body — the same per-type field set the old dataclasses carried — so the
|
||||||
asdict(event) with sse_id flattened to "T:S" string.
|
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__
|
browser_type = getattr(event, "type", "") or ""
|
||||||
# CamelCase → snake_case
|
raw = getattr(event, "raw", None)
|
||||||
browser_type = "".join(
|
# Open-world: degrade a non-mapping `raw` to an empty payload rather than letting
|
||||||
("_" + c.lower() if c.isupper() and i else c.lower())
|
# dict(raw) raise (which would abort the SSE stream mid-response).
|
||||||
for i, c in enumerate(type_name)
|
src = raw if isinstance(raw, Mapping) else {}
|
||||||
)
|
data = {k: v for k, v in src.items() if k != "type"}
|
||||||
data = asdict(event) # type: ignore[arg-type]
|
data["sse_id"] = getattr(event, "sse_id", None)
|
||||||
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']}"
|
|
||||||
return browser_type, data
|
return browser_type, data
|
||||||
|
|
||||||
|
|
||||||
@@ -281,6 +296,20 @@ def _format_sse(event_type: str, data: dict) -> bytes:
|
|||||||
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode()
|
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:
|
async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
|
||||||
"""GET /api/turns/{session_id}/stream?turn_id=N → proxy upstream SSE.
|
"""GET /api/turns/{session_id}/stream?turn_id=N → proxy upstream SSE.
|
||||||
|
|
||||||
@@ -302,24 +331,28 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
|
|||||||
|
|
||||||
async def gen() -> AsyncIterator[bytes]:
|
async def gen() -> AsyncIterator[bytes]:
|
||||||
client = client_factory()
|
client = client_factory()
|
||||||
|
wt_client = _wt_client(client)
|
||||||
try:
|
try:
|
||||||
handle.status = "streaming"
|
handle.status = "streaming"
|
||||||
try:
|
try:
|
||||||
async for event in stream_turn_resilient(client, session_id, handle.content):
|
async for event in wt.stream_turn(wt_client, session_id, handle.content):
|
||||||
# v0.16.0: capture the upstream (Worldtree-assigned)
|
# v0.16.0: capture the upstream (Worldtree-assigned) turn_id from
|
||||||
# turn_id from the first event so cancel paths target
|
# the first event so cancel paths target the real upstream turn,
|
||||||
# the real upstream turn, not our local counter.
|
# not our local counter — parsed from the composite sse_id.
|
||||||
if handle.upstream_turn_id is None:
|
if handle.upstream_turn_id is None:
|
||||||
sse_id = getattr(event, "sse_id", None)
|
handle.upstream_turn_id = _turn_id_from_sse_id(
|
||||||
if sse_id is not None:
|
getattr(event, "sse_id", None)
|
||||||
handle.upstream_turn_id = sse_id.turn_id
|
)
|
||||||
event_type, data = _event_to_browser_payload(event)
|
event_type, data = _event_to_browser_payload(event)
|
||||||
yield _format_sse(event_type, data)
|
yield _format_sse(event_type, data)
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (DoneEvent, ErrorEvent, CancelledEvent)):
|
||||||
handle.status = type(event).__name__.lower()
|
handle.status = event.type or "done"
|
||||||
break
|
break
|
||||||
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
|
except (wt.SessionApiFailed, SseConnectFailed, SseConnectionDropped,
|
||||||
MalformedSseData, TurnIdFlip) as exc:
|
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(
|
yield _format_sse(
|
||||||
"error",
|
"error",
|
||||||
{"exception": type(exc).__name__, "message": str(exc)},
|
{"exception": type(exc).__name__, "message": str(exc)},
|
||||||
@@ -330,7 +363,7 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
|
|||||||
# turn (if it started) — never the local turn_id.
|
# turn (if it started) — never the local turn_id.
|
||||||
if handle.status == "streaming" and handle.upstream_turn_id is not None:
|
if handle.status == "streaming" and handle.upstream_turn_id is not None:
|
||||||
try:
|
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):
|
except (CancelAlreadyCompleted, CancelTurnNotFound):
|
||||||
pass # cooperative race — turn already terminal upstream
|
pass # cooperative race — turn already terminal upstream
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -377,15 +410,18 @@ async def _cancel_turn_endpoint(request: Request) -> JSONResponse:
|
|||||||
client_factory = request.app.state.client_factory
|
client_factory = request.app.state.client_factory
|
||||||
try:
|
try:
|
||||||
async with client_factory() as client:
|
async with client_factory() as client:
|
||||||
await cancel_turn(client, session_id, handle.upstream_turn_id)
|
result = await wt.cancel_turn(
|
||||||
body = {"cancelled": True}
|
_wt_client(client), session_id, handle.upstream_turn_id
|
||||||
|
)
|
||||||
|
body = {"cancelled": bool(result.cancelled)}
|
||||||
except (CancelAlreadyCompleted, CancelTurnNotFound):
|
except (CancelAlreadyCompleted, CancelTurnNotFound):
|
||||||
body = {"cancelled": False, "reason": "race_or_completed"}
|
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)
|
registry.pop((session_id, turn_id), None)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error_code": "cancel_failed", "status": exc.status},
|
{"error_code": "cancel_failed"},
|
||||||
status_code=exc.status,
|
status_code=502,
|
||||||
)
|
)
|
||||||
registry.pop((session_id, turn_id), None)
|
registry.pop((session_id, turn_id), None)
|
||||||
return JSONResponse(body, status_code=200)
|
return JSONResponse(body, status_code=200)
|
||||||
@@ -465,13 +501,13 @@ async def _session_tools_endpoint(request: Request) -> JSONResponse:
|
|||||||
client_factory = request.app.state.client_factory
|
client_factory = request.app.state.client_factory
|
||||||
try:
|
try:
|
||||||
async with client_factory() as client:
|
async with client_factory() as client:
|
||||||
info = await get_session_tools(client, session_id)
|
info = await wt.get_session_tools(_wt_client(client), session_id)
|
||||||
except SessionApiFailed as exc:
|
except wt.SessionApiFailed as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error_code": "session_tools_unavailable", "status": exc.status},
|
{"error_code": "session_tools_unavailable", "status": exc.status},
|
||||||
status_code=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:
|
async def _session_messages_endpoint(request: Request) -> JSONResponse:
|
||||||
@@ -485,13 +521,13 @@ async def _session_messages_endpoint(request: Request) -> JSONResponse:
|
|||||||
client_factory = request.app.state.client_factory
|
client_factory = request.app.state.client_factory
|
||||||
try:
|
try:
|
||||||
async with client_factory() as client:
|
async with client_factory() as client:
|
||||||
data = await get_session_messages(client, session_id)
|
data = await wt.get_session_messages(_wt_client(client), session_id)
|
||||||
except SessionApiFailed as exc:
|
except wt.SessionApiFailed as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error_code": "session_messages_unavailable", "status": exc.status},
|
{"error_code": "session_messages_unavailable", "status": exc.status},
|
||||||
status_code=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:
|
async def _session_bifrost_endpoint(request: Request) -> JSONResponse:
|
||||||
@@ -609,14 +645,15 @@ def create_app(
|
|||||||
]
|
]
|
||||||
if in_flight:
|
if in_flight:
|
||||||
client = client_factory()
|
client = client_factory()
|
||||||
|
wt_client = _wt_client(client)
|
||||||
try:
|
try:
|
||||||
task_to_handle = {
|
task_to_handle = {
|
||||||
asyncio.create_task(
|
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
|
): h
|
||||||
for h in in_flight
|
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).
|
# Per-pending session/turn detail (INV-006 logging fidelity).
|
||||||
for task in pending:
|
for task in pending:
|
||||||
h = task_to_handle[task]
|
h = task_to_handle[task]
|
||||||
|
|||||||
+101
-9
@@ -28,19 +28,20 @@ carries the SDK's parsed `error_code`).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncIterator, Mapping
|
from collections.abc import AsyncGenerator, Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import worldtree_sdk as wtsdk
|
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
|
# 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.
|
# sessions / sse_client is one-way (neither imports wt), so there is no cycle.
|
||||||
from .sessions import (
|
from .sessions import (
|
||||||
AgentNotFound,
|
AgentNotFound,
|
||||||
|
AuthoredHistoryUnavailable,
|
||||||
BifrostBinding,
|
BifrostBinding,
|
||||||
BifrostConsumerKeyMissing,
|
BifrostConsumerKeyMissing,
|
||||||
BifrostHandshakeFailed,
|
BifrostHandshakeFailed,
|
||||||
@@ -81,6 +82,7 @@ def build_client(
|
|||||||
api_key: AuthProvider,
|
api_key: AuthProvider,
|
||||||
admin_key: AuthProvider | None = None,
|
admin_key: AuthProvider | None = None,
|
||||||
transport: httpx.AsyncClient,
|
transport: httpx.AsyncClient,
|
||||||
|
max_reconnects: int = 5,
|
||||||
) -> WorldtreeClient:
|
) -> WorldtreeClient:
|
||||||
"""Construct the adapter's `WorldtreeClient` over a ratatoskr-owned transport.
|
"""Construct the adapter's `WorldtreeClient` over a ratatoskr-owned transport.
|
||||||
|
|
||||||
@@ -88,15 +90,18 @@ def build_client(
|
|||||||
`_owns_client=False`, so `WorldtreeClient.aclose()` never closes it — ratatoskr
|
`_owns_client=False`, so `WorldtreeClient.aclose()` never closes it — ratatoskr
|
||||||
owns the lifecycle exactly as today (INV-CUT-1). ratatoskr's `api_key` /
|
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
|
`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
|
injected transport carries ratatoskr's User-Agent / timeout, and (transitionally)
|
||||||
caller in slice-2), NOT the Authorization header — the SDK adds auth per
|
the default bearer — the SDK adds auth per request, overriding it.
|
||||||
request.
|
|
||||||
|
`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(
|
return WorldtreeClient(
|
||||||
base_url,
|
base_url,
|
||||||
auth=api_key,
|
auth=api_key,
|
||||||
admin_auth=admin_key,
|
admin_auth=admin_key,
|
||||||
transport=transport,
|
transport=transport,
|
||||||
|
max_reconnects=max_reconnects,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -189,10 +194,22 @@ async def create_session(
|
|||||||
body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}
|
body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}
|
||||||
|
|
||||||
try:
|
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:
|
except ApiError as exc:
|
||||||
if exc.status == 404:
|
if exc.status == 404:
|
||||||
raise AgentNotFound(agent_id=agent_id) from exc
|
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:
|
if bifrost is not None and exc.status == 502:
|
||||||
raise BifrostHandshakeFailed(
|
raise BifrostHandshakeFailed(
|
||||||
bifrost_error=_bifrost_error_from_body(exc.body),
|
bifrost_error=_bifrost_error_from_body(exc.body),
|
||||||
@@ -249,7 +266,7 @@ async def get_session_tools(
|
|||||||
|
|
||||||
async def stream_turn(
|
async def stream_turn(
|
||||||
client: WorldtreeClient, session_id: str, content: str
|
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`)
|
"""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
|
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
|
into ratatoskr's caller-semantic exceptions (INV-CUT-2 / DEC-2 — the presenter
|
||||||
@@ -295,6 +312,10 @@ async def stream_turn(
|
|||||||
raise MalformedSseData(raw=exc.raw) from exc
|
raise MalformedSseData(raw=exc.raw) from exc
|
||||||
except wtsdk.TurnIdFlip as exc:
|
except wtsdk.TurnIdFlip as exc:
|
||||||
raise TurnIdFlip(established=exc.established, got=exc.got) from 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(
|
async def cancel_turn(
|
||||||
@@ -319,3 +340,74 @@ async def cancel_turn(
|
|||||||
raise CancelFailed(
|
raise CancelFailed(
|
||||||
status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
|
status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
|
||||||
) from exc
|
) 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. (A non-finite axis is the
|
||||||
|
caller's to reject; the SDK raises `ConfigurationError` pre-HTTP and the CLI
|
||||||
|
surface pre-validates finiteness before calling.)
|
||||||
|
"""
|
||||||
|
assert session_id and isinstance(session_id, str)
|
||||||
|
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
|
||||||
|
|||||||
+177
-36
@@ -7,8 +7,10 @@ import json
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
|
from worldtree_sdk.events import build_event
|
||||||
|
|
||||||
from ratatoskr import cli as cli_mod
|
from ratatoskr import cli as cli_mod
|
||||||
|
from ratatoskr import wt
|
||||||
from ratatoskr.cli import (
|
from ratatoskr.cli import (
|
||||||
ParsedArgs,
|
ParsedArgs,
|
||||||
UsageError,
|
UsageError,
|
||||||
@@ -20,18 +22,7 @@ from ratatoskr.cli import (
|
|||||||
main,
|
main,
|
||||||
)
|
)
|
||||||
from ratatoskr.sessions import BifrostBinding
|
from ratatoskr.sessions import BifrostBinding
|
||||||
from ratatoskr.sse_client import (
|
from ratatoskr.sse_client import SseId
|
||||||
Cancelled,
|
|
||||||
Done,
|
|
||||||
Error,
|
|
||||||
SseId,
|
|
||||||
Text,
|
|
||||||
TextBoundary,
|
|
||||||
Thinking,
|
|
||||||
ToolResult,
|
|
||||||
ToolStart,
|
|
||||||
WorkerPhase,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _FlushCountingIO(io.StringIO):
|
class _FlushCountingIO(io.StringIO):
|
||||||
@@ -333,6 +324,83 @@ SID = SseId(42, 5)
|
|||||||
SID42 = SseId(42, 1)
|
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:
|
class TestCliPresenterState:
|
||||||
"""Tests for the new CliPresenterState — per issue #12 contract."""
|
"""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)
|
state.render(_make_done(duration_ms=72000), stdout=io.StringIO(), stderr=stderr)
|
||||||
assert "duration=1.2m" in stderr.getvalue()
|
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:
|
def test_usage_format_ascii_arrow(self) -> None:
|
||||||
"""usage_format_ascii_arrow [trace]: stderr label contains the natural-language
|
"""usage_format_ascii_arrow [trace]: stderr label contains the natural-language
|
||||||
usage shape with ASCII arrow (-> not →) for CLI scriptability.
|
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(
|
return Done(
|
||||||
sse_id=SID42,
|
sse_id=SID42,
|
||||||
phase="succeeded",
|
phase="succeeded",
|
||||||
@@ -709,7 +810,8 @@ class TestCancelAndLog:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
stderr = 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)
|
||||||
result = await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
result = await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert stderr.getvalue() == ""
|
assert stderr.getvalue() == ""
|
||||||
@@ -721,7 +823,8 @@ class TestCancelAndLog:
|
|||||||
return_value=httpx.Response(500, content=b"boom")
|
return_value=httpx.Response(500, content=b"boom")
|
||||||
)
|
)
|
||||||
stderr = 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)
|
||||||
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
assert "[cancel_failed]" in out
|
assert "[cancel_failed]" in out
|
||||||
@@ -730,11 +833,14 @@ class TestCancelAndLog:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_cancel_already_completed(self) -> None:
|
async def test_cancel_already_completed(self) -> None:
|
||||||
"""cancel_already_completed [scenario]: …"""
|
"""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(
|
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()
|
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)
|
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
assert "[cancel_failed]" in out
|
assert "[cancel_failed]" in out
|
||||||
@@ -743,11 +849,13 @@ class TestCancelAndLog:
|
|||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_cancel_turn_not_found(self) -> None:
|
async def test_cancel_turn_not_found(self) -> None:
|
||||||
"""cancel_turn_not_found [scenario]: 404 → returns None; stderr CancelTurnNotFound."""
|
"""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(
|
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()
|
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)
|
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
assert "[cancel_failed]" in out
|
assert "[cancel_failed]" in out
|
||||||
@@ -760,11 +868,14 @@ class TestCancelAndLog:
|
|||||||
side_effect=httpx.ConnectError("network down")
|
side_effect=httpx.ConnectError("network down")
|
||||||
)
|
)
|
||||||
stderr = 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)
|
||||||
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
|
||||||
out = stderr.getvalue()
|
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 "[cancel_failed]" in out
|
||||||
assert "ConnectError" in out
|
assert "ConnectFailed" in out
|
||||||
|
|
||||||
|
|
||||||
class _GatedStream(httpx.AsyncByteStream):
|
class _GatedStream(httpx.AsyncByteStream):
|
||||||
@@ -797,7 +908,8 @@ class TestRunTurn:
|
|||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout = io.StringIO()
|
stdout = io.StringIO()
|
||||||
stderr = 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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 0
|
assert exit_code == 0
|
||||||
assert stdout.getvalue() == "hello\n"
|
assert stdout.getvalue() == "hello\n"
|
||||||
@@ -820,7 +932,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 2
|
assert exit_code == 2
|
||||||
assert "[error]" in stderr.getvalue()
|
assert "[error]" in stderr.getvalue()
|
||||||
@@ -836,7 +949,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 3
|
assert exit_code == 3
|
||||||
assert "[cancelled]" in stderr.getvalue()
|
assert "[cancelled]" in stderr.getvalue()
|
||||||
@@ -849,7 +963,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
exit_code = await _run_turn(
|
||||||
client, "missing", "hi", sigint, stdout=stdout, stderr=stderr
|
client, "missing", "hi", sigint, stdout=stdout, stderr=stderr
|
||||||
)
|
)
|
||||||
@@ -858,6 +973,21 @@ class TestRunTurn:
|
|||||||
assert "[sse_connect_failed]" in out
|
assert "[sse_connect_failed]" in out
|
||||||
assert "status=404" 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
|
@respx.mock
|
||||||
async def test_connection_dropped(self) -> None:
|
async def test_connection_dropped(self) -> None:
|
||||||
"""connection_dropped [error]: RemoteProtocolError mid-stream → exit 21."""
|
"""connection_dropped [error]: RemoteProtocolError mid-stream → exit 21."""
|
||||||
@@ -882,7 +1012,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 21
|
assert exit_code == 21
|
||||||
assert "[connection_dropped]" in stderr.getvalue()
|
assert "[connection_dropped]" in stderr.getvalue()
|
||||||
@@ -896,7 +1027,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 22
|
assert exit_code == 22
|
||||||
assert "[malformed_sse_id]" in stderr.getvalue()
|
assert "[malformed_sse_id]" in stderr.getvalue()
|
||||||
@@ -913,7 +1045,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 22
|
assert exit_code == 22
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
@@ -933,7 +1066,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 22
|
assert exit_code == 22
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
@@ -955,7 +1089,8 @@ class TestRunTurn:
|
|||||||
)
|
)
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 22
|
assert exit_code == 22
|
||||||
out = stderr.getvalue()
|
out = stderr.getvalue()
|
||||||
@@ -978,7 +1113,8 @@ class TestRunTurn:
|
|||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
sigint.set() # SIGINT before _run_turn even starts
|
sigint.set() # SIGINT before _run_turn even starts
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
exit_code = await asyncio.wait_for(
|
||||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr), timeout=2.0
|
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr), timeout=2.0
|
||||||
)
|
)
|
||||||
@@ -1007,7 +1143,8 @@ class TestRunTurn:
|
|||||||
|
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
task = asyncio.create_task(
|
||||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
)
|
)
|
||||||
@@ -1045,7 +1182,8 @@ class TestRunTurn:
|
|||||||
|
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
task = asyncio.create_task(
|
||||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
)
|
)
|
||||||
@@ -1091,7 +1229,8 @@ class TestRunTurn:
|
|||||||
monkeypatch.setattr(sigint, "wait", counting_wait)
|
monkeypatch.setattr(sigint, "wait", counting_wait)
|
||||||
|
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
task = asyncio.create_task(
|
||||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
)
|
)
|
||||||
@@ -1129,7 +1268,8 @@ class TestRunTurn:
|
|||||||
|
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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(
|
task = asyncio.create_task(
|
||||||
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
)
|
)
|
||||||
@@ -1176,7 +1316,8 @@ class TestRunTurn:
|
|||||||
|
|
||||||
sigint = asyncio.Event()
|
sigint = asyncio.Event()
|
||||||
stdout, stderr = io.StringIO(), io.StringIO()
|
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)
|
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
|
||||||
assert exit_code == 0
|
assert exit_code == 0
|
||||||
assert call_count == 3
|
assert call_count == 3
|
||||||
|
|||||||
+77
-91
@@ -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 asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
from typing import Any, cast
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import worldtree_sdk as wtsdk
|
||||||
|
from worldtree_sdk import ApiError, WorldtreeClient
|
||||||
|
|
||||||
from ratatoskr.first_message import (
|
from ratatoskr.first_message import (
|
||||||
FIRST_MESSAGE_PRESETS,
|
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:
|
class TestPresetFor:
|
||||||
"""first_message contract — preset_for (dict lookup)."""
|
"""first_message contract — preset_for (dict lookup)."""
|
||||||
|
|
||||||
@@ -36,120 +72,70 @@ class TestPresetFor:
|
|||||||
class TestSeedPresetFirstMessage:
|
class TestSeedPresetFirstMessage:
|
||||||
"""first_message contract — seed_preset_first_message (best-effort #347 seed)."""
|
"""first_message contract — seed_preset_first_message (best-effort #347 seed)."""
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
async def test_seeds_preset(self) -> None:
|
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"]
|
content = FIRST_MESSAGE_PRESETS["ratatoskr:sindra"]
|
||||||
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
||||||
route = respx.post("https://w.example/sessions/s1/history").mock(
|
fake = _FakeSessions(result={"seq": 0, "phase": "seeded"})
|
||||||
return_value=httpx.Response(
|
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
|
||||||
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")
|
|
||||||
assert result == content
|
assert result == content
|
||||||
assert route.call_count == 1 # POST-002: exactly one history POST
|
assert len(fake.calls) == 1 # POST-002: exactly one history write
|
||||||
assert json.loads(route.calls[0].request.content) == {
|
session_id, entry = fake.calls[0]
|
||||||
|
assert session_id == "s1"
|
||||||
|
assert entry == {
|
||||||
"author": "assistant",
|
"author": "assistant",
|
||||||
"content": content,
|
"content": content,
|
||||||
"idempotency_key": key,
|
"idempotency_key": key,
|
||||||
}
|
}
|
||||||
|
|
||||||
@respx.mock
|
async def test_no_preset_zero_write(self) -> None:
|
||||||
async def test_no_preset_zero_http(self) -> None:
|
"""no_preset_zero_write [happy]: no-preset agent → None, ZERO write (INV-002)."""
|
||||||
"""no_preset_zero_http [happy]: no-preset agent → None, ZERO HTTP (INV-002)."""
|
fake = _FakeSessions()
|
||||||
route = respx.post("https://w.example/sessions/s1/history").mock(
|
result = await seed_preset_first_message(_wt(fake), "s1", "mimir")
|
||||||
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")
|
|
||||||
assert result is None
|
assert result is None
|
||||||
assert not route.called
|
assert fake.calls == []
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
async def test_feature_absent_swallowed(self) -> None:
|
async def test_feature_absent_swallowed(self) -> None:
|
||||||
"""feature_absent_swallowed [error]: 404 hide-existence → None, no raise (INV-001)."""
|
"""feature_absent_swallowed [error]: 404 → AuthoredHistoryUnavailable → None (INV-001)."""
|
||||||
respx.post("https://w.example/sessions/s1/history").mock(
|
fake = _FakeSessions(error=ApiError("session_not_found", "no", status=404))
|
||||||
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
|
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
|
||||||
)
|
|
||||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
||||||
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
|
||||||
assert result is None
|
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:
|
async def test_session_api_failed_swallowed(self) -> None:
|
||||||
"""session_api_failed_swallowed [error]: 409 → None, no raise (INV-001)."""
|
"""session_api_failed_swallowed [error]: 409 → SessionApiFailed → None (INV-001)."""
|
||||||
respx.post("https://w.example/sessions/s1/history").mock(
|
fake = _FakeSessions(error=ApiError("generation_active", "busy", status=409))
|
||||||
return_value=httpx.Response(409, json={"error_code": "generation_active"})
|
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
|
||||||
)
|
|
||||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
||||||
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
async def test_transport_error_swallowed(self) -> None:
|
async def test_transport_error_swallowed(self) -> None:
|
||||||
"""transport_error_swallowed [error]: httpx.ConnectError → None, no raise (INV-001)."""
|
"""transport_error_swallowed [error]: SDK ConnectFailed → None, no raise (INV-001)."""
|
||||||
respx.post("https://w.example/sessions/s1/history").mock(
|
fake = _FakeSessions(error=wtsdk.ConnectFailed("connect_failed", "boom", status=0))
|
||||||
side_effect=httpx.ConnectError("boom")
|
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
|
||||||
)
|
|
||||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
||||||
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
async def test_unexpected_exception_swallowed(self) -> None:
|
async def test_unexpected_exception_swallowed(self) -> None:
|
||||||
"""unexpected_exception [error]: write raises ValueError → None (broad never-raise)."""
|
"""unexpected_exception [error]: write raises ValueError → None (broad never-raise)."""
|
||||||
respx.post("https://w.example/sessions/s1/history").mock(
|
fake = _FakeSessions(error=ValueError("unexpected"))
|
||||||
side_effect=ValueError("unexpected")
|
result = await seed_preset_first_message(_wt(fake), "s1", "ratatoskr:sindra")
|
||||||
)
|
|
||||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
||||||
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
async def test_cancellation_propagates(self) -> None:
|
async def test_cancellation_propagates(self) -> None:
|
||||||
"""cancellation_propagates [error]: CancelledError from the write is RE-RAISED."""
|
"""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:
|
async def test_malformed_agent_id_no_write(self) -> None:
|
||||||
raise asyncio.CancelledError
|
"""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:
|
async def test_empty_session_id(self) -> None:
|
||||||
"""empty_session_id [adversarial]: "" → None (soft guard); no HTTP; no raise."""
|
"""empty_session_id [adversarial]: "" → None (soft guard); no write; no raise."""
|
||||||
route = respx.post("https://w.example/sessions/s1/history").mock(
|
fake = _FakeSessions()
|
||||||
return_value=httpx.Response(201, json={})
|
result = await seed_preset_first_message(_wt(fake), "", "ratatoskr:sindra")
|
||||||
)
|
|
||||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
|
||||||
result = await seed_preset_first_message(client, "", "ratatoskr:sindra")
|
|
||||||
assert result is None
|
assert result is None
|
||||||
assert not route.called
|
assert fake.calls == []
|
||||||
|
|||||||
+5
-990
File diff suppressed because it is too large
Load Diff
+8
-1249
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,12 @@ type. Server-side serialization (`_event_to_browser_payload`) is
|
|||||||
unit-tested against the fixture. JS-side rendering in
|
unit-tested against the fixture. JS-side rendering in
|
||||||
`src/ratatoskr/web/static/index.html` consumes the same shape — if
|
`src/ratatoskr/web/static/index.html` consumes the same shape — if
|
||||||
this fixture changes, both sides update in lockstep.
|
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
|
from __future__ import annotations
|
||||||
@@ -14,20 +20,8 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ratatoskr.sse_client import (
|
from worldtree_sdk.events import build_event
|
||||||
AffectUpdate,
|
|
||||||
AwaitingLlmFirstToken,
|
|
||||||
Cancelled,
|
|
||||||
Done,
|
|
||||||
Error,
|
|
||||||
SseId,
|
|
||||||
Text,
|
|
||||||
TextBoundary,
|
|
||||||
Thinking,
|
|
||||||
ToolResult,
|
|
||||||
ToolStart,
|
|
||||||
WorkerPhase,
|
|
||||||
)
|
|
||||||
from ratatoskr.web.server import _event_to_browser_payload
|
from ratatoskr.web.server import _event_to_browser_payload
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +30,13 @@ def _load_fixture() -> dict:
|
|||||||
return json.loads(path.read_text())
|
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:
|
def _check(name: str, event: object) -> None:
|
||||||
"""Assert (event_type, data) for `event` matches the fixture entry."""
|
"""Assert (event_type, data) for `event` matches the fixture entry."""
|
||||||
fixture = _load_fixture()
|
fixture = _load_fixture()
|
||||||
@@ -51,58 +52,40 @@ def _check(name: str, event: object) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_worker_phase_matches_fixture() -> None:
|
def test_worker_phase_matches_fixture() -> None:
|
||||||
_check(
|
_check("worker_phase", _ev("worker_phase", "42:3", phase="BuildingPrompt", turn_id=42))
|
||||||
"worker_phase",
|
|
||||||
WorkerPhase(sse_id=SseId(42, 3), phase="BuildingPrompt", turn_id=42),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_thinking_matches_fixture() -> None:
|
def test_thinking_matches_fixture() -> None:
|
||||||
_check(
|
_check("thinking", _ev("thinking", "42:5", content="Let me think..."))
|
||||||
"thinking",
|
|
||||||
Thinking(sse_id=SseId(42, 5), content="Let me think..."),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_matches_fixture() -> None:
|
def test_text_matches_fixture() -> None:
|
||||||
_check(
|
_check("text", _ev("text", "42:7", content="Hello there"))
|
||||||
"text",
|
|
||||||
Text(sse_id=SseId(42, 7), content="Hello there"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_boundary_matches_fixture() -> None:
|
def test_text_boundary_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"text_boundary",
|
"text_boundary",
|
||||||
TextBoundary(
|
_ev("text_boundary", "42:8", kind="sentence", char_offset=11, ts="2026-05-28T00:00:00Z"),
|
||||||
sse_id=SseId(42, 8), kind="sentence",
|
|
||||||
char_offset=11, ts="2026-05-28T00:00:00Z",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_tool_start_matches_fixture() -> None:
|
def test_tool_start_matches_fixture() -> None:
|
||||||
_check(
|
_check("tool_start", _ev("tool_start", "42:9", name="search", arguments={"q": "ratatoskr"}))
|
||||||
"tool_start",
|
|
||||||
ToolStart(sse_id=SseId(42, 9), name="search", arguments={"q": "ratatoskr"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_tool_result_matches_fixture() -> None:
|
def test_tool_result_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"tool_result",
|
"tool_result",
|
||||||
ToolResult(
|
_ev("tool_result", "42:10", name="search", result={"n": 1}, duration_ms=12),
|
||||||
sse_id=SseId(42, 10), name="search",
|
|
||||||
result={"n": 1}, duration_ms=12,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_done_matches_fixture() -> None:
|
def test_done_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"done",
|
"done",
|
||||||
Done(
|
_ev(
|
||||||
sse_id=SseId(42, 11), phase="succeeded", response="Hello there",
|
"done", "42:11", phase="succeeded", response="Hello there",
|
||||||
model="qwen3.6-35-a3b", duration_ms=1234,
|
model="qwen3.6-35-a3b", duration_ms=1234,
|
||||||
usage={
|
usage={
|
||||||
"prompt_tokens": 100, "completion_tokens": 50,
|
"prompt_tokens": 100, "completion_tokens": 50,
|
||||||
@@ -115,8 +98,8 @@ def test_done_matches_fixture() -> None:
|
|||||||
def test_error_matches_fixture() -> None:
|
def test_error_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"error",
|
"error",
|
||||||
Error(
|
_ev(
|
||||||
sse_id=SseId(42, 11), phase="failed",
|
"error", "42:11", phase="failed",
|
||||||
message="llm output invalid", error_code="llm_output_invalid",
|
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:
|
def test_cancelled_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"cancelled",
|
"cancelled",
|
||||||
Cancelled(
|
_ev(
|
||||||
sse_id=SseId(42, 11), phase="cancelled", turn_id=42,
|
"cancelled", "42:11", phase="cancelled", turn_id=42,
|
||||||
reason="user_cancel", partial_message_id=None,
|
reason="user_cancel", partial_message_id=None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -135,8 +118,8 @@ def test_cancelled_matches_fixture() -> None:
|
|||||||
def test_affect_update_matches_fixture() -> None:
|
def test_affect_update_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"affect_update",
|
"affect_update",
|
||||||
AffectUpdate(
|
_ev(
|
||||||
sse_id=SseId(42, 1), status="current", turn_id=42,
|
"affect_update", "42:1", status="current", turn_id=42,
|
||||||
snapshot={
|
snapshot={
|
||||||
"agent_id": "mimir",
|
"agent_id": "mimir",
|
||||||
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
"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:
|
def test_awaiting_llm_first_token_matches_fixture() -> None:
|
||||||
_check(
|
_check(
|
||||||
"awaiting_llm_first_token",
|
"awaiting_llm_first_token",
|
||||||
AwaitingLlmFirstToken(
|
_ev(
|
||||||
sse_id=SseId(42, 2), turn_id=42,
|
"awaiting_llm_first_token", "42:2",
|
||||||
elapsed_ms_since_building_prompt=5012.3,
|
turn_id=42, elapsed_ms_since_building_prompt=5012.3,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -456,15 +456,16 @@ class TestCancelTurnEndpoint:
|
|||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_already_completed_race(self) -> None:
|
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
|
from ratatoskr.web.server import create_app
|
||||||
app = create_app(_mock_client_factory())
|
app = create_app(_mock_client_factory())
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
turn_id = c.post("/api/turns/s-1", json={"content": "hi"}).json()["turn_id"]
|
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)].status = "streaming"
|
||||||
app.state.turn_registry[("s-1", turn_id)].upstream_turn_id = 42
|
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(
|
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}")
|
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -473,7 +474,11 @@ class TestCancelTurnEndpoint:
|
|||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_cancel_failed_500(self) -> None:
|
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
|
from ratatoskr.web.server import create_app
|
||||||
app = create_app(_mock_client_factory())
|
app = create_app(_mock_client_factory())
|
||||||
c = TestClient(app)
|
c = TestClient(app)
|
||||||
@@ -484,7 +489,7 @@ class TestCancelTurnEndpoint:
|
|||||||
return_value=httpx.Response(500, content=b"boom")
|
return_value=httpx.Response(500, content=b"boom")
|
||||||
)
|
)
|
||||||
resp = c.post(f"/api/turns/s-1/cancel?turn_id={turn_id}")
|
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 resp.json()["error_code"] == "cancel_failed"
|
||||||
assert ("s-1", turn_id) not in app.state.turn_registry
|
assert ("s-1", turn_id) not in app.state.turn_registry
|
||||||
|
|
||||||
|
|||||||
+109
-1
@@ -17,10 +17,11 @@ from typing import Any, cast
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import worldtree_sdk as wtsdk
|
import worldtree_sdk as wtsdk
|
||||||
from worldtree_sdk import ApiError, CancelResult, WorldtreeClient
|
from worldtree_sdk import ApiError, CancelResult, PadState, WorldtreeClient
|
||||||
|
|
||||||
from ratatoskr.sessions import (
|
from ratatoskr.sessions import (
|
||||||
AgentNotFound,
|
AgentNotFound,
|
||||||
|
AuthoredHistoryUnavailable,
|
||||||
BifrostBinding,
|
BifrostBinding,
|
||||||
BifrostConsumerKeyMissing,
|
BifrostConsumerKeyMissing,
|
||||||
BifrostHandshakeFailed,
|
BifrostHandshakeFailed,
|
||||||
@@ -46,8 +47,10 @@ from ratatoskr.wt import (
|
|||||||
get_session_messages,
|
get_session_messages,
|
||||||
get_session_tools,
|
get_session_tools,
|
||||||
list_sessions,
|
list_sessions,
|
||||||
|
set_persona_state,
|
||||||
stream_turn,
|
stream_turn,
|
||||||
translate_error,
|
translate_error,
|
||||||
|
write_authored_history,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +104,12 @@ class _FakeSessions:
|
|||||||
async def cancel_turn(self, *args: Any, **kwargs: Any) -> Any:
|
async def cancel_turn(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
return await self._dispatch("cancel_turn", *args, **kwargs)
|
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:
|
class _FakeClient:
|
||||||
def __init__(self, sessions: _FakeSessions) -> None:
|
def __init__(self, sessions: _FakeSessions) -> None:
|
||||||
@@ -218,6 +227,14 @@ class TestCreateSession:
|
|||||||
# INV-CUT: the consumer key rides the SDK's per-request auth, NOT a header.
|
# INV-CUT: the consumer key rides the SDK's per-request auth, NOT a header.
|
||||||
assert kwargs["consumer_key"] == "ck-real"
|
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:
|
async def test_bifrost_without_consumer_key_rejected_pre_http(self) -> None:
|
||||||
fake = _FakeSessions(result={"session_id": "s"})
|
fake = _FakeSessions(result={"session_id": "s"})
|
||||||
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
|
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
|
||||||
@@ -299,6 +316,12 @@ class TestReadPassthroughs:
|
|||||||
await get_session_messages(_wt(fake), "s")
|
await get_session_messages(_wt(fake), "s")
|
||||||
assert ei.value.status == 401
|
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]:
|
async def _drain(aiter: Any) -> list[Any]:
|
||||||
out: list[Any] = []
|
out: list[Any] = []
|
||||||
@@ -376,6 +399,14 @@ class TestStreamTurn:
|
|||||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||||
assert (ei.value.established, ei.value.got) == (5, 7)
|
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:
|
class TestCancelTurn:
|
||||||
async def test_happy_returns_cancel_result(self) -> None:
|
async def test_happy_returns_cancel_result(self) -> None:
|
||||||
@@ -410,3 +441,80 @@ class TestCancelTurn:
|
|||||||
fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed"))
|
fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed"))
|
||||||
with pytest.raises(CancelFailed):
|
with pytest.raises(CancelFailed):
|
||||||
await cancel_turn(_wt(fake), "s", 42)
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user