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:
@@ -164,7 +164,15 @@ others, they get their own row here — the default is NOT a general "any 404
|
||||
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
|
||||
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` (dual-key: status 422 AND error_code; the flat cursor body surfaces the code) |
|
||||
| `ApiError(502)` on bound `sessions.create` | `BifrostHandshakeFailed` — NOT gated on error_code (unlike list's 422): INV-002, the synchronous handshake is the SOLE bound-502 cause; and the SDK's envelope parser prefers the nested `detail` (which carries `bifrost_error`, not `error_code`), so no distinguishing top-level `error_code` surfaces. The route+status IS the discriminator. |
|
||||
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
|
||||
| `ApiError(429)` on `agents.define` (slice-4) | `Tier3QuotaExceeded(retry_after=0)` — the SDK's `ApiError` floor carries no response headers, so the `Retry-After` header the hand-rolled path read is unavailable; spec §2675 pins Phase-2.0 quota to `Retry-After: 0`, so the adapter defaults to 0. A non-zero forward-compat value is unrecoverable until the SDK surfaces headers (INFORM wtsdk-dev; reference-impl posture). |
|
||||
| `ApiError(403 tier3_user_id_unsupported)` on `agents.define` (slice-4) | `Tier3UserIdUnsupported` (dual-key: status 403 AND error_code) |
|
||||
| `ApiError(422 layer_deferred)` on `agents.define` (slice-4) | `Tier3LayerDeferred(field)` — `field` parsed from the body (`detail.field` / flat `field`); the SDK carries `error_code` but not `field`, so the adapter body-parses it (same posture as bound-502's `bifrost_error`) |
|
||||
| `ApiError(404)` on `agents.patch` / `agents.delete` (slice-4) | `Tier3AgentNotFound` (route-discriminated; agents CRUD is NOT a hide-existence route — a 404 there IS "no such agent") |
|
||||
| `ApiError(422 field_not_mutable)` on `agents.patch` (slice-4) | `Tier3FieldNotMutable(field)` (dual-key status+error_code; `field` body-parsed) |
|
||||
| `ApiError(404 persona_not_configured)` on `agents.persona_state` (slice-4) | `PersonaNotConfigured` (dual-key) |
|
||||
| `ApiError(404 agent_not_available)` on `agents.persona_state` (slice-4) | `AgentNotAvailable` (the persona-surface `sessions.AgentNotAvailable`, distinct from the eager-turn `sse_client.AgentNotAvailable`; dual-key) |
|
||||
| `ApiError(403 auth_scope_denied)` on `agents.persona_state` (slice-4) | `AuthScopeDenied(scope="persona.read")` (dual-key) |
|
||||
| **`ApiError` (any other status/route, incl. `agents.list` and any unmatched agent-route code) — the default** | `SessionApiFailed(status, error_code, body)` |
|
||||
|
||||
The default row is load-bearing: any `ApiError` not matched above surfaces as the
|
||||
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
|
||||
@@ -206,6 +214,32 @@ re-anchor its coverage-map rows.
|
||||
SSE parsing); retire contracts #2/#15; final coverage-map re-anchor; minor bump
|
||||
(DEC-6, operator approval).
|
||||
|
||||
### Slice-4 notes (Agents/Tier-3 + `model`→`role` fold, decided at TDD)
|
||||
|
||||
- **`model`→`role` cutover folds in here (scope B).** Worldtree spec 1.2 (`v1.0.0b128`,
|
||||
live on :8080/:8081) made the `/agents/define` response echo `role`, closing the
|
||||
old W-4 `model` echo. The adapter returns the SDK's OPEN-WORLD `DefinedAgent` /
|
||||
`PatchedAgent` dicts verbatim (parity posture); callers read `info["role"]`. The
|
||||
frozen `Tier3AgentInfo` dataclass (which read `body["model"]` and would KeyError
|
||||
post-b128) is DELETED — no dataclass normalization layer survives.
|
||||
- **`ratatoskr.local_agents` schema bump.** `LocalAgentEntry.model` → `.role` (the
|
||||
field stores what the wire now calls a role); `_SCHEMA_VERSION` 1→2 so any
|
||||
pre-cutover on-disk index is discarded cleanly (no-backwards-compat, DEC-3).
|
||||
- **`AgentNotAvailable` name collision.** `sessions.AgentNotAvailable` (persona-state
|
||||
404 `agent_not_available`) and `sse_client.AgentNotAvailable` (eager-turn 409) are
|
||||
distinct types that share a name; `wt` already imports the sse_client one for the
|
||||
stream, so it imports the persona one ALIASED (`PersonaAgentNotAvailable`) and
|
||||
raises it from `get_persona_state`. The web endpoint keeps importing the persona
|
||||
`AgentNotAvailable` from `sessions` (same class), so its `except` is unchanged.
|
||||
- **`ConnectFailed` at every rewired caller (slice-3 foot-gun).** The SDK normalizes
|
||||
ANY transport failure to `ConnectFailed(status=0)` (`request.py`), not a raw httpx
|
||||
error. The rewired tier3 CLI and both web endpoints (`_agents_endpoint`,
|
||||
`_persona_state_endpoint`) catch `wtsdk.ConnectFailed` → their existing
|
||||
network-error surface (CLI exit 21 / web 502). The web `test_network_error_returns_502`
|
||||
(respx `httpx.ConnectError` side-effect) is the RED that proves this.
|
||||
- **`agents.get(agent_id)`** (SDK `GET /agents/{id}`) is NOT wrapped — ratatoskr has no
|
||||
`get_agent` consumer; only list/persona_state/define/patch/delete are in coverage.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched.
|
||||
|
||||
@@ -89,11 +89,11 @@ sub-gap).
|
||||
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `wt.py` `write_authored_history` (SDK `sessions.write_history`) → `cli.py` `--seed-first-message`, `first_message.py` `seed_preset_first_message` (create-path seed) | **wt-adapter re-anchored (slice-3, #20)** — SDK owns the entry shape; v1 author=assistant; 404→AuthoredHistoryUnavailable (hide-existence, route is the discriminator, never probe); 409/422→SessionApiFailed default. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): `--seed-first-message` on a sindra session → **201** (seq=0, phase=seeded, turn_id=2294) → read-back verbatim; create-path preset seed observed via `--new`. (Prior 2026-07-06 hand-rolled proof superseded.) |
|
||||
| `GET /sessions/{id}/messages` (history) | ✅ | `wt.py` `get_session_messages` (SDK `sessions.messages`) → `cli.py` `--seed-first-message` read-back, `web/server.py` messages proxy | **wt-adapter re-anchored (slice-3, #20)** — the #347 seed read-back; open-world passthrough. **LIVE-SMOKE 2026-07-19**: read-back rendered the seeded seq-0 turn as a plain role=assistant message (model-invisible provenance confirmed) |
|
||||
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `wt.py` `cancel_turn` (SDK `sessions.cancel_turn`) → cli/web | **wt-adapter re-anchored (slice-2, #20)** — two-stage Ctrl-C; 404→CancelTurnNotFound, 409→CancelAlreadyCompleted, late-cancel 200 (`cancelled=False`) is a benign result, not an error |
|
||||
| `GET /agents` | ✅ | `sessions.py:341` → `tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index |
|
||||
| `GET /agents/{id}/persona_state` | ✅ | `sessions.py:384` → `tui.py:1132`,`web/server.py:386` | persona hydrate; 404/403 mapped |
|
||||
| `POST /agents/define` | ✅ | `tier3.py:175` → `_run_define` | Tier-3 create |
|
||||
| `PATCH /agents/{id}` | ✅ | `tier3.py:219` → `_run_patch` | Tier-3 mutate (system_prompt/model) |
|
||||
| `DELETE /agents/{id}` | ✅ | `tier3.py:242` → `_run_delete` | Tier-3 hard-delete |
|
||||
| `GET /agents` | ✅ | `wt.py` `list_agents` (SDK `agents.list`) → `web/server.py` `_agents_endpoint` | **wt-adapter re-anchored (slice-4, #20)** — open-world array verbatim (no AgentInfo normalization), merged with the local tier3 index (remote-wins); error→SessionApiFailed default, transport→ConnectFailed→502. **LIVE-SMOKE 2026-07-19** on personal :8081 (b128): 6 agents returned (forseti/lofn/mask/mimir/vili/…) |
|
||||
| `GET /agents/{id}/persona_state` | ✅ | `wt.py` `get_persona_state` (SDK `agents.persona_state`) → `web/server.py` `_persona_state_endpoint` | **wt-adapter re-anchored (slice-4, #20)** — open-world snapshot; dual-key (status,error_code) map: 404 persona_not_configured→PersonaNotConfigured, 404 agent_not_available→AgentNotAvailable, 403 auth_scope_denied→AuthScopeDenied, else default. **LIVE-SMOKE 2026-07-19**: a tier3 agent → correctly mapped `PersonaNotConfigured` (route+code adapter proven) |
|
||||
| `POST /agents/define` | ✅ | `wt.py` `define_agent` (SDK `agents.define`) → `tier3.py` `_run_define` | **wt-adapter re-anchored (slice-4, #20)** — sends AgentDefineInput `{agent_name,role,system_prompt}`, returns open-world `DefinedAgent` (echoes `role`, b128); slug pre-validated; 429→Tier3QuotaExceeded(retry_after=0, header-less floor), 403→Tier3UserIdUnsupported, 422 layer_deferred→Tier3LayerDeferred. **LIVE-SMOKE 2026-07-19**: `define --role thoughtful-character` → `defined ratatoskr:slice4-smoke (thoughtful-character)` |
|
||||
| `PATCH /agents/{id}` | ✅ | `wt.py` `patch_agent` (SDK `agents.patch`) → `tier3.py` `_run_patch` | **wt-adapter re-anchored (slice-4, #20)** — Tier-3 mutate (system_prompt/**role**, model→role folded in); 404→Tier3AgentNotFound, 422 field_not_mutable→Tier3FieldNotMutable. **LIVE-SMOKE 2026-07-19**: `patched ratatoskr:slice4-smoke`; a non-existent id via `python -m` → `[agent_not_found]` (exit 20, class-identity fix proven) |
|
||||
| `DELETE /agents/{id}` | ✅ | `wt.py` `delete_agent` (SDK `agents.delete`) → `tier3.py` `_run_delete` | **wt-adapter re-anchored (slice-4, #20)** — 204→None; 404→Tier3AgentNotFound (route-discriminated, NOT hide-existence). **LIVE-SMOKE 2026-07-19**: `deleted ratatoskr:slice4-smoke` + local index → `[]` |
|
||||
| `GET /me` | ✅ | `sessions.py:411` `get_me` → `cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed |
|
||||
| `GET /capabilities` | ✅ | `sessions.py` `get_capabilities` → `cli.py` `--whoami` | Echo ephemeral-template discovery. **v0.21.2: `--whoami` renderer reads `allowed_roles`/`default_role`** (was the dead `allowed_models`/`default_model`) + tolerates malformed caps; matches conversation-api-spec **v1.1** (`b4a278c`) |
|
||||
| `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools` → `tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) |
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.21.12"
|
||||
version = "0.21.13"
|
||||
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -27,7 +27,11 @@ import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
# v2 (worldtree-sdk cutover slice-4): `model` → `role`. The field holds what the
|
||||
# Worldtree wire now calls a role (spec 1.2 / b128, the define response echoes
|
||||
# `role`); the version bump discards any pre-cutover on-disk index cleanly
|
||||
# (no-backwards-compat — old `{"model": …}` rows are dropped, re-defined fresh).
|
||||
_SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -37,16 +41,17 @@ class LocalAgentEntry:
|
||||
Schema:
|
||||
- ``agent_id``: full "user_id:agent_name" string (Worldtree-owned).
|
||||
- ``agent_name``: slug from define (display name).
|
||||
- ``model``: provider model ID at last define/patch.
|
||||
- ``role``: model-role at last define/patch (e.g. "thoughtful-character";
|
||||
the define/patch response's ``role`` field, W-4 resolved / b128).
|
||||
- ``description``: synthetic display string (typically derived from
|
||||
the system_prompt's first line + a "(tier 3)" prefix; the picker
|
||||
uses this in its ``{id} · {name} — {description}`` rendering).
|
||||
- ``defined_at``: ISO-8601 timestamp from the Tier3AgentInfo response.
|
||||
- ``defined_at``: ISO-8601 timestamp from the define/patch response.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
model: str
|
||||
role: str
|
||||
description: str
|
||||
defined_at: str
|
||||
|
||||
|
||||
+64
-96
@@ -11,26 +11,6 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentInfo:
|
||||
"""Worldtree agent envelope from GET /agents (issue #8).
|
||||
|
||||
INV-005: required fields (`agent_id`, `name`, `description`) take the
|
||||
response value verbatim. Optional fields default to None / [] / {} when
|
||||
omitted by the server, mirroring SessionInfo's INV-001/INV-002
|
||||
origin-conditional defaulting.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
name: str
|
||||
description: str
|
||||
version: str | None
|
||||
capabilities: list[str]
|
||||
supported_models: list[str]
|
||||
persona_traits: dict[str, Any]
|
||||
ui_hints: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BifrostBinding:
|
||||
"""Session-create Bifrost binding (Worldtree BifrostBindingRequest, #160).
|
||||
@@ -169,6 +149,70 @@ class AuthoredHistoryUnavailable(Exception):
|
||||
self.session_id = session_id
|
||||
|
||||
|
||||
# ── Tier-3 (consumer-defined) agent lifecycle exceptions ─────────────────────
|
||||
# The worldtree-sdk adapter (`ratatoskr.wt`) re-raises these from the agents.define/
|
||||
# patch/delete routes. They live HERE (not in `tier3`) so both `wt` and the
|
||||
# `python -m ratatoskr.tier3` CLI reference the SAME class objects: `tier3` is run as
|
||||
# `__main__`, and if these were defined there, `wt`'s `from .tier3 import …` would bind
|
||||
# a SECOND copy under `ratatoskr.tier3` — so a raised exception would not match the
|
||||
# CLI's `except` (the exception would escape as an uncaught traceback). Homing them in
|
||||
# `sessions` (never run as `__main__`) makes the class identity single.
|
||||
|
||||
|
||||
class Tier3QuotaExceeded(Exception):
|
||||
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
|
||||
on the Heimdall key. ``retry_after`` captures the Retry-After header
|
||||
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero).
|
||||
|
||||
Post-cutover the adapter constructs this with ``retry_after=0`` — the SDK's
|
||||
``ApiError`` floor carries no response headers, and §2675 pins Phase-2.0 quota
|
||||
to ``Retry-After: 0``, so the value is spec-canonical."""
|
||||
|
||||
def __init__(self, *, retry_after: int) -> None:
|
||||
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class Tier3UserIdUnsupported(Exception):
|
||||
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
|
||||
is not slug-safe per Phase 2.0 gate (spec §2626)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("tier3 caller user_id is not slug-safe")
|
||||
|
||||
|
||||
class Tier3FieldNotMutable(Exception):
|
||||
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
|
||||
carried a key that's immutable post-define (``agent_name``, ``user_id``,
|
||||
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3LayerDeferred(Exception):
|
||||
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
|
||||
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
|
||||
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
|
||||
|
||||
Note: ``define_agent`` never sends layer fields, so this exception is
|
||||
defense-against-server-side-changes / forward-compat."""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"tier3 layer field deferred: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
|
||||
(spec §2634 + §2641)."""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"tier3 agent not found: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
|
||||
|
||||
@@ -186,82 +230,6 @@ def endpoint_for_plane(plane: str, base_host: str) -> str:
|
||||
return f"http://{base_host}:{ports[plane]}"
|
||||
|
||||
|
||||
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
|
||||
"""GET /agents — list available agents. See contract FN list_agents (issue #8).
|
||||
|
||||
No request params, no pagination. Returns server-ordered list. Optional
|
||||
fields are defaulted to None / [] / {} per INV-005.
|
||||
"""
|
||||
assert client is not None
|
||||
|
||||
resp = await client.get("/agents")
|
||||
if resp.status_code != 200:
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
body = resp.json()
|
||||
return [
|
||||
AgentInfo(
|
||||
agent_id=item["agent_id"],
|
||||
name=item["name"],
|
||||
description=item["description"],
|
||||
version=item.get("version"),
|
||||
capabilities=item.get("capabilities") or [],
|
||||
supported_models=item.get("supported_models") or [],
|
||||
persona_traits=item.get("persona_traits") or {},
|
||||
ui_hints=item.get("ui_hints") or {},
|
||||
)
|
||||
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. Worldtree returns errors as either flat `{"error_code": …}`
|
||||
# OR FastAPI-default `{"detail": {"error_code": …}}` depending on
|
||||
# which handler raised — unwrap both shapes (real wire observed
|
||||
# 2026-05-28 returning the detail-nested form for auth_scope_denied
|
||||
# from /agents/{id}/persona_state).
|
||||
try:
|
||||
err = resp.json()
|
||||
except ValueError:
|
||||
err = None
|
||||
error_code: str | None = None
|
||||
if isinstance(err, dict):
|
||||
error_code = err.get("error_code")
|
||||
if error_code is None and isinstance(err.get("detail"), dict):
|
||||
error_code = err["detail"].get("error_code")
|
||||
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)
|
||||
|
||||
|
||||
async def get_me(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
"""GET /me — the authenticated principal's identity + key metadata (spec §GET /me).
|
||||
|
||||
|
||||
+77
-287
@@ -1,259 +1,43 @@
|
||||
"""Worldtree Tier 3 (consumer-defined) agent lifecycle client.
|
||||
"""Worldtree Tier 3 (consumer-defined) agent CLI + caller-semantic exceptions.
|
||||
|
||||
Implements docs/contracts/issues/15.contract.md. Caller-owned httpx.AsyncClient
|
||||
posture (same as ratatoskr.sessions). Exposes three lifecycle operations:
|
||||
The define / patch / delete wire calls route through the worldtree-sdk adapter
|
||||
(``ratatoskr.wt.define_agent`` / ``patch_agent`` / ``delete_agent``); this module owns
|
||||
the ``python -m ratatoskr.tier3`` CLI and the Tier-3 caller-semantic exception
|
||||
taxonomy the adapter re-raises (quota / user-id / field-not-mutable / layer-deferred /
|
||||
not-found). The picker already handles colon-containing agent_ids generically
|
||||
(issue #8).
|
||||
|
||||
- ``define_agent`` — POST /agents/define
|
||||
- ``patch_agent`` — PATCH /agents/<id>
|
||||
- ``delete_agent`` — DELETE /agents/<id>
|
||||
|
||||
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
|
||||
already handles colon-containing agent_ids generically (issue #8); session
|
||||
creation works unchanged via ``ratatoskr.wt.create_session`` (worldtree-sdk cutover).
|
||||
|
||||
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
|
||||
The old hand-rolled httpx wrappers + the ``Tier3AgentInfo`` dataclass were deleted in
|
||||
the worldtree-sdk cutover (issue #20, slice-4); the adapter returns the SDK's
|
||||
open-world define/patch dicts (echoing ``role`` post-b128, spec 1.2), read here as
|
||||
mappings. ``wt`` imports this module's exceptions at module level; this module imports
|
||||
``wt`` only lazily inside the CLI handlers, so there is no import cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
|
||||
# Per spec §2627: agent_name + user_id slugs are `[a-z][a-z0-9-]{2,63}`.
|
||||
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tier3AgentInfo:
|
||||
"""Worldtree Tier 3 agent envelope returned by define / patch.
|
||||
|
||||
INV-001: ``agent_id`` is always shape ``"<user_id>:<agent_name>"`` —
|
||||
constructed server-side from the auth's user_id + the supplied agent_name.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
user_id: str
|
||||
agent_name: str
|
||||
system_prompt: str
|
||||
model: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class Tier3QuotaExceeded(Exception):
|
||||
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
|
||||
on the Heimdall key. ``retry_after`` captures the Retry-After header
|
||||
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero)."""
|
||||
|
||||
def __init__(self, *, retry_after: int) -> None:
|
||||
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class Tier3UserIdUnsupported(Exception):
|
||||
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
|
||||
is not slug-safe per Phase 2.0 gate (spec §2626)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("tier3 caller user_id is not slug-safe")
|
||||
|
||||
|
||||
class Tier3FieldNotMutable(Exception):
|
||||
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
|
||||
carried a key that's immutable post-define (``agent_name``, ``user_id``,
|
||||
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3LayerDeferred(Exception):
|
||||
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
|
||||
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
|
||||
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
|
||||
|
||||
Note: ``define_agent`` never sends layer fields, so this exception is
|
||||
defense-against-server-side-changes / forward-compat. INV-001 in the
|
||||
request body construction is the first line of defense.
|
||||
"""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"tier3 layer field deferred: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
|
||||
(spec §2634 + §2641)."""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"tier3 agent not found: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
def _extract_error_code(resp: httpx.Response) -> str | None:
|
||||
"""Pluck the ``detail.error_code`` from a Worldtree error envelope.
|
||||
|
||||
Worldtree wraps API errors in ``{"detail": {"error_code": "...", ...}}``
|
||||
per the spec. Returns None on shape mismatch (so callers fall through
|
||||
to the generic ``SessionApiFailed`` branch).
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
code = detail.get("error_code")
|
||||
if isinstance(code, str):
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
def _extract_error_field(resp: httpx.Response) -> str | None:
|
||||
"""Pluck ``detail.field`` from a Worldtree error envelope (used for
|
||||
``field_not_mutable`` and ``layer_deferred`` to surface which field
|
||||
triggered the rejection). Returns None on shape mismatch.
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
field = detail.get("field")
|
||||
if isinstance(field, str):
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def _parse_tier3_agent_info(body: dict) -> Tier3AgentInfo:
|
||||
"""Parse a Worldtree Tier 3 agent JSON body into the frozen dataclass."""
|
||||
return Tier3AgentInfo(
|
||||
agent_id=body["agent_id"],
|
||||
user_id=body["user_id"],
|
||||
agent_name=body["agent_name"],
|
||||
system_prompt=body["system_prompt"],
|
||||
model=body["model"],
|
||||
created_at=body["created_at"],
|
||||
updated_at=body["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
async def define_agent(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
agent_name: str,
|
||||
system_prompt: str,
|
||||
role: str,
|
||||
) -> Tier3AgentInfo:
|
||||
"""POST /agents/define — create a Tier 3 agent.
|
||||
|
||||
See contract FN define_agent. Validates the agent_name slug client-side
|
||||
before the network round-trip; server-side validation is the safety net.
|
||||
Returns a fully populated Tier3AgentInfo on 201. Routes documented error
|
||||
codes to typed exceptions; unknown non-2xx → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert _SLUG_RE.match(agent_name), (
|
||||
f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
|
||||
)
|
||||
assert system_prompt, "system_prompt must be non-empty"
|
||||
assert role, "role must be non-empty"
|
||||
|
||||
# b125 drift: /agents/define takes `role` (a model-role, e.g. "thoughtful-character")
|
||||
# in the request; the response echoes it back as `model`. See #15 follow-up.
|
||||
body = {
|
||||
"agent_name": agent_name,
|
||||
"system_prompt": system_prompt,
|
||||
"role": role,
|
||||
}
|
||||
resp = await client.post("/agents/define", json=body)
|
||||
|
||||
if resp.status_code == 201:
|
||||
return _parse_tier3_agent_info(resp.json())
|
||||
if resp.status_code == 429:
|
||||
# Spec §2675: 51st define → 429 with Retry-After: 0.
|
||||
try:
|
||||
retry_after = int(resp.headers.get("Retry-After", "0"))
|
||||
except (TypeError, ValueError):
|
||||
retry_after = 0
|
||||
raise Tier3QuotaExceeded(retry_after=retry_after)
|
||||
if resp.status_code == 403:
|
||||
if _extract_error_code(resp) == "tier3_user_id_unsupported":
|
||||
raise Tier3UserIdUnsupported()
|
||||
if resp.status_code == 422:
|
||||
code = _extract_error_code(resp)
|
||||
if code == "layer_deferred":
|
||||
raise Tier3LayerDeferred(field=_extract_error_field(resp))
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def patch_agent(
|
||||
client: httpx.AsyncClient,
|
||||
agent_id: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
role: str | None = None,
|
||||
) -> Tier3AgentInfo:
|
||||
"""PATCH /agents/<id> — mutate system_prompt and/or model.
|
||||
|
||||
See contract FN patch_agent. Per spec §2641: only system_prompt + model
|
||||
are mutable in Phase 2.0; any other key returns 422 field_not_mutable.
|
||||
"""
|
||||
assert client is not None
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
assert system_prompt is not None or role is not None, (
|
||||
"patch requires at least one of system_prompt or role"
|
||||
)
|
||||
|
||||
body: dict[str, str] = {}
|
||||
if system_prompt is not None:
|
||||
body["system_prompt"] = system_prompt
|
||||
if role is not None:
|
||||
body["role"] = role
|
||||
resp = await client.patch(f"/agents/{agent_id}", json=body)
|
||||
|
||||
if resp.status_code == 200:
|
||||
return _parse_tier3_agent_info(resp.json())
|
||||
if resp.status_code == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
if resp.status_code == 422:
|
||||
code = _extract_error_code(resp)
|
||||
if code == "field_not_mutable":
|
||||
raise Tier3FieldNotMutable(field=_extract_error_field(resp))
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
|
||||
"""DELETE /agents/<id> — owner hard-delete (cancels active sessions
|
||||
server-side per spec §2636).
|
||||
|
||||
See contract FN delete_agent. 204 on success; 404 if the agent_id
|
||||
doesn't exist; other non-2xx → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
|
||||
resp = await client.delete(f"/agents/{agent_id}")
|
||||
if resp.status_code == 204:
|
||||
return
|
||||
if resp.status_code == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
# The Tier-3 caller-semantic exceptions live in `sessions` (never run as `__main__`)
|
||||
# so the adapter's raise and this CLI's `except` reference the SAME class objects —
|
||||
# see the header note in `sessions.py`. `main()` catches these; `wt` raises them.
|
||||
from ratatoskr.sessions import (
|
||||
Tier3AgentNotFound,
|
||||
Tier3FieldNotMutable,
|
||||
Tier3LayerDeferred,
|
||||
Tier3QuotaExceeded,
|
||||
Tier3UserIdUnsupported,
|
||||
)
|
||||
|
||||
# ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------
|
||||
#
|
||||
# Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes
|
||||
# mirror ratatoskr.cli: 0 happy / 10 usage / 11 auth / 20 api-failure /
|
||||
# 21 network. Outbound requests carry the same User-Agent string.
|
||||
# 21 network. Outbound requests carry the same User-Agent string. The wire calls
|
||||
# route through the worldtree-sdk adapter (ratatoskr.wt) over a ratatoskr-owned
|
||||
# injected transport (INV-CUT-1: the SDK never closes it).
|
||||
|
||||
|
||||
class _Tier3UsageError(Exception):
|
||||
@@ -284,7 +68,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
help="Model role, e.g. thoughtful-character (see GET /models/available-for-characters).",
|
||||
)
|
||||
|
||||
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or model.")
|
||||
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or role.")
|
||||
p_patch.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
|
||||
p_patch.add_argument("--system-prompt", dest="system_prompt", default=None)
|
||||
p_patch.add_argument("--role", default=None)
|
||||
@@ -308,46 +92,54 @@ def _resolve_auth(ns: argparse.Namespace) -> tuple[str, str]:
|
||||
return api_key, server_url
|
||||
|
||||
|
||||
def _transport(server_url: str, api_key: str) -> httpx.AsyncClient:
|
||||
"""The ratatoskr-owned httpx transport the adapter's WorldtreeClient is built
|
||||
over. Carries the User-Agent + a generous read timeout for agent CRUD; the SDK
|
||||
re-applies auth per request (the default bearer here just mirrors it)."""
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
|
||||
return httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
)
|
||||
|
||||
|
||||
async def _run_define(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr import wt
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
add_local_agent,
|
||||
make_description,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
info = await define_agent(
|
||||
async with _transport(server_url, api_key) as transport:
|
||||
client = wt.build_client(server_url, api_key=api_key, transport=transport)
|
||||
info = await wt.define_agent(
|
||||
client,
|
||||
agent_name=ns.name,
|
||||
system_prompt=ns.system_prompt,
|
||||
role=ns.role,
|
||||
)
|
||||
# v0.8.0: persist to local index so the picker can show it.
|
||||
# v0.8.0: persist to local index so the picker can show it. The SDK returns
|
||||
# the open-world define dict — read `role` (echoed post-b128), not `model`.
|
||||
add_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=info.agent_id,
|
||||
agent_name=info.agent_name,
|
||||
model=info.model,
|
||||
description=make_description(info.system_prompt),
|
||||
defined_at=info.created_at,
|
||||
agent_id=info["agent_id"],
|
||||
agent_name=info["agent_name"],
|
||||
role=info["role"],
|
||||
description=make_description(info.get("system_prompt", "")),
|
||||
defined_at=info.get("created_at", ""),
|
||||
)
|
||||
)
|
||||
print(f"defined {info.agent_id} ({info.model})")
|
||||
print(f"defined {info['agent_id']} ({info['role']})")
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_patch(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr import wt
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
make_description,
|
||||
@@ -358,15 +150,9 @@ async def _run_patch(ns: argparse.Namespace) -> int:
|
||||
raise _Tier3UsageError(
|
||||
"patch requires at least one of --system-prompt or --role"
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
info = await patch_agent(
|
||||
async with _transport(server_url, api_key) as transport:
|
||||
client = wt.build_client(server_url, api_key=api_key, transport=transport)
|
||||
info = await wt.patch_agent(
|
||||
client,
|
||||
ns.agent_id,
|
||||
system_prompt=ns.system_prompt,
|
||||
@@ -375,31 +161,25 @@ async def _run_patch(ns: argparse.Namespace) -> int:
|
||||
# v0.8.0: refresh local index with the post-patch state.
|
||||
update_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=info.agent_id,
|
||||
agent_name=info.agent_name,
|
||||
model=info.model,
|
||||
description=make_description(info.system_prompt),
|
||||
defined_at=info.updated_at,
|
||||
agent_id=info["agent_id"],
|
||||
agent_name=info["agent_name"],
|
||||
role=info["role"],
|
||||
description=make_description(info.get("system_prompt", "")),
|
||||
defined_at=info.get("updated_at", ""),
|
||||
)
|
||||
)
|
||||
print(f"patched {info.agent_id}")
|
||||
print(f"patched {info['agent_id']}")
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_delete(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr import wt
|
||||
from ratatoskr.local_agents import remove_local_agent
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
await delete_agent(client, ns.agent_id)
|
||||
async with _transport(server_url, api_key) as transport:
|
||||
client = wt.build_client(server_url, api_key=api_key, transport=transport)
|
||||
await wt.delete_agent(client, ns.agent_id)
|
||||
# v0.8.0: drop from local index so the picker stops listing it.
|
||||
remove_local_agent(ns.agent_id)
|
||||
print(f"deleted {ns.agent_id}")
|
||||
@@ -419,6 +199,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import worldtree_sdk as wtsdk
|
||||
|
||||
from ratatoskr.wt import SessionApiFailed
|
||||
|
||||
parser = _build_parser()
|
||||
try:
|
||||
ns = parser.parse_args(argv)
|
||||
@@ -459,10 +243,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 20
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(
|
||||
f"[api_failed] status={exc.status} body={exc.body!r}\n"
|
||||
f"[api_failed] status={exc.status} "
|
||||
f"error_code={exc.error_code!r} body={exc.body!r}\n"
|
||||
)
|
||||
return 20
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||
except (
|
||||
httpx.ConnectError,
|
||||
httpx.ReadTimeout,
|
||||
httpx.TransportError,
|
||||
wtsdk.ConnectFailed, # SDK normalizes any pre-response transport failure here
|
||||
) as exc:
|
||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||
return 21
|
||||
|
||||
|
||||
+26
-12
@@ -26,7 +26,13 @@ from starlette.responses import (
|
||||
)
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from worldtree_sdk import CancelledEvent, DoneEvent, ErrorEvent, WorldtreeClient
|
||||
from worldtree_sdk import (
|
||||
CancelledEvent,
|
||||
ConnectFailed,
|
||||
DoneEvent,
|
||||
ErrorEvent,
|
||||
WorldtreeClient,
|
||||
)
|
||||
|
||||
from ratatoskr import local_agents as _local_agents
|
||||
from ratatoskr import wt
|
||||
@@ -41,15 +47,13 @@ from ratatoskr.sessions import (
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
endpoint_for_plane,
|
||||
get_persona_state,
|
||||
get_session_bifrost,
|
||||
list_agents,
|
||||
)
|
||||
|
||||
# The turn path (create / stream / cancel / tools / messages) is served by the
|
||||
# worldtree-sdk adapter (`wt.*`), which raises ratatoskr's caller-semantic
|
||||
# exceptions (DEC-2). The hand-rolled endpoints (persona / agents / admin /
|
||||
# bifrost) stay on the `sessions` / `sse_client` wrappers until their own slices.
|
||||
# The turn path (create / stream / cancel / tools / messages) AND the agents /
|
||||
# persona-state reads are served by the worldtree-sdk adapter (`wt.*`), which raises
|
||||
# ratatoskr's caller-semantic exceptions (DEC-2). The remaining hand-rolled endpoint
|
||||
# (admin bifrost) stays on the `sessions` / `sse_client` wrappers until slice 6.
|
||||
from ratatoskr.sse_client import (
|
||||
AdminEvent,
|
||||
CancelAlreadyCompleted,
|
||||
@@ -124,18 +128,22 @@ async def _agents_endpoint(request: Request) -> JSONResponse:
|
||||
client_factory = request.app.state.client_factory
|
||||
try:
|
||||
async with client_factory() as client:
|
||||
upstream = await list_agents(client)
|
||||
except SessionApiFailed as exc:
|
||||
upstream = await wt.list_agents(_wt_client(client))
|
||||
except wt.SessionApiFailed as exc:
|
||||
return JSONResponse(
|
||||
{"error_code": "session_api_failed", "status": exc.status},
|
||||
status_code=exc.status,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
except (httpx.RequestError, ConnectFailed) as exc:
|
||||
# The SDK normalizes a transport failure to ConnectFailed(status=0), not a
|
||||
# raw httpx error; both surface the same network envelope (slice-3 foot-gun).
|
||||
return JSONResponse(
|
||||
{"error_code": "network_error", "message": str(exc)},
|
||||
status_code=502,
|
||||
)
|
||||
upstream_ids = {a.agent_id for a in upstream}
|
||||
# Open-world upstream dicts (parity: no AgentInfo normalization); read `agent_id`
|
||||
# as a mapping key. Local tier3 entries dedup against the upstream ids (remote-wins).
|
||||
upstream_ids = {a["agent_id"] for a in upstream}
|
||||
local = _local_agents.load_local_agents()
|
||||
merged = [_as_dict(a) for a in upstream] + [
|
||||
_as_dict(le) for le in local if le.agent_id not in upstream_ids
|
||||
@@ -433,13 +441,19 @@ async def _persona_state_endpoint(request: Request) -> JSONResponse:
|
||||
client_factory = request.app.state.client_factory
|
||||
try:
|
||||
async with client_factory() as client:
|
||||
snap = await get_persona_state(client, agent_id)
|
||||
snap = await wt.get_persona_state(_wt_client(client), agent_id)
|
||||
except PersonaNotConfigured:
|
||||
return JSONResponse({"error_code": "persona_not_configured"}, status_code=404)
|
||||
except AgentNotAvailable:
|
||||
return JSONResponse({"error_code": "agent_not_available"}, status_code=404)
|
||||
except AuthScopeDenied:
|
||||
return JSONResponse({"error_code": "auth_scope_denied"}, status_code=403)
|
||||
except (httpx.RequestError, ConnectFailed) as exc:
|
||||
# SDK normalizes a transport failure to ConnectFailed(status=0) (slice-3
|
||||
# foot-gun); surface the network envelope rather than a 500 crash.
|
||||
return JSONResponse(
|
||||
{"error_code": "network_error", "message": str(exc)}, status_code=502
|
||||
)
|
||||
return JSONResponse(snap, status_code=200)
|
||||
|
||||
|
||||
|
||||
+157
-1
@@ -29,7 +29,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -40,13 +41,23 @@ from worldtree_sdk import ApiError, AuthProvider, CancelResult, PadState, Worldt
|
||||
# type still live in the retiring `sessions` / `sse_client` modules; they relocate
|
||||
# into this adapter as their call-sites are rewired in later slices. wt →
|
||||
# sessions / sse_client is one-way (neither imports wt), so there is no cycle.
|
||||
from .sessions import (
|
||||
AgentNotAvailable as PersonaAgentNotAvailable,
|
||||
)
|
||||
from .sessions import (
|
||||
AgentNotFound,
|
||||
AuthoredHistoryUnavailable,
|
||||
AuthScopeDenied,
|
||||
BifrostBinding,
|
||||
BifrostConsumerKeyMissing,
|
||||
BifrostHandshakeFailed,
|
||||
InvalidCursor,
|
||||
PersonaNotConfigured,
|
||||
Tier3AgentNotFound,
|
||||
Tier3FieldNotMutable,
|
||||
Tier3LayerDeferred,
|
||||
Tier3QuotaExceeded,
|
||||
Tier3UserIdUnsupported,
|
||||
)
|
||||
from .sse_client import (
|
||||
AgentNotAvailable,
|
||||
@@ -417,3 +428,148 @@ async def write_authored_history(
|
||||
if exc.status == 404:
|
||||
raise AuthoredHistoryUnavailable(session_id=session_id) from exc
|
||||
raise translate_error(exc) from exc
|
||||
|
||||
|
||||
# ── slice-4: agents (Tier-3) adapter routes ──────────────────────────────────
|
||||
# Ratatoskr-semantic surfaces over `WorldtreeClient.agents.*` (list/persona_state/
|
||||
# define/patch/delete). The SDK returns open-world dicts (parity posture — read as
|
||||
# mappings, never normalized into a frozen dataclass) and surfaces the tier3/persona
|
||||
# failure modes on its undiscriminated `ApiError` floor; the adapter maps them by the
|
||||
# ROUTE + (status, error_code) per the § Error map (INV-CUT-2). The `model`→`role`
|
||||
# cutover folds in here: the define/patch responses echo `role` (spec 1.2 / b128), so
|
||||
# callers read `info["role"]` off the open-world dict — no `Tier3AgentInfo` survives.
|
||||
|
||||
# Consumer-agent slug (spec §2627): agent_name is `[a-z][a-z0-9-]{2,63}`.
|
||||
_AGENT_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
|
||||
|
||||
|
||||
def _error_field_from_body(body: str | None) -> str | None:
|
||||
"""Pull the envelope's `field` from an SDK `ApiError` body string.
|
||||
|
||||
The SDK's `ApiError` carries the parsed `error_code` but NOT the envelope's
|
||||
`field`, so the field-bearing tier3 rejections (`layer_deferred`,
|
||||
`field_not_mutable`) body-parse it here — same both-shape unwrap as
|
||||
`_bifrost_error_from_body`, tolerant of `{"detail": {"field": …}}` and a flat
|
||||
top-level `field`. Returns None on any parse failure (the exception still carries
|
||||
a None field, exactly as the hand-rolled path did on shape mismatch).
|
||||
"""
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
err = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(err, dict):
|
||||
return None
|
||||
field = err.get("field")
|
||||
if field is None and isinstance(err.get("detail"), dict):
|
||||
field = err["detail"].get("field")
|
||||
return field
|
||||
|
||||
|
||||
async def list_agents(client: WorldtreeClient) -> Sequence[Mapping[str, Any]]:
|
||||
"""List the caller's agents (GET /agents) as the SDK's open-world array, verbatim
|
||||
(parity: each item read as a mapping, tolerant of wire drift — no `AgentInfo`
|
||||
normalization). Any error → the `SessionApiFailed` default."""
|
||||
try:
|
||||
return await client.agents.list()
|
||||
except ApiError as exc:
|
||||
raise translate_error(exc) from exc
|
||||
|
||||
|
||||
async def get_persona_state(client: WorldtreeClient, agent_id: str) -> Mapping[str, Any]:
|
||||
"""Fetch an agent's persona snapshot (GET /agents/{id}/persona_state, WT #204),
|
||||
open-world dict verbatim. Error map (INV-CUT-2 — dual-key status+error_code): 404
|
||||
`persona_not_configured` → `PersonaNotConfigured`; 404 `agent_not_available` →
|
||||
`AgentNotAvailable` (the persona-surface variant); 403 `auth_scope_denied` →
|
||||
`AuthScopeDenied`; every other error → the `SessionApiFailed` default. A 404 with
|
||||
an unrecognized code stays generic — the error_code is the discriminator, never a
|
||||
bare 404→hidden (this route is NOT hide-existence)."""
|
||||
assert agent_id and isinstance(agent_id, str)
|
||||
try:
|
||||
return await client.agents.persona_state(agent_id)
|
||||
except ApiError as exc:
|
||||
if exc.status == 404 and exc.error_code == "persona_not_configured":
|
||||
raise PersonaNotConfigured(agent_id=agent_id) from exc
|
||||
if exc.status == 404 and exc.error_code == "agent_not_available":
|
||||
raise PersonaAgentNotAvailable(agent_id=agent_id) from exc
|
||||
if exc.status == 403 and exc.error_code == "auth_scope_denied":
|
||||
raise AuthScopeDenied(scope="persona.read") from exc
|
||||
raise translate_error(exc) from exc
|
||||
|
||||
|
||||
async def define_agent(
|
||||
client: WorldtreeClient, *, agent_name: str, system_prompt: str, role: str
|
||||
) -> Mapping[str, Any]:
|
||||
"""Define a Tier-3 consumer agent (POST /agents/define). Sends the
|
||||
`AgentDefineInput` body `{agent_name, role, system_prompt}` and returns the SDK's
|
||||
open-world `DefinedAgent` dict verbatim (echoes `role` post-b128 — read
|
||||
`info["role"]`, no `Tier3AgentInfo`). The slug is validated client-side pre-HTTP
|
||||
(server-side is the safety net). Error map (INV-CUT-2): 429 →
|
||||
`Tier3QuotaExceeded(retry_after=0)` — the SDK's `ApiError` floor drops the
|
||||
`Retry-After` header, and §2675 pins Phase-2.0 quota to 0; 403
|
||||
`tier3_user_id_unsupported` → `Tier3UserIdUnsupported`; 422 `layer_deferred` →
|
||||
`Tier3LayerDeferred(field)`; else the `SessionApiFailed` default."""
|
||||
assert _AGENT_SLUG_RE.match(agent_name), (
|
||||
f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
|
||||
)
|
||||
assert system_prompt and isinstance(system_prompt, str)
|
||||
assert role and isinstance(role, str)
|
||||
try:
|
||||
# Inline literal so it type-checks structurally against the SDK's
|
||||
# AgentDefineInput TypedDict (no import of the SDK's private `_types`).
|
||||
return await client.agents.define(
|
||||
{"agent_name": agent_name, "role": role, "system_prompt": system_prompt}
|
||||
)
|
||||
except ApiError as exc:
|
||||
if exc.status == 429:
|
||||
raise Tier3QuotaExceeded(retry_after=0) from exc
|
||||
if exc.status == 403 and exc.error_code == "tier3_user_id_unsupported":
|
||||
raise Tier3UserIdUnsupported() from exc
|
||||
if exc.status == 422 and exc.error_code == "layer_deferred":
|
||||
raise Tier3LayerDeferred(field=_error_field_from_body(exc.body)) from exc
|
||||
raise translate_error(exc) from exc
|
||||
|
||||
|
||||
async def patch_agent(
|
||||
client: WorldtreeClient,
|
||||
agent_id: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
role: str | None = None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Mutate a Tier-3 agent (PATCH /agents/{id}). At least one of `system_prompt` /
|
||||
`role` is required; the None-valued field is omitted from the body. Returns the
|
||||
open-world `PatchedAgent` dict verbatim. Error map (INV-CUT-2): 404 →
|
||||
`Tier3AgentNotFound` (agents CRUD is NOT hide-existence); 422 `field_not_mutable`
|
||||
→ `Tier3FieldNotMutable(field)`; else the `SessionApiFailed` default."""
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
assert system_prompt is not None or role is not None, (
|
||||
"patch requires at least one of system_prompt or role"
|
||||
)
|
||||
changes: dict[str, Any] = {}
|
||||
if system_prompt is not None:
|
||||
changes["system_prompt"] = system_prompt
|
||||
if role is not None:
|
||||
changes["role"] = role
|
||||
try:
|
||||
return await client.agents.patch(agent_id, changes)
|
||||
except ApiError as exc:
|
||||
if exc.status == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id) from exc
|
||||
if exc.status == 422 and exc.error_code == "field_not_mutable":
|
||||
raise Tier3FieldNotMutable(field=_error_field_from_body(exc.body)) from exc
|
||||
raise translate_error(exc) from exc
|
||||
|
||||
|
||||
async def delete_agent(client: WorldtreeClient, agent_id: str) -> None:
|
||||
"""Hard-delete a Tier-3 agent (DELETE /agents/{id}); resolves on 204 → None. Error
|
||||
map (INV-CUT-2): 404 → `Tier3AgentNotFound` (route-discriminated; NOT
|
||||
hide-existence); else the `SessionApiFailed` default."""
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
try:
|
||||
await client.agents.delete(agent_id)
|
||||
except ApiError as exc:
|
||||
if exc.status == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id) from exc
|
||||
raise translate_error(exc) from exc
|
||||
|
||||
+10
-10
@@ -33,14 +33,14 @@ def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
def _entry(
|
||||
agent_id: str = "ratatoskr:wizard",
|
||||
agent_name: str = "wizard",
|
||||
model: str = "qwen3.6-35-a3b",
|
||||
role: str = "qwen3.6-35-a3b",
|
||||
description: str = "(tier 3) test agent",
|
||||
defined_at: str = "2026-05-25T00:00:00+00:00",
|
||||
) -> LocalAgentEntry:
|
||||
return LocalAgentEntry(
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
model=model,
|
||||
role=role,
|
||||
description=description,
|
||||
defined_at=defined_at,
|
||||
)
|
||||
@@ -91,13 +91,13 @@ class TestLoadEmpty:
|
||||
local_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"agents": [
|
||||
{"agent_id": "incomplete"}, # missing required fields
|
||||
{
|
||||
"agent_id": "ratatoskr:good",
|
||||
"agent_name": "good",
|
||||
"model": "m",
|
||||
"role": "m",
|
||||
"description": "d",
|
||||
"defined_at": "t",
|
||||
},
|
||||
@@ -124,11 +124,11 @@ class TestAdd:
|
||||
assert ids == {"ratatoskr:a", "ratatoskr:b"}
|
||||
|
||||
def test_add_replaces_same_id(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry(model="old-model"))
|
||||
add_local_agent(_entry(model="new-model"))
|
||||
add_local_agent(_entry(role="old-role"))
|
||||
add_local_agent(_entry(role="new-role"))
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].model == "new-model"
|
||||
assert entries[0].role == "new-role"
|
||||
|
||||
def test_creates_parent_dirs(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -141,11 +141,11 @@ class TestAdd:
|
||||
|
||||
class TestUpdate:
|
||||
def test_update_changes_existing(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry(model="v1"))
|
||||
update_local_agent(_entry(model="v2"))
|
||||
add_local_agent(_entry(role="v1"))
|
||||
update_local_agent(_entry(role="v2"))
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].model == "v2"
|
||||
assert entries[0].role == "v2"
|
||||
|
||||
|
||||
class TestRemove:
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
|
||||
+65
-319
@@ -1,4 +1,14 @@
|
||||
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
|
||||
"""CLI integration tests for `python -m ratatoskr.tier3`.
|
||||
|
||||
The define/patch/delete wire calls route through the worldtree-sdk adapter
|
||||
(`ratatoskr.wt`); the adapter's body-building + error-mapping are unit-tested in
|
||||
`test_wt.py` (against a fake `client.agents`). These tests exercise the CLI
|
||||
end-to-end — argv → the real SDK over a respx-mocked HTTP layer → exit code +
|
||||
stdout + the local tier3 index side effects.
|
||||
|
||||
The define/patch response echoes `role` (Worldtree spec 1.2 / b128), read off the
|
||||
SDK's open-world dict; `LocalAgentEntry.role` is the v2-schema field.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -6,317 +16,20 @@ import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
from ratatoskr.tier3 import (
|
||||
Tier3AgentInfo,
|
||||
Tier3AgentNotFound,
|
||||
Tier3FieldNotMutable,
|
||||
Tier3LayerDeferred,
|
||||
Tier3QuotaExceeded,
|
||||
Tier3UserIdUnsupported,
|
||||
define_agent,
|
||||
delete_agent,
|
||||
main,
|
||||
patch_agent,
|
||||
)
|
||||
from ratatoskr.tier3 import main
|
||||
|
||||
# The consumer-agent response echoes `role` (b128), not the former `model`.
|
||||
_FULL_AGENT_RESP = {
|
||||
"agent_id": "ratatoskr:wizard",
|
||||
"user_id": "ratatoskr",
|
||||
"agent_name": "wizard",
|
||||
"system_prompt": "You are a wizard.",
|
||||
"model": "qwen3.6-35-a3b",
|
||||
"role": "qwen3.6-35-a3b",
|
||||
"created_at": "2026-05-25T03:20:09.703601+00:00",
|
||||
"updated_at": "2026-05-25T03:20:09.703601+00:00",
|
||||
}
|
||||
|
||||
|
||||
class TestDefineAgent:
|
||||
@respx.mock
|
||||
async def test_happy_define(self) -> None:
|
||||
"""happy_define [happy,tracer]: 201 → fully populated Tier3AgentInfo."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
info = await define_agent(
|
||||
client,
|
||||
agent_name="wizard",
|
||||
system_prompt="You are a wizard.",
|
||||
role="qwen3.6-35-a3b",
|
||||
)
|
||||
assert isinstance(info, Tier3AgentInfo)
|
||||
assert info.agent_id == "ratatoskr:wizard"
|
||||
assert info.user_id == "ratatoskr"
|
||||
assert info.agent_name == "wizard"
|
||||
assert info.model == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
async def test_request_body_shape(self) -> None:
|
||||
"""request_body_shape [trace]: outbound JSON is exactly the three keys."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await define_agent(
|
||||
client,
|
||||
agent_name="wizard",
|
||||
system_prompt="You are a wizard.",
|
||||
role="qwen3.6-35-a3b",
|
||||
)
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
# INV-001: exactly these three keys — no layer fields, no metadata.
|
||||
assert body == {
|
||||
"agent_name": "wizard",
|
||||
"system_prompt": "You are a wizard.",
|
||||
"role": "qwen3.6-35-a3b",
|
||||
}
|
||||
|
||||
@respx.mock
|
||||
async def test_quota_exceeded(self) -> None:
|
||||
"""quota_exceeded [error]: 429 + Retry-After → Tier3QuotaExceeded."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(
|
||||
429,
|
||||
headers={"Retry-After": "0"},
|
||||
json={"detail": {"error_code": "agent_quota_exceeded"}},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3QuotaExceeded) as exc:
|
||||
await define_agent(
|
||||
client,
|
||||
agent_name="overflow",
|
||||
system_prompt="x",
|
||||
role="m",
|
||||
)
|
||||
assert exc.value.retry_after == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_user_id_unsupported(self) -> None:
|
||||
"""user_id_unsupported [error]: 403 + error_code → Tier3UserIdUnsupported."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(
|
||||
403, json={"detail": {"error_code": "tier3_user_id_unsupported"}}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3UserIdUnsupported):
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="x", role="m"
|
||||
)
|
||||
|
||||
@respx.mock
|
||||
async def test_layer_deferred(self) -> None:
|
||||
"""layer_deferred [error]: 422 + layer_deferred → Tier3LayerDeferred(field)."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(
|
||||
422,
|
||||
json={"detail": {"error_code": "layer_deferred", "field": "persona"}},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3LayerDeferred) as exc:
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="x", role="m"
|
||||
)
|
||||
assert exc.value.field == "persona"
|
||||
|
||||
@respx.mock
|
||||
async def test_bad_slug_assert(self) -> None:
|
||||
"""bad_slug_assert [adversarial]: agent_name with uppercase → AssertionError, no HTTP."""
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(
|
||||
client, agent_name="Wizard", system_prompt="x", role="m"
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_short_slug_assert(self) -> None:
|
||||
"""short_slug_assert [adversarial]: agent_name len < 3 → AssertionError."""
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(
|
||||
client, agent_name="ab", system_prompt="x", role="m"
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_prompt_assert(self) -> None:
|
||||
"""empty_prompt_assert [adversarial]: empty system_prompt → AssertionError."""
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="", role="m"
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_other_5xx(self) -> None:
|
||||
"""other_5xx [error]: 503 → SessionApiFailed(status=503)."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(503, content=b"upstream out")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="x", role="m"
|
||||
)
|
||||
assert exc.value.status == 503
|
||||
|
||||
|
||||
class TestPatchAgent:
|
||||
@respx.mock
|
||||
async def test_happy_patch_both_fields(self) -> None:
|
||||
"""happy_patch_both_fields: both fields set → request body has both."""
|
||||
import json as _json
|
||||
|
||||
updated = {
|
||||
**_FULL_AGENT_RESP,
|
||||
"system_prompt": "new prompt",
|
||||
"model": "different-model",
|
||||
}
|
||||
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(200, json=updated)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
info = await patch_agent(
|
||||
client,
|
||||
"ratatoskr:wizard",
|
||||
system_prompt="new prompt",
|
||||
role="different-model",
|
||||
)
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body == {"system_prompt": "new prompt", "role": "different-model"}
|
||||
assert info.system_prompt == "new prompt"
|
||||
assert info.model == "different-model"
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_patch_single_field(self) -> None:
|
||||
"""happy_patch_single_field: omit role → body has system_prompt only."""
|
||||
import json as _json
|
||||
|
||||
updated = {**_FULL_AGENT_RESP, "system_prompt": "only this"}
|
||||
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(200, json=updated)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await patch_agent(client, "ratatoskr:wizard", system_prompt="only this")
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
# INV-002: body omits the None-valued field entirely
|
||||
assert body == {"system_prompt": "only this"}
|
||||
|
||||
@respx.mock
|
||||
async def test_field_not_mutable(self) -> None:
|
||||
"""field_not_mutable [error]: 422 + error_code → Tier3FieldNotMutable(field)."""
|
||||
respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(
|
||||
422,
|
||||
json={
|
||||
"detail": {"error_code": "field_not_mutable", "field": "agent_name"}
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3FieldNotMutable) as exc:
|
||||
await patch_agent(
|
||||
client, "ratatoskr:wizard", system_prompt="x"
|
||||
)
|
||||
assert exc.value.field == "agent_name"
|
||||
|
||||
@respx.mock
|
||||
async def test_404(self) -> None:
|
||||
"""404 [error]: PATCH on non-existent agent → Tier3AgentNotFound."""
|
||||
respx.patch("https://w.example/agents/ratatoskr:ghost").mock(
|
||||
return_value=httpx.Response(404, content=b"")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3AgentNotFound) as exc:
|
||||
await patch_agent(
|
||||
client, "ratatoskr:ghost", system_prompt="x"
|
||||
)
|
||||
assert exc.value.agent_id == "ratatoskr:ghost"
|
||||
|
||||
@respx.mock
|
||||
async def test_no_fields_assert(self) -> None:
|
||||
"""no_fields_assert [adversarial]: both None → AssertionError, no HTTP."""
|
||||
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await patch_agent(client, "ratatoskr:wizard")
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_non_tier3_id_assert(self) -> None:
|
||||
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
|
||||
route = respx.patch("https://w.example/agents/mimir").mock(
|
||||
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await patch_agent(client, "mimir", system_prompt="x")
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
class TestDeleteAgent:
|
||||
@respx.mock
|
||||
async def test_happy_delete(self) -> None:
|
||||
"""happy_delete [happy,tracer]: 204 → returns None."""
|
||||
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await delete_agent(client, "ratatoskr:wizard")
|
||||
assert result is None
|
||||
|
||||
@respx.mock
|
||||
async def test_404(self) -> None:
|
||||
"""404 [error]: DELETE on non-existent agent → Tier3AgentNotFound."""
|
||||
respx.delete("https://w.example/agents/ratatoskr:ghost").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3AgentNotFound) as exc:
|
||||
await delete_agent(client, "ratatoskr:ghost")
|
||||
assert exc.value.agent_id == "ratatoskr:ghost"
|
||||
|
||||
@respx.mock
|
||||
async def test_non_tier3_id_assert(self) -> None:
|
||||
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
|
||||
route = respx.delete("https://w.example/agents/mimir").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await delete_agent(client, "mimir")
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_other_5xx(self) -> None:
|
||||
"""other_5xx [error]: 500 → SessionApiFailed."""
|
||||
respx.delete("https://w.example/agents/ratatoskr:wizard").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 exc:
|
||||
await delete_agent(client, "ratatoskr:wizard")
|
||||
assert exc.value.status == 500
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _isolated_local_agents(
|
||||
tmp_path: "Path", monkeypatch: pytest.MonkeyPatch
|
||||
@@ -335,8 +48,8 @@ class TestCli:
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolated_local_agents: "Path",
|
||||
) -> None:
|
||||
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation;
|
||||
local index updated with the new entry (v0.8.0 hook).
|
||||
"""cli_define_happy [happy,tracer]: argv → 201 mock → stdout confirmation;
|
||||
local index updated with the new entry (role echoed).
|
||||
"""
|
||||
from ratatoskr.local_agents import load_local_agents
|
||||
|
||||
@@ -354,11 +67,34 @@ class TestCli:
|
||||
out = capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert out.out.strip() == "defined ratatoskr:wizard (qwen3.6-35-a3b)"
|
||||
# v0.8.0: local index now has the new entry.
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
assert entries[0].model == "qwen3.6-35-a3b"
|
||||
assert entries[0].role == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_define_body_shape(
|
||||
self, monkeypatch: pytest.MonkeyPatch, _isolated_local_agents: "Path"
|
||||
) -> None:
|
||||
"""cli_define_body_shape [trace]: outbound JSON is exactly the three keys
|
||||
(the adapter sends AgentDefineInput, no layer fields)."""
|
||||
import json as _json
|
||||
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
main([
|
||||
"define", "--name", "wizard",
|
||||
"--system-prompt", "You are a wizard.", "--role", "qwen3.6-35-a3b",
|
||||
])
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body == {
|
||||
"agent_name": "wizard",
|
||||
"role": "qwen3.6-35-a3b",
|
||||
"system_prompt": "You are a wizard.",
|
||||
}
|
||||
|
||||
@respx.mock
|
||||
def test_cli_patch_happy(
|
||||
@@ -384,6 +120,7 @@ class TestCli:
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
assert entries[0].role == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_delete_happy(
|
||||
@@ -401,11 +138,11 @@ class TestCli:
|
||||
load_local_agents,
|
||||
)
|
||||
|
||||
# Pre-populate so we can verify removal.
|
||||
# Pre-populate so we can verify removal (v2 schema: role, not model).
|
||||
add_local_agent(LocalAgentEntry(
|
||||
agent_id="ratatoskr:wizard",
|
||||
agent_name="wizard",
|
||||
model="m",
|
||||
role="m",
|
||||
description="d",
|
||||
defined_at="t",
|
||||
))
|
||||
@@ -426,10 +163,7 @@ class TestCli:
|
||||
"""cli_missing_auth [error]: no api-key → stderr [auth_error] + exit 11."""
|
||||
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--role", "m",
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 11
|
||||
@@ -446,10 +180,7 @@ class TestCli:
|
||||
return_value=httpx.Response(500, content=b"upstream out")
|
||||
)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--role", "m",
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 20
|
||||
@@ -470,15 +201,30 @@ class TestCli:
|
||||
)
|
||||
)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--role", "m",
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 20
|
||||
assert "[quota_exceeded]" in err
|
||||
|
||||
@respx.mock
|
||||
def test_cli_network_error(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""cli_network_error [error]: a transport failure surfaces as the SDK's
|
||||
ConnectFailed (not a raw httpx error) → stderr [network_error] + exit 21."""
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
side_effect=httpx.ConnectError("refused")
|
||||
)
|
||||
rc = main([
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 21
|
||||
assert "[network_error]" in err
|
||||
|
||||
def test_cli_patch_no_fields(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
@@ -66,7 +66,7 @@ class TestAgentsEndpoint:
|
||||
LocalAgentEntry(
|
||||
agent_id="ratatoskr:sindra",
|
||||
agent_name="sindra",
|
||||
model="artemis-31b-v1i",
|
||||
role="artemis-31b-v1i",
|
||||
description="(tier 3) IDENTITY",
|
||||
defined_at="2026-05-28T00:00:00+00:00",
|
||||
)
|
||||
@@ -118,7 +118,7 @@ class TestAgentsEndpoint:
|
||||
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
|
||||
add_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id="ratatoskr:sindra", agent_name="sindra", model="m",
|
||||
agent_id="ratatoskr:sindra", agent_name="sindra", role="m",
|
||||
description="local-tier3", defined_at="2026-05-28T00:00:00+00:00",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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