feat(#20): agents/tier3 family onto the wt adapter + model→role fold (slice-4)

Cut ratatoskr's consumer agent-lifecycle routes over to worldtree-sdk
(issue #20 slice-4). Five routes now flow through `ratatoskr.wt` over the
SDK's `client.agents.*`, returning open-world dicts and mapping the SDK's
undiscriminated `ApiError` floor by route+(status,error_code) per INV-CUT-2:

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

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

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

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

Error-map rows + slice-4 notes added to the cutover contract; coverage-map
re-anchored. LIVE-SMOKE on personal :8081 (b128): define(thoughtful-character)
→ patch → list(6 agents) → persona_state(→PersonaNotConfigured mapped) →
delete → index empty; non-existent-id patch via `-m` → [agent_not_found]
exit 20. Suite 465 green.
This commit is contained in:
vh
2026-07-19 09:58:01 -07:00
parent 4f92a21cb9
commit c62b4eecb3
14 changed files with 730 additions and 1027 deletions
-288
View File
@@ -5,10 +5,6 @@ import pytest
import respx
from ratatoskr.sessions import (
AgentInfo,
AgentNotAvailable,
AuthScopeDenied,
PersonaNotConfigured,
SessionApiFailed,
create_character,
delete_character,
@@ -16,9 +12,7 @@ from ratatoskr.sessions import (
get_capabilities,
get_character_state,
get_me,
get_persona_state,
get_session_bifrost,
list_agents,
list_character_models,
)
@@ -44,288 +38,6 @@ class TestEndpointForPlane:
endpoint_for_plane("persona", "10.100.10.50")
class TestListAgents:
@respx.mock
async def test_happy_full_shape(self) -> None:
"""happy_full_shape [happy,tracer]: spec full-shape mimir example → all fields."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "mimir",
"name": "Mimir",
"description": "Keeper of the Well of Knowledge.",
"version": "0.2.0",
"capabilities": ["knowledge_base", "semantic_search"],
"supported_models": ["default", "heavy"],
"persona_traits": {
"ocean": {
"openness": 0.7,
"conscientiousness": 0.9,
"extraversion": 0.1,
"agreeableness": 0.5,
"neuroticism": 0.3,
},
"vibe": "contemplative",
},
"ui_hints": {"icon": "well", "color_hint": "#5b8aa3"},
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert len(agents) == 1
a = agents[0]
assert isinstance(a, AgentInfo)
assert a.agent_id == "mimir"
assert a.name == "Mimir"
assert a.description == "Keeper of the Well of Knowledge."
assert a.version == "0.2.0"
assert a.capabilities == ["knowledge_base", "semantic_search"]
assert a.supported_models == ["default", "heavy"]
assert a.persona_traits["vibe"] == "contemplative"
assert a.ui_hints["icon"] == "well"
@respx.mock
async def test_happy_minimum_shape(self) -> None:
"""happy_minimum_shape: required-only agent → optional fields default."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "minimal",
"name": "Minimal Agent",
"description": "Just a sketch.",
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
a = agents[0]
assert a.agent_id == "minimal"
assert a.version is None
assert a.capabilities == []
assert a.supported_models == []
assert a.persona_traits == {}
assert a.ui_hints == {}
@respx.mock
async def test_happy_multi_agent(self) -> None:
"""happy_multi_agent: 3 agents preserve order."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{"agent_id": "a", "name": "A", "description": "x"},
{"agent_id": "b", "name": "B", "description": "y"},
{"agent_id": "c", "name": "C", "description": "z"},
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert [a.agent_id for a in agents] == ["a", "b", "c"]
@respx.mock
async def test_happy_empty(self) -> None:
"""happy_empty: 200 with [] returns empty list (no error)."""
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents == []
@respx.mock
async def test_omit_capabilities_empty_list(self) -> None:
"""omit_capabilities_empty: explicit [] from server still defaults to []."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "a",
"name": "A",
"description": "x",
"capabilities": [],
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents[0].capabilities == []
@respx.mock
async def test_500_raises_session_api_failed(self) -> None:
"""500 → SessionApiFailed with status=500."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(500, content=b"oops")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as excinfo:
await list_agents(client)
assert excinfo.value.status == 500
@respx.mock
async def test_401_raises_session_api_failed(self) -> None:
"""401 → SessionApiFailed with status=401."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(401, content=b'{"error":"unauthorized"}')
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as excinfo:
await list_agents(client)
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
@respx.mock
async def test_auth_scope_denied_detail_envelope(self) -> None:
"""auth_scope_denied_detail_envelope [regression]: real Worldtree
returns `{"detail": {"error_code": "auth_scope_denied", …}}`
(FastAPI default), not flat `{"error_code": …}`. Smoke against
personal:8081 2026-05-28 surfaced this — pre-fix the response
fell through to SessionApiFailed(403) instead of AuthScopeDenied.
"""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(
403,
json={
"detail": {
"error_code": "auth_scope_denied",
"message": "Missing required scope: 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_persona_not_configured_detail_envelope(self) -> None:
"""persona_not_configured_detail_envelope [regression]: same
envelope-shape unwrap on 404 + persona_not_configured.
"""
respx.get("https://w.example/agents/domari/persona_state").mock(
return_value=httpx.Response(
404,
json={"detail": {"error_code": "persona_not_configured"}},
)
)
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"
class TestGetMe:
"""docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me)."""