Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00854ce618 | |||
| 78bfcadb9e | |||
| 44138590ad | |||
| d516537b08 | |||
| 92aa05c688 | |||
| 209427ab23 | |||
| 139771c8d8 | |||
| 489cfee1f0 | |||
| 11ef6830ab | |||
| 9fade55901 | |||
| 9918c10acf | |||
| c086ae2b32 | |||
| d3569904bc | |||
| 82437bd4b9 | |||
| ac690c11d5 | |||
| d845b20efd | |||
| 8463eb22ff | |||
| eb93e6d5f0 | |||
| cfee89ac1c | |||
| 7106af5c09 |
+13
-5
@@ -7,11 +7,19 @@ documents the pin, the vendored artifacts, and the bump procedure.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Worldtree git SHA | `55101e909abcd2219833266b6f905c5bc956e0f0` |
|
||||
| Worldtree HEAD message | `memory: snapshot — #177 Vili v1 + persona async-decouple shipped as v0.19.0` |
|
||||
| Pinned on | 2026-05-20 |
|
||||
| Pinned by | brokkr-smithy-dev (initial scaffold) |
|
||||
| Worldtree version at pin | `v0.19.0` |
|
||||
| Worldtree git SHA | `562001af28d752c3a60d449c7ddd09f44fa9dc9a` |
|
||||
| Worldtree HEAD message | `feat(#201): v0.29.0 — awaiting_llm_first_token SSE heartbeat` |
|
||||
| Pinned on | 2026-05-26 |
|
||||
| Pinned by | ratatoskr-dev (bump for #201 awaiting_llm_first_token SSE) |
|
||||
| Worldtree version at pin | `v0.29.0` |
|
||||
|
||||
## Pin history
|
||||
|
||||
| Date | SHA | Version | Notable deltas consumed |
|
||||
|---|---|---|---|
|
||||
| 2026-05-26 | `562001a` | v0.29.0 | #201 — new SSE event `awaiting_llm_first_token` (heartbeat during BuildingPrompt → CallingLLM gap, default 5s interval) |
|
||||
| 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits |
|
||||
| 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin |
|
||||
|
||||
## Vendored artifacts
|
||||
|
||||
|
||||
@@ -158,8 +158,12 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w
|
||||
- **INV-016**: Input retains keyboard focus across `Ctrl+1` / `Ctrl+2` tab switches.
|
||||
- **INV-017** *(amended v0.5.0)*: `thinking-current` Static docks to the top of the **right column** (above `TabbedContent`), not the left column. Live thinking visibility persists across tab switches. v0.5.0 moves it from left → right so the left column is genuinely content-only.
|
||||
- **INV-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005.
|
||||
- **INV-019** *(new v0.5.0)*: Two TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`). Ctrl+1 activates Tools; Ctrl+2 activates Debug. `pane-name` Static reflects the active tab name dynamically.
|
||||
- **INV-020** *(new v0.5.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` fallback writes to `tools_log`; `WorkerPhase` / `Thinking` / `TextBoundary` fallback writes to `debug_log`; everything else falls back to `log`.
|
||||
- **INV-019** *(amended v0.6.0)*: Three TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`) + `Thinking` (id `thinking-tab`, contains `#thinking-log`). Ctrl+1/Ctrl+2/Ctrl+3 activate respective tabs. `pane-name` Static reflects active tab name dynamically.
|
||||
- **INV-020** *(amended v0.6.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` → `tools_log`; `Thinking` → `thinking_log`; `WorkerPhase` / `TextBoundary` → `debug_log`; everything else → `log`.
|
||||
- **INV-021** *(new v0.6.0)*: `Text` events do NOT route to `log` per-delta. They accumulate into `TuiPresenterState.text_buffer` and update a single `current_text` Static (docked above the prompt). On terminal event (`Done`/`Error`/`Cancelled`), `current_text` is cleared and (raw mode) accumulated text or (non-raw) post-Done `Markdown(response)` is written to `log`. The pre-v0.6.0 per-token RichLog spam is retired.
|
||||
- **INV-022** *(amended v0.7.1)*: Thinking deltas COALESCE on `\n` boundaries before writing to `thinking_log`. The first delta of a run writes `Rule(title=f"turn N · thinking #K start")`; subsequent deltas accumulate in `TuiPresenterState.thinking_chunk_buffer`; whenever the buffer contains `\n`, the leading line(s) flush as RichLog entries (one entry per natural paragraph). The run closes on the next non-thinking event: any tail in the buffer flushes as a final line, then `Rule(title=f"turn N · thinking #K end")`. Pre-v0.7.1 per-delta-per-line caused token-spam (Worldtree emits thinking at token granularity); coalescing produces one log line per natural paragraph, not per token.
|
||||
- **INV-023** *(new v0.6.0)*: Turn-ID header `Rule(title=f"turn N")` is written to all four log panes (`log`, `tools_log`, `debug_log`, `thinking_log`) by `_stream_turn_worker` on the first event of each turn — enables cross-pane visual correlation during multi-turn debugging.
|
||||
- **INV-024** *(amended v0.6.5)*: `thinking-current` Static REMOVED. v0.6.1 placed it inside the Thinking pane (docked bottom); operators reported the bottom-docked Static "scrolling a little section at the bottom" (its 200-char tail acting as a scroll-window) instead of letting the whole pane scroll. v0.6.5 deletes the Static entirely and streams Thinking deltas directly into `thinking_log` (the scrollable RichLog) — the whole pane scrolls naturally as content arrives. The Rule(start) at the first delta of a run is now the live "thinking is happening" indicator.
|
||||
|
||||
## TESTS (additions / changes to test_tui.py)
|
||||
|
||||
|
||||
@@ -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/<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
|
||||
```
|
||||
@@ -55,6 +55,69 @@ conversation_api:
|
||||
|
||||
---
|
||||
|
||||
## Authorization model — agent invocation
|
||||
|
||||
When you call `POST /sessions` against an agent, the authorization check that fires depends on **which kind of agent** you target. There are two distinct scope namespaces — the spelling differs by one character (`agent` vs `agents`) and the granting mechanism differs entirely. Confusing the two is a common source of bug reports.
|
||||
|
||||
### Tier 1 — foundational agents (no `:` in agent_id)
|
||||
|
||||
Agents bundled with Worldtree: `mimir`, `lofn`, `soong`, `forseti`, `domari`, `vili`, `actor`, `saga`, `bragi`, `leif`, `troi`, `cara`, `glados`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon.
|
||||
|
||||
> **About tiers:** Your `tier` is set on the `users` table row your API key resolves to, assigned at key-mint time (see `POST /admin/keys`). Tiers are `anonymous` (dev-mode unauthenticated), `user` (default for newly-issued keys), `free`/`pro` (subscription-shaped, not actively differentiated), and `admin`. The tier you have is visible via `GET /me`'s `tier` field. Tier-derived scopes come from `config/policies.yaml > tiers.<tier>.scopes` — there is no per-key scope override.
|
||||
|
||||
**Authorization rule (singular `agent`):**
|
||||
|
||||
```yaml
|
||||
- id: agent-call-baseline-allow
|
||||
principal:
|
||||
tiers: ["anonymous", "user", "free", "pro", "admin"]
|
||||
action: "agent.call:*"
|
||||
resource: "*"
|
||||
effect: allow
|
||||
```
|
||||
|
||||
This baseline rule lives at `config/policies.yaml`. Every authenticated tier — including the `user` tier that newly-issued keys default to — already passes this check for every Tier 1 agent. **There is no per-agent scope you can add to "grant" Tier 1 access; it's covered by tier.**
|
||||
|
||||
If you get a 422 calling a Tier 1 agent (e.g., `lofn` rejecting with `end_user_id_required`), that's a **request-body validation**, not a scope denial. Check the `error_code` in the response detail — `END_USER_ID_REQUIRED` means the agent requires an `end_user_id` field in the request body; `AUTH_SCOPE_DENIED` (403) would be the actual scope problem. They're not interchangeable.
|
||||
|
||||
### Tier 3 — consumer-defined agents (`:` in agent_id)
|
||||
|
||||
Agents created at runtime via `POST /agents/define`. The agent_id is `<owner_user_id>:<agent_name>`, e.g., `acme:support-bot`. The `:` in the path is the trigger that switches the auth model.
|
||||
|
||||
**Authorization is DB-backed per-resource, NOT policy-driven (plural `agents`):**
|
||||
|
||||
```
|
||||
scope action checked: agents.call:<owner_user_id>:<agent_name>
|
||||
^^^^^^
|
||||
PLURAL — different namespace from Tier 1
|
||||
```
|
||||
|
||||
There is **no blanket allow rule** for `agents.call:*` in policy. The grant comes from the live `consumer_agents` table:
|
||||
|
||||
- A non-soft-deleted row in `consumer_agents` owned by `ctx.user_id` IS the grant.
|
||||
- Cascade soft-delete and owner-initiated `DELETE` revoke it.
|
||||
- Missing row → policy defaults to deny (403 `auth_scope_denied`).
|
||||
|
||||
To "add the scope" for a Tier 3 agent, you don't amend any config or call an admin endpoint — you `POST /agents/define` to register it under your `user_id`. Owning the row IS the grant. You cannot call another user's Tier 3 agent; ownership is checked at session-create (`row.user_id == ctx.user_id`).
|
||||
|
||||
### Common pitfalls
|
||||
|
||||
- **Singular vs plural.** Tier 1 uses `agent.call:*` (singular `agent`). Tier 3 uses `agents.call:<owner>:<name>` (plural `agents`). One character difference, two completely different mechanisms. There is no Tier 1 scope named `agent.call:mimir` or `agents.call:mimir` — Tier 1 is granted by baseline rule, not per-agent name.
|
||||
- **No scope-mutation API.** `POST /admin/keys` accepts `{user_id, label, tier}` only. There is no per-key scope override mechanism in the storage schema. To change a user's effective scopes, change their `tier`, not their key. Per-resource Tier 3 grants flow through `POST /agents/define` (and its DELETE counterpart), not through admin endpoints.
|
||||
- **422 vs 403.** A 422 is body-validation (e.g., `end_user_id_required`); a 403 is auth-policy denial (`auth_scope_denied`). Different fix paths. Read the `error_code` in `detail`.
|
||||
|
||||
### Quick decision table for consumers
|
||||
|
||||
| Target | Auth requirement |
|
||||
|---|---|
|
||||
| Tier 1 agent (e.g., `mimir`) | Authenticated tier ≥ `user`. No additional body requirements |
|
||||
| Tier 1 agent `lofn` (the default welcoming intermediary) | Authenticated tier ≥ `user` + `end_user_id` field required in request body. 422 `END_USER_ID_REQUIRED` if absent |
|
||||
| Tier 3 agent (any agent_id containing `:`) | `end_user_id` field required in body. AND the row must be owner-matched: `POST /agents/define` first to create a row under your `user_id`, then session-create works against your existing key. Cross-user Tier 3 invocation is rejected with 403 |
|
||||
|
||||
> **Programmatic discovery of `end_user_id` requirements:** as of v0.22.x there is no field on `GET /agents` indicating which agents require `end_user_id` — the spec line above (lofn + Tier 3) is the authoritative list, and 422 `END_USER_ID_REQUIRED` is the fallback signal at request time. Adding a discoverable `requires_end_user_id` field on `AgentInfoResponse` is on the table as a small future capability; ping if you want to drive it.
|
||||
|
||||
---
|
||||
|
||||
## GET /me
|
||||
|
||||
Returns the authenticated principal's identity and key metadata. Lets a client verify its key on boot without triggering agent-config-loading side effects.
|
||||
@@ -1878,6 +1941,65 @@ Tool-using turns cycle through `CallingLLM → ProcessingTools → CallingLLM
|
||||
|
||||
Clients that don't need phase events can filter on `event["type"] != "worker_phase"` client-side. Existing SSE consumers that switch on `event["type"]` ignore this event type without code changes.
|
||||
|
||||
### affect_update
|
||||
|
||||
Persona-state observability event (issue #204). Fires twice per turn for agents with `persona.enabled: true` on non-ephemeral sessions; suppressed entirely for persona-disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined agents (Phase 2.0), and ephemeral sessions.
|
||||
|
||||
**Start-of-turn — `status: "current"`:**
|
||||
|
||||
Emitted immediately at the start of each qualifying turn, before any `worker_phase` event. Carries the agent's current persona snapshot reflecting all prior turns' completed appraisals.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "affect_update",
|
||||
"status": "current",
|
||||
"turn_id": 42,
|
||||
"snapshot": {
|
||||
"agent_id": "mimir",
|
||||
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
||||
"dominant_emotion": "curiosity",
|
||||
"emotions_active": [
|
||||
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
|
||||
],
|
||||
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
|
||||
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
|
||||
"last_updated_at": "2026-05-25T22:30:18+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**End-of-turn — `status: "scheduled"`:**
|
||||
|
||||
Emitted after the post-turn appraisal task has been scheduled (per #177 Phase A's fire-and-forget discipline) and before `done`. Lightweight notification — no PAD numbers, since the appraisal is still running asynchronously. The result lands in the NEXT turn's `status: "current"` snapshot.
|
||||
|
||||
```json
|
||||
{"type": "affect_update", "status": "scheduled", "turn_id": 42}
|
||||
```
|
||||
|
||||
`scheduled` is skipped on turn failure/cancel paths (the appraisal was never reached); `current` still fires unconditionally for qualifying turns.
|
||||
|
||||
Bootstrap reads available via `GET /agents/{agent_id}/persona_state` (same `snapshot` shape, requires `persona.read` scope).
|
||||
|
||||
### awaiting_llm_first_token
|
||||
|
||||
Periodic heartbeat event (issue #201) emitted at a configurable interval during the gap between `worker_phase: phase="BuildingPrompt"` and `worker_phase: phase="CallingLLM"`. Solves the legitimate-slow first-token visibility gap: consumer TUIs can render a "thinking for Ns…" timer rather than a frozen line during heavy-CoT prompt warmup.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 5012.3
|
||||
}
|
||||
```
|
||||
|
||||
`elapsed_ms_since_building_prompt` is the server-authoritative wall-clock milliseconds since `BuildingPrompt` was emitted. Independent of network latency or clock skew.
|
||||
|
||||
Heartbeats stop the moment the engine produces its first event (the `CallingLLM` marker). They do NOT re-fire during tool-roundtrip `CallingLLM` re-entries — the heartbeat is scoped to the FIRST `BuildingPrompt → CallingLLM` gap only.
|
||||
|
||||
**Configuration:** `conversation_api.awaiting_llm_first_token_heartbeat_s` (default `5.0`). Per-agent override via `agent.conversation.awaiting_llm_first_token_heartbeat_s`. Value `0.0` disables emission entirely.
|
||||
|
||||
Cancellation paths (stall watchdog, user-cancel) also stop the heartbeat — no `awaiting_llm_first_token` event appears after the terminal `cancelled` event.
|
||||
|
||||
### thinking
|
||||
|
||||
Incremental reasoning/thinking content (from thinking-enabled models).
|
||||
|
||||
@@ -1851,6 +1851,103 @@ SQLite `consumer_agents` table.
|
||||
before any other processing; non-slug user_ids return 403
|
||||
`tier3_user_id_unsupported`.
|
||||
|
||||
### Persona-state observability (issue #204)
|
||||
|
||||
- **INV-204-1 (affect_update event type)**: `affect_update` is a
|
||||
top-level SSE event `type` discriminator, sibling to `worker_phase`
|
||||
/ `tool_*` / `text` / `thinking` / `done`. Not a `worker_phase` sub-
|
||||
phase. INV-061's "BuildingPrompt is the FIRST event" property is
|
||||
scoped to `worker_phase` events only — `affect_update status="current"`
|
||||
may precede BuildingPrompt for persona-enabled agents.
|
||||
- **INV-204-2 (per-turn emission)**: For agents with persona enabled
|
||||
on non-ephemeral sessions, `stream_turn` emits `status="current"`
|
||||
before any other SSE event on a successful or failed turn, and
|
||||
`status="scheduled"` after `update_after_turn` schedules the
|
||||
appraisal task (success path only — skipped on cancel / error
|
||||
before update_after_turn was reached). See contract
|
||||
`docs/contracts/issues/204.contract.md`.
|
||||
- **INV-204-3 (emission suppression)**: Persona-disabled agents and
|
||||
ephemeral sessions emit ZERO `affect_update` events.
|
||||
- **INV-204-6 / INV-204-7 (persona_state endpoint)**: New
|
||||
`GET /agents/{agent_id}/persona_state` gated on Heimdall scope
|
||||
`persona.read`. Route ordering: auth → Tier 3 short-circuit (404
|
||||
`persona_not_configured`) → Tier 1/2 existence (404
|
||||
`agent_not_available`) → persona-enabled check (404
|
||||
`persona_not_configured`) → snapshot (200).
|
||||
- **INV-204-9 (read-only registry primitive)**: `PersonaRegistry.get_state`
|
||||
is mutex-free and never mutates `persona.emotions`. Eventual
|
||||
consistency under concurrent `_appraisal_wrapper` mutations.
|
||||
- **INV-204-14 (replay participation)**: `affect_update` events flow
|
||||
through `_publish`, so SSE resume / replay handles them with no
|
||||
special case.
|
||||
|
||||
## Amendment — AwaitingLLMFirstToken heartbeat (issue #201, INV-201-1..7)
|
||||
|
||||
Adds a periodic SSE heartbeat event during the gap between
|
||||
`BuildingPrompt` and `CallingLLM` so consumers can distinguish
|
||||
"engine is thinking" from "engine is wedged" without out-of-band
|
||||
server inspection. Filed by ratatoskr-dev; ships in v0.29.0.
|
||||
|
||||
- **INV-201-1 (new top-level event type)**: `awaiting_llm_first_token`
|
||||
is a new top-level SSE event type, sibling to `worker_phase` /
|
||||
`tool_*` / `text` / `thinking` / `debug` / `done` / `affect_update`.
|
||||
`_WORKER_PHASE_VOCAB` is NOT extended; INV-053 / INV-054 unchanged.
|
||||
Same precedent as #204's `affect_update`.
|
||||
|
||||
- **INV-201-2 (config-gated emission)**: Heartbeat emission requires
|
||||
`awaiting_llm_first_token_heartbeat_s > 0.0`. When the resolved
|
||||
value is `0.0`, the heartbeat task is never started and zero
|
||||
`awaiting_llm_first_token` events emit for the turn. When > 0.0,
|
||||
the task starts immediately after `_publish_phase("BuildingPrompt")`
|
||||
and emits an event every `interval` seconds until cancelled.
|
||||
|
||||
- **INV-201-3 (defense-in-depth cancellation)**: The heartbeat task
|
||||
is cancelled at three sites (idempotent via the `_cancel_heartbeat`
|
||||
helper): (a) immediately before `_publish_phase("CallingLLM")` on
|
||||
the engine-first-event path; (b) inside the `cancelled`/`error`
|
||||
handling that wraps `_handle_cancel` (covers stall + user-cancel
|
||||
paths); (c) in the outer `finally` block alongside
|
||||
`_clear_stall_timer`. After cancellation, no further
|
||||
`awaiting_llm_first_token` events emit.
|
||||
|
||||
- **INV-201-4 (wire shape)**: Payload is exactly `{type:
|
||||
"awaiting_llm_first_token", turn_id: <int>,
|
||||
elapsed_ms_since_building_prompt: <float>}` plus the composite `id:
|
||||
"<turn_id>:<seq>"` stamped by `_publish`. No additional fields.
|
||||
`elapsed_ms_since_building_prompt` is `(time.monotonic() -
|
||||
building_prompt_t) * 1000.0` where `building_prompt_t` is captured
|
||||
immediately before `BuildingPrompt` is published.
|
||||
|
||||
- **INV-201-5 (first-gap-only scope)**: Heartbeat is scoped to the
|
||||
FIRST `BuildingPrompt → CallingLLM` gap of the turn. Tool round-trip
|
||||
`CallingLLM` re-entries (INV-058) emit ZERO
|
||||
`awaiting_llm_first_token` events. Out-of-scope sub-phases
|
||||
(`AwaitingToolResult`, `AwaitingNextLLMCall`) would be separate
|
||||
follow-up features.
|
||||
|
||||
- **INV-201-6 (replay participation)**: Heartbeat events flow through
|
||||
`_publish → _replay_buffer + queue` per INV-060 — same replay
|
||||
semantics as worker_phase events. On `Last-Event-ID` reconnect,
|
||||
prior heartbeats replay identically.
|
||||
|
||||
- **INV-201-7 (config resolution precedence)**: Per-agent
|
||||
`agent.conversation.awaiting_llm_first_token_heartbeat_s` →
|
||||
`api_cfg.awaiting_llm_first_token_heartbeat_s` → built-in `5.0`.
|
||||
Negative values raise `ConfigurationError` at agent load; `0.0`
|
||||
is valid and means "disabled." Mirrors the `_resolve_stall_timeout_s`
|
||||
precedence pattern (INV-038).
|
||||
|
||||
### Mechanism note
|
||||
|
||||
The heartbeat task is a separate `asyncio.Task` (NOT `loop.call_later`,
|
||||
because heartbeats repeat at an interval rather than fire once at a
|
||||
timeout). An `asyncio.Queue` shared between the heartbeat task and the
|
||||
generator carries events; the generator uses
|
||||
`asyncio.wait(return_when=FIRST_COMPLETED)` to race the engine's
|
||||
`__anext__` against the heartbeat queue's `get` ONLY during the first
|
||||
iteration. After `CallingLLM` fires, the heartbeat task is cancelled
|
||||
and subsequent iterations use the original non-race pattern.
|
||||
|
||||
### Storage extension
|
||||
|
||||
The `consumer_agents` table lives in `core/heimdall/storage/sqlite.py`
|
||||
|
||||
+15
-3
@@ -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.5.0 content-only main pane + Debug tab):_
|
||||
_As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_
|
||||
|
||||
**Status: v0.5.0 shipped.** Nine core issues complete (`sse_client`
|
||||
**Status: v0.8.2 shipped.** Eleven 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,19 @@ 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.5.0 refactor(tui): content-only main pane + Debug tab + chrome dark
|
||||
- v0.8.2 fix(tui): drop post-Done Markdown body re-render (no double-print)
|
||||
- `11ef683` fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
|
||||
- `9fade55` feat(local_agents): JSON-backed local tier-3 index + picker merge (v0.8.0)
|
||||
- `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1)
|
||||
- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
|
||||
- `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)
|
||||
- `8463eb2` style(tui): kill remaining blue + thinking-current into pane (v0.6.1)
|
||||
- `cfee89a` refactor(tui): streaming + turn headers + Thinking pane (v0.6.0)
|
||||
- `7106af5` style(tui): UI polish pass — terminal label colors, placeholders (v0.5.1)
|
||||
- `ffd22fb` refactor(tui): content-only main pane + Debug tab + chrome dark (v0.5.0)
|
||||
- `2756f5f` style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1)
|
||||
- `24e4371` feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
|
||||
- `d30be12` feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.5.0"
|
||||
version = "0.14.1"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -42,9 +42,9 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
|
||||
# Ratatoskr is built against Worldtree at this commit; the vendored
|
||||
# spec snapshot in docs/ reflects that SHA.
|
||||
[tool.ratatoskr.spec-pin]
|
||||
worldtree-spec-rev = "55101e909abcd2219833266b6f905c5bc956e0f0"
|
||||
worldtree-version = "v0.19.0"
|
||||
pinned-on = "2026-05-20"
|
||||
worldtree-spec-rev = "562001af28d752c3a60d449c7ddd09f44fa9dc9a"
|
||||
worldtree-version = "v0.29.0"
|
||||
pinned-on = "2026-05-26"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/ratatoskr"]
|
||||
|
||||
@@ -18,6 +18,8 @@ import httpx
|
||||
|
||||
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
CancelFailed,
|
||||
Cancelled,
|
||||
@@ -205,6 +207,7 @@ class CliPresenterState:
|
||||
(
|
||||
WorkerPhase, Thinking, Text, TextBoundary,
|
||||
ToolStart, ToolResult, Done, Error, Cancelled,
|
||||
AffectUpdate, AwaitingLlmFirstToken,
|
||||
),
|
||||
)
|
||||
# Thinking events accumulate into the open run.
|
||||
@@ -275,6 +278,28 @@ class CliPresenterState:
|
||||
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
|
||||
)
|
||||
return
|
||||
if isinstance(event, AffectUpdate):
|
||||
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
|
||||
# one line to stderr with status + (for current) dominant_emotion.
|
||||
if event.snapshot is not None:
|
||||
dom = event.snapshot.get("dominant_emotion")
|
||||
stderr.write(
|
||||
f". affect_update: status={event.status} turn_id={event.turn_id} "
|
||||
f"dominant_emotion={dom!r}\n"
|
||||
)
|
||||
else:
|
||||
stderr.write(
|
||||
f". affect_update: status={event.status} turn_id={event.turn_id}\n"
|
||||
)
|
||||
return
|
||||
if isinstance(event, AwaitingLlmFirstToken):
|
||||
# Worldtree #201 / v0.29.0. Heartbeat during BuildingPrompt →
|
||||
# CallingLLM gap. Stderr surface, one line per heartbeat.
|
||||
secs = event.elapsed_ms_since_building_prompt / 1000.0
|
||||
stderr.write(
|
||||
f". awaiting_llm_first_token: turn_id={event.turn_id} elapsed={secs:.1f}s\n"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def _cancel_and_log(
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Local index of tier-3 agents defined via `python -m ratatoskr.tier3`.
|
||||
|
||||
Workaround for Worldtree's ``GET /agents`` not returning consumer-defined
|
||||
agents (the public list excludes tier-3 per-spec; see issue #15 smoke
|
||||
findings). Local file maintains a list of agent_ids + display metadata so
|
||||
the picker can show them alongside foundational agents.
|
||||
|
||||
Storage shape: JSON at ``$XDG_CONFIG_HOME/ratatoskr/local_agents.json``
|
||||
(default ``~/.config/ratatoskr/local_agents.json``). Override via
|
||||
``$RATATOSKR_LOCAL_AGENTS`` env var for tests / per-machine isolation.
|
||||
|
||||
If Worldtree later starts returning tier-3 agents in ``GET /agents``, this
|
||||
module's role narrows to redundant local cache; can be removed cleanly
|
||||
since the picker's dedup-by-agent-id keeps remote-wins behavior.
|
||||
|
||||
Failure modes are lenient: missing file → empty index; corrupt JSON or
|
||||
schema mismatch → empty index (no crash). The picker continues to show
|
||||
foundational agents either way; the local-tier-3 surface degrades to
|
||||
"operator passes --agent ratatoskr:<name> explicitly" — the
|
||||
pre-v0.8.0 workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalAgentEntry:
|
||||
"""One row in the local tier-3 agent index.
|
||||
|
||||
Schema:
|
||||
- ``agent_id``: full "user_id:agent_name" string (Worldtree-owned).
|
||||
- ``agent_name``: slug from define (display name).
|
||||
- ``model``: provider model ID at last define/patch.
|
||||
- ``description``: synthetic display string (typically derived from
|
||||
the system_prompt's first line + a "(tier 3)" prefix; the picker
|
||||
uses this in its ``{id} · {name} — {description}`` rendering).
|
||||
- ``defined_at``: ISO-8601 timestamp from the Tier3AgentInfo response.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
model: str
|
||||
description: str
|
||||
defined_at: str
|
||||
|
||||
|
||||
def _local_agents_path() -> Path:
|
||||
"""Resolve the local index file path with XDG + env-var override."""
|
||||
override = os.environ.get("RATATOSKR_LOCAL_AGENTS")
|
||||
if override:
|
||||
return Path(override)
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else (Path.home() / ".config")
|
||||
return base / "ratatoskr" / "local_agents.json"
|
||||
|
||||
|
||||
def load_local_agents() -> list[LocalAgentEntry]:
|
||||
"""Read the local index. Returns ``[]`` on missing file, corrupt JSON,
|
||||
schema mismatch, or any read error — never raises.
|
||||
"""
|
||||
path = _local_agents_path()
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
if not isinstance(raw, dict) or raw.get("version") != _SCHEMA_VERSION:
|
||||
return []
|
||||
agents = raw.get("agents", [])
|
||||
if not isinstance(agents, list):
|
||||
return []
|
||||
out: list[LocalAgentEntry] = []
|
||||
for item in agents:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
out.append(LocalAgentEntry(**item))
|
||||
except TypeError:
|
||||
# Malformed row (missing/extra fields) — skip silently.
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _save_local_agents(agents: list[LocalAgentEntry]) -> None:
|
||||
"""Persist the index. Creates parent dir as needed."""
|
||||
path = _local_agents_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"version": _SCHEMA_VERSION, "agents": [asdict(a) for a in agents]}
|
||||
path.write_text(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
def add_local_agent(entry: LocalAgentEntry) -> None:
|
||||
"""Add (or replace) an agent in the local index. agent_id is the key."""
|
||||
agents = [a for a in load_local_agents() if a.agent_id != entry.agent_id]
|
||||
agents.append(entry)
|
||||
_save_local_agents(agents)
|
||||
|
||||
|
||||
def update_local_agent(entry: LocalAgentEntry) -> None:
|
||||
"""Update an existing entry. Identical semantics to ``add_local_agent``
|
||||
(agent_id is the dedup key), exposed separately so callers can
|
||||
self-document intent.
|
||||
"""
|
||||
add_local_agent(entry)
|
||||
|
||||
|
||||
def remove_local_agent(agent_id: str) -> None:
|
||||
"""Remove an entry by agent_id. No-op if absent (idempotent)."""
|
||||
agents = [a for a in load_local_agents() if a.agent_id != agent_id]
|
||||
_save_local_agents(agents)
|
||||
|
||||
|
||||
def make_description(system_prompt: str) -> str:
|
||||
"""Synthesize a one-line description for the picker from a system prompt.
|
||||
|
||||
Strategy: first non-empty line, stripped of leading markdown heading
|
||||
markers and whitespace, prefixed with "(tier 3) ", truncated to 80
|
||||
chars. Falls back to "(tier 3) custom system prompt" if the prompt is
|
||||
empty (defensive — define rejects empty prompts at PRE-002).
|
||||
"""
|
||||
for line in system_prompt.splitlines():
|
||||
stripped = line.lstrip("# ").strip()
|
||||
if stripped:
|
||||
label = f"(tier 3) {stripped}"
|
||||
return label[:80] + ("…" if len(label) > 80 else "")
|
||||
return "(tier 3) custom system prompt"
|
||||
@@ -89,6 +89,46 @@ class SessionApiFailed(Exception):
|
||||
self.body = body
|
||||
|
||||
|
||||
# Worldtree #204 / v0.28.0 — persona_state endpoint failure modes.
|
||||
class PersonaNotConfigured(Exception):
|
||||
"""Raised on HTTP 404 `persona_not_configured` from GET persona_state.
|
||||
|
||||
Agent exists but has no persona surface: persona-disabled Tier 1/2
|
||||
agents (e.g. `domari`, `muninn`) and all Tier 3 consumer-defined
|
||||
agents (Phase 2.0). Distinct from `AgentNotAvailable` which means the
|
||||
agent_id is unknown entirely.
|
||||
"""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"persona not configured for agent_id: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
class AgentNotAvailable(Exception):
|
||||
"""Raised on HTTP 404 `agent_not_available` from GET persona_state.
|
||||
|
||||
The agent_id is unknown to the server. Distinct from
|
||||
`PersonaNotConfigured` (agent exists but has no persona).
|
||||
"""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"agent not available: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
class AuthScopeDenied(Exception):
|
||||
"""Raised on HTTP 403 `auth_scope_denied` from a Heimdall-scoped endpoint.
|
||||
|
||||
The API key lacks the required scope (e.g. `persona.read` for
|
||||
GET /agents/{id}/persona_state). User-tier keys carry `persona.read`
|
||||
by default; this surfaces when a narrower key is in use.
|
||||
"""
|
||||
|
||||
def __init__(self, *, scope: str) -> None:
|
||||
super().__init__(f"auth scope denied: required={scope!r}")
|
||||
self.scope = scope
|
||||
|
||||
|
||||
async def list_sessions(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
@@ -200,3 +240,45 @@ async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
|
||||
)
|
||||
for item in body
|
||||
]
|
||||
|
||||
|
||||
async def get_persona_state(
|
||||
client: httpx.AsyncClient, agent_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""GET /agents/{agent_id}/persona_state — fetch current persona snapshot.
|
||||
|
||||
Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the
|
||||
`affect_update` SSE event's `status="current"` emission: pad,
|
||||
dominant_emotion, emotions_active, baseline_pad, mood_drift,
|
||||
last_updated_at. Bootstrap read for clients that want to populate a
|
||||
persona pane on session-open without waiting for turn-1's `affect_update`.
|
||||
|
||||
Auth: requires Heimdall `persona.read` scope (user-tier default).
|
||||
|
||||
Failure modes (mapped to typed exceptions per the spec error_codes):
|
||||
- 404 `persona_not_configured` → PersonaNotConfigured (persona-disabled
|
||||
agents: domari / muninn, and all Tier 3 in Phase 2.0)
|
||||
- 404 `agent_not_available` → AgentNotAvailable (unknown agent_id)
|
||||
- 403 `auth_scope_denied` → AuthScopeDenied (key lacks persona.read)
|
||||
- any other non-2xx → SessionApiFailed (preserves the broader-error
|
||||
precedent from list_agents / list_sessions / create_session)
|
||||
"""
|
||||
assert client is not None
|
||||
assert agent_id and isinstance(agent_id, str)
|
||||
|
||||
resp = await client.get(f"/agents/{agent_id}/persona_state")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
# Discriminate the 4xx error_code sub-codes; everything else falls through.
|
||||
try:
|
||||
err = resp.json()
|
||||
error_code = err.get("error_code") if isinstance(err, dict) else None
|
||||
except ValueError:
|
||||
error_code = None
|
||||
if resp.status_code == 404 and error_code == "persona_not_configured":
|
||||
raise PersonaNotConfigured(agent_id=agent_id)
|
||||
if resp.status_code == 404 and error_code == "agent_not_available":
|
||||
raise AgentNotAvailable(agent_id=agent_id)
|
||||
if resp.status_code == 403 and error_code == "auth_scope_denied":
|
||||
raise AuthScopeDenied(scope="persona.read")
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
@@ -111,6 +111,55 @@ class Cancelled:
|
||||
partial_message_id: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AwaitingLlmFirstToken:
|
||||
"""SSE event `awaiting_llm_first_token`: heartbeat during slow first-token.
|
||||
|
||||
Fires at the configured interval (default 5s) during the gap between
|
||||
`worker_phase` phase=BuildingPrompt and phase=CallingLLM. Lets clients
|
||||
render a live "thinking for Ns…" indicator instead of a frozen line
|
||||
during legitimate-slow first-token latency. Stops the moment CallingLLM
|
||||
fires (defense-in-depth at three sites); no heartbeat after Cancelled
|
||||
or stalled terminal events. Tool round-trip re-entries do NOT re-fire
|
||||
heartbeats — INV-201-5 scopes the mechanism to the FIRST gap only.
|
||||
|
||||
`elapsed_ms_since_building_prompt` is server-authoritative
|
||||
`time.monotonic()`-based — independent of network latency or clock
|
||||
skew, monotonically increasing across the heartbeat sequence.
|
||||
|
||||
See docs/conversation-api-spec.md § awaiting_llm_first_token
|
||||
(Worldtree #201, v0.29.0).
|
||||
"""
|
||||
|
||||
sse_id: SseId
|
||||
turn_id: int
|
||||
elapsed_ms_since_building_prompt: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AffectUpdate:
|
||||
"""SSE event `affect_update`: persona-state observability snapshot.
|
||||
|
||||
Two emissions per qualifying turn (persona-enabled agent on non-
|
||||
ephemeral session): `status="current"` at turn start carrying the full
|
||||
snapshot, `status="scheduled"` after post-turn appraisal kicks off
|
||||
(lightweight — `snapshot` is None). Suppressed entirely for persona-
|
||||
disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined
|
||||
agents (Phase 2.0), and ephemeral sessions.
|
||||
|
||||
Bootstrap reads available via `GET /agents/{agent_id}/persona_state`
|
||||
(same `snapshot` shape, requires `persona.read` scope).
|
||||
|
||||
See docs/conversation-api-spec.md § affect_update (Worldtree #204,
|
||||
v0.28.0).
|
||||
"""
|
||||
|
||||
sse_id: SseId
|
||||
status: str # "current" | "scheduled"
|
||||
turn_id: int
|
||||
snapshot: dict[str, Any] | None # None when status="scheduled"
|
||||
|
||||
|
||||
Event = (
|
||||
WorkerPhase
|
||||
| Thinking
|
||||
@@ -121,6 +170,8 @@ Event = (
|
||||
| Done
|
||||
| Error
|
||||
| Cancelled
|
||||
| AffectUpdate
|
||||
| AwaitingLlmFirstToken
|
||||
)
|
||||
|
||||
|
||||
@@ -284,6 +335,26 @@ def _envelope_for_type(body: dict[str, Any], sse_id: SseId) -> Event:
|
||||
reason=body.get("reason"),
|
||||
partial_message_id=body.get("partial_message_id"),
|
||||
)
|
||||
if t == "awaiting_llm_first_token":
|
||||
# Worldtree #201 / v0.29.0: top-level heartbeat during BuildingPrompt
|
||||
# → CallingLLM gap. Lets clients render live elapsed-time indicators
|
||||
# instead of frozen lines on legitimate-slow first-token latency.
|
||||
return AwaitingLlmFirstToken(
|
||||
sse_id=sse_id,
|
||||
turn_id=body["turn_id"],
|
||||
elapsed_ms_since_building_prompt=body["elapsed_ms_since_building_prompt"],
|
||||
)
|
||||
if t == "affect_update":
|
||||
# Worldtree #204 / v0.28.0: persona-state observability event.
|
||||
# status="current" carries full snapshot at turn start;
|
||||
# status="scheduled" omits snapshot (lightweight post-appraisal-
|
||||
# kickoff notification).
|
||||
return AffectUpdate(
|
||||
sse_id=sse_id,
|
||||
status=body["status"],
|
||||
turn_id=body["turn_id"],
|
||||
snapshot=body.get("snapshot"),
|
||||
)
|
||||
raise ValueError(f"unknown SSE event type: {t!r}")
|
||||
|
||||
|
||||
@@ -309,6 +380,14 @@ async def _iter_events(
|
||||
# with a bad id is still a keepalive). Don't reorder.
|
||||
if sse.data == "":
|
||||
continue
|
||||
# v0.8.1: empty-id frames are also treated as keepalives. Worldtree
|
||||
# SOMETIMES emits events without an `id:` line (observed mid-stream
|
||||
# on the qwen3.6-35-a3b-heretic provider, 2026-05-25). Per the SSE
|
||||
# RFC, events without ids are legitimate (they just don't update
|
||||
# Last-Event-ID); the previous strict behavior crashed every turn
|
||||
# on the offending agent. Treat same as empty-data: skip silently.
|
||||
if sse.id == "":
|
||||
continue
|
||||
try:
|
||||
sse_id = _parse_sse_id(sse.id)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""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/<id>
|
||||
- ``delete_agent`` — DELETE /agents/<id>
|
||||
|
||||
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
|
||||
already handles colon-containing agent_ids generically (issue #8); session
|
||||
creation works unchanged via ``ratatoskr.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 ``"<user_id>:<agent_name>"`` —
|
||||
constructed server-side from the auth's user_id + the supplied agent_name.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
user_id: str
|
||||
agent_name: str
|
||||
system_prompt: str
|
||||
model: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class Tier3QuotaExceeded(Exception):
|
||||
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
|
||||
on the Heimdall key. ``retry_after`` captures the Retry-After header
|
||||
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero)."""
|
||||
|
||||
def __init__(self, *, retry_after: int) -> None:
|
||||
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class Tier3UserIdUnsupported(Exception):
|
||||
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
|
||||
is not slug-safe per Phase 2.0 gate (spec §2626)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("tier3 caller user_id is not slug-safe")
|
||||
|
||||
|
||||
class Tier3FieldNotMutable(Exception):
|
||||
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
|
||||
carried a key that's immutable post-define (``agent_name``, ``user_id``,
|
||||
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3LayerDeferred(Exception):
|
||||
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
|
||||
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
|
||||
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
|
||||
|
||||
Note: ``define_agent`` never sends layer fields, so this exception is
|
||||
defense-against-server-side-changes / forward-compat. INV-001 in the
|
||||
request body construction is the first line of defense.
|
||||
"""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"tier3 layer field deferred: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
|
||||
(spec §2634 + §2641)."""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"tier3 agent not found: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
def _extract_error_code(resp: httpx.Response) -> str | None:
|
||||
"""Pluck the ``detail.error_code`` from a Worldtree error envelope.
|
||||
|
||||
Worldtree wraps API errors in ``{"detail": {"error_code": "...", ...}}``
|
||||
per the spec. Returns None on shape mismatch (so callers fall through
|
||||
to the generic ``SessionApiFailed`` branch).
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
code = detail.get("error_code")
|
||||
if isinstance(code, str):
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
def _extract_error_field(resp: httpx.Response) -> str | None:
|
||||
"""Pluck ``detail.field`` from a Worldtree error envelope (used for
|
||||
``field_not_mutable`` and ``layer_deferred`` to surface which field
|
||||
triggered the rejection). Returns None on shape mismatch.
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
field = detail.get("field")
|
||||
if isinstance(field, str):
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def _parse_tier3_agent_info(body: dict) -> Tier3AgentInfo:
|
||||
"""Parse a Worldtree Tier 3 agent JSON body into the frozen dataclass."""
|
||||
return Tier3AgentInfo(
|
||||
agent_id=body["agent_id"],
|
||||
user_id=body["user_id"],
|
||||
agent_name=body["agent_name"],
|
||||
system_prompt=body["system_prompt"],
|
||||
model=body["model"],
|
||||
created_at=body["created_at"],
|
||||
updated_at=body["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
async def define_agent(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
agent_name: str,
|
||||
system_prompt: str,
|
||||
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/<id> — mutate system_prompt and/or model.
|
||||
|
||||
See contract FN patch_agent. Per spec §2641: only system_prompt + model
|
||||
are mutable in Phase 2.0; any other key returns 422 field_not_mutable.
|
||||
"""
|
||||
assert client is not None
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
assert system_prompt is not None or 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/<id> — owner hard-delete (cancels active sessions
|
||||
server-side per spec §2636).
|
||||
|
||||
See contract FN delete_agent. 204 on success; 404 if the agent_id
|
||||
doesn't exist; other non-2xx → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
|
||||
resp = await client.delete(f"/agents/{agent_id}")
|
||||
if resp.status_code == 204:
|
||||
return
|
||||
if resp.status_code == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
# ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------
|
||||
#
|
||||
# Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes
|
||||
# mirror ratatoskr.cli: 0 happy / 10 usage / 11 auth / 20 api-failure /
|
||||
# 21 network. Outbound requests carry the same User-Agent string.
|
||||
|
||||
|
||||
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 "<user_id>:<agent_name>" 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 "<user_id>:<agent_name>" 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
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
add_local_agent,
|
||||
make_description,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
info = await define_agent(
|
||||
client,
|
||||
agent_name=ns.name,
|
||||
system_prompt=ns.system_prompt,
|
||||
model=ns.model,
|
||||
)
|
||||
# v0.8.0: persist to local index so the picker can show it.
|
||||
add_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=info.agent_id,
|
||||
agent_name=info.agent_name,
|
||||
model=info.model,
|
||||
description=make_description(info.system_prompt),
|
||||
defined_at=info.created_at,
|
||||
)
|
||||
)
|
||||
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
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
make_description,
|
||||
update_local_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,
|
||||
)
|
||||
# v0.8.0: refresh local index with the post-patch state.
|
||||
update_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=info.agent_id,
|
||||
agent_name=info.agent_name,
|
||||
model=info.model,
|
||||
description=make_description(info.system_prompt),
|
||||
defined_at=info.updated_at,
|
||||
)
|
||||
)
|
||||
print(f"patched {info.agent_id}")
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_delete(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr.local_agents import remove_local_agent
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
await delete_agent(client, ns.agent_id)
|
||||
# v0.8.0: drop from local index so the picker stops listing it.
|
||||
remove_local_agent(ns.agent_id)
|
||||
print(f"deleted {ns.agent_id}")
|
||||
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())
|
||||
+909
-129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
"""Tests for ratatoskr.local_agents.
|
||||
|
||||
Use ``$RATATOSKR_LOCAL_AGENTS`` env-var override + pytest tmp_path to
|
||||
isolate from the operator's real ``~/.config/ratatoskr/local_agents.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
_local_agents_path,
|
||||
add_local_agent,
|
||||
load_local_agents,
|
||||
make_description,
|
||||
remove_local_agent,
|
||||
update_local_agent,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point $RATATOSKR_LOCAL_AGENTS at a fresh tmp file for the test."""
|
||||
path = tmp_path / "local_agents.json"
|
||||
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path))
|
||||
return path
|
||||
|
||||
|
||||
def _entry(
|
||||
agent_id: str = "ratatoskr:wizard",
|
||||
agent_name: str = "wizard",
|
||||
model: str = "qwen3.6-35-a3b",
|
||||
description: str = "(tier 3) test agent",
|
||||
defined_at: str = "2026-05-25T00:00:00+00:00",
|
||||
) -> LocalAgentEntry:
|
||||
return LocalAgentEntry(
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
model=model,
|
||||
description=description,
|
||||
defined_at=defined_at,
|
||||
)
|
||||
|
||||
|
||||
class TestPathResolution:
|
||||
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", "/tmp/custom-agents.json")
|
||||
assert _local_agents_path() == Path("/tmp/custom-agents.json")
|
||||
|
||||
def test_xdg_config_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
|
||||
assert (
|
||||
_local_agents_path()
|
||||
== Path("/tmp/xdg-config/ratatoskr/local_agents.json")
|
||||
)
|
||||
|
||||
def test_default_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
path = _local_agents_path()
|
||||
assert path == Path.home() / ".config" / "ratatoskr" / "local_agents.json"
|
||||
|
||||
|
||||
class TestLoadEmpty:
|
||||
def test_missing_file_returns_empty(self, local_path: Path) -> None:
|
||||
assert not local_path.exists()
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_corrupt_json_returns_empty(self, local_path: Path) -> None:
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_text("not json at all")
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_wrong_schema_version_returns_empty(self, local_path: Path) -> None:
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_text(json.dumps({"version": 999, "agents": []}))
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_missing_version_key_returns_empty(self, local_path: Path) -> None:
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_text(json.dumps({"agents": []}))
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_malformed_row_skipped(self, local_path: Path) -> None:
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"agents": [
|
||||
{"agent_id": "incomplete"}, # missing required fields
|
||||
{
|
||||
"agent_id": "ratatoskr:good",
|
||||
"agent_name": "good",
|
||||
"model": "m",
|
||||
"description": "d",
|
||||
"defined_at": "t",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:good"
|
||||
|
||||
|
||||
class TestAdd:
|
||||
def test_add_one(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry())
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
|
||||
def test_add_two_different(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry(agent_id="ratatoskr:a", agent_name="a"))
|
||||
add_local_agent(_entry(agent_id="ratatoskr:b", agent_name="b"))
|
||||
ids = {e.agent_id for e in load_local_agents()}
|
||||
assert ids == {"ratatoskr:a", "ratatoskr:b"}
|
||||
|
||||
def test_add_replaces_same_id(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry(model="old-model"))
|
||||
add_local_agent(_entry(model="new-model"))
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].model == "new-model"
|
||||
|
||||
def test_creates_parent_dirs(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
nested = tmp_path / "deep" / "nested" / "path" / "agents.json"
|
||||
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(nested))
|
||||
add_local_agent(_entry())
|
||||
assert nested.exists()
|
||||
|
||||
|
||||
class TestUpdate:
|
||||
def test_update_changes_existing(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry(model="v1"))
|
||||
update_local_agent(_entry(model="v2"))
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].model == "v2"
|
||||
|
||||
|
||||
class TestRemove:
|
||||
def test_remove_existing(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry())
|
||||
remove_local_agent("ratatoskr:wizard")
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_remove_missing_is_noop(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry())
|
||||
remove_local_agent("ratatoskr:doesnotexist")
|
||||
assert len(load_local_agents()) == 1
|
||||
|
||||
|
||||
class TestMakeDescription:
|
||||
def test_first_nonempty_line(self) -> None:
|
||||
prompt = "\n\n# IDENTITY\nYou are a test agent..."
|
||||
desc = make_description(prompt)
|
||||
assert desc.startswith("(tier 3) IDENTITY")
|
||||
|
||||
def test_strips_heading_markers(self) -> None:
|
||||
prompt = "# A nice heading\nMore prompt..."
|
||||
desc = make_description(prompt)
|
||||
assert "(tier 3) A nice heading" == desc
|
||||
|
||||
def test_truncates_long(self) -> None:
|
||||
prompt = "x" * 200
|
||||
desc = make_description(prompt)
|
||||
# 80 char cap including the prefix
|
||||
assert len(desc) == 81 # 80 + ellipsis char
|
||||
assert desc.endswith("…")
|
||||
|
||||
def test_empty_prompt_fallback(self) -> None:
|
||||
desc = make_description("")
|
||||
assert desc == "(tier 3) custom system prompt"
|
||||
|
||||
def test_whitespace_only_fallback(self) -> None:
|
||||
desc = make_description(" \n\n ")
|
||||
assert desc == "(tier 3) custom system prompt"
|
||||
@@ -6,11 +6,15 @@ import respx
|
||||
|
||||
from ratatoskr.sessions import (
|
||||
AgentInfo,
|
||||
AgentNotAvailable,
|
||||
AgentNotFound,
|
||||
AuthScopeDenied,
|
||||
InvalidCursor,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
SessionPage,
|
||||
create_session,
|
||||
get_persona_state,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
)
|
||||
@@ -556,3 +560,108 @@ class TestListAgents:
|
||||
with pytest.raises(SessionApiFailed) as excinfo:
|
||||
await list_agents(client)
|
||||
assert excinfo.value.status == 401
|
||||
|
||||
|
||||
class TestGetPersonaState:
|
||||
"""Worldtree #204 / v0.28.0 — GET /agents/{agent_id}/persona_state.
|
||||
|
||||
Bootstrap read for the persona snapshot — same shape as `affect_update`'s
|
||||
`current` snapshot. Auth via `persona.read` scope (user-tier default).
|
||||
"""
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_full_snapshot(self) -> None:
|
||||
"""happy_full_snapshot [happy,tracer]: 200 → snapshot dict with pad +
|
||||
dominant_emotion + emotions_active + baseline_pad + mood_drift.
|
||||
"""
|
||||
snapshot = {
|
||||
"agent_id": "mimir",
|
||||
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
||||
"dominant_emotion": "curiosity",
|
||||
"emotions_active": [
|
||||
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
|
||||
],
|
||||
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
|
||||
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
|
||||
"last_updated_at": "2026-05-25T22:30:18+00:00",
|
||||
}
|
||||
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||
return_value=httpx.Response(200, json=snapshot)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await get_persona_state(client, "mimir")
|
||||
assert result == snapshot
|
||||
|
||||
@respx.mock
|
||||
async def test_persona_not_configured_404(self) -> None:
|
||||
"""persona_not_configured_404 [error]: 404 with error_code
|
||||
persona_not_configured → PersonaNotConfigured. Agent exists but has
|
||||
no persona surface (e.g. domari, muninn, Tier 3).
|
||||
"""
|
||||
respx.get("https://w.example/agents/domari/persona_state").mock(
|
||||
return_value=httpx.Response(
|
||||
404, json={"error_code": "persona_not_configured", "message": "no persona"}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(PersonaNotConfigured) as exc_info:
|
||||
await get_persona_state(client, "domari")
|
||||
assert exc_info.value.agent_id == "domari"
|
||||
|
||||
@respx.mock
|
||||
async def test_agent_not_available_404(self) -> None:
|
||||
"""agent_not_available_404 [error]: 404 with error_code
|
||||
agent_not_available → AgentNotAvailable. Distinct from
|
||||
persona_not_configured — the agent_id itself is unknown.
|
||||
"""
|
||||
respx.get("https://w.example/agents/bogus/persona_state").mock(
|
||||
return_value=httpx.Response(
|
||||
404, json={"error_code": "agent_not_available", "message": "unknown agent"}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AgentNotAvailable) as exc_info:
|
||||
await get_persona_state(client, "bogus")
|
||||
assert exc_info.value.agent_id == "bogus"
|
||||
|
||||
@respx.mock
|
||||
async def test_auth_scope_denied_403(self) -> None:
|
||||
"""auth_scope_denied_403 [error]: 403 with error_code auth_scope_denied
|
||||
→ AuthScopeDenied. Key lacks `persona.read` scope.
|
||||
"""
|
||||
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||
return_value=httpx.Response(
|
||||
403,
|
||||
json={"error_code": "auth_scope_denied", "message": "missing persona.read"},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AuthScopeDenied) as exc_info:
|
||||
await get_persona_state(client, "mimir")
|
||||
assert exc_info.value.scope == "persona.read"
|
||||
|
||||
@respx.mock
|
||||
async def test_404_unknown_error_code_falls_through(self) -> None:
|
||||
"""404_unknown_error_code_falls_through [adversarial]: 404 without the
|
||||
two known error codes → SessionApiFailed (don't swallow novel failure
|
||||
modes as something more specific than they are).
|
||||
"""
|
||||
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||
return_value=httpx.Response(404, json={"error_code": "novel_404"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc_info:
|
||||
await get_persona_state(client, "mimir")
|
||||
assert exc_info.value.status == 404
|
||||
|
||||
@respx.mock
|
||||
async def test_500_unexpected_status(self) -> None:
|
||||
"""500_unexpected_status [error]: 5xx → SessionApiFailed (matches the
|
||||
list_agents / list_sessions / create_session precedent)."""
|
||||
respx.get("https://w.example/agents/mimir/persona_state").mock(
|
||||
return_value=httpx.Response(500, content=b"boom")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc_info:
|
||||
await get_persona_state(client, "mimir")
|
||||
assert exc_info.value.status == 500
|
||||
|
||||
@@ -5,6 +5,8 @@ import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
Cancelled,
|
||||
CancelResult,
|
||||
@@ -711,6 +713,47 @@ def _sse_raw_chunk(sse_id: str, raw_data: str) -> bytes:
|
||||
return f"id: {sse_id}\ndata: {raw_data}\n\n".encode()
|
||||
|
||||
|
||||
def _sse_no_id_chunk(data: str) -> bytes:
|
||||
"""SSE frame with NO id line + arbitrary data (v0.8.1: keepalive shape)."""
|
||||
return f"data: {data}\n\n".encode()
|
||||
|
||||
|
||||
class TestEmptyIdSkipped:
|
||||
@respx.mock
|
||||
async def test_empty_id_on_first_event_skipped(self) -> None:
|
||||
"""empty_id_on_first_event_skipped [v0.8.1]: stream starts with an
|
||||
event carrying NO `id:` line → httpx_sse exposes sse.id == ''
|
||||
(no prior id to inherit). Pre-v0.8.1: MalformedSseId raw='' crashed
|
||||
the turn. v0.8.1: treat same as empty-data keepalive — skip silently.
|
||||
|
||||
Observed 2026-05-25 on Worldtree's qwen3.6-35-a3b-heretic provider:
|
||||
the first stream frame had no id line, every turn died with
|
||||
`[malformed_sse_id] raw=''`.
|
||||
"""
|
||||
from ratatoskr.sse_client import Done as _Done
|
||||
from ratatoskr.sse_client import Text as _Text
|
||||
|
||||
# First frame: no id line (httpx_sse → sse.id = ""). Skip it.
|
||||
# Subsequent frames have ids; normal processing resumes.
|
||||
stream = (
|
||||
_sse_no_id_chunk('{"type":"keepalive"}') # ← skipped (sse.id == "")
|
||||
+ _sse_chunk("42:1", {"type": "text", "content": "first"})
|
||||
+ _sse_chunk("42:2", _DONE_42_6)
|
||||
)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
# 2 events — the no-id frame is invisible (no MalformedSseId crash).
|
||||
assert len(events) == 2
|
||||
assert isinstance(events[0], _Text)
|
||||
assert events[0].content == "first"
|
||||
assert isinstance(events[1], _Done)
|
||||
|
||||
|
||||
class TestEmptyDataSkipped:
|
||||
@respx.mock
|
||||
async def test_empty_data_skipped(self) -> None:
|
||||
@@ -836,3 +879,164 @@ class TestEmptyDataSkipped:
|
||||
assert exc_info.value.raw == "x" * 200
|
||||
# Exception message also only contains the truncated form
|
||||
assert "x" * 5000 not in str(exc_info.value)
|
||||
|
||||
|
||||
class TestAffectUpdate:
|
||||
"""Worldtree #204 / v0.28.0 — persona-state observability SSE event.
|
||||
|
||||
Two emissions per qualifying turn (persona-enabled agent, non-ephemeral
|
||||
session): `status: "current"` at turn start with full snapshot, then
|
||||
`status: "scheduled"` near turn end (lightweight, no snapshot).
|
||||
|
||||
See docs/conversation-api-spec.md § affect_update.
|
||||
"""
|
||||
|
||||
@respx.mock
|
||||
async def test_current_status_parsed_with_snapshot(self) -> None:
|
||||
"""current_status_parsed_with_snapshot [tracer]: status=current carries
|
||||
the full snapshot dict; AffectUpdate.snapshot is populated with the
|
||||
nested PAD / dominant_emotion / emotions_active fields.
|
||||
"""
|
||||
snapshot = {
|
||||
"agent_id": "mimir",
|
||||
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
||||
"dominant_emotion": "curiosity",
|
||||
"emotions_active": [
|
||||
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
|
||||
],
|
||||
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
|
||||
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
|
||||
"last_updated_at": "2026-05-25T22:30:18+00:00",
|
||||
}
|
||||
stream = _sse_chunk(
|
||||
"42:1",
|
||||
{
|
||||
"type": "affect_update",
|
||||
"status": "current",
|
||||
"turn_id": 42,
|
||||
"snapshot": snapshot,
|
||||
},
|
||||
) + _sse_chunk("42:2", _DONE_42_6)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
affect = events[0]
|
||||
assert isinstance(affect, AffectUpdate)
|
||||
assert affect.status == "current"
|
||||
assert affect.turn_id == 42
|
||||
assert affect.snapshot == snapshot
|
||||
assert affect.sse_id == SseId(42, 1)
|
||||
|
||||
@respx.mock
|
||||
async def test_scheduled_status_parsed_no_snapshot(self) -> None:
|
||||
"""scheduled_status_parsed_no_snapshot [trace]: status=scheduled carries
|
||||
no snapshot field; AffectUpdate.snapshot is None.
|
||||
"""
|
||||
stream = (
|
||||
_sse_chunk("42:1", {"type": "text", "content": "x"})
|
||||
+ _sse_chunk(
|
||||
"42:2",
|
||||
{"type": "affect_update", "status": "scheduled", "turn_id": 42},
|
||||
)
|
||||
+ _sse_chunk("42:3", _DONE_42_6)
|
||||
)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
affect = next(e for e in events if isinstance(e, AffectUpdate))
|
||||
assert affect.status == "scheduled"
|
||||
assert affect.turn_id == 42
|
||||
assert affect.snapshot is None
|
||||
assert affect.sse_id == SseId(42, 2)
|
||||
|
||||
|
||||
class TestAwaitingLlmFirstToken:
|
||||
"""Worldtree #201 / v0.29.0 — `awaiting_llm_first_token` SSE heartbeat.
|
||||
|
||||
Top-level event (not a worker_phase extension) fired during the
|
||||
BuildingPrompt → CallingLLM gap at the configured interval (default
|
||||
5s). Server-authoritative elapsed_ms is time.monotonic()-based and
|
||||
monotonically increasing across the heartbeat sequence.
|
||||
|
||||
See docs/conversation-api-spec.md § awaiting_llm_first_token.
|
||||
"""
|
||||
|
||||
@respx.mock
|
||||
async def test_single_heartbeat_parsed(self) -> None:
|
||||
"""single_heartbeat_parsed [tracer]: type=awaiting_llm_first_token →
|
||||
AwaitingLlmFirstToken(turn_id, elapsed_ms_since_building_prompt).
|
||||
"""
|
||||
stream = _sse_chunk(
|
||||
"42:1",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 5012.3,
|
||||
},
|
||||
) + _sse_chunk("42:2", _DONE_42_6)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
beat = events[0]
|
||||
assert isinstance(beat, AwaitingLlmFirstToken)
|
||||
assert beat.turn_id == 42
|
||||
assert beat.elapsed_ms_since_building_prompt == 5012.3
|
||||
assert beat.sse_id == SseId(42, 1)
|
||||
|
||||
@respx.mock
|
||||
async def test_heartbeat_sequence_monotonic(self) -> None:
|
||||
"""heartbeat_sequence_monotonic [scenario]: three consecutive heartbeats
|
||||
in one turn — elapsed_ms_since_building_prompt monotonically increases,
|
||||
all carry the same turn_id.
|
||||
"""
|
||||
stream = (
|
||||
_sse_chunk(
|
||||
"42:1",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 5000.0,
|
||||
},
|
||||
)
|
||||
+ _sse_chunk(
|
||||
"42:2",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 10005.4,
|
||||
},
|
||||
)
|
||||
+ _sse_chunk(
|
||||
"42:3",
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 15011.8,
|
||||
},
|
||||
)
|
||||
+ _sse_chunk("42:4", _DONE_42_6)
|
||||
)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
beats = [e for e in events if isinstance(e, AwaitingLlmFirstToken)]
|
||||
assert len(beats) == 3
|
||||
elapsed = [b.elapsed_ms_since_building_prompt for b in beats]
|
||||
assert elapsed == sorted(elapsed) # monotonically increasing
|
||||
assert all(b.turn_id == 42 for b in beats)
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _isolated_local_agents(
|
||||
tmp_path: "Path", monkeypatch: pytest.MonkeyPatch
|
||||
) -> "Path":
|
||||
"""Isolate the v0.8.0 local-tier-3 index from the operator's real file."""
|
||||
path = tmp_path / "local_agents.json"
|
||||
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path))
|
||||
return path
|
||||
|
||||
|
||||
class TestCli:
|
||||
@respx.mock
|
||||
def test_cli_define_happy(
|
||||
self,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolated_local_agents: "Path",
|
||||
) -> None:
|
||||
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation;
|
||||
local index updated with the new entry (v0.8.0 hook).
|
||||
"""
|
||||
from ratatoskr.local_agents import load_local_agents
|
||||
|
||||
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)"
|
||||
# v0.8.0: local index now has the new entry.
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
assert entries[0].model == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_patch_happy(
|
||||
self,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolated_local_agents: "Path",
|
||||
) -> None:
|
||||
"""cli_patch_happy [happy]: argv → 200 mock → stdout confirmation;
|
||||
local index refreshed with the post-patch state.
|
||||
"""
|
||||
from ratatoskr.local_agents import load_local_agents
|
||||
|
||||
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"
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_delete_happy(
|
||||
self,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolated_local_agents: "Path",
|
||||
) -> None:
|
||||
"""cli_delete_happy [happy]: argv → 204 mock → stdout confirmation;
|
||||
local index entry removed (v0.8.0 hook).
|
||||
"""
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
add_local_agent,
|
||||
load_local_agents,
|
||||
)
|
||||
|
||||
# Pre-populate so we can verify removal.
|
||||
add_local_agent(LocalAgentEntry(
|
||||
agent_id="ratatoskr:wizard",
|
||||
agent_name="wizard",
|
||||
model="m",
|
||||
description="d",
|
||||
defined_at="t",
|
||||
))
|
||||
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"
|
||||
assert load_local_agents() == []
|
||||
|
||||
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
|
||||
+1004
-376
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user