c086ae2b32
Issue #15. Worldtree Phase 2.0 ships Tier 3 (consumer-defined) agents at `<user_id>:<agent_name>`; ratatoskr now exposes their lifecycle via a dedicated module + CLI tool. The picker handles the colon-containing agent_id generically (per issue #8 out-of- scope clause); session creation works unchanged. What was missing was a way to DEFINE / PATCH / DELETE these agents from ratatoskr itself — operators previously had to curl the API directly. ## Public surface (ratatoskr.tier3) Tier3AgentInfo (frozen dataclass) define_agent (client, *, agent_name, system_prompt, model) → Info patch_agent (client, agent_id, *, system_prompt?, model?) → Info delete_agent (client, agent_id) → None Tier3QuotaExceeded — 429 agent_quota_exceeded (50-agent cap) Tier3UserIdUnsupported — 403 tier3_user_id_unsupported Tier3FieldNotMutable — 422 field_not_mutable (PATCH) Tier3LayerDeferred — 422 layer_deferred (define, defense-only) Tier3AgentNotFound — 404 SessionApiFailed (reused) — all other non-2xx Caller-owned httpx.AsyncClient posture (same as ratatoskr.sessions). Module is standalone — does NOT import sessions/sse_client/tui/cli beyond reusing the USER_AGENT constant from cli. ## CLI (python -m ratatoskr.tier3 <subcommand>) define --name <slug> --system-prompt <str> --model <id> patch <agent_id> [--system-prompt <str>] [--model <id>] delete <agent_id> Auth resolution mirrors ratatoskr.cli verbatim — --api-key flag > $WORLDTREE_API_KEY > exit 11. Server URL via --server > $WORLDTREE_API_URL > http://localhost:8000. Exit codes follow the cli.py matrix: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21 (network). ## Real-world finding from live smoke Tier-3 agents do NOT appear in `GET /agents` — the public list filters them out. The picker won't surface tier-3 agents; operators bypass it via `ratatoskr --send "..." --new --agent ratatoskr:<n>` directly. This contradicts the contract's acceptance assumption ("the new tier-3 agent should appear in the list") — caught at smoke time. The picker integration was hopeful; the real shape is "you know your tier-3 agent_id because you defined it." Adding a ratatoskr-side `tier3 list` subcommand would need a Worldtree endpoint that doesn't exist today; surfacing to worldtree-dev as a followup. ## Live lifecycle smoke (personal Worldtree v0.16.2) $ python -m ratatoskr.tier3 define --name smoke-tier3 \ --system-prompt "..." --model qwen3.6-35-a3b → defined ratatoskr:smoke-tier3 (qwen3.6-35-a3b) $ ratatoskr --send "hello via tier-3" --new --agent ratatoskr:smoke-tier3 → [done] turn_id=286 model=qwen3.6-35-a3b duration=14.2s usage 44 in → 390 out (434 total, 0 cached) $ python -m ratatoskr.tier3 delete ratatoskr:smoke-tier3 → deleted ratatoskr:smoke-tier3 $ python -m ratatoskr.tier3 delete ratatoskr:smoke-tier3 → [agent_not_found] ratatoskr:smoke-tier3 (exit 20) The colon-containing agent_id flowed transparently through ratatoskr.sessions.create_session, the SSE stream's text + worker_phase + done events all rendered correctly, and the ratatoskr.sessions module needed zero changes. ## Contract docs/contracts/issues/15.contract.md — new module spec; drift-check clean. Acceptance criterion about "appears in GET /agents" should be amended in a follow-up to reflect the empirical finding. ## Tests +26 tests (264 total GREEN, was 238). Covers all error paths via respx mocking — quota, user_id, layer_deferred, field_not_mutable, 404, 5xx — plus CLI happy + error paths. ruff clean. Minor bump (v0.6.5 → v0.7.0) per SemVer etiquette: new public module + CLI surface; new caller-visible behavior.
291 lines
19 KiB
Markdown
291 lines
19 KiB
Markdown
---
|
|
contract_version: "2.1"
|
|
target_module: "ratatoskr.tier3"
|
|
scope: "New module `ratatoskr.tier3` exposing Worldtree's Tier 3 (consumer-defined) agent lifecycle: `define_agent` (POST /agents/define), `patch_agent` (PATCH /agents/<id>), `delete_agent` (DELETE /agents/<id>), plus `Tier3AgentInfo` frozen dataclass. Plus a thin CLI entry point (`python -m ratatoskr.tier3 <define|patch|delete>`) that mirrors `ratatoskr.cli`'s env-var posture (`WORLDTREE_API_URL`, `WORLDTREE_API_KEY`). Convention-aligned with `ratatoskr.sessions` (issue #2): caller-owned httpx.AsyncClient, no Worldtree imports, response parsing into frozen dataclass, exception `.body` truncated to `[:1024]`. Picker stays generic — agents with `:` in agent_id show in the list like any other per issue #8's out-of-scope clause. Goal: ratatoskr operators can define, mutate, and delete Tier 3 agents from the command line, then exercise the full session flow against them to observe how Tier 3 agent_ids (colon-containing) flow through the picker / session-create / SSE stream."
|
|
depends_on:
|
|
- "httpx"
|
|
used_by: []
|
|
language: "python"
|
|
complexity: "low"
|
|
estimated_loc: 250
|
|
confidence: 0.9
|
|
assumptions:
|
|
- "Tier 3 endpoints land at the same `WORLDTREE_API_URL` as the rest of the Conversation API — no separate hostname / port. Auth via the same bearer key. The caller's user_id is derived server-side from the API key's owner; the agent's `agent_id` is constructed as `<auth_user_id>:<agent_name>`. Live probe against personal Worldtree (2026-05-25) confirmed: POST with `{agent_name: 'smoke-test', ...}` and `Authorization: Bearer <key>` returned `agent_id=ratatoskr:smoke-test`, `user_id=ratatoskr`."
|
|
- "Per Worldtree spec §2576-2750: `agent_name` is a strict slug `[a-z][a-z0-9-]{2,63}` and immutable after definition. `user_id` is derived from the auth, must be slug-safe (`[a-z][a-z0-9-]{2,63}` per Phase 2.0 gate). PATCH accepts ONLY `system_prompt` and/or `model`; any other key (including the immutable `agent_name`, `user_id`, or layer fields `persona`/`motivational`/`valence`/`memory` — even with `null` value) returns 422 `field_not_mutable` BEFORE the DB lookup."
|
|
- "**Layer fields are explicitly null** on define. Phase 2.0 ships baseline addressing + ownership + lifecycle only; `persona` / `motivational` / `valence` / `memory` are schema-reserved. Non-null on these → 422 `layer_deferred`. The module's `define_agent` does NOT expose these as parameters at all — sending them would require an amendment when a future Phase enables them."
|
|
- "**`model` field is a provider model ID, not a profile alias.** Live probe found: `model='default'` (an llm_profiles profile name) returns 422 `model_not_available`; `model='qwen3.6-35-a3b'` (an actual provider model ID) returns 201. The CLI / module take the string verbatim and pass through — validation is server-side. Operators discover valid IDs via the model `metadata` on existing sessions or out-of-band."
|
|
- "**Quota: 50 Tier 3 agents per Heimdall key.** 51st define → 429 `agent_quota_exceeded` with `Retry-After: 0`. The module raises `Tier3QuotaExceeded(retry_after=0)` — the retry_after field captures the header value verbatim for forward-compat if Worldtree later returns a non-zero throttle."
|
|
- "**Key-revocation cascade is server-side.** When an API key is revoked (`DELETE /admin/keys/{key_id}`), every Tier 3 agent with `owner_key_hash` equal to the revoked key's hash is soft-deleted in the same SQL transaction. Active sessions on those agents return 401 `auth_revoked` on next message. The ratatoskr module doesn't track or simulate this — operators discover it via runtime 401s and the admin-side audit log."
|
|
- "**Picker integration is implicit** — no changes to `ratatoskr.tui.AgentPickerApp` for this issue. Tier 3 agents appear in `GET /agents` if defined and the picker's existing format `{agent_id} · {name} — {description}` renders the colon-containing agent_id without special-casing. Per issue #8 out-of-scope clause, ratatoskr does not visually distinguish Tier 1 vs Tier 3 in the picker — same UX surface."
|
|
- "**Session-create with colon-containing agent_id works unchanged.** Issue #5 already routes `end_user_id` into the POST /sessions body, which Tier 3 session-create requires from Phase 2.0 (per spec §2649-2664). No `ratatoskr.sessions` change needed."
|
|
- "**CLI uses argparse with subparsers** (define / patch / delete). The subparsers entry point lives at `python -m ratatoskr.tier3` via `__main__.py`. Output on success: prints a one-line summary (`defined ratatoskr:wizard (qwen3.6-35-a3b)` / `patched ratatoskr:wizard` / `deleted ratatoskr:wizard`). Output on error: `[<error_code>] <message>` to stderr + non-zero exit. Exit codes mirror `ratatoskr.cli`: 0 happy / 10 usage / 11 auth / 20 api-failure / 21 network."
|
|
- "**No `list` subcommand in v1.** A `tier3 list` operation would have to filter `GET /agents` by prefix-matching the caller's user_id, but that prefix isn't exposed in the response — only the agent_id is, and you'd have to introspect the auth's user_id. Operators discover their own Tier 3 agents by reading the `GET /agents` list (which the picker already surfaces) and looking for `<their-user-id>:*` entries. Add `list` in a follow-up if operators report friction."
|
|
- "**Module is standalone**: does NOT import or interact with `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` / `ratatoskr.cli` beyond reusing the `USER_AGENT` constant from `ratatoskr.cli`. Cross-module use is one-way (cli supplies the user-agent string; tier3 does not import sessions). This keeps the module surface minimal and testable in isolation."
|
|
- "**The CLI's `python -m ratatoskr.tier3` entry point uses sys.argv handling that mirrors `ratatoskr.cli`** — a top-level `main(argv: list[str] | None = None) -> int` function that argparse-dispatches to subcommand handlers. Each subcommand handler is an async coroutine wrapped by `asyncio.run(...)`. Auth resolution: `--api-key` flag > `$WORLDTREE_API_KEY` env > `_AuthError` (exit 11). Server URL: `--server` > `$WORLDTREE_API_URL` > default `http://localhost:8000` (same default as `ratatoskr.cli`)."
|
|
- "**Tests use `respx` for HTTP mocking** (same pattern as `tests/test_sessions.py`). New test file: `tests/test_tier3.py`. Cover all success + error response codes per the ERROR_ROUTING matrix below. No live network in unit tests — the live smoke is in the acceptance criteria, not the unit tests."
|
|
open_questions:
|
|
- "Should `define_agent` accept an optional `bifrost` parameter for Bifrost-bound Tier 3 sessions? The spec §2658 shows `bifrost` as a session-create field (not define-time). Draft: no — Bifrost binding is per-session; if a Tier 3 agent needs Bifrost on every session, that's an orthogonal feature on POST /sessions, not POST /agents/define. Issue #5's `--end-user-id` already covers the session-create-side parameters."
|
|
- "Should the CLI also offer `--end-user-id` for sessions created via tier3 + ratatoskr-cli composition? Draft: no — once an agent is defined, operators use the main `ratatoskr --new --agent <id> --end-user-id <eid>` flow; tier3 CLI is define/patch/delete only."
|
|
- "Should `delete_agent` support a `--force` flag for 'really delete even if active sessions exist'? Per spec §2634-2639, `DELETE` already cancels active sessions and revokes the per-resource scope grant on the owner — there's no soft fail. Draft: no — the spec's hard-delete-with-cascade behavior is the right shape; ratatoskr doesn't need to wrap it."
|
|
prd:
|
|
issue: 15
|
|
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/15"
|
|
body_sha256_16: "03367d7b451ab17f"
|
|
lock_in_comment_id: null
|
|
lock_in_sha256_16: null
|
|
lock_in_at: null
|
|
pinned_at: "2026-05-25T03:21:38+00:00"
|
|
dependencies:
|
|
- issue: 2
|
|
path: "src/ratatoskr/sessions.py"
|
|
reason: "Convention dependency, not a code dependency. Issue #2 (`ratatoskr.sessions`) is the posture template: caller-owned httpx client, async-native, no Worldtree imports, response-parsing into frozen dataclasses, exception body truncation to [:1024]. `ratatoskr.tier3` follows the same shape verbatim."
|
|
- issue: 3
|
|
path: "src/ratatoskr/cli.py"
|
|
reason: "Convention dependency only. `ratatoskr.tier3.__main__` mirrors `ratatoskr.cli`'s argparse + env-fallback + exit-code shape. Imports `USER_AGENT` from `ratatoskr.cli` so outbound HTTP carries the same identity string."
|
|
---
|
|
|
|
# Tier 3 — Consumer-defined agent lifecycle module
|
|
|
|
## Context
|
|
|
|
Worldtree's Tier 3 (Phase 2.0, spec §2576-2750) lets the consumer define their own agents at `<user_id>:<agent_name>`. The agent's `user_id` is the auth's user identity (derived from the API key's owner); the `agent_name` is supplied at define-time. The lifecycle is owner-only — only the key that defined an agent can patch / delete it (modulo the key-revocation cascade).
|
|
|
|
`ratatoskr.tier3` exposes this lifecycle as a Python module + small CLI tool. Picker integration is implicit (Tier 3 agents already appear in `GET /agents` per issue #8). Session-create works unchanged through `ratatoskr.sessions.create_session` since the colon-containing agent_id is opaque to that layer.
|
|
|
|
## Public surface
|
|
|
|
```python
|
|
@dataclass(frozen=True)
|
|
class Tier3AgentInfo:
|
|
"""Worldtree Tier 3 agent envelope returned by define / patch."""
|
|
|
|
agent_id: str # f"{user_id}:{agent_name}"
|
|
user_id: str
|
|
agent_name: str
|
|
system_prompt: str
|
|
model: str
|
|
created_at: str # ISO 8601 with offset
|
|
updated_at: str # ISO 8601 with offset
|
|
|
|
|
|
async def define_agent(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
agent_name: str,
|
|
system_prompt: str,
|
|
model: str,
|
|
) -> Tier3AgentInfo:
|
|
"""POST /agents/define → 201 with Tier3AgentInfo. See FN define_agent."""
|
|
|
|
|
|
async def patch_agent(
|
|
client: httpx.AsyncClient,
|
|
agent_id: str,
|
|
*,
|
|
system_prompt: str | None = None,
|
|
model: str | None = None,
|
|
) -> Tier3AgentInfo:
|
|
"""PATCH /agents/<id> → 200 with updated Tier3AgentInfo. See FN patch_agent."""
|
|
|
|
|
|
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
|
|
"""DELETE /agents/<id> → 204. See FN delete_agent."""
|
|
```
|
|
|
|
## Exception classes
|
|
|
|
```python
|
|
class Tier3QuotaExceeded(Exception):
|
|
"""429 agent_quota_exceeded — 50-agent cap reached on the Heimdall key."""
|
|
def __init__(self, *, retry_after: int) -> None: ...
|
|
retry_after: int
|
|
|
|
class Tier3UserIdUnsupported(Exception):
|
|
"""403 tier3_user_id_unsupported — auth's user_id not slug-safe."""
|
|
|
|
class Tier3FieldNotMutable(Exception):
|
|
"""422 field_not_mutable — PATCH carrying an immutable key."""
|
|
def __init__(self, *, field: str | None) -> None: ...
|
|
field: str | None
|
|
|
|
class Tier3LayerDeferred(Exception):
|
|
"""422 layer_deferred — define carrying non-null layer field."""
|
|
def __init__(self, *, field: str | None) -> None: ...
|
|
field: str | None
|
|
|
|
class Tier3AgentNotFound(Exception):
|
|
"""404 — patch/delete on non-existent agent."""
|
|
def __init__(self, *, agent_id: str) -> None: ...
|
|
agent_id: str
|
|
|
|
# Reused from ratatoskr.sessions (one-way import — sessions doesn't depend on tier3):
|
|
# SessionApiFailed(status, body) for all other non-2xx responses.
|
|
```
|
|
|
|
## Functions
|
|
|
|
### FN define_agent
|
|
|
|
```
|
|
FN define_agent(
|
|
client: httpx.AsyncClient,
|
|
*, agent_name: str, system_prompt: str, model: str,
|
|
) -> Tier3AgentInfo
|
|
BRIEF: POST /agents/define → 201 with Tier3AgentInfo.
|
|
|
|
PRE-001: agent_name matches `[a-z][a-z0-9-]{2,63}` (slug guard — client-side
|
|
assert; the server enforces too, but this prevents wire round-trip
|
|
for trivially-bad input).
|
|
PRE-002: system_prompt is non-empty.
|
|
PRE-003: model is non-empty.
|
|
|
|
STEPS:
|
|
1. assert PRE-001/002/003.
|
|
2. body = {
|
|
"agent_name": agent_name,
|
|
"system_prompt": system_prompt,
|
|
"model": model,
|
|
}
|
|
3. resp = await client.post("/agents/define", json=body)
|
|
4. ROUTE response status:
|
|
201 → parse body into Tier3AgentInfo, return.
|
|
422 → inspect error_code:
|
|
layer_deferred → raise Tier3LayerDeferred(field=err.get("field"))
|
|
(others) → raise SessionApiFailed(status=422, body=resp.content)
|
|
403 + tier3_user_id_unsupported → raise Tier3UserIdUnsupported
|
|
429 → raise Tier3QuotaExceeded(retry_after=int(resp.headers.get("Retry-After", 0)))
|
|
other → raise SessionApiFailed(status, body)
|
|
|
|
POST-001: returned Tier3AgentInfo has agent_id of shape "<user_id>:<agent_name>".
|
|
```
|
|
|
|
### FN patch_agent
|
|
|
|
```
|
|
FN patch_agent(
|
|
client: httpx.AsyncClient, agent_id: str,
|
|
*, system_prompt: str | None = None, model: str | None = None,
|
|
) -> Tier3AgentInfo
|
|
BRIEF: PATCH /agents/<id> → 200 with updated Tier3AgentInfo.
|
|
|
|
PRE-001: agent_id contains `:` (Tier 3 shape).
|
|
PRE-002: at least one of system_prompt or model is non-None (no-op patches
|
|
are still server-accepted but client-side assert avoids the round-trip).
|
|
|
|
STEPS:
|
|
1. assert PRE-001/002.
|
|
2. body = {}; if system_prompt is not None: body["system_prompt"] = system_prompt;
|
|
if model is not None: body["model"] = model.
|
|
3. resp = await client.patch(f"/agents/{agent_id}", json=body)
|
|
4. ROUTE response status:
|
|
200 → parse, return.
|
|
404 → raise Tier3AgentNotFound(agent_id=agent_id)
|
|
422 + field_not_mutable → raise Tier3FieldNotMutable(field=err.get("field"))
|
|
other → raise SessionApiFailed(status, body)
|
|
```
|
|
|
|
### FN delete_agent
|
|
|
|
```
|
|
FN delete_agent(client: httpx.AsyncClient, agent_id: str) -> None
|
|
BRIEF: DELETE /agents/<id> → 204.
|
|
|
|
PRE-001: agent_id contains `:` (Tier 3 shape).
|
|
|
|
STEPS:
|
|
1. assert PRE-001.
|
|
2. resp = await client.delete(f"/agents/{agent_id}")
|
|
3. ROUTE response status:
|
|
204 → return None.
|
|
404 → raise Tier3AgentNotFound(agent_id=agent_id)
|
|
other → raise SessionApiFailed(status, body)
|
|
```
|
|
|
|
## CLI surface (`python -m ratatoskr.tier3`)
|
|
|
|
```
|
|
$ python -m ratatoskr.tier3 define --name wizard \
|
|
--system-prompt "You are a guided-elicitation wizard..." \
|
|
--model qwen3.6-35-a3b
|
|
defined ratatoskr:wizard (qwen3.6-35-a3b)
|
|
|
|
$ python -m ratatoskr.tier3 patch ratatoskr:wizard --system-prompt "New prompt"
|
|
patched ratatoskr:wizard
|
|
|
|
$ python -m ratatoskr.tier3 delete ratatoskr:wizard
|
|
deleted ratatoskr:wizard
|
|
```
|
|
|
|
Auth + server URL: same env-var fallback as `ratatoskr.cli`. Exit codes: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21 (network).
|
|
|
|
## Invariants
|
|
|
|
- **INV-001**: `define_agent` request body carries exactly `{agent_name, system_prompt, model}` — no layer fields, no `bifrost`, no `metadata`. Phase 2.0 baseline shape only.
|
|
- **INV-002**: `patch_agent` request body carries ONLY `system_prompt` and/or `model` — every other key is omitted. Server-side 422 `field_not_mutable` is the safety net; client-side body-construction is the first line.
|
|
- **INV-003**: `delete_agent` is fire-and-confirm — no body, no retry, no soft-delete. Cascade handling is server-side; ratatoskr doesn't track it.
|
|
- **INV-004**: All exceptions carry a `[:1024]` body cap (when applicable) per the issue #2 convention.
|
|
- **INV-005**: CLI auth resolution mirrors `ratatoskr.cli`: `--api-key` flag > `$WORLDTREE_API_KEY` > exit 11.
|
|
- **INV-006**: CLI server URL resolution mirrors `ratatoskr.cli`: `--server` > `$WORLDTREE_API_URL` > `http://localhost:8000`.
|
|
- **INV-007**: Module never imports `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` (one-way: only `cli.USER_AGENT` is imported, and only by `__main__.py` for the outbound User-Agent header).
|
|
- **INV-008**: All HTTP through caller-owned `httpx.AsyncClient` — module never constructs its own client. (`__main__` constructs one for the CLI entry point per ratatoskr.cli's pattern.)
|
|
|
|
## TESTS (tests/test_tier3.py — new file)
|
|
|
|
```
|
|
- test_define_happy: 201 + full response shape → Tier3AgentInfo populated.
|
|
- test_define_quota_exceeded: 429 + Retry-After header → Tier3QuotaExceeded(retry_after=N).
|
|
- test_define_user_id_unsupported: 403 tier3_user_id_unsupported → Tier3UserIdUnsupported.
|
|
- test_define_layer_deferred_persona: 422 layer_deferred → Tier3LayerDeferred (would only fire if the body sent a layer field; the module never sends one, so this asserts server-side defense but reflecting a 422 we don't actually generate. Test exercises the response path, not the request).
|
|
- test_define_bad_slug: PRE-001 assertion fires before HTTP for agent_name="X" (uppercase) or "ab" (too short).
|
|
- test_define_empty_prompt: PRE-002 assertion fires for empty system_prompt.
|
|
- test_define_other_5xx: 503 → SessionApiFailed(status=503).
|
|
- test_patch_happy_both_fields: 200 + updated body → Tier3AgentInfo.
|
|
- test_patch_happy_single_field: 200 with only system_prompt set; body omits model.
|
|
- test_patch_field_not_mutable: 422 field_not_mutable → Tier3FieldNotMutable.
|
|
- test_patch_404: 404 → Tier3AgentNotFound(agent_id=...).
|
|
- test_patch_no_args: PRE-002 assertion fires (both None).
|
|
- test_patch_non_tier3_id: PRE-001 assertion fires for agent_id without `:`.
|
|
- test_delete_happy: 204 → returns None.
|
|
- test_delete_404: 404 → Tier3AgentNotFound.
|
|
- test_delete_non_tier3_id: PRE-001 assertion fires.
|
|
- test_delete_other_5xx: 500 → SessionApiFailed.
|
|
- test_cli_define_happy: argv → 201 mock → stdout="defined ratatoskr:wizard (qwen3.6-35-a3b)" + exit 0.
|
|
- test_cli_patch_happy: argv → 200 mock → stdout="patched ratatoskr:wizard" + exit 0.
|
|
- test_cli_delete_happy: argv → 204 mock → stdout="deleted ratatoskr:wizard" + exit 0.
|
|
- test_cli_missing_auth: no API key → stderr "[auth_error]" + exit 11.
|
|
- test_cli_api_failed: 500 mock → stderr "[api_failed]" + exit 20.
|
|
```
|
|
|
|
## ERROR_ROUTING (module + CLI)
|
|
|
|
| HTTP shape | error_code | Exception (module) | CLI label | Exit |
|
|
|---|---|---|---|---|
|
|
| 201 / 200 / 204 | — | (none — happy) | one-line confirmation on stdout | 0 |
|
|
| 429 | agent_quota_exceeded | `Tier3QuotaExceeded(retry_after=N)` | `[quota_exceeded] retry_after=N` | 20 |
|
|
| 403 | tier3_user_id_unsupported | `Tier3UserIdUnsupported` | `[user_id_unsupported]` | 20 |
|
|
| 404 | — | `Tier3AgentNotFound(agent_id=...)` | `[agent_not_found] <id>` | 20 |
|
|
| 422 | field_not_mutable | `Tier3FieldNotMutable(field=...)` | `[field_not_mutable] field=...` | 20 |
|
|
| 422 | layer_deferred | `Tier3LayerDeferred(field=...)` | `[layer_deferred] field=...` | 20 |
|
|
| any other non-2xx | — | `SessionApiFailed(status, body)` | `[api_failed] status=N body=...` | 20 |
|
|
| httpx.ConnectError / ReadTimeout / TransportError | — | propagates | `[network_error] T: M` | 21 |
|
|
| PRE-001/002/003 assertion violation | — | `AssertionError` | `[usage_error] <msg>` | 10 |
|
|
| no auth | — | `_AuthError` (reused from cli) | `[auth_error] no API key` | 11 |
|
|
|
|
## Layout after this module lands
|
|
|
|
```
|
|
src/ratatoskr/
|
|
__init__.py
|
|
cli.py (existing, unchanged)
|
|
sessions.py (existing, unchanged)
|
|
sse_client.py (existing, unchanged)
|
|
tui.py (existing, unchanged)
|
|
tier3.py NEW
|
|
__main__/ (no change — main cli still entry-point)
|
|
|
|
# CLI invocation:
|
|
$ python -m ratatoskr.tier3 define --name wizard ...
|
|
$ python -m ratatoskr.tier3 patch ratatoskr:wizard ...
|
|
$ python -m ratatoskr.tier3 delete ratatoskr:wizard
|
|
```
|