From c7016f23a6f31be6dee8fa9ff36fe9eec18f6f00 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sat, 18 Jul 2026 12:02:18 -0700 Subject: [PATCH] feat(#19): ephemeral-template (Echo) session creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_session could only mint foundational sessions; an ephemeral template (agent_id="echo") returned 422 ephemeral_requires_config because ratatoskr never sent the required config block — Echo was uncreatable, surfacing as an opaque session_api_failed at the CLI. Thread an opaque, role/model-agnostic config passthrough through the create path so Echo sessions are creatable. - sessions.py: create_session(config=...) verbatim passthrough (PRE-004 Mapping / PRE-005 config-xor-bifrost guards); SessionInfo gains kind + config, captured defensively (.get) on both create and list. - cli.py: --system-prompt flag builds config={"system_prompt": ...} (validation: non-empty, requires --new+--agent, xor bifrost); _amain surfaces kind=; the --whoami renderer now reads allowed_roles/default_role (was reading the dead allowed_models/default_model) and tolerates a malformed capabilities shape. - contract #2 amended (Amendment 2026-07-18); Heid-panel contract-reviewed + diff-scoped bug-hunted (one whoami null-join gap found + fixed). Canonical grounding: config.role, never config.model (worldtree-dev althing 01KXT976NN91DRBZBPXNZ2BVZR; ADR-0012 role cutover). Verified end-to-end against the live v0.16.2 target. TDD across create + CLI; full suite green (534). Closes #19. --- docs/contracts/issues/2.contract.md | 153 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/ratatoskr/cli.py | 53 +++++++++- src/ratatoskr/sessions.py | 23 +++++ tests/test_cli.py | 116 ++++++++++++++++++++- tests/test_sessions.py | 142 ++++++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 481 insertions(+), 10 deletions(-) diff --git a/docs/contracts/issues/2.contract.md b/docs/contracts/issues/2.contract.md index 1a86550..5a051c2 100644 --- a/docs/contracts/issues/2.contract.md +++ b/docs/contracts/issues/2.contract.md @@ -470,3 +470,156 @@ TESTS: not_found_404 [error]: 404 → SessionApiFailed(status=404) empty_session_id [adversarial]: "" → AssertionError; no HTTP issued ``` + +## Amendment 2026-07-18 — ephemeral-template (Echo) session creation + +**Motivation.** `create_session` could only mint *foundational* sessions +(`{"agent_id": }`). Attempting to start an **ephemeral +template** session — e.g. `agent_id="echo"` — returned `422 +ephemeral_requires_config` because the request carried no `config`. Ephemeral +templates (issue #161: Echo, a blank-slate per-session host) require the consumer +to supply a `config` object with the session's `system_prompt` at create time; +that config is frozen for the session's lifetime. This amendment threads a +`config` passthrough through `create_session`, captures the two new response +fields (`kind`, `config`) on `SessionInfo`, and corrects the `get_capabilities` +metadata shape. + +**Canonical grounding (role, NOT model).** worldtree-dev confirmed on althing +(thread `01KXT976NN91DRBZBPXNZ2BVZR`, 2026-07-18) that the model→role cutover +(commit `bb4d551`, "Complete model role cutover", ADR-0012 role-based model +access) is canonical NOW on both surfaces: +- `GET /capabilities` ephemeral-template metadata keys are **`allowed_roles` / + `default_role`** (NOT `allowed_models` / `default_model`). +- The create-time selector is **`config.role`** (NOT `config.model`). A non-empty + `config.model` **hard-rejects** with `model_not_allowed` (the error code was + repurposed to mean "the `model` field itself is not permitted here"). Omitted / + null `role` resolves server-side to the template's `default_role` (`"echo"`). +- The stored/echoed config snapshot is `{"system_prompt": , "role": }`. + +Ratatoskr therefore stays **canonical-agnostic at the wrapper** (`config` is an +opaque passthrough dict) and **role-correct at the CLI** (builds +`{"system_prompt": ...}`; never emits `model`). The pinned +`docs/conversation-api-spec.md` was re-synced to **v1.1** (worldtree commit +`b4a278c`): its echo section now documents `allowed_roles`/`default_role`, +`config.role` (omitted → `default_role` "echo"), the repurposed +`model_not_allowed` (any non-empty `config.model` hard-rejects), and the new +`role_required` error; the frozen OpenAPI is untouched. Empirically +confirmed against the live v0.16.2 target: `POST /sessions +{"agent_id":"echo","config":{"system_prompt":"..."}}` → `201` with +`{"kind":"ephemeral","config":{"system_prompt":"...","role":"echo"}}`. + +### SessionInfo — two new response fields + +`SessionInfo` gains two optional fields, defaulted so every existing +construction site and caller is unaffected (both `create_session` and +`list_sessions` build `SessionInfo` with keyword args; no positional callers +exist): + +- `kind: str | None = None` — `"ephemeral"` for Echo sessions, `"foundational"` + for all others. Present on both the create 201 and `GET /sessions` list items + (spec §Ephemeral Templates). Captured defensively via `.get("kind")` (None when + a pre-cutover server omits it). +- `config: dict[str, Any] | None = None` — the frozen ephemeral config + (`{"system_prompt", "role"}`) on the create 201; `None` for foundational + sessions and (typically) list items. Captured via `.get("config")`. + +- **INV-001 amendment [hard]**: `create_session` additionally populates + `kind = body.get("kind")` and `config = body.get("config")` from the 201 body. + The five original create-side fields and their fixed list-only defaults + (`name=None, archived=False, tags=[]`) are unchanged. +- **INV-002 amendment [hard]**: `list_sessions` additionally populates + `kind = item.get("kind")` and `config = item.get("config")`. In practice the + list endpoint does NOT echo the frozen config, so `config` is `None` for list + items today; the `.get("config")` form is deliberate forward-compat — if a + future server includes it on list items, it passes through unmodified rather + than being force-nulled. (Heid panel 2026-07-18: earlier "stays None" wording + over-claimed against the passthrough; corrected here.) + +### create_session — `config` passthrough (supersedes the FN block above) + +```contract +FN create_session(client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None, bifrost: BifrostBinding | None = None, consumer_key: str | None = None, config: Mapping[str, Any] | None = None) -> SessionInfo +BRIEF: POST /sessions to create a session. Foundational: {"agent_id": agent_id} (+ end_user_id / bifrost per issues #5/#17). Ephemeral (issue #161): when `config` is non-None it is passed through verbatim as the request body's "config" key — the caller (CLI) builds {"system_prompt": } for Echo; the wrapper is role/model-agnostic and NEVER injects a selector. Returns SessionInfo populated from the 201, now including kind + config. (bifrost / consumer_key params + their PRE-001/POST-002 semantics are specified in issue #17's contract; shown here only to keep the signature honest.) +PRE: [PRE-001 hard] client is not None -- assert client is not None +PRE: [PRE-002 hard] agent_id is a non-empty string -- assert agent_id and isinstance(agent_id, str) +PRE: [PRE-003 hard, issue #5] end_user_id is None OR a non-empty string +PRE: [PRE-004 hard, issue #161] config is None OR a Mapping -- assert config is None or isinstance(config, Mapping) +PRE: [PRE-005 hard, issue #161] config and bifrost are not BOTH set — ephemeral sessions do not accept a Bifrost binding (server would 422 ephemeral_does_not_accept_bifrost); the CLI enforces this at arg-parse, this assert is defense-in-depth -- assert not (config is not None and bifrost is not None) +POST: [POST-001 side_effect] exactly one POST to /sessions; body carries "agent_id" always, "end_user_id"/"bifrost" per issues #5/#17, and "config": config iff config is not None. No "config" key when config is None (foundational baseline byte-identical to pre-#161). +POST: [POST-002 return_value] returns SessionInfo with session_id, agent_id, created_at, last_active, metadata, message_count populated per INV-001, PLUS kind = body.get("kind") and config = body.get("config"). +ERROR_ROUTING: + HTTP 404 unknown_agent_id: raise AgentNotFound(agent_id=agent_id); abort + HTTP 422 (ephemeral validation, issue #161): raise SessionApiFailed(status=422, body=resp.content). The body's error_code names the fault; recognized ephemeral codes: ephemeral_requires_config (config absent for an ephemeral template), foundational_does_not_accept_config (config sent to a foundational agent), system_prompt_required / system_prompt_empty / system_prompt_too_large (config.system_prompt missing / whitespace / >32768 bytes), model_not_allowed (config.model present — forbidden post-cutover), ephemeral_does_not_accept_bifrost. NOT mapped to per-code typed exceptions — the raw code in .body is honest + debuggable (mirrors the #5 end_user_id_required posture). abort. + HTTP 422 (other validation_failed) / other non-201: raise SessionApiFailed(status=resp.status_code, body=resp.content); abort. (bifrost 502 → BifrostHandshakeFailed per #17.) +STEPS: + 1. [setup] Validate PRE-001..PRE-005 + 2. [sequential] body = {"agent_id": agent_id}; IF end_user_id is not None: body["end_user_id"] = end_user_id; IF bifrost is not None: body["bifrost"] = {...} (per #17); IF config is not None: body["config"] = config + 3. [sequential] headers per #17 (bound create uses consumer_key); CALL client.post("/sessions", json=body, headers=headers) + 4. [branch] IF 404 → AgentNotFound; ELIF bifrost and 502 → BifrostHandshakeFailed (#17); ELIF != 201 → SessionApiFailed + 5. [sequential] body = resp.json() + 6. [cleanup] RETURN SessionInfo(... unchanged create-side fields ..., kind=body.get("kind"), config=body.get("config")) +TESTS: + happy_ephemeral_create [happy,tracer]: config={"system_prompt":"You are X."}, agent_id="echo" → outbound body == {"agent_id":"echo","config":{"system_prompt":"You are X."}} byte-for-byte; 201 {"kind":"ephemeral","config":{"system_prompt":"You are X.","role":"echo"},...} → SessionInfo.kind=="ephemeral" and .config=={"system_prompt":"You are X.","role":"echo"} + foundational_omits_config [trace]: config omitted, agent_id="mimir" → outbound body has NO "config" key (byte-identical to pre-#161 baseline); 201 without kind/config → SessionInfo.kind is None and .config is None + foundational_captures_kind [happy]: 201 {"kind":"foundational",...} for a normal agent → SessionInfo.kind=="foundational", .config is None + ephemeral_requires_config_422 [error]: agent_id="echo", config omitted → 422 {"error_code":"ephemeral_requires_config"} → SessionApiFailed(status=422); .body contains the code + model_not_allowed_422 [error]: config={"system_prompt":"x","model":"glm5-turbo"} → 422 {"error_code":"model_not_allowed"} → SessionApiFailed(status=422) (regression guard: the CLI never sends model, but the wrapper passes config through verbatim, so a caller that injects model gets the honest server rejection) + config_and_bifrost_conflict [adversarial]: config={...} AND bifrost=BifrostBinding(...) → AssertionError (PRE-005); no HTTP issued + config_not_a_mapping [adversarial]: config="not-a-dict" → AssertionError (PRE-004); no HTTP issued +``` + +### get_capabilities — corrected ephemeral-template metadata shape + +The 2026-06-30 amendment's `get_capabilities` BRIEF documented the pre-cutover +`{allowed_models, default_model}` shape. Canonical (per the althing grounding +above) is **`{allowed_roles, default_role, system_prompt_max_bytes}}`**. The +wrapper is unaffected (returns the parsed dict verbatim, no field access), but +its BRIEF is corrected for honesty, and the **`--whoami` renderer +(`ratatoskr.cli`) is fixed** to read `allowed_roles` / `default_role` (it +currently reads the dead `allowed_models` / `default_model` keys and renders +`default=? models=[]` against a live server). + +- get_capabilities BRIEF now reads: `GET /capabilities → {ephemeral_templates: + {echo: {allowed_roles, default_role, system_prompt_max_bytes}}}`. Behavior, + PRE, POST, ERROR_ROUTING, STEPS unchanged (verbatim dict passthrough). + +### CLI surface (ratatoskr.cli — consumer glue, TDD'd in test_cli) + +- New `--system-prompt ` flag → builds `config={"system_prompt": }` for + the `--new` create. `ParsedArgs.system_prompt: str | None = None`. +- Validation: `--system-prompt`, when passed, must be non-empty, requires `--new` + + `--agent`, and is **mutually exclusive with the bifrost flags** + (`--bifrost-url` / `--bifrost-plane`) — ephemeral sessions reject a binding. +- `_amain` passes `config` to `create_session`; the demoted create line surfaces + `kind=` when present. +- No `--role` / `--model` flag in this amendment: Echo's only `allowed_role` is + `"echo"` and omitted role defaults server-side, so a selector flag is premature + (add `--role` if/when a template advertises multiple roles). + +### Supersession + Heid panel triage (2026-07-18) + +- **Supersedes the "Bifrost binding out of scope" out-of-scope bullet** (the + base "create_session does not accept or send a `bifrost` field" line). That + bullet is stale: issue #17 made bifrost an accepted create parameter, and this + amendment's FN block reflects the current signature (`bifrost` / `consumer_key` + present, semantics owned by #17). Read the base out-of-scope bifrost line as + historical. +- **Error-body truncation (INV-004).** INV-004 [hard] specifies exception `.body` + truncated to `[:1024]`. The implemented module dropped that truncation + module-wide (every `SessionApiFailed` raise passes `resp.content`), so INV-004 + is stale against the code independent of this amendment. This amendment's + create_session error routing follows the module's actual practice + (`resp.content`) for consistency with its sibling endpoints; reconciling + INV-004 vs the code across the whole module is a separate cleanup, flagged not + fixed here. (Heid panel convergent finding, all three arms.) +- **CLI section is documentation, not module-acceptance.** This contract's + `target_module` is `ratatoskr.sessions`; the `--system-prompt` flag + + `--whoami` renderer changes live in `ratatoskr.cli` and are verified in + `test_cli`, not by this module contract's acceptance. They are documented here + only so the sessions-surface change and its single consumer read as one unit. +- **Deferred (pre-existing #2 coherence items, not this amendment's scope):** + frontmatter "two entry points" scope line is stale vs the ~15 amended FNs; + `item.get("metadata", {})` does not defend against an explicit-null `metadata` + (unlike the `or` idiom on `archived`/`tags`); and the panel's recurring + structural rec — a "current effective surface" map for this 7-amendment + contract. Surfaced to the operator as separate cleanup candidates. diff --git a/pyproject.toml b/pyproject.toml index 5aea37d..ba1a177 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.21.1" +version = "0.21.2" description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index fd67550..3df1599 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -124,6 +124,9 @@ class ParsedArgs: # #347 authored-history-write reference-consumer probe: create a fresh # session bound to --agent, seed an authored assistant first-message (seq-0). seed_first_message: str | None = None + # Issue #161: ephemeral-template (Echo) create. --system-prompt supplies the + # frozen session config {"system_prompt": ...}; None on the foundational path. + system_prompt: str | None = None class _ArgparseError(Exception): @@ -153,6 +156,8 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: parser.add_argument("--characters", action="store_true") parser.add_argument("--set-persona-pad", dest="set_persona_pad", default=None) parser.add_argument("--seed-first-message", dest="seed_first_message", default=None) + # Issue #161: ephemeral-template (Echo) system prompt → config at create. + parser.add_argument("--system-prompt", dest="system_prompt", default=None) # Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir). parser.add_argument("--end-user-id", dest="end_user_id", default=None) # Issue #17: bind the created session to our own Bifrost provider plane. @@ -173,6 +178,10 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: # Issue #5 INV-001: --end-user-id, if passed, MUST be non-empty (mirrors --send). if ns.end_user_id is not None and not ns.end_user_id: raise UsageError("--end-user-id must be non-empty when passed") + # Issue #161: --system-prompt, if passed, MUST be non-empty (server rejects + # whitespace-only with system_prompt_empty; refuse client-side). + if ns.system_prompt is not None and not ns.system_prompt.strip(): + raise UsageError("--system-prompt must be non-empty when passed") if sum([ns.whoami, ns.characters, bool(ns.set_persona_pad), bool(ns.seed_first_message)]) > 1: raise UsageError( "--whoami / --characters / --set-persona-pad / --seed-first-message " @@ -260,6 +269,19 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: # Flag > env > None; None leaves the admin panes showing "not configured". admin_key = ns.admin_key or os.environ.get("RATATOSKR_ADMIN_API_KEY") or None + # Issue #161: an ephemeral system prompt is a session-CREATE concern bound to + # --agent, and ephemeral sessions reject a Bifrost binding (server 422 + # ephemeral_does_not_accept_bifrost) — enforce both client-side. + if ns.system_prompt is not None: + if not ns.new: + raise UsageError("--system-prompt requires --new (it configures a session at create)") + if not ns.agent: + raise UsageError("--system-prompt requires --agent