Files
ratatoskr/docs/contracts/issues/15.contract.md
T
vh c086ae2b32 feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
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.
2026-05-24 20:31:10 -07:00

19 KiB

contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
contract_version target_module scope depends_on used_by language complexity estimated_loc confidence assumptions open_questions prd dependencies
2.1 ratatoskr.tier3 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.
httpx
python low 250 0.9
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.
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.
issue issue_url body_sha256_16 lock_in_comment_id lock_in_sha256_16 lock_in_at pinned_at
15 #15 03367d7b451ab17f null null null 2026-05-25T03:21:38+00:00
issue path reason
2 src/ratatoskr/sessions.py 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 path reason
3 src/ratatoskr/cli.py 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

@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

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