Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d516537b08 |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.11.0"
|
version = "0.12.0"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -89,6 +89,46 @@ class SessionApiFailed(Exception):
|
|||||||
self.body = body
|
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.
|
||||||
|
|
||||||
|
Agent exists but has no persona surface: persona-disabled Tier 1/2
|
||||||
|
agents (e.g. `domari`, `muninn`) and all Tier 3 consumer-defined
|
||||||
|
agents (Phase 2.0). Distinct from `AgentNotAvailable` which means the
|
||||||
|
agent_id is unknown entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, agent_id: str) -> None:
|
||||||
|
super().__init__(f"persona not configured for agent_id: {agent_id!r}")
|
||||||
|
self.agent_id = agent_id
|
||||||
|
|
||||||
|
|
||||||
|
class AgentNotAvailable(Exception):
|
||||||
|
"""Raised on HTTP 404 `agent_not_available` from GET persona_state.
|
||||||
|
|
||||||
|
The agent_id is unknown to the server. Distinct from
|
||||||
|
`PersonaNotConfigured` (agent exists but has no persona).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, agent_id: str) -> None:
|
||||||
|
super().__init__(f"agent not available: {agent_id!r}")
|
||||||
|
self.agent_id = agent_id
|
||||||
|
|
||||||
|
|
||||||
|
class AuthScopeDenied(Exception):
|
||||||
|
"""Raised on HTTP 403 `auth_scope_denied` from a Heimdall-scoped endpoint.
|
||||||
|
|
||||||
|
The API key lacks the required scope (e.g. `persona.read` for
|
||||||
|
GET /agents/{id}/persona_state). User-tier keys carry `persona.read`
|
||||||
|
by default; this surfaces when a narrower key is in use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, scope: str) -> None:
|
||||||
|
super().__init__(f"auth scope denied: required={scope!r}")
|
||||||
|
self.scope = scope
|
||||||
|
|
||||||
|
|
||||||
async def list_sessions(
|
async def list_sessions(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
*,
|
*,
|
||||||
@@ -200,3 +240,45 @@ async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
|
|||||||
)
|
)
|
||||||
for item in body
|
for item in body
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_persona_state(
|
||||||
|
client: httpx.AsyncClient, agent_id: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""GET /agents/{agent_id}/persona_state — fetch current persona snapshot.
|
||||||
|
|
||||||
|
Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the
|
||||||
|
`affect_update` SSE event's `status="current"` emission: pad,
|
||||||
|
dominant_emotion, emotions_active, baseline_pad, mood_drift,
|
||||||
|
last_updated_at. Bootstrap read for clients that want to populate a
|
||||||
|
persona pane on session-open without waiting for turn-1's `affect_update`.
|
||||||
|
|
||||||
|
Auth: requires Heimdall `persona.read` scope (user-tier default).
|
||||||
|
|
||||||
|
Failure modes (mapped to typed exceptions per the spec error_codes):
|
||||||
|
- 404 `persona_not_configured` → PersonaNotConfigured (persona-disabled
|
||||||
|
agents: domari / muninn, and all Tier 3 in Phase 2.0)
|
||||||
|
- 404 `agent_not_available` → AgentNotAvailable (unknown agent_id)
|
||||||
|
- 403 `auth_scope_denied` → AuthScopeDenied (key lacks persona.read)
|
||||||
|
- any other non-2xx → SessionApiFailed (preserves the broader-error
|
||||||
|
precedent from list_agents / list_sessions / create_session)
|
||||||
|
"""
|
||||||
|
assert client is not None
|
||||||
|
assert agent_id and isinstance(agent_id, str)
|
||||||
|
|
||||||
|
resp = await client.get(f"/agents/{agent_id}/persona_state")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
# Discriminate the 4xx error_code sub-codes; everything else falls through.
|
||||||
|
try:
|
||||||
|
err = resp.json()
|
||||||
|
error_code = err.get("error_code") if isinstance(err, dict) else None
|
||||||
|
except ValueError:
|
||||||
|
error_code = None
|
||||||
|
if resp.status_code == 404 and error_code == "persona_not_configured":
|
||||||
|
raise PersonaNotConfigured(agent_id=agent_id)
|
||||||
|
if resp.status_code == 404 and error_code == "agent_not_available":
|
||||||
|
raise AgentNotAvailable(agent_id=agent_id)
|
||||||
|
if resp.status_code == 403 and error_code == "auth_scope_denied":
|
||||||
|
raise AuthScopeDenied(scope="persona.read")
|
||||||
|
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import respx
|
|||||||
|
|
||||||
from ratatoskr.sessions import (
|
from ratatoskr.sessions import (
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
|
AgentNotAvailable,
|
||||||
AgentNotFound,
|
AgentNotFound,
|
||||||
|
AuthScopeDenied,
|
||||||
InvalidCursor,
|
InvalidCursor,
|
||||||
|
PersonaNotConfigured,
|
||||||
SessionApiFailed,
|
SessionApiFailed,
|
||||||
SessionPage,
|
SessionPage,
|
||||||
create_session,
|
create_session,
|
||||||
|
get_persona_state,
|
||||||
list_agents,
|
list_agents,
|
||||||
list_sessions,
|
list_sessions,
|
||||||
)
|
)
|
||||||
@@ -556,3 +560,108 @@ class TestListAgents:
|
|||||||
with pytest.raises(SessionApiFailed) as excinfo:
|
with pytest.raises(SessionApiFailed) as excinfo:
|
||||||
await list_agents(client)
|
await list_agents(client)
|
||||||
assert excinfo.value.status == 401
|
assert excinfo.value.status == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPersonaState:
|
||||||
|
"""Worldtree #204 / v0.28.0 — GET /agents/{agent_id}/persona_state.
|
||||||
|
|
||||||
|
Bootstrap read for the persona snapshot — same shape as `affect_update`'s
|
||||||
|
`current` snapshot. Auth via `persona.read` scope (user-tier default).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_happy_full_snapshot(self) -> None:
|
||||||
|
"""happy_full_snapshot [happy,tracer]: 200 → snapshot dict with pad +
|
||||||
|
dominant_emotion + emotions_active + baseline_pad + mood_drift.
|
||||||
|
"""
|
||||||
|
snapshot = {
|
||||||
|
"agent_id": "mimir",
|
||||||
|
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
||||||
|
"dominant_emotion": "curiosity",
|
||||||
|
"emotions_active": [
|
||||||
|
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
|
||||||
|
],
|
||||||
|
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
|
||||||
|
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
|
||||||
|
"last_updated_at": "2026-05-25T22:30:18+00:00",
|
||||||
|
}
|
||||||
|
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||||
|
return_value=httpx.Response(200, json=snapshot)
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||||
|
result = await get_persona_state(client, "mimir")
|
||||||
|
assert result == snapshot
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_persona_not_configured_404(self) -> None:
|
||||||
|
"""persona_not_configured_404 [error]: 404 with error_code
|
||||||
|
persona_not_configured → PersonaNotConfigured. Agent exists but has
|
||||||
|
no persona surface (e.g. domari, muninn, Tier 3).
|
||||||
|
"""
|
||||||
|
respx.get("https://w.example/agents/domari/persona_state").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
404, json={"error_code": "persona_not_configured", "message": "no persona"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||||
|
with pytest.raises(PersonaNotConfigured) as exc_info:
|
||||||
|
await get_persona_state(client, "domari")
|
||||||
|
assert exc_info.value.agent_id == "domari"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_agent_not_available_404(self) -> None:
|
||||||
|
"""agent_not_available_404 [error]: 404 with error_code
|
||||||
|
agent_not_available → AgentNotAvailable. Distinct from
|
||||||
|
persona_not_configured — the agent_id itself is unknown.
|
||||||
|
"""
|
||||||
|
respx.get("https://w.example/agents/bogus/persona_state").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
404, json={"error_code": "agent_not_available", "message": "unknown agent"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||||
|
with pytest.raises(AgentNotAvailable) as exc_info:
|
||||||
|
await get_persona_state(client, "bogus")
|
||||||
|
assert exc_info.value.agent_id == "bogus"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_auth_scope_denied_403(self) -> None:
|
||||||
|
"""auth_scope_denied_403 [error]: 403 with error_code auth_scope_denied
|
||||||
|
→ AuthScopeDenied. Key lacks `persona.read` scope.
|
||||||
|
"""
|
||||||
|
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
403,
|
||||||
|
json={"error_code": "auth_scope_denied", "message": "missing persona.read"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||||
|
with pytest.raises(AuthScopeDenied) as exc_info:
|
||||||
|
await get_persona_state(client, "mimir")
|
||||||
|
assert exc_info.value.scope == "persona.read"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_404_unknown_error_code_falls_through(self) -> None:
|
||||||
|
"""404_unknown_error_code_falls_through [adversarial]: 404 without the
|
||||||
|
two known error codes → SessionApiFailed (don't swallow novel failure
|
||||||
|
modes as something more specific than they are).
|
||||||
|
"""
|
||||||
|
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||||
|
return_value=httpx.Response(404, json={"error_code": "novel_404"})
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||||
|
with pytest.raises(SessionApiFailed) as exc_info:
|
||||||
|
await get_persona_state(client, "mimir")
|
||||||
|
assert exc_info.value.status == 404
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_500_unexpected_status(self) -> None:
|
||||||
|
"""500_unexpected_status [error]: 5xx → SessionApiFailed (matches the
|
||||||
|
list_agents / list_sessions / create_session precedent)."""
|
||||||
|
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||||
|
return_value=httpx.Response(500, content=b"boom")
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||||
|
with pytest.raises(SessionApiFailed) as exc_info:
|
||||||
|
await get_persona_state(client, "mimir")
|
||||||
|
assert exc_info.value.status == 500
|
||||||
|
|||||||
Reference in New Issue
Block a user