Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7be162e84d | |||
| f533464c54 |
@@ -79,7 +79,8 @@ to `:8391`/`:8390` (#17 unbuilt) → web chat = persona + debug only; no memory
|
||||
|
||||
**NEXT (fresh context): #17 — bifrost-binding the chat client.** The single unblock for BOTH (a) memory persistence
|
||||
in the web/TUI/CLI chat (Sindra remembering in a real session, not just scripts) AND (b) feeding the persona pane
|
||||
affect telemetry once WT ships Tier-3 persona_state. Contract `docs/contracts/issues/17.contract.md` is WRITTEN +
|
||||
from OUR `:8390` affect store — `affect.emit` PAD/valence, ours to render per ADR-0009 (NOT a WT persona_state to
|
||||
wait for; see the reframe in Recent decisions). Contract `docs/contracts/issues/17.contract.md` is WRITTEN +
|
||||
Heid-reviewed + drift-clean; **TDD is the next step** — slice 1 = the `create_session` bifrost-binding primitive
|
||||
(consumer-key per-request bearer + missing-key precondition + 502→`BifrostHandshakeFailed`, respx-mocked), then
|
||||
`endpoint_for_plane` → dispatch-layer op-feed → CLI/TUI/web triggers → live smoke. althing monitor armed.
|
||||
@@ -265,7 +266,7 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[2026-06-18]` **FULL-COVERAGE proof — verbose-persona memory works under Stage 2 (v0.36.0).** Re-smoked `ratatoskr:sindra-probe` (sindra's theatrical prompt; FRESH agent + end_user `verbose-v2` to dodge the WT promotion-dedup): the high-volume turn promoted the user fact CLEANLY (not a meta-description) and cold-recalled @ 0.694. Confirms `:8081` is on v0.36.0 and closes the verbose-persona caveat end-to-end. Then started `ratatoskr-web` (`:8765`, consumer key, `ratatoskr:sindra` in the picker) for the operator's Sindra session — **persona + debug only; the web client does NOT bind to `:8391` (#17 unbuilt), so no memory persistence in the web chat** (memory-enabled chat is the #17 build, or a manual bound session).
|
||||
|
||||
- `[2026-06-18]` **Tier-3 persona_state is a Worldtree limitation (not ours); sindra → thoughtful-character.** The web Persona-pane 404 ("persona not available") = `persona_state` hard-404s ALL Tier-3 (colon-id) agents by design (`api.py:1220`); a persona block in the define doesn't help (endpoint short-circuits before the row). Pinged worldtree-dev on the Tier-3 persona_state roadmap (thread `01KVCR6P…`) + de-uglied our web pane (`index.html loadPersona`) to render a clear message (v0.17.7). Separately switched `ratatoskr:sindra` to the `thoughtful-character` role (→ `mistral-small-4-reasoning`) via DELETE+redefine (kept her prompt; added an inert persona block — inert because the pane's 404 is endpoint-side + we don't bind affect). Tier-3 persona/affect observability is gated on BOTH WT shipping Tier-3 persona_state AND our #17 affect-binding.
|
||||
- `[2026-06-18]` **Tier-3 persona_state is a Worldtree limitation (not ours); sindra → thoughtful-character.** The web Persona-pane 404 ("persona not available") = `persona_state` hard-404s ALL Tier-3 (colon-id) agents by design (`api.py:1220`); a persona block in the define doesn't help (endpoint short-circuits before the row). Pinged worldtree-dev on the Tier-3 persona_state roadmap (thread `01KVCR6P…`) + de-uglied our web pane (`index.html loadPersona`) to render a clear message (v0.17.7). Separately switched `ratatoskr:sindra` to the `thoughtful-character` role (→ `mistral-small-4-reasoning`) via DELETE+redefine (kept her prompt; added an inert persona block). **REFRAME (worldtree-dev `01KVCRK9…`):** persona_state GET is **Tier-1-only by ADR-0009**, the colon-id 404 is correct-by-design (NOT a stub to wait for). Tier-3 affect is **CLIENT-persisted** — we ALREADY hold the PAD `{pleasure,arousal,dominance}` + valence at `:8390` from the `affect.emit` payload. So the Persona-pane fix is **OUR side**: #17 affect-binding → WT dispatches `affect.emit` → `:8390` → the web pane renders PAD/valence from our own store. WT #289 (`affect.fetch`/`affect:read`) = optional mediated-read (their intelligence over our raw store), NOT a prerequisite. WT #300 = their forthcoming v1 client-impl guide (who-owns-what), folding in this exact case.
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.17.7"
|
||||
version = "0.17.8"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+115
-2
@@ -59,6 +59,20 @@ class AgentInfo:
|
||||
ui_hints: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BifrostBinding:
|
||||
"""Session-create Bifrost binding (Worldtree BifrostBindingRequest, #160).
|
||||
|
||||
Issue #17. `endpoint_url` is the WORLDTREE-VISIBLE base URL of ONE provider
|
||||
plane (memory :8391 / affect :8390). `scope` is an opaque pass-through copied
|
||||
into the handshake JWT unchanged (≤256 chars); ratatoskr does not interpret
|
||||
it and v1 sends None.
|
||||
"""
|
||||
|
||||
endpoint_url: str
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
class AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 from POST /sessions — unknown agent_id."""
|
||||
|
||||
@@ -89,6 +103,40 @@ class SessionApiFailed(Exception):
|
||||
self.body = body
|
||||
|
||||
|
||||
# Issue #17 — Bifrost-bind failure modes on POST /sessions.
|
||||
class BifrostConsumerKeyMissing(Exception):
|
||||
"""A bifrost binding was requested without a consumer_key.
|
||||
|
||||
Raised BEFORE any HTTP (INV-001): the bound create must never silently fall
|
||||
back to the client's default canary key — the consumer key IS the handshake
|
||||
identity Worldtree signs the Bifrost JWT with.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"bifrost binding requires a non-empty consumer_key; refusing to "
|
||||
"fall back to the canary key (INV-001)"
|
||||
)
|
||||
|
||||
|
||||
class BifrostHandshakeFailed(Exception):
|
||||
"""Raised on HTTP 502 `bifrost_handshake_failed` from a bound POST /sessions.
|
||||
|
||||
INV-002: the Bifrost handshake runs synchronously at session-create, so a
|
||||
handshake failure (bad URL / down provider / wrong key / HTTPS rejection)
|
||||
fails SESSION CREATION — surfaced on the create path, never deferred to the
|
||||
first turn. `bifrost_error` carries the spec-level code (e.g.
|
||||
`bifrost.auth_rejected`); `body` is truncated to ≤1024 bytes, consistent with
|
||||
`SessionApiFailed` (INV-004 precedent).
|
||||
"""
|
||||
|
||||
def __init__(self, *, bifrost_error: str | None, body: bytes) -> None:
|
||||
body = body[:1024]
|
||||
super().__init__(f"bifrost handshake failed: bifrost_error={bifrost_error!r}")
|
||||
self.bifrost_error = bifrost_error
|
||||
self.body = body
|
||||
|
||||
|
||||
# Worldtree #204 / v0.28.0 — persona_state endpoint failure modes.
|
||||
class PersonaNotConfigured(Exception):
|
||||
"""Raised on HTTP 404 `persona_not_configured` from GET persona_state.
|
||||
@@ -176,11 +224,51 @@ async def list_sessions(
|
||||
return SessionPage(items=items, next_cursor=body.get("next_cursor"))
|
||||
|
||||
|
||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
|
||||
|
||||
Issue #17 dev helper: `memory` → :8391, `affect` → :8390. Returns the
|
||||
Worldtree-visible base (e.g. `http://10.100.10.50:8391`), NOT the client's
|
||||
loopback — Worldtree must reach the provider over the network. `http://` is
|
||||
deliberate: the HTTPS relaxation is allowlist-side (Worldtree's
|
||||
BIFROST_CLIENT_ALLOWED_HOSTS), not a URL concern. A production HTTPS endpoint
|
||||
is supplied directly, bypassing this helper.
|
||||
"""
|
||||
if plane not in ("memory", "affect"):
|
||||
raise ValueError(
|
||||
f"unknown plane: {plane!r} (expected 'memory' or 'affect')"
|
||||
)
|
||||
port = 8391 if plane == "memory" else 8390
|
||||
return f"http://{base_host}:{port}"
|
||||
|
||||
|
||||
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,
|
||||
) -> SessionInfo:
|
||||
"""POST /sessions to create a new session. See contract FN create_session.
|
||||
|
||||
@@ -188,17 +276,42 @@ async def create_session(
|
||||
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)
|
||||
|
||||
body: dict[str, str] = {"agent_id": agent_id}
|
||||
# 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
|
||||
resp = await client.post("/sessions", json=body)
|
||||
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()
|
||||
|
||||
@@ -9,11 +9,15 @@ from ratatoskr.sessions import (
|
||||
AgentNotAvailable,
|
||||
AgentNotFound,
|
||||
AuthScopeDenied,
|
||||
BifrostBinding,
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
InvalidCursor,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
SessionPage,
|
||||
create_session,
|
||||
endpoint_for_plane,
|
||||
get_persona_state,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
@@ -206,6 +210,186 @@ class TestCreateSession:
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
class TestCreateSessionBifrostBind:
|
||||
"""Issue #17 slice 1 — the create_session Bifrost-bind primitive."""
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_happy_consumer_key_and_body(self) -> None:
|
||||
"""bind_happy [tracer]: a bifrost binding makes the body carry the
|
||||
`bifrost` field AND overrides the bearer to the consumer key (NOT the
|
||||
client's canary default), 201 → SessionInfo. Proves the bind path
|
||||
end-to-end (FN create_session STEPS 1-2, POST-001, INV-001)."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"session_id": "s-bound",
|
||||
"agent_id": "ratatoskr:sindra",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-06-18T12:00:00+00:00",
|
||||
"last_active": "2026-06-18T12:00:00+00:00",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://w.example",
|
||||
headers={"Authorization": "Bearer canary-key"},
|
||||
) as client:
|
||||
info = await create_session(
|
||||
client,
|
||||
"ratatoskr:sindra",
|
||||
end_user_id="smoke-user",
|
||||
bifrost=binding,
|
||||
consumer_key="consumer-key",
|
||||
)
|
||||
req = route.calls[0].request
|
||||
body = _json.loads(req.content)
|
||||
# body carries the bifrost field alongside agent_id/end_user_id
|
||||
assert body == {
|
||||
"agent_id": "ratatoskr:sindra",
|
||||
"end_user_id": "smoke-user",
|
||||
"bifrost": {
|
||||
"endpoint_url": "http://10.100.10.50:8391",
|
||||
"scope": None,
|
||||
},
|
||||
}
|
||||
# bearer overridden to the consumer key (INV-001: never the canary default)
|
||||
assert req.headers["Authorization"] == "Bearer consumer-key"
|
||||
assert info.session_id == "s-bound"
|
||||
assert info.agent_id == "ratatoskr:sindra"
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_without_consumer_key_raises_before_http(self) -> None:
|
||||
"""missing_key [adversarial]: bifrost set but consumer_key None →
|
||||
BifrostConsumerKeyMissing BEFORE any HTTP (PRE-001, INV-001: never fall
|
||||
back to the canary key)."""
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, content=b"{}")
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(BifrostConsumerKeyMissing):
|
||||
await create_session(client, "ratatoskr:sindra", bifrost=binding)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_with_empty_consumer_key_raises_before_http(self) -> None:
|
||||
"""empty_key [adversarial]: empty-string consumer_key is also rejected
|
||||
before HTTP (PRE-001 requires a NON-EMPTY str)."""
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, content=b"{}")
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(BifrostConsumerKeyMissing):
|
||||
await create_session(
|
||||
client, "ratatoskr:sindra", bifrost=binding, consumer_key=""
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_handshake_failure_maps_to_502(self) -> None:
|
||||
"""handshake_502 [adversarial]: a bound create that 502s with
|
||||
detail.bifrost_error → BifrostHandshakeFailed carrying the bifrost_error
|
||||
+ raw body (POST-002, INV-002 bind-time failure). 'bifrost.auth_rejected'
|
||||
is the canary-key-instead-of-consumer-key tell."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
502,
|
||||
json={
|
||||
"error_code": "bifrost_handshake_failed",
|
||||
"detail": {"bifrost_error": "bifrost.auth_rejected"},
|
||||
},
|
||||
)
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(BifrostHandshakeFailed) as exc_info:
|
||||
await create_session(
|
||||
client, "ratatoskr:sindra", bifrost=binding, consumer_key="ck"
|
||||
)
|
||||
assert exc_info.value.bifrost_error == "bifrost.auth_rejected"
|
||||
# the raw 502 body is carried for debugging
|
||||
assert exc_info.value.body
|
||||
|
||||
@respx.mock
|
||||
async def test_bind_ephemeral_rejection_is_session_api_failed(self) -> None:
|
||||
"""ephemeral_422 [boundary]: 422 ephemeral_does_not_accept_bifrost is a
|
||||
generic create failure → SessionApiFailed, NOT a distinct exception
|
||||
(POST-003 — deliberate, an operator config error)."""
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
422, json={"error_code": "ephemeral_does_not_accept_bifrost"}
|
||||
)
|
||||
)
|
||||
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc_info:
|
||||
await create_session(
|
||||
client, "echo", bifrost=binding, consumer_key="ck"
|
||||
)
|
||||
assert exc_info.value.status == 422
|
||||
|
||||
@respx.mock
|
||||
async def test_unbound_create_unchanged_no_auth_override(self) -> None:
|
||||
"""unbound_unchanged [regression]: with no bifrost, the body is the
|
||||
pre-#17 shape AND create_session sends NO per-request Authorization
|
||||
override — the client's default canary bearer governs (INV-001: the two
|
||||
call sites never cross)."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"session_id": "s1",
|
||||
"agent_id": "mimir",
|
||||
"message_count": 0,
|
||||
"created_at": "2026-04-15T12:00:00+00:00",
|
||||
"last_active": "2026-04-15T12:00:00+00:00",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://w.example",
|
||||
headers={"Authorization": "Bearer canary-key"},
|
||||
) as client:
|
||||
await create_session(client, "mimir")
|
||||
req = route.calls[0].request
|
||||
body = _json.loads(req.content)
|
||||
assert body == {"agent_id": "mimir"}
|
||||
# the client default bearer is used unchanged — no consumer-key override
|
||||
assert req.headers["Authorization"] == "Bearer canary-key"
|
||||
|
||||
|
||||
class TestEndpointForPlane:
|
||||
"""Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL."""
|
||||
|
||||
def test_memory_plane_maps_to_8391(self) -> None:
|
||||
"""memory [tracer]: 'memory' → http://<host>:8391 (POST-001)."""
|
||||
assert (
|
||||
endpoint_for_plane("memory", "10.100.10.50")
|
||||
== "http://10.100.10.50:8391"
|
||||
)
|
||||
|
||||
def test_affect_plane_maps_to_8390(self) -> None:
|
||||
"""affect: 'affect' → http://<host>:8390 (POST-001)."""
|
||||
assert (
|
||||
endpoint_for_plane("affect", "10.100.10.50")
|
||||
== "http://10.100.10.50:8390"
|
||||
)
|
||||
|
||||
def test_unknown_plane_raises_value_error(self) -> None:
|
||||
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
|
||||
with pytest.raises(ValueError):
|
||||
endpoint_for_plane("persona", "10.100.10.50")
|
||||
|
||||
|
||||
def _list_item(
|
||||
*,
|
||||
session_id: str = "s1",
|
||||
|
||||
Reference in New Issue
Block a user