Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00854ce618 | |||
| 78bfcadb9e | |||
| 44138590ad | |||
| d516537b08 | |||
| 92aa05c688 | |||
| 209427ab23 | |||
| 139771c8d8 | |||
| 489cfee1f0 | |||
| 11ef6830ab | |||
| 9fade55901 | |||
| 9918c10acf | |||
| c086ae2b32 | |||
| d3569904bc | |||
| 82437bd4b9 | |||
| ac690c11d5 |
+13
-5
@@ -7,11 +7,19 @@ documents the pin, the vendored artifacts, and the bump procedure.
|
|||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Worldtree git SHA | `55101e909abcd2219833266b6f905c5bc956e0f0` |
|
| Worldtree git SHA | `562001af28d752c3a60d449c7ddd09f44fa9dc9a` |
|
||||||
| Worldtree HEAD message | `memory: snapshot — #177 Vili v1 + persona async-decouple shipped as v0.19.0` |
|
| Worldtree HEAD message | `feat(#201): v0.29.0 — awaiting_llm_first_token SSE heartbeat` |
|
||||||
| Pinned on | 2026-05-20 |
|
| Pinned on | 2026-05-26 |
|
||||||
| Pinned by | brokkr-smithy-dev (initial scaffold) |
|
| Pinned by | ratatoskr-dev (bump for #201 awaiting_llm_first_token SSE) |
|
||||||
| Worldtree version at pin | `v0.19.0` |
|
| 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
|
## Vendored artifacts
|
||||||
|
|
||||||
|
|||||||
@@ -161,9 +161,9 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w
|
|||||||
- **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-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-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-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** *(new v0.6.0)*: Closed thinking runs route to `thinking_log`, NOT `debug_log`. Each closed run writes three entries: `Rule(title=f"turn N · thinking #K start")`, `Markdown(content)`, `Rule(title=f"turn N · thinking #K end")` — the model's chain-of-thought is presented as rendered Markdown (model reasoning often has lists / code / structure) wrapped in operator-visible start/end markers. `thinking_run_index` increments per-run within a turn.
|
- **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-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.1)*: `thinking-current` Static lives INSIDE the Thinking TabPane (docked bottom, below `thinking-log`) — co-located with closed thinking runs so the operator sees streaming + history in one pane. Pre-v0.6.1 it sat above the TabbedContent (right-column header) which created a top/bottom discontinuity; the co-located shape resolves that. Trade-off: live thinking is now visible only when the Thinking tab is active (Ctrl+3). Prefix `thinking… ` self-identifies the widget contents.
|
- **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)
|
## 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
|
## 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.
|
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.
|
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
|
### thinking
|
||||||
|
|
||||||
Incremental reasoning/thinking content (from thinking-enabled models).
|
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
|
before any other processing; non-slug user_ids return 403
|
||||||
`tier3_user_id_unsupported`.
|
`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
|
### Storage extension
|
||||||
|
|
||||||
The `consumer_agents` table lives in `core/heimdall/storage/sqlite.py`
|
The `consumer_agents` table lives in `core/heimdall/storage/sqlite.py`
|
||||||
|
|||||||
+11
-3
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
|
|||||||
|
|
||||||
## Current state / in-flight
|
## Current state / in-flight
|
||||||
|
|
||||||
_As of 2026-05-24 (post-v0.6.2 neutral-dark palette):_
|
_As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_
|
||||||
|
|
||||||
**Status: v0.6.2 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
|
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
||||||
startup error visibility #6, presenter contract semantics amendment
|
startup error visibility #6, presenter contract semantics amendment
|
||||||
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
||||||
@@ -51,7 +51,15 @@ Static in the footer (static "Tools" v1; dynamic when more tabs
|
|||||||
land). CLI mode (--send) unaffected by design — INV-018.
|
land). CLI mode (--send) unaffected by design — INV-018.
|
||||||
|
|
||||||
Last commits on `main`:
|
Last commits on `main`:
|
||||||
- v0.6.2 style(tui): neutralize Australis dark palette — bg no longer blue-tinted
|
- 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)
|
- `8463eb2` style(tui): kill remaining blue + thinking-current into pane (v0.6.1)
|
||||||
- `cfee89a` refactor(tui): streaming + turn headers + Thinking pane (v0.6.0)
|
- `cfee89a` refactor(tui): streaming + turn headers + Thinking pane (v0.6.0)
|
||||||
- `7106af5` style(tui): UI polish pass — terminal label colors, placeholders (v0.5.1)
|
- `7106af5` style(tui): UI polish pass — terminal label colors, placeholders (v0.5.1)
|
||||||
|
|||||||
+4
-4
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.6.2"
|
version = "0.14.1"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
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
|
# Ratatoskr is built against Worldtree at this commit; the vendored
|
||||||
# spec snapshot in docs/ reflects that SHA.
|
# spec snapshot in docs/ reflects that SHA.
|
||||||
[tool.ratatoskr.spec-pin]
|
[tool.ratatoskr.spec-pin]
|
||||||
worldtree-spec-rev = "55101e909abcd2219833266b6f905c5bc956e0f0"
|
worldtree-spec-rev = "562001af28d752c3a60d449c7ddd09f44fa9dc9a"
|
||||||
worldtree-version = "v0.19.0"
|
worldtree-version = "v0.29.0"
|
||||||
pinned-on = "2026-05-20"
|
pinned-on = "2026-05-26"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/ratatoskr"]
|
packages = ["src/ratatoskr"]
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import httpx
|
|||||||
|
|
||||||
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
|
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
|
||||||
from ratatoskr.sse_client import (
|
from ratatoskr.sse_client import (
|
||||||
|
AffectUpdate,
|
||||||
|
AwaitingLlmFirstToken,
|
||||||
CancelAlreadyCompleted,
|
CancelAlreadyCompleted,
|
||||||
CancelFailed,
|
CancelFailed,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
@@ -205,6 +207,7 @@ class CliPresenterState:
|
|||||||
(
|
(
|
||||||
WorkerPhase, Thinking, Text, TextBoundary,
|
WorkerPhase, Thinking, Text, TextBoundary,
|
||||||
ToolStart, ToolResult, Done, Error, Cancelled,
|
ToolStart, ToolResult, Done, Error, Cancelled,
|
||||||
|
AffectUpdate, AwaitingLlmFirstToken,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Thinking events accumulate into the open run.
|
# 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"
|
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
|
||||||
)
|
)
|
||||||
return
|
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(
|
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
|
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(
|
async def list_sessions(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
*,
|
*,
|
||||||
@@ -200,3 +240,45 @@ async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
|
|||||||
)
|
)
|
||||||
for item in body
|
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
|
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 = (
|
Event = (
|
||||||
WorkerPhase
|
WorkerPhase
|
||||||
| Thinking
|
| Thinking
|
||||||
@@ -121,6 +170,8 @@ Event = (
|
|||||||
| Done
|
| Done
|
||||||
| Error
|
| Error
|
||||||
| Cancelled
|
| Cancelled
|
||||||
|
| AffectUpdate
|
||||||
|
| AwaitingLlmFirstToken
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -284,6 +335,26 @@ def _envelope_for_type(body: dict[str, Any], sse_id: SseId) -> Event:
|
|||||||
reason=body.get("reason"),
|
reason=body.get("reason"),
|
||||||
partial_message_id=body.get("partial_message_id"),
|
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}")
|
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.
|
# with a bad id is still a keepalive). Don't reorder.
|
||||||
if sse.data == "":
|
if sse.data == "":
|
||||||
continue
|
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:
|
try:
|
||||||
sse_id = _parse_sse_id(sse.id)
|
sse_id = _parse_sse_id(sse.id)
|
||||||
except ValueError as exc:
|
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())
|
||||||
+780
-221
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 (
|
from ratatoskr.sessions import (
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
|
AgentNotAvailable,
|
||||||
AgentNotFound,
|
AgentNotFound,
|
||||||
|
AuthScopeDenied,
|
||||||
InvalidCursor,
|
InvalidCursor,
|
||||||
|
PersonaNotConfigured,
|
||||||
SessionApiFailed,
|
SessionApiFailed,
|
||||||
SessionPage,
|
SessionPage,
|
||||||
create_session,
|
create_session,
|
||||||
|
get_persona_state,
|
||||||
list_agents,
|
list_agents,
|
||||||
list_sessions,
|
list_sessions,
|
||||||
)
|
)
|
||||||
@@ -556,3 +560,108 @@ class TestListAgents:
|
|||||||
with pytest.raises(SessionApiFailed) as excinfo:
|
with pytest.raises(SessionApiFailed) as excinfo:
|
||||||
await list_agents(client)
|
await list_agents(client)
|
||||||
assert excinfo.value.status == 401
|
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
|
import respx
|
||||||
|
|
||||||
from ratatoskr.sse_client import (
|
from ratatoskr.sse_client import (
|
||||||
|
AffectUpdate,
|
||||||
|
AwaitingLlmFirstToken,
|
||||||
CancelAlreadyCompleted,
|
CancelAlreadyCompleted,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
CancelResult,
|
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()
|
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:
|
class TestEmptyDataSkipped:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_empty_data_skipped(self) -> None:
|
async def test_empty_data_skipped(self) -> None:
|
||||||
@@ -836,3 +879,164 @@ class TestEmptyDataSkipped:
|
|||||||
assert exc_info.value.raw == "x" * 200
|
assert exc_info.value.raw == "x" * 200
|
||||||
# Exception message also only contains the truncated form
|
# Exception message also only contains the truncated form
|
||||||
assert "x" * 5000 not in str(exc_info.value)
|
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
|
||||||
+917
-363
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user