be171304f5
Codifies 'give an agent a first message' (Worldtree #347): new module ratatoskr.first_message (FIRST_MESSAGE_PRESETS + seed_preset_first_message) seeds a preset agent's opening as a #347 authored turn-0 on every new session, wired into all three create paths — cli._amain (--send --new), tui._resolve_then_run (bare --new), web._create_session_endpoint (POST /api/sessions). seed_preset_first_message is strictly best-effort (INV-001): it soft-guards its inputs (return None, never assert), bounds the write with asyncio.wait_for so a stalled /history can't block create (the CLI/TUI clients disable read timeout for SSE), and swallows every exception except asyncio.CancelledError (which propagates) — so it can NEVER raise into or block the session-create path it is wired into. Per-content idempotency key → idempotent replay, no dup. Seeded with ratatoskr:sindra, whose opening greeting moved out of her card: her live system_prompt was PATCHed (non-destructive) to drop the Startup workaround the #347 first-message now replaces. Quality gate (both cross-frontier panels): heid-code-review returned zero implementation drift (2 test-only fixups applied); heid-bug-hunt caught the gap the conformance lens can't see — code matched the contract's narrow ERROR_ROUTING but INV-001's 'never raises' is broader — driving the broad-except + soft-guard + wait_for hardening above. Contract docs/contracts/first_message.contract.md (module-scoped, validated). TDD: 12 unit + 1 web wire-in; the 3 existing sindra bind tests gained a history-endpoint mock (creating a preset agent now auto-seeds). Suite 615 green, ruff+mypy clean. Auto-seed live-proven generation-free against personal :8081.
143 lines
9.5 KiB
Markdown
143 lines
9.5 KiB
Markdown
---
|
|
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
|
|
```
|