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:
@@ -19,13 +19,23 @@ import pytest
|
||||
import worldtree_sdk as wtsdk
|
||||
from worldtree_sdk import ApiError, CancelResult, PadState, WorldtreeClient
|
||||
|
||||
from ratatoskr.sessions import (
|
||||
AgentNotAvailable as PersonaAgentNotAvailable,
|
||||
)
|
||||
from ratatoskr.sessions import (
|
||||
AgentNotFound,
|
||||
AuthoredHistoryUnavailable,
|
||||
AuthScopeDenied,
|
||||
BifrostBinding,
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
InvalidCursor,
|
||||
PersonaNotConfigured,
|
||||
Tier3AgentNotFound,
|
||||
Tier3FieldNotMutable,
|
||||
Tier3LayerDeferred,
|
||||
Tier3QuotaExceeded,
|
||||
Tier3UserIdUnsupported,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AgentNotAvailable,
|
||||
@@ -44,9 +54,14 @@ from ratatoskr.wt import (
|
||||
build_client,
|
||||
cancel_turn,
|
||||
create_session,
|
||||
define_agent,
|
||||
delete_agent,
|
||||
get_persona_state,
|
||||
get_session_messages,
|
||||
get_session_tools,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
patch_agent,
|
||||
set_persona_state,
|
||||
stream_turn,
|
||||
translate_error,
|
||||
@@ -528,3 +543,266 @@ class TestWriteAuthoredHistory:
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await write_authored_history(_wt(fake), "s", content="hi", idempotency_key="k")
|
||||
assert ei.value.status == 409
|
||||
|
||||
|
||||
# ── slice-4: agents (Tier-3) adapter routes ──────────────────────────────────
|
||||
|
||||
|
||||
class _FakeAgents:
|
||||
"""Stand-in for `WorldtreeClient.agents` — records the last call and returns a
|
||||
canned result or raises a canned error. Same shape as `_FakeSessions`."""
|
||||
|
||||
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 list(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("list", *args, **kwargs)
|
||||
|
||||
async def persona_state(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("persona_state", *args, **kwargs)
|
||||
|
||||
async def define(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("define", *args, **kwargs)
|
||||
|
||||
async def patch(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("patch", *args, **kwargs)
|
||||
|
||||
async def delete(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._dispatch("delete", *args, **kwargs)
|
||||
|
||||
|
||||
class _FakeAgentsClient:
|
||||
def __init__(self, agents: _FakeAgents) -> None:
|
||||
self.agents = agents
|
||||
|
||||
|
||||
def _wta(agents: _FakeAgents) -> WorldtreeClient:
|
||||
"""Cast the structural agents-fake to the nominal client type (the agent route
|
||||
functions only touch `client.agents.*`)."""
|
||||
return cast(WorldtreeClient, _FakeAgentsClient(agents))
|
||||
|
||||
|
||||
class TestListAgents:
|
||||
"""slice-4: list_agents → SDK agents.list(); open-world array verbatim."""
|
||||
|
||||
async def test_happy_returns_array_verbatim(self) -> None:
|
||||
data = [{"agent_id": "mimir", "name": "Mimir", "description": "k"}]
|
||||
fake = _FakeAgents(result=data)
|
||||
out = await list_agents(_wta(fake))
|
||||
assert out is data # open-world passthrough, no AgentInfo normalization
|
||||
assert fake.calls[-1][0] == "list"
|
||||
|
||||
async def test_error_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("upstream", "boom", status=500))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await list_agents(_wta(fake))
|
||||
assert ei.value.status == 500
|
||||
|
||||
|
||||
class TestGetPersonaState:
|
||||
"""slice-4: get_persona_state → SDK agents.persona_state(id); dict verbatim.
|
||||
404 sub-codes + 403 auth_scope_denied map by (status, error_code)."""
|
||||
|
||||
async def test_happy_returns_dict_verbatim(self) -> None:
|
||||
snap = {"pad": {}, "dominant_emotion": "curiosity"}
|
||||
fake = _FakeAgents(result=snap)
|
||||
out = await get_persona_state(_wta(fake), "mimir")
|
||||
assert out is snap
|
||||
assert fake.calls[-1] == ("persona_state", ("mimir",), {})
|
||||
|
||||
async def test_persona_not_configured(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("persona_not_configured", "no", status=404))
|
||||
with pytest.raises(PersonaNotConfigured) as ei:
|
||||
await get_persona_state(_wta(fake), "domari")
|
||||
assert ei.value.agent_id == "domari"
|
||||
|
||||
async def test_agent_not_available(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("agent_not_available", "no", status=404))
|
||||
with pytest.raises(PersonaAgentNotAvailable) as ei:
|
||||
await get_persona_state(_wta(fake), "bogus")
|
||||
assert ei.value.agent_id == "bogus"
|
||||
|
||||
async def test_auth_scope_denied(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("auth_scope_denied", "no", status=403))
|
||||
with pytest.raises(AuthScopeDenied) as ei:
|
||||
await get_persona_state(_wta(fake), "mimir")
|
||||
assert ei.value.scope == "persona.read"
|
||||
|
||||
async def test_other_404_without_code_maps_to_default(self) -> None:
|
||||
# A 404 whose error_code is neither persona sub-code → generic default,
|
||||
# NOT a spurious PersonaNotConfigured (the code is the discriminator).
|
||||
fake = _FakeAgents(error=ApiError("weird", "no", status=404))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await get_persona_state(_wta(fake), "mimir")
|
||||
assert ei.value.status == 404
|
||||
|
||||
async def test_empty_agent_id_asserts(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await get_persona_state(_wta(fake), "")
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
class TestDefineAgent:
|
||||
"""slice-4: define_agent → SDK agents.define(); open-world DefinedAgent dict
|
||||
(echoes `role` post-b128). Slug validated client-side; tier3 error rows."""
|
||||
|
||||
async def test_happy_builds_body_and_returns_dict(self) -> None:
|
||||
resp = {"agent_id": "ratatoskr:wizard", "role": "thoughtful-character"}
|
||||
fake = _FakeAgents(result=resp)
|
||||
out = await define_agent(
|
||||
_wta(fake), agent_name="wizard", system_prompt="You are a wizard.",
|
||||
role="thoughtful-character",
|
||||
)
|
||||
assert out is resp # open-world passthrough (no Tier3AgentInfo)
|
||||
name, args, _kwargs = fake.calls[-1]
|
||||
assert name == "define"
|
||||
# AgentDefineInput body: exactly the three keys, no layer fields.
|
||||
assert args[0] == {
|
||||
"agent_name": "wizard",
|
||||
"role": "thoughtful-character",
|
||||
"system_prompt": "You are a wizard.",
|
||||
}
|
||||
|
||||
async def test_quota_exceeded_defaults_retry_after_zero(self) -> None:
|
||||
# The SDK's ApiError floor drops the Retry-After header; spec §2675 pins it
|
||||
# to 0, so the adapter defaults retry_after=0.
|
||||
fake = _FakeAgents(error=ApiError("agent_quota_exceeded", "full", status=429))
|
||||
with pytest.raises(Tier3QuotaExceeded) as ei:
|
||||
await define_agent(_wta(fake), agent_name="overflow", system_prompt="x", role="m")
|
||||
assert ei.value.retry_after == 0
|
||||
|
||||
async def test_user_id_unsupported(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("tier3_user_id_unsupported", "no", status=403))
|
||||
with pytest.raises(Tier3UserIdUnsupported):
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
||||
|
||||
async def test_layer_deferred_field_parsed_from_body(self) -> None:
|
||||
# `field` is not on ApiError — the adapter body-parses detail.field.
|
||||
fake = _FakeAgents(error=ApiError(
|
||||
"layer_deferred", "no", status=422,
|
||||
body='{"detail": {"error_code": "layer_deferred", "field": "persona"}}',
|
||||
))
|
||||
with pytest.raises(Tier3LayerDeferred) as ei:
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
||||
assert ei.value.field == "persona"
|
||||
|
||||
async def test_bad_slug_asserts_no_call(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(_wta(fake), agent_name="Wizard", system_prompt="x", role="m")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_short_slug_asserts_no_call(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(_wta(fake), agent_name="ab", system_prompt="x", role="m")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_empty_prompt_asserts(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="", role="m")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_empty_role_asserts(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_other_5xx_maps_to_default(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("upstream", "out", status=503))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m")
|
||||
assert ei.value.status == 503
|
||||
|
||||
|
||||
class TestPatchAgent:
|
||||
"""slice-4: patch_agent → SDK agents.patch(id, changes); open PatchedAgent dict."""
|
||||
|
||||
async def test_happy_both_fields(self) -> None:
|
||||
resp = {"agent_id": "ratatoskr:wizard", "role": "different"}
|
||||
fake = _FakeAgents(result=resp)
|
||||
out = await patch_agent(
|
||||
_wta(fake), "ratatoskr:wizard", system_prompt="new", role="different"
|
||||
)
|
||||
assert out is resp
|
||||
name, args, _kwargs = fake.calls[-1]
|
||||
assert name == "patch"
|
||||
assert args[0] == "ratatoskr:wizard"
|
||||
assert args[1] == {"system_prompt": "new", "role": "different"}
|
||||
|
||||
async def test_happy_single_field_omits_none(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="only this")
|
||||
assert fake.calls[-1][1][1] == {"system_prompt": "only this"}
|
||||
|
||||
async def test_404_maps_to_agent_not_found(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("not_found", "no", status=404))
|
||||
with pytest.raises(Tier3AgentNotFound) as ei:
|
||||
await patch_agent(_wta(fake), "ratatoskr:ghost", system_prompt="x")
|
||||
assert ei.value.agent_id == "ratatoskr:ghost"
|
||||
|
||||
async def test_field_not_mutable_field_parsed(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError(
|
||||
"field_not_mutable", "no", status=422,
|
||||
body='{"detail": {"error_code": "field_not_mutable", "field": "agent_name"}}',
|
||||
))
|
||||
with pytest.raises(Tier3FieldNotMutable) as ei:
|
||||
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
|
||||
assert ei.value.field == "agent_name"
|
||||
|
||||
async def test_no_fields_asserts_no_call(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await patch_agent(_wta(fake), "ratatoskr:wizard")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_non_tier3_id_asserts(self) -> None:
|
||||
fake = _FakeAgents(result={})
|
||||
with pytest.raises(AssertionError):
|
||||
await patch_agent(_wta(fake), "mimir", system_prompt="x")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_other_error_maps_to_default(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("upstream", "boom", status=500))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x")
|
||||
assert ei.value.status == 500
|
||||
|
||||
|
||||
class TestDeleteAgent:
|
||||
"""slice-4: delete_agent → SDK agents.delete(id); None on 204; 404 → not-found."""
|
||||
|
||||
async def test_happy_returns_none(self) -> None:
|
||||
fake = _FakeAgents(result=None)
|
||||
out = await delete_agent(_wta(fake), "ratatoskr:wizard")
|
||||
assert out is None
|
||||
assert fake.calls[-1] == ("delete", ("ratatoskr:wizard",), {})
|
||||
|
||||
async def test_404_maps_to_agent_not_found(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("not_found", "no", status=404))
|
||||
with pytest.raises(Tier3AgentNotFound) as ei:
|
||||
await delete_agent(_wta(fake), "ratatoskr:ghost")
|
||||
assert ei.value.agent_id == "ratatoskr:ghost"
|
||||
|
||||
async def test_non_tier3_id_asserts(self) -> None:
|
||||
fake = _FakeAgents(result=None)
|
||||
with pytest.raises(AssertionError):
|
||||
await delete_agent(_wta(fake), "mimir")
|
||||
assert fake.calls == []
|
||||
|
||||
async def test_other_error_maps_to_default(self) -> None:
|
||||
fake = _FakeAgents(error=ApiError("upstream", "oops", status=500))
|
||||
with pytest.raises(SessionApiFailed) as ei:
|
||||
await delete_agent(_wta(fake), "ratatoskr:wizard")
|
||||
assert ei.value.status == 500
|
||||
|
||||
Reference in New Issue
Block a user