Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb158ae47d |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.21.3"
|
version = "0.21.4"
|
||||||
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"
|
||||||
|
|||||||
@@ -27,9 +27,25 @@ carries the SDK's parsed `error_code`).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from worldtree_sdk import ApiError, AuthProvider, WorldtreeClient
|
from worldtree_sdk import ApiError, AuthProvider, WorldtreeClient
|
||||||
|
|
||||||
|
# Transitional (slice-2): the caller-semantic exceptions + the BifrostBinding input
|
||||||
|
# type still live in the retiring `sessions` module; they relocate into this adapter
|
||||||
|
# as their call-sites are rewired in later slice-2 commits. wt → sessions is one-way
|
||||||
|
# (sessions never imports wt), so there is no cycle.
|
||||||
|
from .sessions import (
|
||||||
|
AgentNotFound,
|
||||||
|
BifrostBinding,
|
||||||
|
BifrostConsumerKeyMissing,
|
||||||
|
BifrostHandshakeFailed,
|
||||||
|
InvalidCursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SessionApiFailed(Exception):
|
class SessionApiFailed(Exception):
|
||||||
"""The adapter's DEFAULT caller-semantic error (INV-CUT-2 default row): any SDK
|
"""The adapter's DEFAULT caller-semantic error (INV-CUT-2 default row): any SDK
|
||||||
@@ -90,3 +106,129 @@ def translate_error(exc: BaseException) -> BaseException:
|
|||||||
status=exc.status, error_code=exc.error_code, body=exc.body
|
status=exc.status, error_code=exc.error_code, body=exc.body
|
||||||
)
|
)
|
||||||
return exc
|
return exc
|
||||||
|
|
||||||
|
|
||||||
|
# ── slice-2: sessions/turn adapter routes ────────────────────────────────────
|
||||||
|
# Ratatoskr-semantic call surfaces over `WorldtreeClient.sessions.*`. Each builds
|
||||||
|
# the request from ratatoskr's domain params, delegates the HTTP to the SDK, and
|
||||||
|
# maps the SDK's `ApiError` floor by ROUTE (INV-CUT-2) — route-specific rows first,
|
||||||
|
# `translate_error`'s `SessionApiFailed` default otherwise. Open-world reads are
|
||||||
|
# returned verbatim (the parity-pass posture: presenters read them as mappings,
|
||||||
|
# tolerant of wire drift). The turn STREAM + cancel land alongside the presenter
|
||||||
|
# rewire in the next slice-2 commit.
|
||||||
|
|
||||||
|
|
||||||
|
def _bifrost_error_from_body(body: str | None) -> str | None:
|
||||||
|
"""Pull the spec-level `bifrost_error` from a bound-create 502 body string.
|
||||||
|
|
||||||
|
Tolerates both the FastAPI-nested `{"detail": {"bifrost_error": …}}` shape (the
|
||||||
|
real wire form) and a flat top-level `bifrost_error` — the same both-shape
|
||||||
|
unwrap the hand-rolled path used, adapted to the SDK's already-parsed str body.
|
||||||
|
"""
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
err = json.loads(body)
|
||||||
|
except (json.JSONDecodeError, 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: WorldtreeClient,
|
||||||
|
agent_id: str,
|
||||||
|
*,
|
||||||
|
end_user_id: str | None = None,
|
||||||
|
bifrost: BifrostBinding | None = None,
|
||||||
|
consumer_key: str | None = None,
|
||||||
|
config: Mapping[str, Any] | None = None,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
"""Create a session (POST /sessions), returning the open-world create result.
|
||||||
|
|
||||||
|
Body-building mirrors the hand-rolled path: `{agent_id}` plus `end_user_id` /
|
||||||
|
`config` / `bifrost` when set. A bound create authenticates with `consumer_key`
|
||||||
|
via the SDK's per-request auth (never a header, never the canary fallback —
|
||||||
|
INV-001); the key is required pre-HTTP. Error map (INV-CUT-2): 404 →
|
||||||
|
`AgentNotFound`; a bound 502 → `BifrostHandshakeFailed`; otherwise the
|
||||||
|
`SessionApiFailed` default.
|
||||||
|
"""
|
||||||
|
assert agent_id and isinstance(agent_id, str)
|
||||||
|
assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
|
||||||
|
assert config is None or isinstance(config, Mapping)
|
||||||
|
# Ephemeral config + Bifrost binding are mutually exclusive (server 422s).
|
||||||
|
assert not (config is not None and bifrost is not None)
|
||||||
|
# INV-001: a bound create REQUIRES a non-empty consumer key — enforced pre-HTTP
|
||||||
|
# so it never falls back to the canary bearer.
|
||||||
|
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
|
||||||
|
if config is not None:
|
||||||
|
body["config"] = dict(config)
|
||||||
|
if bifrost is not None:
|
||||||
|
body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await client.sessions.create(body, consumer_key=consumer_key)
|
||||||
|
except ApiError as exc:
|
||||||
|
if exc.status == 404:
|
||||||
|
raise AgentNotFound(agent_id=agent_id) from exc
|
||||||
|
if bifrost is not None and exc.status == 502:
|
||||||
|
raise BifrostHandshakeFailed(
|
||||||
|
bifrost_error=_bifrost_error_from_body(exc.body),
|
||||||
|
body=(exc.body or "").encode(),
|
||||||
|
) from exc
|
||||||
|
raise translate_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def list_sessions(
|
||||||
|
client: WorldtreeClient,
|
||||||
|
*,
|
||||||
|
include_archived: bool = False,
|
||||||
|
limit: int = 50,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
"""List sessions (GET /sessions), returning the open-world page verbatim. A 422
|
||||||
|
`cursor_invalid` → `InvalidCursor`; otherwise the `SessionApiFailed` default."""
|
||||||
|
assert 1 <= limit <= 200
|
||||||
|
assert cursor is None or (isinstance(cursor, str) and cursor)
|
||||||
|
try:
|
||||||
|
return await client.sessions.list(
|
||||||
|
limit=limit, cursor=cursor, include_archived=include_archived or None
|
||||||
|
)
|
||||||
|
except ApiError as exc:
|
||||||
|
if exc.status == 422 and exc.error_code == "cursor_invalid":
|
||||||
|
raise InvalidCursor(raw=cursor) from exc
|
||||||
|
raise translate_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_messages(
|
||||||
|
client: WorldtreeClient, session_id: str
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
"""The session's message history (GET /sessions/{id}/messages), verbatim. Any
|
||||||
|
error → the `SessionApiFailed` default (owner-scoped; 404 hide-existence stays
|
||||||
|
generic here — messages is not a hide-existence-mapped route)."""
|
||||||
|
assert session_id and isinstance(session_id, str)
|
||||||
|
try:
|
||||||
|
return await client.sessions.messages(session_id)
|
||||||
|
except ApiError as exc:
|
||||||
|
raise translate_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_tools(
|
||||||
|
client: WorldtreeClient, session_id: str
|
||||||
|
) -> Mapping[str, Any]:
|
||||||
|
"""The owner-scoped tool inventory (GET /sessions/{id}/tools), verbatim. Any
|
||||||
|
error → the `SessionApiFailed` default."""
|
||||||
|
assert session_id and isinstance(session_id, str)
|
||||||
|
try:
|
||||||
|
return await client.sessions.tools(session_id)
|
||||||
|
except ApiError as exc:
|
||||||
|
raise translate_error(exc) from exc
|
||||||
|
|||||||
+178
-1
@@ -12,10 +12,69 @@ slice 2. These tests hit no network (WorldtreeClient does no I/O at construction
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import pytest
|
||||||
from worldtree_sdk import AgentNotAvailable, ApiError, WorldtreeClient
|
from worldtree_sdk import AgentNotAvailable, ApiError, WorldtreeClient
|
||||||
|
|
||||||
from ratatoskr.wt import SessionApiFailed, build_client, translate_error
|
from ratatoskr.sessions import (
|
||||||
|
AgentNotFound,
|
||||||
|
BifrostBinding,
|
||||||
|
BifrostConsumerKeyMissing,
|
||||||
|
BifrostHandshakeFailed,
|
||||||
|
InvalidCursor,
|
||||||
|
)
|
||||||
|
from ratatoskr.wt import (
|
||||||
|
SessionApiFailed,
|
||||||
|
build_client,
|
||||||
|
create_session,
|
||||||
|
get_session_messages,
|
||||||
|
get_session_tools,
|
||||||
|
list_sessions,
|
||||||
|
translate_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSessions:
|
||||||
|
"""A stand-in for `WorldtreeClient.sessions` — records the last call and
|
||||||
|
returns a canned result or raises a canned error. Lets the adapter's
|
||||||
|
body-building + error-mapping be unit-tested without any SDK HTTP."""
|
||||||
|
|
||||||
|
def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None:
|
||||||
|
self._result = result
|
||||||
|
self._error = error
|
||||||
|
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
|
||||||
|
|
||||||
|
async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
self.calls.append((name, args, kwargs))
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return self._result
|
||||||
|
|
||||||
|
async def create(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
return await self._dispatch("create", *args, **kwargs)
|
||||||
|
|
||||||
|
async def list(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
return await self._dispatch("list", *args, **kwargs)
|
||||||
|
|
||||||
|
async def messages(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
return await self._dispatch("messages", *args, **kwargs)
|
||||||
|
|
||||||
|
async def tools(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
return await self._dispatch("tools", *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
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 the adapter is typed
|
||||||
|
against — the route functions only touch `client.sessions.*`, which the fake
|
||||||
|
provides. (No network; construction does no I/O.)"""
|
||||||
|
return cast(WorldtreeClient, _FakeClient(sessions))
|
||||||
|
|
||||||
|
|
||||||
class TestBuildClient:
|
class TestBuildClient:
|
||||||
@@ -83,3 +142,121 @@ class TestTranslateError:
|
|||||||
def test_non_worldtree_error_passes_through_unchanged(self) -> None:
|
def test_non_worldtree_error_passes_through_unchanged(self) -> None:
|
||||||
exc = ValueError("unrelated")
|
exc = ValueError("unrelated")
|
||||||
assert translate_error(exc) is exc
|
assert translate_error(exc) is exc
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateSession:
|
||||||
|
async def test_happy_returns_sdk_dict_and_builds_body(self) -> None:
|
||||||
|
info = {"session_id": "s-1", "agent_id": "mimir", "created_at": "t", "last_active": "t"}
|
||||||
|
fake = _FakeSessions(result=info)
|
||||||
|
client = _wt(fake)
|
||||||
|
out = await create_session(client, "mimir", end_user_id="u-9")
|
||||||
|
assert out is info # open-world passthrough — no re-shaping
|
||||||
|
name, args, kwargs = fake.calls[-1]
|
||||||
|
assert name == "create"
|
||||||
|
assert args[0] == {"agent_id": "mimir", "end_user_id": "u-9"}
|
||||||
|
assert kwargs["consumer_key"] is None
|
||||||
|
|
||||||
|
async def test_config_passthrough(self) -> None:
|
||||||
|
fake = _FakeSessions(result={"session_id": "s"})
|
||||||
|
await create_session(
|
||||||
|
_wt(fake), "echo", config={"system_prompt": "be terse"}
|
||||||
|
)
|
||||||
|
assert fake.calls[-1][1][0] == {
|
||||||
|
"agent_id": "echo",
|
||||||
|
"config": {"system_prompt": "be terse"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_bifrost_bound_body_and_consumer_key(self) -> None:
|
||||||
|
fake = _FakeSessions(result={"session_id": "s"})
|
||||||
|
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
|
||||||
|
await create_session(
|
||||||
|
_wt(fake), "sindra", bifrost=binding, consumer_key="ck-real"
|
||||||
|
)
|
||||||
|
_name, args, kwargs = fake.calls[-1]
|
||||||
|
assert args[0] == {
|
||||||
|
"agent_id": "sindra",
|
||||||
|
"bifrost": {"endpoint_url": "http://h:8391", "scope": None},
|
||||||
|
}
|
||||||
|
# INV-CUT: the consumer key rides the SDK's per-request auth, NOT a header.
|
||||||
|
assert kwargs["consumer_key"] == "ck-real"
|
||||||
|
|
||||||
|
async def test_bifrost_without_consumer_key_rejected_pre_http(self) -> None:
|
||||||
|
fake = _FakeSessions(result={"session_id": "s"})
|
||||||
|
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
|
||||||
|
with pytest.raises(BifrostConsumerKeyMissing):
|
||||||
|
await create_session(_wt(fake), "sindra", bifrost=binding)
|
||||||
|
assert fake.calls == [] # never reached the SDK
|
||||||
|
|
||||||
|
async def test_404_maps_to_agent_not_found(self) -> None:
|
||||||
|
fake = _FakeSessions(error=ApiError("agent_not_found", "no", status=404))
|
||||||
|
with pytest.raises(AgentNotFound) as ei:
|
||||||
|
await create_session(_wt(fake), "ghost")
|
||||||
|
assert ei.value.agent_id == "ghost"
|
||||||
|
|
||||||
|
async def test_bound_502_maps_to_bifrost_handshake_failed(self) -> None:
|
||||||
|
body = '{"detail": {"bifrost_error": "bifrost.auth_rejected"}}'
|
||||||
|
fake = _FakeSessions(
|
||||||
|
error=ApiError("bifrost_handshake_failed", "boom", status=502, body=body)
|
||||||
|
)
|
||||||
|
binding = BifrostBinding(endpoint_url="http://h:8391", scope=None)
|
||||||
|
with pytest.raises(BifrostHandshakeFailed) as ei:
|
||||||
|
await create_session(
|
||||||
|
_wt(fake), "sindra", bifrost=binding, consumer_key="ck"
|
||||||
|
)
|
||||||
|
assert ei.value.bifrost_error == "bifrost.auth_rejected"
|
||||||
|
|
||||||
|
async def test_unbound_502_stays_session_api_failed(self) -> None:
|
||||||
|
fake = _FakeSessions(error=ApiError("upstream", "boom", status=502, body="x"))
|
||||||
|
with pytest.raises(SessionApiFailed) as ei:
|
||||||
|
await create_session(_wt(fake), "mimir")
|
||||||
|
assert ei.value.status == 502
|
||||||
|
|
||||||
|
async def test_default_error_maps_to_session_api_failed(self) -> None:
|
||||||
|
fake = _FakeSessions(error=ApiError("weird", "boom", status=418, body="teapot"))
|
||||||
|
with pytest.raises(SessionApiFailed) as ei:
|
||||||
|
await create_session(_wt(fake), "mimir")
|
||||||
|
assert ei.value.status == 418
|
||||||
|
assert ei.value.error_code == "weird"
|
||||||
|
|
||||||
|
|
||||||
|
class TestListSessions:
|
||||||
|
async def test_passes_params_and_returns_dict(self) -> None:
|
||||||
|
page: dict[str, Any] = {"items": [], "next_cursor": None}
|
||||||
|
fake = _FakeSessions(result=page)
|
||||||
|
out = await list_sessions(_wt(fake), limit=10, cursor="c1", include_archived=True)
|
||||||
|
assert out is page
|
||||||
|
kwargs = fake.calls[-1][2]
|
||||||
|
assert kwargs["limit"] == 10
|
||||||
|
assert kwargs["cursor"] == "c1"
|
||||||
|
assert kwargs["include_archived"] is True
|
||||||
|
|
||||||
|
async def test_422_cursor_invalid_maps_to_invalid_cursor(self) -> None:
|
||||||
|
fake = _FakeSessions(error=ApiError("cursor_invalid", "bad", status=422))
|
||||||
|
with pytest.raises(InvalidCursor) as ei:
|
||||||
|
await list_sessions(_wt(fake), cursor="bogus")
|
||||||
|
assert ei.value.raw == "bogus"
|
||||||
|
|
||||||
|
async def test_other_422_stays_session_api_failed(self) -> None:
|
||||||
|
fake = _FakeSessions(error=ApiError("validation_failed", "x", status=422))
|
||||||
|
with pytest.raises(SessionApiFailed):
|
||||||
|
await list_sessions(_wt(fake))
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadPassthroughs:
|
||||||
|
async def test_messages_returns_dict(self) -> None:
|
||||||
|
data = {"session_id": "s", "items": []}
|
||||||
|
fake = _FakeSessions(result=data)
|
||||||
|
assert await get_session_messages(_wt(fake), "s") is data
|
||||||
|
assert fake.calls[-1][0] == "messages"
|
||||||
|
|
||||||
|
async def test_tools_returns_dict(self) -> None:
|
||||||
|
data = {"agent_id": "mimir", "builtin_tools": []}
|
||||||
|
fake = _FakeSessions(result=data)
|
||||||
|
assert await get_session_tools(_wt(fake), "s") is data
|
||||||
|
assert fake.calls[-1][0] == "tools"
|
||||||
|
|
||||||
|
async def test_messages_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_messages(_wt(fake), "s")
|
||||||
|
assert ei.value.status == 401
|
||||||
|
|||||||
Reference in New Issue
Block a user