From c086ae2b32adaca6452be2989b5bbcf77a5162c6 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 24 May 2026 20:31:10 -0700 Subject: [PATCH] feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #15. Worldtree Phase 2.0 ships Tier 3 (consumer-defined) agents at `:`; 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 ) define --name --system-prompt --model patch [--system-prompt ] [--model ] delete 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:` 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. --- docs/contracts/issues/15.contract.md | 290 ++++++++++++++++++ persistent-memory.md | 7 +- pyproject.toml | 2 +- src/ratatoskr/tier3.py | 438 +++++++++++++++++++++++++++ tests/test_tier3.py | 437 ++++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 1171 insertions(+), 5 deletions(-) create mode 100644 docs/contracts/issues/15.contract.md create mode 100644 src/ratatoskr/tier3.py create mode 100644 tests/test_tier3.py diff --git a/docs/contracts/issues/15.contract.md b/docs/contracts/issues/15.contract.md new file mode 100644 index 0000000..76f946c --- /dev/null +++ b/docs/contracts/issues/15.contract.md @@ -0,0 +1,290 @@ +--- +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/), `delete_agent` (DELETE /agents/), plus `Tier3AgentInfo` frozen dataclass. Plus a thin CLI entry point (`python -m ratatoskr.tier3 `) 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 `:`. Live probe against personal Worldtree (2026-05-25) confirmed: POST with `{agent_name: 'smoke-test', ...}` and `Authorization: Bearer ` 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: `[] ` 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 `:*` 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 --end-user-id ` 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 `:`. 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/ → 200 with updated Tier3AgentInfo. See FN patch_agent.""" + + +async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None: + """DELETE /agents/ → 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 ":". +``` + +### 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/ → 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/ → 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] ` | 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] ` | 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 +``` diff --git a/persistent-memory.md b/persistent-memory.md index 04e4240..9bbbfc4 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool. ## Current state / in-flight -_As of 2026-05-24 (post-v0.6.5 thinking streams into whole pane):_ +_As of 2026-05-25 (post-v0.7.0 Tier 3 agent lifecycle):_ -**Status: v0.6.5 shipped.** Nine core issues complete (`sse_client` +**Status: v0.7.0 shipped.** Ten core features complete (`sse_client` #1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI startup error visibility #6, presenter contract semantics amendment #12, startup agent picker #8, §5 layout reshape + Tools pane #13) @@ -51,7 +51,8 @@ Static in the footer (static "Tools" v1; dynamic when more tabs land). CLI mode (--send) unaffected by design — INV-018. Last commits on `main`: -- v0.6.5 refactor(tui): thinking streams into thinking-log (no Static) +- v0.7.0 feat(tier3): ratatoskr.tier3 module + CLI — Worldtree Tier 3 lifecycle +- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5) - `82437bd` style(tui): picker highlighted item → Aurora blue (v0.6.4) - `ac690c1` style(tui): restore Australis palette, only $background → pure black (v0.6.3) - `d845b20` style(tui): neutralize Australis dark palette (v0.6.2, reverted) diff --git a/pyproject.toml b/pyproject.toml index 7d42187..62b60c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.6.5" +version = "0.7.0" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/tier3.py b/src/ratatoskr/tier3.py new file mode 100644 index 0000000..16f27d6 --- /dev/null +++ b/src/ratatoskr/tier3.py @@ -0,0 +1,438 @@ +"""Worldtree Tier 3 (consumer-defined) agent lifecycle client. + +Implements docs/contracts/issues/15.contract.md. Caller-owned httpx.AsyncClient +posture (same as ratatoskr.sessions). Exposes three lifecycle operations: + +- ``define_agent`` — POST /agents/define +- ``patch_agent`` — PATCH /agents/ +- ``delete_agent`` — DELETE /agents/ + +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.sessions.create_session``. + +Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0). +""" + +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 ``":"`` — + 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, + model: 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 model, "model must be non-empty" + + body = { + "agent_name": agent_name, + "system_prompt": system_prompt, + "model": model, + } + 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, + model: str | None = None, +) -> Tier3AgentInfo: + """PATCH /agents/ — 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 model is not None, ( + "patch requires at least one of system_prompt or model" + ) + + body: dict[str, str] = {} + if system_prompt is not None: + body["system_prompt"] = system_prompt + if model is not None: + body["model"] = model + 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/ — 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) + + +# ---- CLI (`python -m ratatoskr.tier3 `) ------------------------ +# +# 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. + + +class _Tier3UsageError(Exception): + """Argparse usage violation → exit 10.""" + + +class _Tier3AuthError(Exception): + """No API key resolvable → exit 11.""" + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="python -m ratatoskr.tier3", + description="Worldtree Tier 3 (consumer-defined) agent lifecycle.", + ) + parser.add_argument("--api-key", dest="api_key", default=None) + parser.add_argument("--server", dest="server", default=None) + sub = parser.add_subparsers(dest="cmd", required=True) + + p_define = sub.add_parser("define", help="Create a Tier 3 agent.") + p_define.add_argument("--name", required=True, help="agent_name (slug).") + p_define.add_argument( + "--system-prompt", dest="system_prompt", required=True, + help="System prompt the agent ships with.", + ) + p_define.add_argument( + "--model", required=True, + help="Provider model ID (NOT a profile alias; e.g., qwen3.6-35-a3b).", + ) + + p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or model.") + p_patch.add_argument("agent_id", help='Full ":" form.') + p_patch.add_argument("--system-prompt", dest="system_prompt", default=None) + p_patch.add_argument("--model", default=None) + + p_delete = sub.add_parser("delete", help="Hard-delete a Tier 3 agent.") + p_delete.add_argument("agent_id", help='Full ":" form.') + + return parser + + +def _resolve_auth(ns: argparse.Namespace) -> tuple[str, str]: + """Resolve API key + server URL with the same env-var fallback as cli.py.""" + import os + + api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or "" + if not api_key: + raise _Tier3AuthError("no API key (set --api-key or WORLDTREE_API_KEY)") + server_url = ( + ns.server or os.environ.get("WORLDTREE_API_URL") or "http://localhost:8000" + ) + return api_key, server_url + + +async def _run_define(ns: argparse.Namespace) -> int: + api_key, server_url = _resolve_auth(ns) + from ratatoskr.cli import USER_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: + info = await define_agent( + client, + agent_name=ns.name, + system_prompt=ns.system_prompt, + model=ns.model, + ) + print(f"defined {info.agent_id} ({info.model})") + return 0 + + +async def _run_patch(ns: argparse.Namespace) -> int: + api_key, server_url = _resolve_auth(ns) + from ratatoskr.cli import USER_AGENT + + if ns.system_prompt is None and ns.model is None: + raise _Tier3UsageError( + "patch requires at least one of --system-prompt or --model" + ) + 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( + client, + ns.agent_id, + system_prompt=ns.system_prompt, + model=ns.model, + ) + 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 + + 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) + print(f"deleted {ns.agent_id}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Sync entry point — argparse + dispatch + error → exit-code mapping. + + Mirrors ratatoskr.cli.main()'s error-routing matrix: + 0 happy + 10 usage error + 11 auth error + 20 api-failure (typed exception or generic SessionApiFailed) + 21 network error + """ + import asyncio + import sys + + parser = _build_parser() + try: + ns = parser.parse_args(argv) + except SystemExit as exc: + return int(exc.code) if exc.code is not None else 0 + + handler = { + "define": _run_define, + "patch": _run_patch, + "delete": _run_delete, + }[ns.cmd] + + try: + return asyncio.run(handler(ns)) + except _Tier3UsageError as exc: + sys.stderr.write(f"[usage_error] {exc}\n") + return 10 + except _Tier3AuthError as exc: + sys.stderr.write(f"[auth_error] {exc}\n") + return 11 + except AssertionError as exc: + sys.stderr.write(f"[usage_error] {exc}\n") + return 10 + except Tier3QuotaExceeded as exc: + sys.stderr.write(f"[quota_exceeded] retry_after={exc.retry_after}\n") + return 20 + except Tier3UserIdUnsupported: + sys.stderr.write("[user_id_unsupported]\n") + return 20 + except Tier3AgentNotFound as exc: + sys.stderr.write(f"[agent_not_found] {exc.agent_id}\n") + return 20 + except Tier3FieldNotMutable as exc: + sys.stderr.write(f"[field_not_mutable] field={exc.field}\n") + return 20 + except Tier3LayerDeferred as exc: + sys.stderr.write(f"[layer_deferred] field={exc.field}\n") + return 20 + except SessionApiFailed as exc: + sys.stderr.write( + f"[api_failed] status={exc.status} body={exc.body!r}\n" + ) + return 20 + except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: + sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") + return 21 + + +if __name__ == "__main__": + import sys + + sys.exit(main()) diff --git a/tests/test_tier3.py b/tests/test_tier3.py new file mode 100644 index 0000000..52c2059 --- /dev/null +++ b/tests/test_tier3.py @@ -0,0 +1,437 @@ +"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md.""" + +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, +) + +_FULL_AGENT_RESP = { + "agent_id": "ratatoskr:wizard", + "user_id": "ratatoskr", + "agent_name": "wizard", + "system_prompt": "You are a wizard.", + "model": "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.", + model="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.", + model="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.", + "model": "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", + model="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", model="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", model="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", model="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", model="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="", model="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", model="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", + model="different-model", + ) + body = _json.loads(route.calls[0].request.content) + assert body == {"system_prompt": "new prompt", "model": "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 model → 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 + + +class TestCli: + @respx.mock + def test_cli_define_happy( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """cli_define_happy [happy]: argv → 201 mock → stdout confirmation.""" + monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") + monkeypatch.setenv("WORLDTREE_API_KEY", "k") + respx.post("https://w.example/agents/define").mock( + return_value=httpx.Response(201, json=_FULL_AGENT_RESP) + ) + rc = main([ + "define", + "--name", "wizard", + "--system-prompt", "You are a wizard.", + "--model", "qwen3.6-35-a3b", + ]) + out = capsys.readouterr() + assert rc == 0 + assert out.out.strip() == "defined ratatoskr:wizard (qwen3.6-35-a3b)" + + @respx.mock + def test_cli_patch_happy( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """cli_patch_happy [happy]: argv → 200 mock → stdout confirmation.""" + monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") + monkeypatch.setenv("WORLDTREE_API_KEY", "k") + respx.patch("https://w.example/agents/ratatoskr:wizard").mock( + return_value=httpx.Response(200, json=_FULL_AGENT_RESP) + ) + rc = main(["patch", "ratatoskr:wizard", "--system-prompt", "new"]) + out = capsys.readouterr() + assert rc == 0 + assert out.out.strip() == "patched ratatoskr:wizard" + + @respx.mock + def test_cli_delete_happy( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """cli_delete_happy [happy]: argv → 204 mock → stdout confirmation.""" + monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") + monkeypatch.setenv("WORLDTREE_API_KEY", "k") + respx.delete("https://w.example/agents/ratatoskr:wizard").mock( + return_value=httpx.Response(204) + ) + rc = main(["delete", "ratatoskr:wizard"]) + out = capsys.readouterr() + assert rc == 0 + assert out.out.strip() == "deleted ratatoskr:wizard" + + def test_cli_missing_auth( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """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", + "--model", "m", + ]) + err = capsys.readouterr().err + assert rc == 11 + assert "[auth_error]" in err + + @respx.mock + def test_cli_api_failed( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """cli_api_failed [error]: 500 → stderr [api_failed] + exit 20.""" + monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") + monkeypatch.setenv("WORLDTREE_API_KEY", "k") + respx.post("https://w.example/agents/define").mock( + return_value=httpx.Response(500, content=b"upstream out") + ) + rc = main([ + "define", + "--name", "wizard", + "--system-prompt", "x", + "--model", "m", + ]) + err = capsys.readouterr().err + assert rc == 20 + assert "[api_failed]" in err + + @respx.mock + def test_cli_quota_exceeded( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """cli_quota_exceeded [error]: 429 → stderr [quota_exceeded] + exit 20.""" + monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") + monkeypatch.setenv("WORLDTREE_API_KEY", "k") + respx.post("https://w.example/agents/define").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "0"}, + json={"detail": {"error_code": "agent_quota_exceeded"}}, + ) + ) + rc = main([ + "define", + "--name", "wizard", + "--system-prompt", "x", + "--model", "m", + ]) + err = capsys.readouterr().err + assert rc == 20 + assert "[quota_exceeded]" in err + + def test_cli_patch_no_fields( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """cli_patch_no_fields [error]: patch with no flags → [usage_error] + exit 10.""" + monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") + monkeypatch.setenv("WORLDTREE_API_KEY", "k") + rc = main(["patch", "ratatoskr:wizard"]) + err = capsys.readouterr().err + assert rc == 10 + assert "[usage_error]" in err diff --git a/uv.lock b/uv.lock index 23e3f2f..3420a39 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.6.5" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "httpx" },