Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be171304f5 | |||
| 4aec3061d5 |
@@ -0,0 +1,142 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "ratatoskr.first_message"
|
||||
purpose: "Per-agent authored first-message presets — seed an agent's opening as a #347 authored turn-0 onto new sessions (CLI + web), the durable replacement for a system-prompt startup instruction."
|
||||
touches:
|
||||
- src/ratatoskr/first_message.py
|
||||
- tests/test_first_message.py
|
||||
scope: >
|
||||
Per-agent authored first-message presets (Worldtree #347 consumer feature).
|
||||
When a new session is created for an agent that has a preset opening, seed it
|
||||
as a #347 authored first-message (POST /sessions/{id}/history, author=assistant,
|
||||
seq-0) so the session opens in-character before the user speaks — the durable
|
||||
replacement for a system-prompt "startup" instruction. Two entry points:
|
||||
`preset_for` (lookup) and `seed_preset_first_message` (best-effort seed).
|
||||
Consumed by ratatoskr.cli (the `--new` session path) and ratatoskr.web.server
|
||||
(the POST /api/sessions endpoint). Depends on ratatoskr.sessions
|
||||
(write_authored_history + its exceptions); no core.* / worldtree.* imports.
|
||||
depends_on:
|
||||
- "httpx"
|
||||
- "ratatoskr.sessions"
|
||||
used_by:
|
||||
- "ratatoskr.cli"
|
||||
- "ratatoskr.web.server"
|
||||
language: "python"
|
||||
complexity: "low"
|
||||
estimated_loc: 60
|
||||
confidence: 0.9
|
||||
assumptions:
|
||||
- "write_authored_history (contract #2 amendment 2026-07-06) is the seed primitive: 200/201 → ack dict, 404 → AuthoredHistoryUnavailable (hide-existence), other non-2xx → SessionApiFailed."
|
||||
- "The preset registry is a static in-module dict keyed by agent_id; editing it is how an operator tunes an agent's opening. Seeded with ratatoskr:sindra only."
|
||||
- "Auto-seed is BEST-EFFORT and MUST NOT block session creation: an instance without the session.history.write grant returns the hide-404, which is swallowed (session opens with no seeded greeting)."
|
||||
---
|
||||
|
||||
# First-message presets — authored openings on session-create (#347)
|
||||
|
||||
## Context
|
||||
|
||||
`ratatoskr.first_message` holds per-agent authored-opening presets and seeds them
|
||||
onto new sessions via the #347 authored-history-write primitive. It is the
|
||||
durable form of "give an agent a first message": instead of a system-prompt
|
||||
`Startup:` instruction (a workaround for the pre-#347 world where the assistant
|
||||
could not author turn-0), the opening lives as a real seeded assistant turn-0.
|
||||
|
||||
Consumed at both session-create sites — `ratatoskr.cli._amain` (the `--new` path)
|
||||
and `ratatoskr.web.server._create_session_endpoint` (POST /api/sessions) — so
|
||||
every new session for a preset agent opens in-character regardless of surface.
|
||||
|
||||
## Data flow
|
||||
|
||||
**In:** a live `httpx.AsyncClient` (caller-owned, base_url + bearer set), a fresh
|
||||
`session_id`, and the bound `agent_id`.
|
||||
|
||||
**Out:** on a preset agent, one `POST /sessions/{session_id}/history` (author=assistant,
|
||||
the preset text, per-content idempotency key). Returns the seeded content on
|
||||
success, else `None`.
|
||||
|
||||
**Side effects:** at most one outbound authored-history write; never raises to the
|
||||
caller (best-effort).
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-001 [hard]**: `seed_preset_first_message` NEVER raises (the sole exception is
|
||||
`asyncio.CancelledError`, which propagates — cancellation is not a seed failure) and
|
||||
NEVER blocks session creation. It soft-guards its inputs (a bad arg returns `None`,
|
||||
not `AssertionError`), bounds the write with `asyncio.wait_for(_SEED_TIMEOUT_S)` so a
|
||||
stalled `/history` can't hang the create path, and swallows EVERY other exception (the
|
||||
hide-404, `SessionApiFailed`, `httpx.HTTPError`, `TimeoutError`, and any unexpected
|
||||
error) → `None`. The `broad-except` is deliberate: this helper is wired INTO three
|
||||
session-create paths, so any escape would abort a create that already succeeded.
|
||||
- **INV-002 [hard]**: a no-preset agent issues ZERO HTTP (early return before any
|
||||
request).
|
||||
- **INV-003 [hard]**: the seed body is the preset text verbatim, author="assistant",
|
||||
with a per-content idempotency key (`"ratatoskr-preset-" + sha256(text)[:12]`), so
|
||||
a repeat seed of the same session+preset is an idempotent 200 replay, never a
|
||||
duplicate turn.
|
||||
- **INV-004 [hard]**: no `core.*` / `worldtree.*` imports (reference-consumer
|
||||
boundary; verified by `tests/test_no_worldtree_imports.py`, which rglobs every
|
||||
`.py` under `src/ratatoskr/` — this module included, so no per-module import
|
||||
test is needed here).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Multi-turn / scripted openers.** v1 seeds exactly one assistant turn-0. A
|
||||
multi-message opening scene is a future concern.
|
||||
- **Runtime/remote preset config.** The registry is an in-module dict; no file/DB/env
|
||||
loading. Add that only when a second consumer needs operator-editable presets.
|
||||
- **Non-assistant authors.** v1 is author=assistant only (matches #347 v1); a
|
||||
user/system opener is deferred with the #347 engine surface.
|
||||
- **TUI-only surfaces.** Both real session-create paths (CLI + web) are wired; the
|
||||
bare-TUI picker resumes existing sessions (no create), so it needs no seed.
|
||||
|
||||
---
|
||||
|
||||
```contract
|
||||
FN preset_for(agent_id: str) -> str | None
|
||||
BRIEF: Return the authored first-message preset for agent_id, or None when the agent has no preset. Pure dict lookup over FIRST_MESSAGE_PRESETS.
|
||||
PRE: [PRE-001 hard] agent_id is a non-empty str -- assert agent_id and isinstance(agent_id, str)
|
||||
POST: [POST-001 return_value] returns FIRST_MESSAGE_PRESETS.get(agent_id) (str for a preset agent, None otherwise)
|
||||
STEPS:
|
||||
1. [setup, prescriptive] assert PRE-001
|
||||
2. [sequential, prescriptive] RETURN FIRST_MESSAGE_PRESETS.get(agent_id)
|
||||
TESTS:
|
||||
preset_hit [happy]: preset_for("ratatoskr:sindra") is a non-empty str
|
||||
preset_miss [happy]: preset_for("mimir") is None
|
||||
empty_agent_id [adversarial]: preset_for("") → AssertionError
|
||||
|
||||
FN seed_preset_first_message(client: httpx.AsyncClient, session_id: str, agent_id: str) -> str | None
|
||||
BRIEF: Best-effort seed of an agent's preset opening as a #347 authored first-message on session_id. If agent_id has a preset, POST it via write_authored_history (author=assistant, per-content idempotency key, the await bounded by asyncio.wait_for(_SEED_TIMEOUT_S)) and return the seeded content; on no-preset, a malformed input, OR ANY exception except asyncio.CancelledError, return None WITHOUT raising. Never raises (except CancelledError, which propagates) and never blocks session creation — it is wired into three create paths.
|
||||
PRE: [PRE-001 hard] client is not None -- soft-guarded: return None (NOT assert) if violated, so a wiring bug can't crash the create path (INV-001)
|
||||
PRE: [PRE-002 hard] session_id is a non-empty str -- soft-guarded: return None if violated
|
||||
PRE: [PRE-003 hard] agent_id is a non-empty str -- soft-guarded: return None if violated (also guards FIRST_MESSAGE_PRESETS.get against a non-hashable/non-str id)
|
||||
POST: [POST-001 return_value] preset agent + successful write → returns the preset text; no-preset, malformed input, OR any swallowed failure → None
|
||||
POST: [POST-002 side_effect] a no-preset / malformed-input call issues ZERO HTTP; a preset agent issues exactly one POST /sessions/{session_id}/history with body author="assistant", content=preset, idempotency_key="ratatoskr-preset-"+sha256(preset)[:12], the await bounded by _SEED_TIMEOUT_S so a stalled response cannot block
|
||||
ERROR_ROUTING:
|
||||
asyncio.CancelledError:
|
||||
local_handling: RE-RAISE (cancellation is not a seed failure; never swallow it — and it is a BaseException, so `except Exception` would miss it anyway)
|
||||
flow_control: propagate
|
||||
state_recovery: n/a
|
||||
any other Exception (hide-404 AuthoredHistoryUnavailable, SessionApiFailed 409/422/etc., httpx.HTTPError, TimeoutError from wait_for, any unexpected error):
|
||||
local_handling: swallow; return None
|
||||
flow_control: continue (never blocks session create)
|
||||
state_recovery: session opens with no seeded greeting
|
||||
STEPS:
|
||||
1. [setup, prescriptive] Soft-guard: IF agent_id is not a non-empty str: RETURN None (before any dict lookup — guards a non-hashable id)
|
||||
2. [sequential, prescriptive] content = FIRST_MESSAGE_PRESETS.get(agent_id); IF content is None: RETURN None (INV-002 — zero HTTP)
|
||||
3. [sequential, prescriptive] Soft-guard: IF client is None OR session_id is not a non-empty str: RETURN None
|
||||
4. [sequential, prescriptive] key = "ratatoskr-preset-" + sha256(content utf-8)[:12]
|
||||
5. [sequential, prescriptive] TRY: await asyncio.wait_for(write_authored_history(client, session_id, content=content, idempotency_key=key), timeout=_SEED_TIMEOUT_S)
|
||||
tool: { destructive: false, idempotent: true, read_only: false, open_world: false }
|
||||
6. [branch, prescriptive] EXCEPT asyncio.CancelledError: RAISE; EXCEPT Exception: RETURN None
|
||||
7. [cleanup, prescriptive] RETURN content
|
||||
TESTS:
|
||||
seeds_preset [happy,tracer]: preset agent, mock 201 → returns the preset text; exactly one POST /sessions/{id}/history; body author="assistant" + content=preset + idempotency_key="ratatoskr-preset-"+sha256(preset)[:12]
|
||||
no_preset_zero_http [happy]: agent "mimir" → returns None; NO HTTP issued
|
||||
feature_absent_swallowed [error]: preset agent, mock 404 session_not_found → returns None, no raise
|
||||
session_api_failed_swallowed [error]: preset agent, mock 409 → returns None, no raise
|
||||
transport_error_swallowed [error]: preset agent, mock httpx.ConnectError → returns None, no raise
|
||||
unexpected_exception_swallowed [error]: preset agent, write raises ValueError → returns None, no raise (INV-001 broad never-raise)
|
||||
cancellation_propagates [error]: preset agent, write raises asyncio.CancelledError → RE-RAISED (never swallowed)
|
||||
malformed_agent_id_no_http [adversarial]: agent_id=123 (non-str) OR "" → None; NO HTTP; no raise
|
||||
empty_session_id [adversarial]: session_id="" (preset agent) → None (soft guard); NO HTTP; no raise
|
||||
```
|
||||
@@ -69,7 +69,7 @@ sub-gap).
|
||||
|---|---|---|---|
|
||||
| `POST /sessions` | ✅ | `sessions.py:307` → `cli.py:482`,`tui.py:1508`,`web/server.py:155` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed |
|
||||
| `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) |
|
||||
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history` → `cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **Live-proof pending** the `session.history.write` grant (requested infra-ops 2026-07-06) — ungranted returns the hide-404, so the probe exercises the feature-absent fallback until granted (Tier-2 precedent: ✅ code-complete + graceful-degrade) |
|
||||
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history` → `cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **LIVE-PROVEN 2026-07-06** on personal :8081 (grant applied via a rule-based Heimdall allow, worldtree-dev): create mimir session → seed → **201** (seq=0, phase=seeded, turn_id=1798) → GET /messages reads it back as a plain role=assistant turn (model-invisible provenance confirmed). Hide-404 for ungranted is unit+probe covered |
|
||||
| `GET /sessions/{id}/messages` (history) | ✅ | `sessions.py:635` `get_session_messages` → `cli.py:758` `--seed-first-message` read-back | un-deferred as the #347 seed read-back — confirms model-invisible provenance (a seed reads back as a normal `role=assistant` turn) |
|
||||
| `POST /sessions/{id}/turns/{turn_id}/cancel` | ✅ | `sse_client.py:581` → cli/tui/web | two-stage Ctrl-C; 404/409 mapped |
|
||||
| `GET /agents` | ✅ | `sessions.py:341` → `tui.py:1472`,`web/server.py:100` | Tier-1 roster; merged with local index |
|
||||
|
||||
@@ -41,7 +41,7 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
_As of 2026-07-06:_
|
||||
|
||||
**LATEST (2026-07-06 cont.): #347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) + OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`).** worldtree-dev shipped #347 as spec 2.3.0 (deployed on personal b22 `879cefe`); ratatoskr built the consumer side via direct in-session TDD: `write_authored_history` (POST /sessions/{id}/history) + `get_session_messages` (un-deferred read-back) + a `--seed-first-message` one-shot probe (create session -> seed -> read-back), with **404-as-feature-absent per hide-existence** (`AuthoredHistoryUnavailable`, distinct from SessionApiFailed; caller never capability-probes). Contract #2 amended + TDD (19 new tests; suite 601 green; ruff clean; mypy only the sibling-consistent `resp.json()` no-any-return). Coverage-map re-converged: **REST 19/41** (#347 route + messages read-back close the one gap the re-vendor opened). **LIVE-PROOF PENDING** the `session.history.write` grant (requested infra-ops `01KWW3KQEY`, monitor armed) -- ungranted the route returns the hide-404, so the probe exercises the feature-absent fallback until granted. **OPEN TAIL-2 (worldtree-dev `c9e59ec`, LOCAL not-yet-origin):** Tier-3 persona/memory/persona_state PROSE docs landed in `docs/conversation-api-spec.md` § "Tier 3" (they serialize as freeform `Any` in the OpenAPI JSON, hence prose-not-schema) -> (a) prose markdown re-vendor pending (tolerate_drift pin), (b) a **likely `set_persona_state` body-shape drift to align**: my `--set-persona-pad` sends `{pad:[list]}`, the doc's canonical is `{pad:{pleasure,arousal,dominance}}` (PAD-only #317, pull-over-push #289, cross-owner 404; never live-proven so untested). worldtree-dev foot-guns: persona.ocean = SINGLE-LETTER UPPERCASE `{O,C,E,A,N}` on /agents/define (spelled-out -> 422; the #348 mismatch) vs spelled-out lowercase on POST /characters; memory = `{embedder_version(==pinned else 422), tier3_dreaming}`, stm_* deprecated no-ops, allows_world_scope removed->422; only `valence` still 422s (layer_deferred).
|
||||
**LATEST (2026-07-06 cont.): #347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) + OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`).** worldtree-dev shipped #347 as spec 2.3.0 (deployed on personal b22 `879cefe`); ratatoskr built the consumer side via direct in-session TDD: `write_authored_history` (POST /sessions/{id}/history) + `get_session_messages` (un-deferred read-back) + a `--seed-first-message` one-shot probe (create session -> seed -> read-back), with **404-as-feature-absent per hide-existence** (`AuthoredHistoryUnavailable`, distinct from SessionApiFailed; caller never capability-probes). Contract #2 amended + TDD (19 new tests; suite 601 green; ruff clean; mypy only the sibling-consistent `resp.json()` no-any-return). Coverage-map re-converged: **REST 19/41** (#347 route + messages read-back close the one gap the re-vendor opened). **LIVE-PROVEN 2026-07-06 on personal :8081.** Vuong approved the `session.history.write` grant; worldtree-dev authored a **rule-based Heimdall allow** (the PDP is rule-based, NOT scope-on-key -- our key user_id=ratatoskr is unchanged; policy: user_id=ratatoskr->ALLOW, all others->DENY with hide-404 preserved), applied to personal's bind-mounted `policies.yaml` by infra-ops. Smoke: create mimir session -> seed -> **201** (seq=0, phase=seeded, turn_id=1798) -> GET /messages reads it back as a plain role=assistant turn (**model-invisible provenance confirmed**). The full #347 consumer side is now live-proven; hide-404 for ungranted stays unit+probe covered. **OPEN TAIL-2 (worldtree-dev `c9e59ec`, LOCAL not-yet-origin):** Tier-3 persona/memory/persona_state PROSE docs landed in `docs/conversation-api-spec.md` § "Tier 3" (they serialize as freeform `Any` in the OpenAPI JSON, hence prose-not-schema) -> (a) prose markdown re-vendor pending (tolerate_drift pin), (b) a **likely `set_persona_state` body-shape drift to align**: my `--set-persona-pad` sends `{pad:[list]}`, the doc's canonical is `{pad:{pleasure,arousal,dominance}}` (PAD-only #317, pull-over-push #289, cross-owner 404; never live-proven so untested). worldtree-dev foot-guns: persona.ocean = SINGLE-LETTER UPPERCASE `{O,C,E,A,N}` on /agents/define (spelled-out -> 422; the #348 mismatch) vs spelled-out lowercase on POST /characters; memory = `{embedder_version(==pinned else 422), tier3_dreaming}`, stm_* deprecated no-ops, allows_world_scope removed->422; only `valence` still 422s (layer_deferred).
|
||||
|
||||
**Prior arcs this session (2026-07-04 -> 07-06), both with worldtree-dev (a tooling script + proposal docs; the #347 CONSUMER work above is the new production code):**
|
||||
|
||||
@@ -161,6 +161,8 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[2026-07-06]` **Tail-2 SHIPPED (`v0.19.7`) — Tier-3 prose docs re-vendored + persona_state body-shape aligned.** worldtree-dev landed the Tier-3 persona/memory/persona_state PROSE docs (`c9e59ec`, on origin) — they serialize as freeform `Any` in the OpenAPI JSON, so the **prose is their source of truth** (my earlier "2.3.0 = #347-only, tail-2 collapsed" was half-wrong: the JSON was #347-only but the prose is separate). Re-vendored `docs/conversation-api-spec.md` (markdown pin, tolerate_drift; `worldtree-spec-rev` 879cefe->c9e59ec, SPEC-PIN history row added). **Consumer fix:** `--set-persona-pad`/`_set_persona_probe` was sending `{pad:[list]}` but the canonical SET body (#317) is `{pad:{pleasure,arousal,dominance}}` (named dict) — aligned it + added a len!=3 guard; updated contract #2 note + set_persona_state docstring + tests. The `set_persona_state` WRAPPER was already correct (freeform pass-through); only the CLI probe drifted. TDD (probe test asserts the dict; +1 wrong-count test). Suite **602 green**, ruff clean. **heid-code-review on #347 (dispatched + returned this session): UNANIMOUS ZERO DRIFT** (Gróa/Hulda/Regin all confirmed the hide-existence 404->`AuthoredHistoryUnavailable` routing holds at wrapper/probe/test layers + the extra="forbid" body-omission + the deliberate write-vs-read 404 asymmetry — confirmation-not-discovery for a well-TDD'd slice against a prescriptive contract). worldtree-dev foot-guns banked in SPEC-PIN + [[reference_worldtree_affect_surface_map]]: ocean single-letter `{O,C,E,A,N}` on /agents/define (#348) vs spelled-out on /characters; memory `{embedder_version, tier3_dreaming}`, stm_* deprecated, allows_world_scope removed->422; only `valence` still 422s.
|
||||
|
||||
- `[2026-07-06]` **Sindra rewritten onto a #347 authored first-message + first-message-preset AUTO-SEED SHIPPED (`v0.19.8`).** Operator "rewrite Sindra" now that #347 first-messages work. Her card had a `**Startup:**` block (a pre-#347 workaround: "introduce yourself + ask for Intensity/Mood/Willingness" with a verbatim scripted greeting) — precisely what #347 replaces. Rewrite, all NON-destructive: **(1)** lifted her scripted opening into a #347 first-message (punctuation-fixed); **(2) PATCHed her live definition** — `PATCH /agents/ratatoskr:sindra` (body `ConsumerAgentPatchRequest` = system_prompt+role, extra=forbid; keeps OCEAN/persona/memory) removing the Startup block -> a 1-line `**Opening:**` fallback + reworded the axes-persist line (25686->25449 chars, verified Startup gone); **(3) codified auto-seed:** NEW module `src/ratatoskr/first_message.py` (`FIRST_MESSAGE_PRESETS` dict {agent_id->text} + `seed_preset_first_message` best-effort helper) wired into ALL 3 session-create paths — cli `_amain` (`--send --new`), tui `_resolve_then_run` (bare `--new`), web `_create_session_endpoint` (POST /api/sessions) — so every new Sindra session opens with her greeting. **Best-effort (INV-001: swallows AuthoredHistoryUnavailable/SessionApiFailed/httpx.HTTPError -> NEVER blocks create)**; per-content idempotency key (`ratatoskr-preset-`+sha256[:12]). Contract `docs/contracts/first_message.contract.md` (module-scoped: `module:`+`purpose:`+`touches:` required, NOT `target_module:`) + TDD (9 unit + 1 web wire-in; **the 3 existing sindra bind tests needed a history-endpoint mock** since creating a preset agent now auto-seeds). Suite **612 green**, ruff+mypy clean. **LIVE-PROVEN generation-free**: create sindra session -> auto-seed -> read-back seq-0 assistant greeting (409 chars). Sindra's greeting now lives canonically in the preset registry (repo); her server card no longer carries it. Patch bump (single-commit feature, no downstream coordination). **FOOT-GUN: sindra requires `end_user_id` on session-create (422 `end_user_id_required`) — all real paths pass it from env (RATATOSKR_END_USER_ID) / web server config.** **Then the full quality gate (operator-directed, folded into v0.19.8): heid-code-review (unanimous ZERO implementation drift; 2 test-only fixups — INV-004 verification-claim made explicit re the global rglob test + an exactly-one-POST assertion) + heid-bug-hunt (3/3 convergence caught what the conformance lens structurally COULDN'T — the code matched the contract's NARROW 3-type ERROR_ROUTING, but INV-001's "NEVER raises" is BROADER). HARDENED: broad `except Exception` → None (re-raise `asyncio.CancelledError`, itself a BaseException), soft-guard PREs (return None, NOT assert — a wiring bug can't crash the create path it's wired into), and `asyncio.wait_for(_SEED_TIMEOUT_S=10s)` bounding the seed write (the CLI/TUI clients run read=None for SSE → a stalled /history would otherwise block create forever). Suite 615 green. LESSON: code-matches-ERROR_ROUTING ≠ honors-broad-INV-001 — heid-code-review confirms contract-conformance, heid-bug-hunt catches robustness gaps the contract's own narrow clauses miss; run both.**
|
||||
|
||||
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.19.7"
|
||||
version = "0.19.8"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing import Any, TextIO
|
||||
|
||||
import httpx
|
||||
|
||||
from ratatoskr.first_message import seed_preset_first_message
|
||||
from ratatoskr.sessions import (
|
||||
AgentNotFound,
|
||||
AuthoredHistoryUnavailable,
|
||||
@@ -598,6 +599,11 @@ async def _amain(args: ParsedArgs) -> int:
|
||||
sys.stderr.write(
|
||||
f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n"
|
||||
)
|
||||
# #347 authored first-message: seed the agent's preset opening (best-effort).
|
||||
if await seed_preset_first_message(client, info.session_id, args.agent_id):
|
||||
sys.stderr.write(
|
||||
f". first_message: seeded preset opening for {info.agent_id}\n"
|
||||
)
|
||||
# Issue #17 bound-state indicator: plane + endpoint + status, so the
|
||||
# operator sees WHICH identity/endpoint bound (not a bare boolean).
|
||||
if args.bifrost is not None:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Per-agent authored first-message presets (Worldtree #347 consumer feature).
|
||||
|
||||
When a new session is created for an agent that has a preset opening, seed it as
|
||||
a #347 authored first-message (``POST /sessions/{id}/history``, author=assistant,
|
||||
seq-0) so the session opens in-character before the user speaks — the durable
|
||||
replacement for a system-prompt "startup" instruction.
|
||||
|
||||
Best-effort by design: an instance without the ``session.history.write`` grant
|
||||
returns the hide-existence 404, which is swallowed so session creation is never
|
||||
blocked (the session simply opens with no seeded greeting). See
|
||||
``docs/contracts/first_message.contract.md``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
|
||||
import httpx
|
||||
|
||||
from ratatoskr.sessions import write_authored_history
|
||||
|
||||
# Cap the best-effort seed write. The CLI/TUI create paths reuse an httpx client
|
||||
# with NO read timeout (it streams SSE turns), so an accepted-but-never-answered
|
||||
# POST /history would otherwise block session creation forever — violating INV-001's
|
||||
# "never block". asyncio.wait_for bounds the seed regardless of the client's timeout.
|
||||
_SEED_TIMEOUT_S = 10.0
|
||||
|
||||
# agent_id -> the authored opening seeded onto new sessions for that agent.
|
||||
# Editing this dict is how an operator tunes an agent's first turn. Keep entries
|
||||
# under the server's authored_content_max_bytes (8192 bytes) budget.
|
||||
FIRST_MESSAGE_PRESETS: dict[str, str] = {
|
||||
"ratatoskr:sindra": (
|
||||
"Hey there. I'm Sindra—glad you found me. So, three things before we start:\n\n"
|
||||
"How intense should I be? 1 is slow and teasing, 10 is relentless.\n\n"
|
||||
"What mood am I in today? Sweetheart, Vixen, Queen, Siren, or Brat?\n\n"
|
||||
"And how willing am I to begin? Enthusiastic (I want you now), Hesitant "
|
||||
"(you'll need to coax me out), Resistant (playful pushback), or Unwilling "
|
||||
"(I don't want this at all, until you prove otherwise)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def preset_for(agent_id: str) -> str | None:
|
||||
"""Return the authored first-message preset for ``agent_id``, or None if none."""
|
||||
assert agent_id and isinstance(agent_id, str)
|
||||
return FIRST_MESSAGE_PRESETS.get(agent_id)
|
||||
|
||||
|
||||
async def seed_preset_first_message(
|
||||
client: httpx.AsyncClient, session_id: str, agent_id: str
|
||||
) -> str | None:
|
||||
"""Best-effort: seed ``agent_id``'s preset opening as a #347 authored
|
||||
first-message on ``session_id``; return the seeded text, or None.
|
||||
|
||||
Best-effort (INV-001): a no-preset agent, a malformed call, a slow write
|
||||
(bounded by ``_SEED_TIMEOUT_S``), the hide-existence 404, or ANY other
|
||||
exception all resolve to None WITHOUT raising — this MUST NOT block or fail
|
||||
session creation. Only ``asyncio.CancelledError`` propagates (cancellation is
|
||||
not a seed failure). Inputs are soft-guarded (return None), never asserted, so
|
||||
a wiring bug can't crash the create path this is wired into. A no-preset agent
|
||||
issues zero HTTP (INV-002). The per-content idempotency key makes a repeat on
|
||||
the same session an idempotent 200 replay (INV-003).
|
||||
"""
|
||||
# Soft input guards — a bad arg degrades to "no first message", never raises.
|
||||
if not (isinstance(agent_id, str) and agent_id):
|
||||
return None
|
||||
content = FIRST_MESSAGE_PRESETS.get(agent_id)
|
||||
if content is None:
|
||||
return None
|
||||
if client is None or not (isinstance(session_id, str) and session_id):
|
||||
return None
|
||||
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
write_authored_history(
|
||||
client, session_id, content=content, idempotency_key=key
|
||||
),
|
||||
timeout=_SEED_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise # cancellation is not a seed failure — never swallow it
|
||||
except Exception:
|
||||
return None # any other failure (404/409/422/timeout/unexpected) → no greeting
|
||||
return content
|
||||
@@ -33,6 +33,7 @@ from textual.widgets import (
|
||||
)
|
||||
|
||||
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
|
||||
from ratatoskr.first_message import seed_preset_first_message
|
||||
from ratatoskr.sessions import (
|
||||
AgentInfo,
|
||||
AgentNotAvailable,
|
||||
@@ -414,7 +415,7 @@ class TuiPresenterState:
|
||||
self,
|
||||
event: Event,
|
||||
*,
|
||||
transcript: "VerticalScroll",
|
||||
transcript: VerticalScroll,
|
||||
tools_log: RichLog,
|
||||
debug_log: RichLog,
|
||||
thinking_log: RichLog,
|
||||
@@ -1324,7 +1325,6 @@ class RatatoskrApp(App[int]):
|
||||
On 200: header populated, pane shows full detail, audit logged.
|
||||
"""
|
||||
assert self.client is not None and self.agent_id is not None
|
||||
from rich.text import Text as RichText
|
||||
try:
|
||||
snapshot = await get_persona_state(self.client, self.agent_id)
|
||||
self._update_persona_surfaces(snapshot)
|
||||
@@ -1899,6 +1899,8 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
||||
)
|
||||
session_id = info.session_id
|
||||
agent_id: str | None = info.agent_id
|
||||
# #347 authored first-message: seed the agent's preset opening (best-effort).
|
||||
await seed_preset_first_message(client, session_id, chosen_agent_id)
|
||||
else:
|
||||
assert resolved_session_id is not None
|
||||
session_id = resolved_session_id
|
||||
@@ -1914,7 +1916,7 @@ async def _cancel_via_sse(
|
||||
turn_id: int,
|
||||
*,
|
||||
transcript: VerticalScroll,
|
||||
audit: "Callable[[str], None] | None" = None,
|
||||
audit: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from ratatoskr import local_agents as _local_agents
|
||||
from ratatoskr.first_message import seed_preset_first_message
|
||||
from ratatoskr.sessions import (
|
||||
AgentNotAvailable,
|
||||
AgentNotFound,
|
||||
@@ -47,8 +48,8 @@ from ratatoskr.sessions import (
|
||||
from ratatoskr.sse_client import (
|
||||
AdminEvent,
|
||||
CancelAlreadyCompleted,
|
||||
Cancelled,
|
||||
CancelFailed,
|
||||
Cancelled,
|
||||
CancelTurnNotFound,
|
||||
Done,
|
||||
Error,
|
||||
@@ -168,6 +169,9 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
|
||||
bifrost=bifrost,
|
||||
consumer_key=consumer_key if bifrost else None,
|
||||
)
|
||||
# #347 authored first-message: seed the agent's preset opening
|
||||
# (best-effort; never blocks create — see first_message INV-001).
|
||||
await seed_preset_first_message(client, info.session_id, agent_id)
|
||||
except AgentNotFound:
|
||||
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
|
||||
except BifrostConsumerKeyMissing:
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for ratatoskr.first_message per docs/contracts/first_message.contract.md."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.first_message import (
|
||||
FIRST_MESSAGE_PRESETS,
|
||||
preset_for,
|
||||
seed_preset_first_message,
|
||||
)
|
||||
|
||||
|
||||
class TestPresetFor:
|
||||
"""first_message contract — preset_for (dict lookup)."""
|
||||
|
||||
def test_preset_hit(self) -> None:
|
||||
"""preset_hit [happy,tracer]: sindra has a non-empty str preset."""
|
||||
val = preset_for("ratatoskr:sindra")
|
||||
assert isinstance(val, str) and val
|
||||
|
||||
def test_preset_miss(self) -> None:
|
||||
"""preset_miss [happy]: an agent with no preset → None."""
|
||||
assert preset_for("mimir") is None
|
||||
|
||||
def test_empty_agent_id(self) -> None:
|
||||
"""empty_agent_id [adversarial]: "" → AssertionError."""
|
||||
with pytest.raises(AssertionError):
|
||||
preset_for("")
|
||||
|
||||
|
||||
class TestSeedPresetFirstMessage:
|
||||
"""first_message contract — seed_preset_first_message (best-effort #347 seed)."""
|
||||
|
||||
@respx.mock
|
||||
async def test_seeds_preset(self) -> None:
|
||||
"""seeds_preset [happy,tracer]: preset agent → one history POST, correct body."""
|
||||
content = FIRST_MESSAGE_PRESETS["ratatoskr:sindra"]
|
||||
key = "ratatoskr-preset-" + hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
||||
route = respx.post("https://w.example/sessions/s1/history").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"author": "assistant",
|
||||
"seq": 0,
|
||||
"phase": "seeded",
|
||||
"turn_id": "t1",
|
||||
"session_id": "s1",
|
||||
"content_chars": len(content),
|
||||
"injected_at": "2026-07-06T00:00:00+00:00",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
||||
assert result == content
|
||||
assert route.call_count == 1 # POST-002: exactly one history POST
|
||||
assert json.loads(route.calls[0].request.content) == {
|
||||
"author": "assistant",
|
||||
"content": content,
|
||||
"idempotency_key": key,
|
||||
}
|
||||
|
||||
@respx.mock
|
||||
async def test_no_preset_zero_http(self) -> None:
|
||||
"""no_preset_zero_http [happy]: no-preset agent → None, ZERO HTTP (INV-002)."""
|
||||
route = respx.post("https://w.example/sessions/s1/history").mock(
|
||||
return_value=httpx.Response(201, json={})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await seed_preset_first_message(client, "s1", "mimir")
|
||||
assert result is None
|
||||
assert not route.called
|
||||
|
||||
@respx.mock
|
||||
async def test_feature_absent_swallowed(self) -> None:
|
||||
"""feature_absent_swallowed [error]: 404 hide-existence → None, no raise (INV-001)."""
|
||||
respx.post("https://w.example/sessions/s1/history").mock(
|
||||
return_value=httpx.Response(404, json={"error_code": "session_not_found"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
||||
assert result is None
|
||||
|
||||
@respx.mock
|
||||
async def test_session_api_failed_swallowed(self) -> None:
|
||||
"""session_api_failed_swallowed [error]: 409 → None, no raise (INV-001)."""
|
||||
respx.post("https://w.example/sessions/s1/history").mock(
|
||||
return_value=httpx.Response(409, json={"error_code": "generation_active"})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
||||
assert result is None
|
||||
|
||||
@respx.mock
|
||||
async def test_transport_error_swallowed(self) -> None:
|
||||
"""transport_error_swallowed [error]: httpx.ConnectError → None, no raise (INV-001)."""
|
||||
respx.post("https://w.example/sessions/s1/history").mock(
|
||||
side_effect=httpx.ConnectError("boom")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
||||
assert result is None
|
||||
|
||||
@respx.mock
|
||||
async def test_unexpected_exception_swallowed(self) -> None:
|
||||
"""unexpected_exception [error]: write raises ValueError → None (broad never-raise)."""
|
||||
respx.post("https://w.example/sessions/s1/history").mock(
|
||||
side_effect=ValueError("unexpected")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
||||
assert result is None
|
||||
|
||||
async def test_cancellation_propagates(self) -> None:
|
||||
"""cancellation_propagates [error]: CancelledError from the write is RE-RAISED."""
|
||||
import ratatoskr.first_message as fm
|
||||
|
||||
async def _cancel(*_a: object, **_k: object) -> None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
orig = fm.write_authored_history
|
||||
fm.write_authored_history = _cancel # type: ignore[assignment]
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await seed_preset_first_message(client, "s1", "ratatoskr:sindra")
|
||||
finally:
|
||||
fm.write_authored_history = orig # type: ignore[assignment]
|
||||
|
||||
@respx.mock
|
||||
async def test_malformed_agent_id_no_http(self) -> None:
|
||||
"""malformed_agent_id [adversarial]: non-str or empty agent_id → None; no HTTP; no raise."""
|
||||
route = respx.post(url__regex=r".*/history$").mock(
|
||||
return_value=httpx.Response(201, json={})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
assert await seed_preset_first_message(client, "s1", 123) is None # type: ignore[arg-type]
|
||||
assert await seed_preset_first_message(client, "s1", "") is None
|
||||
assert not route.called
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_session_id(self) -> None:
|
||||
"""empty_session_id [adversarial]: "" → None (soft guard); no HTTP; no raise."""
|
||||
route = respx.post("https://w.example/sessions/s1/history").mock(
|
||||
return_value=httpx.Response(201, json={})
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await seed_preset_first_message(client, "", "ratatoskr:sindra")
|
||||
assert result is None
|
||||
assert not route.called
|
||||
@@ -2922,6 +2922,10 @@ class TestTuiBifrostBind:
|
||||
},
|
||||
)
|
||||
)
|
||||
# sindra is a preset agent → the TUI create path now auto-seeds a #347 first-message.
|
||||
respx.post("https://w.example/sessions/s-bound/history").mock(
|
||||
return_value=httpx.Response(201, json={})
|
||||
)
|
||||
|
||||
async def fake_run_async(self) -> int:
|
||||
return 0
|
||||
|
||||
@@ -175,6 +175,29 @@ class TestCreateSessionEndpoint:
|
||||
resp = TestClient(app).post("/api/sessions", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
@respx.mock
|
||||
def test_preset_agent_auto_seeds_first_message(self) -> None:
|
||||
"""#347: a preset agent gets its opening seeded on create; a non-preset agent does not."""
|
||||
respx.post("https://w.example/sessions").mock(return_value=httpx.Response(201, json=_CREATE_OK))
|
||||
hist = respx.post("https://w.example/sessions/s-1/history").mock(
|
||||
return_value=httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"author": "assistant", "seq": 0, "phase": "seeded", "turn_id": "t1",
|
||||
"session_id": "s-1", "content_chars": 1, "injected_at": "t",
|
||||
},
|
||||
)
|
||||
)
|
||||
from ratatoskr.web.server import create_app
|
||||
app = create_app(_mock_client_factory())
|
||||
client = TestClient(app)
|
||||
# preset agent → the endpoint seeds a first-message
|
||||
assert client.post("/api/sessions", json={"agent_id": "ratatoskr:sindra"}).status_code == 201
|
||||
assert hist.call_count == 1
|
||||
# non-preset agent → no seed (count unchanged)
|
||||
assert client.post("/api/sessions", json={"agent_id": "mimir"}).status_code == 201
|
||||
assert hist.call_count == 1
|
||||
|
||||
|
||||
_SNAPSHOT = {
|
||||
"agent_id": "mimir",
|
||||
@@ -821,6 +844,10 @@ class TestWebBifrostBind:
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK)
|
||||
)
|
||||
# sindra is a preset agent → the endpoint now auto-seeds a #347 first-message.
|
||||
respx.post("https://w.example/sessions/s-1/history").mock(
|
||||
return_value=httpx.Response(201, json={})
|
||||
)
|
||||
app = create_app(
|
||||
_mock_client_factory(),
|
||||
bifrost_consumer_key="server-ck",
|
||||
@@ -856,6 +883,10 @@ class TestWebBifrostBind:
|
||||
route = respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK)
|
||||
)
|
||||
# sindra is a preset agent → the endpoint now auto-seeds a #347 first-message.
|
||||
respx.post("https://w.example/sessions/s-1/history").mock(
|
||||
return_value=httpx.Response(201, json={})
|
||||
)
|
||||
app = create_app(
|
||||
_mock_client_factory(),
|
||||
bifrost_consumer_key="server-ck",
|
||||
|
||||
Reference in New Issue
Block a user