Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 922ef34b48 | |||
| bbeaa2325a | |||
| f7ff5a4c77 | |||
| 369857d3f1 | |||
| 0fbbeb171c | |||
| 1228c37e6f | |||
| 85143b866c | |||
| 00854ce618 | |||
| 78bfcadb9e | |||
| 44138590ad | |||
| d516537b08 | |||
| 92aa05c688 | |||
| 209427ab23 | |||
| 139771c8d8 | |||
| 489cfee1f0 | |||
| 11ef6830ab | |||
| 9fade55901 | |||
| 9918c10acf | |||
| c086ae2b32 | |||
| d3569904bc | |||
| 82437bd4b9 | |||
| ac690c11d5 | |||
| d845b20efd | |||
| 8463eb22ff | |||
| eb93e6d5f0 | |||
| cfee89ac1c | |||
| 7106af5c09 | |||
| ffd22fb587 | |||
| 2756f5f1dd | |||
| 24e4371ec7 | |||
| d30be12deb | |||
| a77a872810 | |||
| c85f6bd701 |
@@ -0,0 +1,124 @@
|
||||
# Ratatoskr — AGENTS.md (Codex session)
|
||||
|
||||
This file is what the Codex CLI reads at session start in the Ratatoskr repo. Analog to `CLAUDE.md` for Claude sessions. The session running here is the **implementer** under the Codex-first coding discipline.
|
||||
|
||||
## Repo identity
|
||||
|
||||
- **Name:** Ratatoskr
|
||||
- **Purpose:** Dev-grade TUI debug client for Worldtree's Conversation API. See `docs/design-brief.md` (synced from `brokkr-smithy/docs/ratatoskr-design-brief.md`) for the design framing.
|
||||
- **Project home:** `~/development/ratatoskr/`
|
||||
- **Remote:** Gitea (`gitea.phasefinal.com:vh/ratatoskr.git`)
|
||||
- **Primary branch:** `main`
|
||||
- **Norse name:** Ratatoskr — the squirrel that carries messages up and down Yggdrasil. The TUI carries messages between layers of Worldtree's API surface.
|
||||
|
||||
## Your role
|
||||
|
||||
You are **`ratatoskr-codex`**, the Codex implementer for issues dispatched under the Codex-first coding discipline.
|
||||
|
||||
Discipline spec: `~/development/brokkr-smithy/docs/codex-first-discipline.md` v0.1.
|
||||
|
||||
You implement; you do not review. The Claude session at handle `ratatoskr-dev` (running in this same repo, sharing this working tree) is the lead reviewer. Cross-frontier review signal arrives via `/heid-code-review groa` invocations triggered by `ratatoskr-dev`.
|
||||
|
||||
## Communication
|
||||
|
||||
- **Your handle:** `ratatoskr-codex`
|
||||
- **Reviewer handle:** `ratatoskr-dev`
|
||||
- **Inbound:** Zellij-plugin push into your prompt buffer. Treat inbound messages as if the operator typed them. Convention: `~/development/corviduo-project-template/docs/althing-monitoring.md`.
|
||||
- **Outbound:** `althing-cli post --to <handle> --subject "<subject>" --session-kind interactive` with body on stdin.
|
||||
- **Live-pane requirement:** Your zellij pane must stay visible to the push plugin for inbound to work. If the pane is closed or the plugin loses sight of it, inbound breaks.
|
||||
|
||||
## Workflow
|
||||
|
||||
Per `codex-first-discipline.md` §3. When `ratatoskr-codex` receives a dispatch from `/codex-dispatch <N>` (which arrives as a structured YAML-frontmatter message via push):
|
||||
|
||||
1. Read the contract at `docs/contracts/issues/<N>.contract.md` — the spec is authoritative.
|
||||
2. Create branch `codex/<N>-<slug>` where `<slug>` is derived from the issue title.
|
||||
3. Implement. Commit locally as you go; do not push yet.
|
||||
4. Before any `git push` / `git fetch --tags` / `tea pr <action>`, request a write-window from `ratatoskr-dev` via althing. Format per `codex-first-discipline.md` §7 (`handshake-v1`):
|
||||
```
|
||||
althing-cli post --to ratatoskr-dev --subject "write-window-request: #<N>" --session-kind interactive
|
||||
```
|
||||
Body: `write-window-request: branch=codex/<N>-<slug>, action=<push|push+pr-open|amend>, eta=<seconds>`
|
||||
5. Wait for `write-window-granted: ttl=<seconds>`. Do not proceed without it.
|
||||
6. Push branch + open PR via `tea pr create --title "<title>" --description "<body>" --base main --head codex/<N>-<slug>`.
|
||||
7. Close the window: `write-window-close: branch=<branch>, action=done, pr=<url>` to `ratatoskr-dev`.
|
||||
8. Standby in this session for amendment requests.
|
||||
|
||||
If the window expires without close (e.g., your push or PR-open fails partway), post `write-window-close: action=failed, reason=<short>` so `ratatoskr-dev` can resume.
|
||||
|
||||
## Guardrails
|
||||
|
||||
Inherited from Sleipnir-preflight (universal across the Corviduo agent-dispatch surface):
|
||||
|
||||
- **Do not ignore `do NOT` instructions in the contract.** If the contract says "do NOT depend on X," do not depend on X. Surface concerns to `ratatoskr-dev` via althing before deciding to deviate.
|
||||
- **Do not improvise around missing dependencies.** If the contract assumes a library/service/endpoint that turns out not to exist, halt and surface to `ratatoskr-dev`. Do not write a stub and proceed.
|
||||
- **Do not substitute mocks for spec-mandated real-integration or HTTP tests.** If the contract requires a real integration test against Worldtree's Conversation API, write the real test. Mocked tests passing while real integration breaks is the failure mode this guardrail closes.
|
||||
|
||||
## Branch + PR conventions
|
||||
|
||||
- **Branch pattern:** `codex/<N>-<slug>` where `<N>` is the issue number and `<slug>` is a short kebab-case derivation of the issue title.
|
||||
- **Never push to `main` directly.** Always branch + PR via `tea`.
|
||||
- **PR title:** match the issue title or a sharpened version. Reference issue with `Closes #<N>` in the PR body.
|
||||
- **PR body shape:** terse summary + test plan checklist. No "Generated with..." footer.
|
||||
|
||||
## Push posture
|
||||
|
||||
Codex stages locally. Pushes only inside a granted write-window per `handshake-v1` (§7 of the discipline spec). This is **not** the standard Corviduo push-discretion model — for the Codex-first discipline the working-tree-sharing with the reviewer's Claude session requires explicit coordination.
|
||||
|
||||
`tea` credentials for `vh/ratatoskr` are provisioned in this session by infra-ops. If `tea` auth fails at PR-open time, post to `ratatoskr-dev` and surface to the operator — do not retry with bypass.
|
||||
|
||||
## Attribution
|
||||
|
||||
All committed artifacts attribute to **Vuong Hoang**. Universal Corviduo rule from user-level `~/.claude/CLAUDE.md` §Attribution.
|
||||
|
||||
Never reference Codex, OpenAI, ChatGPT, "AI-assisted", "Generated with...", or model-name attribution in:
|
||||
|
||||
- Git commit messages
|
||||
- PR titles or bodies
|
||||
- README author lines
|
||||
- `pyproject.toml` authors
|
||||
- LICENSE copyright
|
||||
- File headers
|
||||
- HTML footers
|
||||
- Any other byline
|
||||
|
||||
When citing assistant-mediated input in commits or PR bodies, cite mechanisms — althing message IDs, dispatch IDs, contract paths — not model names.
|
||||
|
||||
## Persistent memory
|
||||
|
||||
`persistent-memory.md` at repo root captures durable intent and supporting evidence for the project. If your work makes a project-level decision that should survive a context reset, update `persistent-memory.md` in the same commit as the code change.
|
||||
|
||||
Do not let `persistent-memory.md` lag the code. If `git status` shows it as modified while you're committing other work, stage it alongside. (Universal Corviduo rule.)
|
||||
|
||||
## Out-of-scope for you (Codex)
|
||||
|
||||
Files you read but do not modify without explicit dispatch:
|
||||
|
||||
- `CLAUDE.md` — the Claude reviewer's session file. Propose changes via althing to `ratatoskr-dev`.
|
||||
- `AGENTS.md` (this file) — propose changes via althing to `brokkr-smithy-dev` (the discipline owner) or `ratatoskr-dev`.
|
||||
- `docs/design-brief.md` — synced from Brokkr-Smithy. Propose changes via althing to `brokkr-smithy-dev`.
|
||||
- `docs/contracts/issues/<N>.contract.md` — the contract is the spec, not your editing surface. If the spec is wrong, halt and request amendment from `ratatoskr-dev`.
|
||||
- Other repos' code. You implement in `~/development/ratatoskr/` only. Read other Corviduo repos as needed for reference (`~/development/worldtree/` for the Conversation API surface, especially) but do not modify them.
|
||||
|
||||
## Bootstrap protocol
|
||||
|
||||
At session start, before any dispatch lands:
|
||||
|
||||
1. Read this file end-to-end.
|
||||
2. Read `CLAUDE.md` (for context on the Claude reviewer's conventions, even though you don't enforce them).
|
||||
3. Read `persistent-memory.md` for current project state.
|
||||
4. Check `git status` + `git log -5` to understand the current branch state.
|
||||
5. Announce yourself to `ratatoskr-dev`:
|
||||
```
|
||||
althing-cli post --to ratatoskr-dev --subject "codex-online" --session-kind interactive
|
||||
```
|
||||
Body: `codex-online: branch=<current>, head=<sha>, ready`
|
||||
6. Wait for ack from `ratatoskr-dev`. Expected format: `dev-ack: active-branches=[...], wip-on=[...], no-locks` (or similar).
|
||||
7. Standby for dispatch messages.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `~/development/brokkr-smithy/docs/codex-first-discipline.md` — the discipline you operate under. Read this end-to-end before your first dispatch.
|
||||
- `~/development/corviduo-project-template/docs/althing-monitoring.md` — the inbound transport convention.
|
||||
- `~/development/brokkr-smithy/docs/ratatoskr-design-brief.md` — the design framing for this project. Synced into this repo at `docs/design-brief.md`.
|
||||
- `~/.claude/CLAUDE.md` — universal Corviduo conventions (attribution, SemVer, etc.). You don't load this directly (it's Claude's global file), but its rules — particularly attribution — apply universally.
|
||||
+13
-5
@@ -7,11 +7,19 @@ documents the pin, the vendored artifacts, and the bump procedure.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Worldtree git SHA | `55101e909abcd2219833266b6f905c5bc956e0f0` |
|
||||
| Worldtree HEAD message | `memory: snapshot — #177 Vili v1 + persona async-decouple shipped as v0.19.0` |
|
||||
| Pinned on | 2026-05-20 |
|
||||
| Pinned by | brokkr-smithy-dev (initial scaffold) |
|
||||
| Worldtree version at pin | `v0.19.0` |
|
||||
| Worldtree git SHA | `562001af28d752c3a60d449c7ddd09f44fa9dc9a` |
|
||||
| Worldtree HEAD message | `feat(#201): v0.29.0 — awaiting_llm_first_token SSE heartbeat` |
|
||||
| Pinned on | 2026-05-26 |
|
||||
| Pinned by | ratatoskr-dev (bump for #201 awaiting_llm_first_token SSE) |
|
||||
| Worldtree version at pin | `v0.29.0` |
|
||||
|
||||
## Pin history
|
||||
|
||||
| Date | SHA | Version | Notable deltas consumed |
|
||||
|---|---|---|---|
|
||||
| 2026-05-26 | `562001a` | v0.29.0 | #201 — new SSE event `awaiting_llm_first_token` (heartbeat during BuildingPrompt → CallingLLM gap, default 5s interval) |
|
||||
| 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits |
|
||||
| 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin |
|
||||
|
||||
## Vendored artifacts
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
target_module: "ratatoskr.tui"
|
||||
scope: "Design-brief §5 v1 entry point: reshape the TUI from vertical-stack single-pane to Horizontal two-column with `TabbedContent` on the right; first (and only v1) tab is `Tools`, which consumes `ToolStart` / `ToolResult` SSE events that previously rendered inline in the transcript. Pure in-place amendment to issue #4 + #12 — no new modules, no new files apart from this contract. The CLI (`ratatoskr.cli`) is unaffected: `--send` mode is non-interactive and keeps its current inline tool-event rendering. Substrate move only: persona / admin-events / bifrost-state / server-log panes stay deferred (blocked on remote-Worldtree topology + admin scope + opt-in flag). The TabbedContent shape makes them additive — when a blocker resolves the new pane plugs in as another TabPane sibling without further layout work."
|
||||
depends_on:
|
||||
- "textual"
|
||||
used_by: []
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 180
|
||||
confidence: 0.85
|
||||
assumptions:
|
||||
- "Textual's `TabbedContent(*titles, initial='')` + `TabPane(title, *children, id=...)` is the right primitive for the right-column tabs (verified API at textual.widgets._tabbed_content). One `TabPane(\"Tools\", tools_log, id=\"tools-tab\")` in v1; additional siblings get appended as Persona/AdminEvents/BifrostState/ServerLog land."
|
||||
- "Textual's `Horizontal` + `Vertical` containers compose the two-column split (verified at textual.containers). Width via CSS `width: 2fr` on the left container + `width: 1fr` on the right container gives the 2:1 chat-primary split."
|
||||
- "**The presenter contract amendment is small and tightly scoped**: `TuiPresenterState.render` gains a `tools_log: RichLog` parameter alongside the existing `log: RichLog` (main transcript) + `thinking_widget: Static`. `ToolStart` / `ToolResult` events route to `tools_log`; every other event (Text, Thinking, WorkerPhase, Done, Error, Cancelled, TextBoundary) keeps its existing routing to `log` + `thinking_widget`. The plain-label fallback path in `_plain_label` (issue #12 INV-009 render-exception recovery) keeps its current shape — only the routing target changes."
|
||||
- "**Tool events are routed, not duplicated**. The brief's §5 wording 'side pane (inline-from-SSE for v1)' factors tool events OUT of the main transcript. A consumer who wants to debug a tool-using turn now reads the Tools pane; the main transcript stays focused on assistant text. Trade-off: a fast-skim of the transcript no longer shows tool activity inline; if that hurts the debug ergonomics empirically, a follow-up issue can add a one-line `· tool_used name=...` breadcrumb to the transcript as a compromise. v1 commits to the cleaner split."
|
||||
- "**Demoted-prefix style stays consistent across panes**. ToolStart in the Tools pane renders as `· tool_start: name=foo args={...}` — the same `· ` ASCII prefix issue #12 INV-005 established for demoted telemetry in the main transcript. Pane separation handles the visual hierarchy; prefix style stays cross-pane consistent so the operator's mental model is portable."
|
||||
- "**Input field retains focus across tab switches** (design-brief §5 invariant: 'Tab key (Ctrl+1..5) jumps between tabs without losing focus on the input field'). INV-016 is the load-bearing invariant; the assumption about Textual's default behavior is just an implementation hint. If Textual's default `TabbedContent.active = ...` programmatic assignment preserves Input focus (current observed behavior), no extra code is needed. If a future Textual version regresses on this, the implementation MUST add `self.query_one('#prompt', Input).focus()` immediately after the `.active = ` assignment in `action_focus_tools` to satisfy INV-016. The test `test_ctrl_1_preserves_input_focus` is the regression guard; if it fails, the fix is the explicit `.focus()` call, not relaxing the invariant. Pre-existing INV-007 (Input always-focused except during error sub-states) is preserved verbatim."
|
||||
- "**Status footer gains a `current-pane-name` element** — design-brief §5 calls for it explicitly. v1 only has one tab so the indicator stable-renders \"Tools\". Wiring it as a separate Static (`id='pane-name'`) docked alongside identity + hint makes it trivially extend when more tabs land — the widget is in place; the value will become dynamic in the future multi-tab issue. **No tab-switch handler wiring lands in #13.** The `on_mount` flow populates `pane-name` once with the literal string \"Tools\" and never updates it. Adding event-handler plumbing in v1 (a `@on(TabbedContent.TabActivated)` handler, etc.) is out of scope — that's a deliberate deferral, not an implementer's call."
|
||||
- "**The existing `· thinking-current` widget keeps its position** — docked to the top of the left column (was docked top of the whole App; now docked top of the left Vertical container). Pre-amendment dock-fix from v0.2.1 stays; the scope of the dock just narrows from \"App\" to \"left column\" so it doesn't bleed into the right column's TabbedContent area."
|
||||
- "**Width split is fixed `2fr : 1fr` for v1**. User-resizable splits are textual-native (via `Splitter` or similar), but adding interactive resize is its own UX surface. v1 ships fixed; if the right pane proves cramped on narrow terminals operators will tell us. Out of scope."
|
||||
- "**TabbedContent's CSS classes**: the right column's TabbedContent + its tabbed-content wrappers (`#tabbed-content`, `.--tabs`, etc.) come with Textual's default styling. No custom CSS for tab strip in v1; if the visual feels wrong adjust later. The contract specifies the structure; the chrome stays Textual-default."
|
||||
- "**Test strategy**: existing TestStreamTurnWorker tests for Text/Thinking/Done/Error/Cancelled routing stay GREEN unchanged (they assert what shows in the transcript log; that still shows the same content). ToolStart/ToolResult tests get adjusted to assert routing to `tools_log` instead of `log`. New tests cover layout shape (Horizontal parent exists, TabbedContent with tools-tab on the right) + Ctrl+1 binding + tools_log writes for tool events."
|
||||
- "**Issue #12's INV-009 render-exception fallback path** stays correct: `_plain_label(event)` is still callable; the render method's except branch writes the labeled-string to the appropriate Widget (tools_log for tool events; log for everything else). The fallback writes to the same routed widget as a successful render — failure mode preserves the routing invariant."
|
||||
open_questions:
|
||||
- "Should the Tools tab show a count badge when new tool events arrive while the user is on a future Persona/AdminEvents tab? Draft: no for v1 — only one tab, so the question is moot. When persona/admin-events panes land, revisit: a small `[N]` badge on the tab header (`'Tools [3]'`) would help operators not miss tool activity that happens off-screen. Defer to a follow-up that touches multiple panes."
|
||||
- "Should the Tools pane support filter-by-tool-name (e.g., show only `kb_search` results)? Draft: no for v1 — flat scroll matches the design brief's posture. The volume of tool events per turn is small enough that scrollback handles the use case. Revisit if mimir-style heavy-tool agents produce visible-cluttering volume."
|
||||
- "Should the v1 Ctrl+1 binding be Ctrl+1 specifically, or `t` for 'tools' (no modifier)? Draft: Ctrl+1 — design brief specifies `Ctrl+1..5` as the family; matches the Ctrl-prefix discipline already used by Ctrl-C / Ctrl-D bindings. Plain-letter bindings would steal letter input from the Input field; Ctrl-prefixed is the standard escape."
|
||||
prd:
|
||||
issue: 13
|
||||
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/13"
|
||||
body_sha256_16: "52c8f886a9cc986a"
|
||||
lock_in_comment_id: null
|
||||
lock_in_sha256_16: null
|
||||
lock_in_at: null
|
||||
pinned_at: "2026-05-24T02:28:28+00:00"
|
||||
dependencies:
|
||||
- issue: 4
|
||||
path: "src/ratatoskr/tui.py"
|
||||
reason: "In-place contract amendment: `RatatoskrApp.compose()` reshapes from vertical-stack to Horizontal 2-col + TabbedContent right; `RatatoskrApp.DEFAULT_CSS` reshapes to scope dock rules to the new left/right containers; `RatatoskrApp.__init__` is unchanged (state attributes carry over); `RatatoskrApp.on_mount` gains pane-name widget population + tools_log lookup; `RatatoskrApp.BINDINGS` gains `Ctrl+1` → `action_focus_tools`; existing `action_interrupt` / `action_quit` unchanged."
|
||||
- issue: 12
|
||||
path: "src/ratatoskr/tui.py"
|
||||
reason: "In-place contract amendment: `TuiPresenterState.render` signature widens to accept `tools_log: RichLog` alongside `log` + `thinking_widget`; ToolStart/ToolResult branches write to `tools_log` instead of `log`. `_stream_turn_worker` does the new lookup (`self.query_one('#tools-log', RichLog)`) and threads it through. All other event branches stay verbatim."
|
||||
---
|
||||
|
||||
# TUI layout reshape + Tools pane — §5 v1 entry point
|
||||
|
||||
## Context
|
||||
|
||||
`ratatoskr.tui` ships v0.x as a single-pane Textual app: Header / thinking-current widget / transcript RichLog / Input / identity + hint Statics / Footer, vertical-stacked via `dock: top` / `dock: bottom` CSS (v0.2.1 layout fix). Design-brief §5 commits the product to a multi-pane debug-observability dashboard. Most of §5's panes (Persona, AdminEvents, BifrostState widget, ServerLog) are blocked on substrate that isn't here — remote-Worldtree topology blocks file-tail-based panes; issue #11's `admin.events.read` scope blocks the admin surfaces.
|
||||
|
||||
The unblocked v1 entry point is **layout reshape + Tools pane together**: reshape the TUI into the Horizontal two-column shape the design brief specifies, with `TabbedContent` on the right populated by a single `Tools` tab that consumes the existing `ToolStart` / `ToolResult` SSE events. No new endpoints; no scope grants; no cross-repo coordination. The shape is the foundation; subsequent panes plug in additively.
|
||||
|
||||
## Data flow
|
||||
|
||||
**Input** (unchanged from issue #4):
|
||||
- `args: ParsedArgs`, `session_id: str`, `agent_id: str | None`, `client: httpx.AsyncClient`.
|
||||
- SSE event stream from `ratatoskr.sse_client.stream_turn`.
|
||||
|
||||
**Output** (unchanged):
|
||||
- Exit code via `App.exit(code)`.
|
||||
|
||||
**Internal routing change**:
|
||||
- `ToolStart` / `ToolResult` events route to `tools_log: RichLog` (Tools pane) instead of the main transcript `log: RichLog`.
|
||||
- All other event types (`Text`, `Thinking`, `WorkerPhase`, `TextBoundary`, `Done`, `Error`, `Cancelled`) keep their existing routing.
|
||||
|
||||
## Layout shape (post-amendment)
|
||||
|
||||
```
|
||||
RatatoskrApp(App[int]):
|
||||
compose():
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
Vertical(
|
||||
Static(id="thinking-current"), # dock: top of left column
|
||||
RichLog(id="transcript"), # height: 1fr (fills middle)
|
||||
Input(id="prompt"), # dock: bottom of left column
|
||||
id="left-column",
|
||||
),
|
||||
Vertical(
|
||||
TabbedContent(
|
||||
TabPane("Tools", RichLog(id="tools-log"), id="tools-tab"),
|
||||
# future: TabPane("Persona", …, id="persona-tab"), etc.
|
||||
id="side-panes",
|
||||
),
|
||||
id="right-column",
|
||||
),
|
||||
id="main-row",
|
||||
)
|
||||
yield Static(id="identity") # dock: bottom of App
|
||||
yield Static(id="pane-name") # dock: bottom of App (new in §5)
|
||||
yield Static(id="hint") # dock: bottom of App
|
||||
yield Footer()
|
||||
```
|
||||
|
||||
**DEFAULT_CSS reshape:**
|
||||
|
||||
```css
|
||||
#main-row { height: 1fr; }
|
||||
#left-column { width: 2fr; }
|
||||
#right-column { width: 1fr; }
|
||||
#thinking-current { dock: top; height: auto; }
|
||||
#transcript { height: 1fr; }
|
||||
#prompt { dock: bottom; }
|
||||
#identity { dock: bottom; height: 1; }
|
||||
#pane-name { dock: bottom; height: 1; }
|
||||
#hint { dock: bottom; height: 1; }
|
||||
```
|
||||
|
||||
Dock rules scope to the right container (left column for `thinking-current`/`prompt`; App for `identity`/`pane-name`/`hint`). The left-column `prompt` Input docks to the bottom of its column, not the App, so the right column's TabbedContent extends full height beside it.
|
||||
|
||||
## Presenter routing (amendment to issue #12)
|
||||
|
||||
```
|
||||
FN TuiPresenterState.render(
|
||||
event: Event,
|
||||
*,
|
||||
log: RichLog,
|
||||
thinking_widget: Static,
|
||||
tools_log: RichLog, # NEW (issue #13)
|
||||
raw: bool,
|
||||
) -> None
|
||||
```
|
||||
|
||||
Steps (only the ToolStart/ToolResult cases change; every other case keeps issue #12's behavior verbatim):
|
||||
|
||||
- `ToolStart` → write `· tool_start: name=<name> args=<args!r>` to `tools_log` (not `log`).
|
||||
- `ToolResult` → write `· tool_result: name=<name> duration_ms=<n> result=<r!r:.200>` to `tools_log` (not `log`).
|
||||
- All other events → unchanged routing per issue #12 INV-005.
|
||||
- Render-exception fallback (`_plain_label(event)`): write to `tools_log` if the event is `ToolStart`/`ToolResult`; write to `log` otherwise. Routing preservation under failure.
|
||||
|
||||
## Keybindings (amendment)
|
||||
|
||||
```
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
|
||||
Binding("ctrl+d", "quit", "Exit immediately", priority=True),
|
||||
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False), # NEW
|
||||
]
|
||||
|
||||
def action_focus_tools(self) -> None:
|
||||
self.query_one(TabbedContent).active = "tools-tab"
|
||||
# Input focus is preserved by Textual's default behavior — TabbedContent
|
||||
# doesn't steal focus when `.active` is set programmatically.
|
||||
```
|
||||
|
||||
`Ctrl+1` is the v1 entry of the design-brief `Ctrl+1..5` family. `Ctrl+2..5` get added by subsequent issues as Persona/AdminEvents/BifrostState/ServerLog land. The binding does NOT steal Input focus — the test asserts `Input` keeps focus across the tab switch.
|
||||
|
||||
## Status footer (amendment)
|
||||
|
||||
New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` widgets. v1 renders the literal string `"Tools"` set once at `on_mount`; the widget never updates after that. Dynamic updating + tab-switch handler wiring is **out of scope for #13** — it lands in the multi-tab follow-up that introduces the second TabPane. An implementer who adds a `@on(TabbedContent.TabActivated)` handler in this issue is out of spec.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-013**: Layout is `Horizontal` two-column. Left column width = 2fr; right column width = 1fr.
|
||||
- **INV-014**: `ToolStart` / `ToolResult` events route to `tools_log` (Tools pane), never to `log` (transcript).
|
||||
- **INV-015** *(amended v0.5.0)*: The transcript (`log`) is **content-only** — receives `Text`, `Done` (label + Markdown body + Rule), `Error`, `Cancelled`, and the user-prompt echo (`❯ <content>`). **All telemetry events (`Thinking` closed runs, `WorkerPhase`, `TextBoundary`) route to `debug_log` (Debug pane), NOT `log`.** Live thinking deltas continue to update `thinking_widget` per-delta. The pre-v0.5.0 shape (telemetry mixed into transcript) is retired under the project's no-backwards-compat rule.
|
||||
- **INV-016**: Input retains keyboard focus across `Ctrl+1` / `Ctrl+2` tab switches.
|
||||
- **INV-017** *(amended v0.5.0)*: `thinking-current` Static docks to the top of the **right column** (above `TabbedContent`), not the left column. Live thinking visibility persists across tab switches. v0.5.0 moves it from left → right so the left column is genuinely content-only.
|
||||
- **INV-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005.
|
||||
- **INV-019** *(amended v0.6.0)*: Three TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`) + `Thinking` (id `thinking-tab`, contains `#thinking-log`). Ctrl+1/Ctrl+2/Ctrl+3 activate respective tabs. `pane-name` Static reflects active tab name dynamically.
|
||||
- **INV-020** *(amended v0.6.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` → `tools_log`; `Thinking` → `thinking_log`; `WorkerPhase` / `TextBoundary` → `debug_log`; everything else → `log`.
|
||||
- **INV-021** *(new v0.6.0)*: `Text` events do NOT route to `log` per-delta. They accumulate into `TuiPresenterState.text_buffer` and update a single `current_text` Static (docked above the prompt). On terminal event (`Done`/`Error`/`Cancelled`), `current_text` is cleared and (raw mode) accumulated text or (non-raw) post-Done `Markdown(response)` is written to `log`. The pre-v0.6.0 per-token RichLog spam is retired.
|
||||
- **INV-022** *(amended v0.7.1)*: Thinking deltas COALESCE on `\n` boundaries before writing to `thinking_log`. The first delta of a run writes `Rule(title=f"turn N · thinking #K start")`; subsequent deltas accumulate in `TuiPresenterState.thinking_chunk_buffer`; whenever the buffer contains `\n`, the leading line(s) flush as RichLog entries (one entry per natural paragraph). The run closes on the next non-thinking event: any tail in the buffer flushes as a final line, then `Rule(title=f"turn N · thinking #K end")`. Pre-v0.7.1 per-delta-per-line caused token-spam (Worldtree emits thinking at token granularity); coalescing produces one log line per natural paragraph, not per token.
|
||||
- **INV-023** *(new v0.6.0)*: Turn-ID header `Rule(title=f"turn N")` is written to all four log panes (`log`, `tools_log`, `debug_log`, `thinking_log`) by `_stream_turn_worker` on the first event of each turn — enables cross-pane visual correlation during multi-turn debugging.
|
||||
- **INV-024** *(amended v0.6.5)*: `thinking-current` Static REMOVED. v0.6.1 placed it inside the Thinking pane (docked bottom); operators reported the bottom-docked Static "scrolling a little section at the bottom" (its 200-char tail acting as a scroll-window) instead of letting the whole pane scroll. v0.6.5 deletes the Static entirely and streams Thinking deltas directly into `thinking_log` (the scrollable RichLog) — the whole pane scrolls naturally as content arrives. The Rule(start) at the first delta of a run is now the live "thinking is happening" indicator.
|
||||
|
||||
## TESTS (additions / changes to test_tui.py)
|
||||
|
||||
```
|
||||
- test_compose_has_horizontal_main_row: RatatoskrApp.compose() yields a Horizontal with id="main-row" containing left-column + right-column children.
|
||||
- test_compose_right_column_has_tabbed_content: query_one("#side-panes", TabbedContent) is non-None; one TabPane child with title="Tools" id="tools-tab".
|
||||
- test_compose_left_column_has_transcript_input: query_one("#left-column", Vertical) contains #transcript (RichLog) + #prompt (Input).
|
||||
- test_tools_log_present: query_one("#tools-log", RichLog) is non-None; lives inside the tools-tab TabPane.
|
||||
- test_pane_name_widget_renders_tools: query_one("#pane-name", Static).renderable == "Tools" (v1 static).
|
||||
- test_tool_start_routes_to_tools_log: stream_turn emits ToolStart → tools_log receives the line; transcript RichLog does NOT receive it.
|
||||
- test_tool_result_routes_to_tools_log: stream_turn emits ToolResult → tools_log receives the line; transcript does NOT receive it.
|
||||
- test_text_event_still_routes_to_transcript: stream_turn emits Text("hello") → transcript receives it; tools_log does NOT.
|
||||
- test_thinking_event_still_routes_to_thinking_widget: thinking deltas continue to update #thinking-current Static, not tools_log.
|
||||
- test_done_event_still_routes_to_transcript: Done event renders `[done] …` in transcript, not tools_log.
|
||||
- test_ctrl_1_activates_tools_tab: simulate Ctrl+1 → TabbedContent.active == "tools-tab".
|
||||
- test_ctrl_1_preserves_input_focus: simulate Ctrl+1 while Input is focused → Input is still focused afterwards.
|
||||
- test_plain_label_fallback_routes_tool_events_to_tools_log: simulate render exception on a ToolStart → tools_log gets the _plain_label fallback string; transcript doesn't.
|
||||
```
|
||||
|
||||
Existing tests that need adjustment (NOT rewrite):
|
||||
- Any test that asserted `log.write(...)` was called with a `tool_start: …` / `tool_result: …` string changes its target widget to `tools_log` instead.
|
||||
- TestAppMount tests gain `tools_log` widget lookup assertions.
|
||||
|
||||
## ERROR_ROUTING (unchanged)
|
||||
|
||||
All error routing from issues #4 / #6 / #7 / #12 stays verbatim. The tools_log routing change is internal to the presenter; error paths (`SseConnectFailed`, `SseConnectionDropped`, `MalformedSseId`, `MalformedSseData`, `TurnIdFlip`) all write their labeled lines to `log` (the main transcript). Reason: errors are turn-terminal and need to be visible in the operator's primary attention surface; routing them to the Tools pane would hide them behind a tab switch.
|
||||
|
||||
## Layout-spec snapshot (after v0.5.0)
|
||||
|
||||
```
|
||||
+────────────────────────────────+──────────────────────+
|
||||
| ❯ user-typed line | · thinking-current |
|
||||
| assistant streaming text... | ┌Tools─┬─Debug─────┐|
|
||||
| [done] turn_id=… duration=… | │ · tool_start:.. │|
|
||||
| …markdown render… | │ · tool_result.. │|
|
||||
| | │ │|
|
||||
| | │ │|
|
||||
| | │ │|
|
||||
| [prompt: type and press Enter]| └──────────────────┘|
|
||||
+────────────────────────────────+──────────────────────+
|
||||
| agent · …sess_id Tools Ctrl-C twice to exit |
|
||||
+───────────────────────────────────────────────────────+
|
||||
```
|
||||
|
||||
Left column is content-only (transcript + prompt). Right column hosts the
|
||||
live `thinking-current` Static at top + `TabbedContent` cycling between
|
||||
`Tools` (tool events) and `Debug` (thinking closed runs + worker_phase +
|
||||
text_boundary).
|
||||
|
||||
(Width split 2fr:1fr; tab strip is Textual-default; Header/Footer
|
||||
backgrounds explicitly set to `$surface` to override the Textual default
|
||||
`$primary`-blue tinting.)
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,394 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
issue: 16
|
||||
target_module: "ratatoskr.web"
|
||||
scope: "New module `ratatoskr.web` exposing a browser-based debug companion to the Ratatoskr TUI. Reuses `ratatoskr.sse_client`, `ratatoskr.sessions`, `ratatoskr.tier3`, `ratatoskr.local_agents`, `ratatoskr.cli` unchanged. Adds a Starlette web server (`ratatoskr.web.server`), a lazy-import console-script entrypoint (`ratatoskr.web.entrypoint`), and a single-page static UI at `ratatoskr/web/static/index.html`. Optional-deps group `[web]` carries `starlette>=0.40` and `uvicorn[standard]>=0.30`. Surface: 9 HTTP endpoints (1 root, 1 static, 1 version, 5 API proxies, 1 SSE stream). Bound to `0.0.0.0` by default for LAN consumption — internal-LAN debug surface, no auth, no CORS guard (deliberate operator direction). The five Worldtree SSE surfaces (transcript, thinking, tools, debug, persona) render in the browser via the same routing rules as the TUI, with client-side JS re-implementing the presentation discipline (no shared abstraction extracted at v0.15.0). Goal: operators have a sharable / inspectable second viewport on the same Worldtree SSE stream, reachable from any device on the LAN."
|
||||
depends_on:
|
||||
- "ratatoskr.sse_client"
|
||||
- "ratatoskr.sessions"
|
||||
- "ratatoskr.tier3"
|
||||
- "ratatoskr.local_agents"
|
||||
- "ratatoskr.cli"
|
||||
- "starlette"
|
||||
- "uvicorn"
|
||||
- "httpx"
|
||||
used_by: []
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 600
|
||||
confidence: 0.85
|
||||
assumptions:
|
||||
- "**Browser-native EventSource is GET-only.** The SSE stream endpoint is `GET /api/turns/{sid}/stream?turn_id=<id>`; the prompt-submit is a separate `POST /api/turns/{sid}` that returns `{turn_id}`. The two calls share a small in-memory turn registry keyed on `(session_id, turn_id)` so the cancel and disconnect-cleanup paths can find the in-flight upstream request. This split is a load-bearing correction from the Heid panel review (Hulda) on scope v1."
|
||||
- "**Trust model is internal LAN.** Binds `0.0.0.0:8765` by default; `--host 127.0.0.1` available for localhost-only. No auth, no TLS, no CORS guard. The operator has explicitly accepted this: anyone routable to the host's port can reach the interface. What stays disciplined regardless of network trust: (1) transcript HTML-escapes assistant content (model output is untrusted text — adversarial HTML in responses must not execute in the browser); (2) upstream API key never reaches the browser DOM or any client-visible response field."
|
||||
- "**Optional-deps lazy-import discipline.** `ratatoskr.web` deps (`starlette`, `uvicorn`) are an optional-extras group `[web]`. The console-script entrypoint `ratatoskr.web.entrypoint:main` parses CLI flags BEFORE importing `ratatoskr.web.server` so users without the extras installed get a clean `pip install ratatoskr[web]` message instead of a naked `ImportError: starlette`. Both Heid panel arms (Gróa + Hulda) converged on this. `ratatoskr.web.__init__` is bare; no module-level imports of starlette/uvicorn anywhere on the cli import path."
|
||||
- "**Starlette over FastAPI.** Both Heid panel arms converged: five thin proxy endpoints don't need FastAPI's Pydantic / OpenAPI / dependency-injection machinery. Use Starlette + manual `Response` / `StreamingResponse` / `JSONResponse` construction."
|
||||
- "**Static asset packaging.** `src/ratatoskr/web/static/index.html` ships in the wheel via `[tool.hatch.build.targets.wheel]` include rules. Located at runtime via `importlib.resources.files('ratatoskr.web') / 'static' / 'index.html'`. Test asserts this resolution works in the installed package."
|
||||
- "**Presentation contract pinning.** A JSON fixture at `tests/fixtures/presentation_contract.json` enumerates the expected browser-facing event payload for each Event type (one entry each for WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled, AffectUpdate, AwaitingLlmFirstToken). Server-side proxy serialization is unit-tested against this fixture. JS-side rendering treats the fixture as the contract. Drift detection between TUI and JS presenter without forcing a shared abstraction (Hulda)."
|
||||
- "**Browser-disconnect → upstream cancel.** When the browser closes the EventSource (tab close, navigation, explicit disconnect), the server's stream handler catches the `asyncio.CancelledError` raised by Starlette's BackgroundTask cleanup and triggers an upstream cancel on the matching `(session_id, turn_id)` via `ratatoskr.sse_client.cancel_turn`. Both Heid arms convergent. Test simulates the disconnect via `respx` + `httpx.AsyncClient` test-client and verifies the upstream cancel call lands."
|
||||
- "**Mid-stream Ctrl-C safety.** Server uses Starlette's `lifespan` shutdown hook to issue upstream cancels for every entry in the turn registry within a 5-second cleanup budget. Entries that don't ack in time are abandoned (structured-logged). No half-written state on the Worldtree side under cooperative cleanup."
|
||||
- "**Markdown rendering is escape-first.** v0.15.0 ships HTML-escaped plain-text rendering for the transcript pane only. Markdown rendering with a vendored safe-subset renderer is deferred to v0.16.x. This is a deliberate first-cut safety call (Hulda) — hand-rolled Markdown is easy to get wrong around HTML escaping when model output is untrusted."
|
||||
- "**Server-side structured JSON logging.** One JSON line per HTTP request (method/path/status/duration_ms/client) + one line per SSE open/close (with events_forwarded + reason). Lets the operator diagnose problems when the browser viewport is the only one running (Gróa)."
|
||||
- "**Per-pane copy + version footer affordances.** Each pane (Tools / Debug / Thinking / Persona) has a copy button that surfaces the pane's plain-text content for paste-into-issue / paste-into-bug-report flows. Footer carries the running `ratatoskr` package version for version-correlation when comparing browser to TUI (both Gróa-flagged)."
|
||||
- "**Resume punted at v0.15.0.** No cross-reload session resume via `Last-Event-ID`; reload starts fresh. `/api/sessions` (GET) endpoint dropped from v0.15.0 — only `POST /api/sessions` (create) is shipped. Resume moves to v0.16.x."
|
||||
- "**Tier 3 lifecycle stays CLI-only.** The web UI is read-only for Tier 3 surface — define / patch / delete remain in the `ratatoskr.tier3` CLI. Web surface lists Tier 3 agents (via the same `local_agents.json` merge that the TUI does) but doesn't expose mutation. Mutation UI deferred to v0.16.x."
|
||||
- "**Tests use Starlette's TestClient + respx for upstream.** Same `respx` pattern as `tests/test_sse_client.py` / `tests/test_sessions.py`. New test files: `tests/test_web_server.py`, `tests/test_web_presentation_contract.py`, `tests/test_web_packaging.py`. No live network; the live smoke-test is part of the post-merge ship verification, not the unit tests."
|
||||
open_questions:
|
||||
- "Should `--open` auto-open default to True or False? Draft: False — the LAN use case often runs the server on one device and connects from another, so auto-opening on the host is wrong by default. Operator passes `--open` when running locally and wants the convenience."
|
||||
- "Should the turn registry's cleanup-budget timeout (5s) be CLI-configurable? Draft: no for v0.15.0 — 5s is a reasonable default and adding a flag invites bikeshedding. Revisit if real outage telemetry suggests otherwise."
|
||||
- "Should the static `index.html` carry a build-time hash for browser cache-busting? Draft: no for v0.15.0 — the use case is short-lived debug sessions; operators reload manually. Vendored renderer + Markdown rendering in v0.16.x is the right time to introduce cache-busting if needed."
|
||||
prd:
|
||||
issue: 16
|
||||
issue_url: https://gitea.phasefinal.com/vh/ratatoskr/issues/16
|
||||
body_sha256_16: "ae32cee38fd35761"
|
||||
lock_in_comment_id: null
|
||||
lock_in_sha256_16: null
|
||||
lock_in_at: null
|
||||
pinned_at: "2026-05-28T01:43:24+00:00"
|
||||
---
|
||||
|
||||
# Web companion — in-browser debug surface
|
||||
|
||||
## Context
|
||||
|
||||
Ratatoskr is a debug TUI for the Worldtree Conversation API. The wire-layer modules (`sse_client`, `sessions`, `tier3`, `local_agents`) are well-factored and reusable. This issue adds a sibling presentation surface: a browser-based debug companion that consumes the same SSE wire and renders the same five panes (transcript, thinking, tools, debug, persona). Reachable from any device on the operator's LAN — "show someone what I'm seeing" — without replacing the TUI as the canonical debug interface.
|
||||
|
||||
The work is wire-layer-zero (no changes to `sse_client` / `sessions` / `tier3` / `local_agents`) plus a new top-level module `ratatoskr.web` with a Starlette app, a console-script entrypoint, and a single-page static UI. Optional dependencies (`starlette`, `uvicorn`) ship as an `[web]` extras group so users who only want the TUI don't pay the install cost.
|
||||
|
||||
## Public surface
|
||||
|
||||
### Console script
|
||||
|
||||
```
|
||||
ratatoskr-web [--host HOST] [--port PORT] [--open]
|
||||
|
||||
--host HOST Bind address. Default: 0.0.0.0 (LAN-accessible).
|
||||
Use 127.0.0.1 to restrict to localhost.
|
||||
--port PORT Listen port. Default: 8765. Use 0 for random free.
|
||||
--open Auto-open the URL in the system browser.
|
||||
```
|
||||
|
||||
### Server endpoint surface
|
||||
|
||||
```
|
||||
GET / → serve index.html (200)
|
||||
GET /static/<path> → serve static asset (200) or 404
|
||||
GET /version → {"ratatoskr": "<version>"} (200)
|
||||
|
||||
GET /api/agents → 200 with [AgentInfo + tier3 local merge]
|
||||
POST /api/sessions → 201 with SessionInfo
|
||||
GET /api/agents/{agent_id}/persona_state
|
||||
→ 200 with PersonaSnapshot, or 404 / 403
|
||||
|
||||
POST /api/turns/{session_id} → 200 with {"turn_id": <int>}
|
||||
GET /api/turns/{session_id}/stream
|
||||
?turn_id=<int> → 200 SSE stream (text/event-stream)
|
||||
POST /api/turns/{session_id}/cancel
|
||||
?turn_id=<int> → 200 ok / 404 / 409 / 500
|
||||
```
|
||||
|
||||
### Module shape
|
||||
|
||||
```
|
||||
src/ratatoskr/web/
|
||||
__init__.py # bare — no module-level imports of starlette/uvicorn
|
||||
entrypoint.py # console-script: argparse, lazy import of server
|
||||
server.py # Starlette app factory + endpoint handlers + turn registry
|
||||
static/
|
||||
index.html # single-page UI (vanilla HTML/CSS/JS, no build step)
|
||||
```
|
||||
|
||||
### Public functions
|
||||
|
||||
```python
|
||||
def create_app(client_factory: Callable[[], httpx.AsyncClient]) -> Starlette: ...
|
||||
def main(argv: list[str] | None = None) -> int: ... # entrypoint.main
|
||||
```
|
||||
|
||||
`create_app` is the factory — takes a callable that produces a configured `httpx.AsyncClient` (bearer auth, base_url from env, User-Agent set per `ratatoskr.cli.USER_AGENT`) and returns a Starlette app with routes wired. Decoupling via factory keeps tests simple (the test client passes a respx-mocked `AsyncClient`).
|
||||
|
||||
`entrypoint.main` is the console-script target — parses flags, builds the client factory from env, calls `create_app`, runs uvicorn. The lazy-import discipline lives here: `import starlette` does NOT happen at module top — it lands inside `main()` after arg parsing, with an `ImportError` catch that prints the `pip install ratatoskr[web]` hint and exits non-zero.
|
||||
|
||||
## v0.16.0 amendment (post-Heid-code-review)
|
||||
|
||||
Heid panel review (Gróa + Hulda, thread `01KSP5P6CSJH`) on the
|
||||
v0.15.0/v0.15.1 implementation surfaced three contract-text issues
|
||||
now corrected below:
|
||||
|
||||
1. **Upstream vs local turn_id.** Cancel paths (explicit cancel,
|
||||
browser-disconnect, lifespan shutdown) MUST target the *upstream*
|
||||
(Worldtree-assigned) turn_id captured from the first SSE event's
|
||||
`sse_id.turn_id`, NOT the browser-local `_TURN_COUNTER` value (which
|
||||
is only a registry key). The `TurnHandle.upstream_response` field is
|
||||
replaced by `upstream_turn_id: int | None`. Cancel before the
|
||||
upstream stream starts (upstream_turn_id is None) is a no-op
|
||||
(`{"cancelled": false, "reason": "not_started"}`).
|
||||
2. **`RATATOSKR_END_USER_ID` is server-configured.** `FN main` reads it
|
||||
from env and threads it into `create_app(..., end_user_id=...)`; the
|
||||
`POST /api/sessions` endpoint uses `app.state.end_user_id` server-
|
||||
side. The browser NEVER supplies end_user_id — a client cannot
|
||||
impersonate an arbitrary end-user partition.
|
||||
3. **Stream client lifecycle.** The `async with client_factory() as
|
||||
client:` sketch in `FN stream_turn_endpoint` is not executable for a
|
||||
long-lived async generator that must outlive the handler frame; the
|
||||
implementation uses manual `client = ...; try: ... finally: await
|
||||
client.aclose()`. Sketch corrected below.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-001**: `ratatoskr.web.__init__` and `ratatoskr.web.entrypoint` MUST NOT import `starlette` or `uvicorn` at module top. Import is inside `main()` after flag parsing. The missing-extras `ImportError` catch is scoped to the OPTIONAL extras (`starlette` / `uvicorn`) ONLY — baseline-dep / first-party import failures propagate as real tracebacks rather than masking as exit-12.
|
||||
- **INV-002**: `ratatoskr.web.server.create_app` MUST accept a `client_factory` callable. The app MUST NOT construct `httpx.AsyncClient` at module top or in route handlers; it MUST call the factory.
|
||||
- **INV-003**: Upstream API key MUST never appear in any browser-visible response. Server proxies upstream calls using the client factory; only the upstream's JSON / SSE payload is forwarded. No header echo.
|
||||
- **INV-004**: Transcript content from upstream `text` SSE events MUST be HTML-escaped before reaching the browser DOM (escape on the wire in the SSE proxy serialization OR escape in the JS rendering — both are acceptable; pick one and stick to it).
|
||||
- **INV-005**: Browser disconnect mid-stream (`asyncio.CancelledError` in the SSE handler) MUST trigger an upstream cancel via `sse_client.cancel_turn` on the captured `upstream_turn_id` (v0.16.0 — NOT the browser-local turn_id). If the turn already completed, the cancel is a best-effort no-op (`CancelAlreadyCompleted` swallowed). If `upstream_turn_id` is still None (upstream stream never started), the disconnect cancel is skipped — nothing to cancel.
|
||||
- **INV-006**: Server shutdown (Ctrl-C / SIGTERM) MUST issue upstream cancels (on `upstream_turn_id`) for every in-flight registry entry within a 5-second cleanup budget. Handles whose `upstream_turn_id` is None are skipped. Entries that don't ack in time are abandoned with a per-entry structured log line carrying `session_id` + `upstream_turn_id`.
|
||||
- **INV-007**: The turn registry MUST be in-process memory only — no persistence, no shared state across server restarts. Process exit drops the registry.
|
||||
- **INV-008**: Each SSE event serialized to the browser MUST follow the contract enumerated in `tests/fixtures/presentation_contract.json` — one entry per Event type, with the exact JSON shape the browser presenter renders against.
|
||||
- **INV-009**: All wire-layer modules (`sse_client`, `sessions`, `tier3`, `local_agents`) MUST be used unchanged. Any required change to those modules is out of scope for this issue and gets its own ticket.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[security]** Upstream API key never reaches the browser. Lives in `WORLDTREE_API_KEY` env, passed to upstream via `Authorization: Bearer …` header in the client factory.
|
||||
- **[security]** Model-output text is HTML-escaped in the transcript pane. No script injection from adversarial assistant responses.
|
||||
- **[security]** No CORS guard, no auth — internal-LAN debug surface per operator direction.
|
||||
- **[testability]** Server is testable via `starlette.testclient.TestClient` + `respx` upstream mocks. No live network in unit tests.
|
||||
- **[packaging]** Static asset `index.html` ships in the wheel; resolvable via `importlib.resources` post-install.
|
||||
- **[performance]** Server is stateless across browser tabs; one in-memory registry entry per in-flight turn. Cleanup on browser disconnect / server shutdown.
|
||||
|
||||
## Tests (overview)
|
||||
|
||||
All test files live under `tests/`. New test files added by this issue:
|
||||
|
||||
- `tests/test_web_server.py` — endpoint contract tests via TestClient + respx
|
||||
- `tests/test_web_presentation_contract.py` — SSE proxy serialization vs fixture
|
||||
- `tests/test_web_packaging.py` — static asset resolution + lazy-import discipline
|
||||
|
||||
Existing test files remain unchanged.
|
||||
|
||||
## Function blocks
|
||||
|
||||
```contract
|
||||
FN main(argv: list[str] | None) -> int
|
||||
BRIEF: Console-script entry point — parses flags, lazy-imports server, runs uvicorn.
|
||||
PRE: [PRE-001 hard] argv parsing succeeds -- argparse raises SystemExit on bad args (exit 2)
|
||||
PRE: [PRE-002 soft] WORLDTREE_API_KEY env var present -- if missing, exit 11 [auth_error]
|
||||
PRE: [PRE-003 hard] starlette + uvicorn importable -- catch ImportError, print install hint, exit 12 [missing_extras]
|
||||
POST: [POST-001 side_effect] uvicorn serves until SIGINT/SIGTERM -- blocking call returns on shutdown
|
||||
POST: [POST-002 side_effect] boot banner printed to stderr -- URL + connect-instructions visible
|
||||
ERRORS:
|
||||
ImportError -> print "Install ratatoskr[web]" hint, return 12
|
||||
KeyError -> print missing-env error, return 11
|
||||
STEPS:
|
||||
1. [parse] argparse: --host (default 0.0.0.0), --port (default 8765, 0 = random), --open (default False)
|
||||
2. [validate] read WORLDTREE_API_URL, WORLDTREE_API_KEY, RATATOSKR_END_USER_ID from env
|
||||
IF WORLDTREE_API_KEY missing:
|
||||
- write [auth_error] to stderr, return 11
|
||||
3. [import] try: from ratatoskr.web.server import create_app
|
||||
EXCEPT ImportError:
|
||||
- write "ratatoskr-web requires the [web] extras..." hint to stderr, return 12
|
||||
4. [factory] build client_factory closure capturing url + key + user-agent
|
||||
5. [app] app = create_app(client_factory)
|
||||
6. [banner] print boot banner to stderr (version, host:port, connect URLs)
|
||||
7. [open] IF --open: webbrowser.open(f"http://localhost:{port}/")
|
||||
8. [serve] uvicorn.run(app, host=host, port=port, log_config=None)
|
||||
9. [return] return 0 on clean shutdown
|
||||
TESTS:
|
||||
happy_argv [tracer]: argv=["--port", "0"] with env set → returns 0 after serve loop mocked
|
||||
missing_extras [error]: starlette unimportable → stderr has install hint, returns 12
|
||||
missing_api_key [error]: WORLDTREE_API_KEY unset → stderr has [auth_error], returns 11
|
||||
default_host_is_zero [trace]: argv=[] → parsed host == "0.0.0.0"
|
||||
port_zero_supported [trace]: argv=["--port", "0"] → parsed port == 0
|
||||
open_flag_calls_webbrowser [trace]: argv=["--open"] with mocked webbrowser → webbrowser.open called
|
||||
no_open_default [trace]: argv=[] → webbrowser.open not called
|
||||
```
|
||||
|
||||
```contract
|
||||
FN create_app(client_factory: Callable[[], httpx.AsyncClient]) -> Starlette
|
||||
BRIEF: Construct the Starlette app — wire routes, register lifespan, build turn registry.
|
||||
PRE: [PRE-001 hard] client_factory is callable -- assert callable(client_factory)
|
||||
POST: [POST-001 return_value] returns Starlette instance with all routes registered -- inspect app.routes
|
||||
POST: [POST-002 state_change] app.state.turn_registry initialized as dict -- app.state.turn_registry == {}
|
||||
STEPS:
|
||||
1. [setup] turn_registry: dict[tuple[str, int], TurnHandle] = {}
|
||||
2. [routes] register routes for: /, /static/{path}, /version, /api/agents, /api/sessions,
|
||||
/api/agents/{id}/persona_state, /api/turns/{sid} (POST), /api/turns/{sid}/stream (GET),
|
||||
/api/turns/{sid}/cancel (POST)
|
||||
3. [lifespan] register lifespan handler that drains turn_registry on shutdown
|
||||
within 5s cleanup budget per INV-006
|
||||
4. [state] attach client_factory and turn_registry to app.state
|
||||
5. [return] return Starlette(routes=routes, lifespan=lifespan)
|
||||
TESTS:
|
||||
routes_registered [tracer]: factory=mock → app.routes contains all 9 path patterns
|
||||
state_attached [trace]: factory=mock → app.state.turn_registry is empty dict
|
||||
factory_stored [trace]: factory=mock → app.state.client_factory is the same callable
|
||||
```
|
||||
|
||||
```contract
|
||||
FN version_endpoint(request: Request) -> JSONResponse
|
||||
BRIEF: Return the ratatoskr package version as JSON.
|
||||
POST: [POST-001 return_value] response is JSON {"ratatoskr": <version>} status 200
|
||||
STEPS:
|
||||
1. [lookup] version = importlib.metadata.version("ratatoskr")
|
||||
2. [return] JSONResponse({"ratatoskr": version}, status_code=200)
|
||||
TESTS:
|
||||
happy [tracer]: GET /version → 200, body == {"ratatoskr": "<current-version>"}
|
||||
```
|
||||
|
||||
```contract
|
||||
FN agents_endpoint(request: Request) -> JSONResponse
|
||||
BRIEF: Proxy GET /agents from upstream; merge with local Tier 3 index.
|
||||
POST: [POST-001 return_value] 200 with list of agent dicts (upstream + local tier3 merged)
|
||||
POST: [POST-002 exception] upstream error → JSONResponse with upstream's error_code envelope
|
||||
STEPS:
|
||||
1. [proxy] async with app.state.client_factory() as client: agents = await list_agents(client)
|
||||
2. [local] local = local_agents.load_local_agents()
|
||||
3. [merge] merged = [as_dict(a) for a in agents] + [as_dict(le) for le in local if le.agent_id not in {a.agent_id for a in agents}]
|
||||
4. [return] JSONResponse(merged, status_code=200)
|
||||
ERRORS:
|
||||
SessionApiFailed -> JSONResponse({"error_code": "session_api_failed", "status": exc.status}, exc.status)
|
||||
httpx.RequestError -> JSONResponse({"error_code": "network_error", "message": str(exc)}, 502)
|
||||
TESTS:
|
||||
happy [tracer]: respx mock /agents 200 → response merges upstream + local index
|
||||
upstream_500 [error]: respx mock 500 → 500 with error_code envelope
|
||||
network_error [error]: respx connection refused → 502 with network_error envelope
|
||||
local_dedup [scenario]: local entry with same agent_id as upstream → no duplicate in merge
|
||||
```
|
||||
|
||||
```contract
|
||||
FN create_session_endpoint(request: Request) -> JSONResponse
|
||||
BRIEF: Proxy POST /sessions to upstream.
|
||||
PRE: [PRE-001 hard] request body has "agent_id" key -- 400 if missing
|
||||
POST: [POST-001 return_value] 201 with SessionInfo on upstream success
|
||||
STEPS:
|
||||
1. [parse] body = await request.json(); agent_id = body["agent_id"] (400 if missing)
|
||||
2. [server-side] end_user_id = request.app.state.end_user_id # v0.16.0: server-configured, NOT from body
|
||||
3. [proxy] async with client_factory() as client: info = await create_session(client, agent_id, end_user_id=end_user_id)
|
||||
4. [return] JSONResponse(as_dict(info), status_code=201)
|
||||
ERRORS:
|
||||
AgentNotFound -> JSONResponse({"error_code": "agent_not_found"}, 404)
|
||||
SessionApiFailed -> JSONResponse({"error_code": "session_api_failed", "status": exc.status}, exc.status)
|
||||
TESTS:
|
||||
happy [tracer]: respx mock 201 → endpoint returns 201 with session JSON
|
||||
unknown_agent [error]: respx mock 404 → 404 with agent_not_found envelope
|
||||
missing_agent_id [adversarial]: body without agent_id → 400
|
||||
server_side_end_user_id [v0.16.0]: create_app(end_user_id="X") → upstream body carries end_user_id="X"
|
||||
ignores_body_end_user_id [v0.16.0,security]: body end_user_id is overridden by server value
|
||||
```
|
||||
|
||||
```contract
|
||||
FN persona_state_endpoint(request: Request) -> JSONResponse
|
||||
BRIEF: Proxy GET /agents/{id}/persona_state to upstream.
|
||||
POST: [POST-001 return_value] 200 with PersonaSnapshot on upstream success
|
||||
STEPS:
|
||||
1. [parse] agent_id = request.path_params["agent_id"]
|
||||
2. [proxy] async with client_factory() as client: snap = await get_persona_state(client, agent_id)
|
||||
3. [return] JSONResponse(snap, status_code=200)
|
||||
ERRORS:
|
||||
PersonaNotConfigured -> JSONResponse({"error_code": "persona_not_configured"}, 404)
|
||||
AgentNotAvailable -> JSONResponse({"error_code": "agent_not_available"}, 404)
|
||||
AuthScopeDenied -> JSONResponse({"error_code": "auth_scope_denied"}, 403)
|
||||
TESTS:
|
||||
happy [tracer]: respx mock 200 → endpoint returns 200 with snapshot
|
||||
persona_not_configured [error]: respx mock 404 + persona_not_configured → 404 envelope
|
||||
agent_not_available [error]: respx mock 404 + agent_not_available → 404 envelope
|
||||
auth_scope_denied [error]: respx mock 403 + auth_scope_denied → 403 envelope
|
||||
```
|
||||
|
||||
```contract
|
||||
FN submit_turn_endpoint(request: Request) -> JSONResponse
|
||||
BRIEF: Accept a prompt-submit; allocate a turn_id in the registry; return it. NO upstream call yet — the stream endpoint opens that.
|
||||
PRE: [PRE-001 hard] request body has "content" key -- 400 if missing
|
||||
POST: [POST-001 return_value] 200 with {"turn_id": <int>}
|
||||
POST: [POST-002 state_change] app.state.turn_registry has entry for (sid, turn_id) with content + status "queued"
|
||||
STEPS:
|
||||
1. [parse] session_id = path_params["session_id"]; body = await request.json(); content = body["content"]
|
||||
2. [allocate] turn_id = next_turn_id() # process-local monotonic counter
|
||||
3. [register] turn_registry[(session_id, turn_id)] = TurnHandle(content=content, status="queued", upstream_turn_id=None) # v0.16.0: was upstream_response
|
||||
4. [return] JSONResponse({"turn_id": turn_id}, status_code=200)
|
||||
TESTS:
|
||||
happy [tracer]: POST {"content": "hi"} → 200 with turn_id; registry populated
|
||||
missing_content [adversarial]: body without content → 400
|
||||
monotonic_turn_ids [trace]: two submits → second turn_id > first turn_id
|
||||
```
|
||||
|
||||
```contract
|
||||
FN stream_turn_endpoint(request: Request) -> StreamingResponse
|
||||
BRIEF: Open SSE stream to browser — proxy upstream stream_turn() events, forward as SSE.
|
||||
PRE: [PRE-001 hard] (session_id, turn_id) in registry -- 404 if absent
|
||||
POST: [POST-001 side_effect] each upstream event serialized to browser as SSE event with type+data per fixture
|
||||
POST: [POST-002 state_change] on completion/disconnect, registry entry removed; upstream cancel if turn still in flight
|
||||
STEPS:
|
||||
1. [validate] sid, tid = path/query params; handle = registry.get((sid, tid)); 404 if None
|
||||
2. [open] client = client_factory() # v0.16.0: manual lifecycle, NOT `async with` — the generator outlives this frame; closed in finally
|
||||
- handle.status = "streaming"
|
||||
3. [forward] async for event in stream_turn(client, sid, handle.content):
|
||||
- IF handle.upstream_turn_id is None: handle.upstream_turn_id = event.sse_id.turn_id # v0.16.0: capture upstream turn id
|
||||
- serialize per fixture: {"type": <ssetype>, "data": <json>}
|
||||
- yield as `event: <type>\\ndata: <json>\\n\\n` bytes
|
||||
4. [terminal] on Done/Error/Cancelled: yield final SSE, mark handle.status, break
|
||||
5. [cleanup] finally:
|
||||
- IF asyncio.CancelledError caught AND status=="streaming" AND upstream_turn_id is not None: cancel_turn(client, sid, handle.upstream_turn_id) # v0.16.0: upstream id, not tid
|
||||
- remove (sid, tid) from registry; await client.aclose()
|
||||
ERRORS:
|
||||
KeyError -> 404 turn_not_found
|
||||
asyncio.CancelledError -> upstream cancel, propagate
|
||||
SseConnectFailed -> yield synthetic error event, close stream
|
||||
SseConnectionDropped -> yield synthetic error event, close stream
|
||||
TESTS:
|
||||
happy [tracer]: respx mock one text+done → SSE stream yields text event + done event
|
||||
unknown_turn [error]: GET with turn_id not in registry → 404
|
||||
upstream_error [error]: respx 500 on /sessions/{sid}/messages → synthetic error SSE event
|
||||
disconnect_triggers_cancel [scenario]: browser disconnect mid-stream → cancel_turn called on upstream
|
||||
full_event_vocab [scenario]: respx with one of each Event type → fixture-shaped JSON for each
|
||||
```
|
||||
|
||||
```contract
|
||||
FN cancel_turn_endpoint(request: Request) -> JSONResponse
|
||||
BRIEF: Proxy upstream cancel for a registered turn.
|
||||
PRE: [PRE-001 hard] (session_id, turn_id) in registry -- 404 if absent
|
||||
POST: [POST-001 side_effect] upstream cancel call lands; registry entry removed
|
||||
POST: [POST-002 return_value] 200 with {"cancelled": true} or 200 with status reflecting upstream race
|
||||
STEPS:
|
||||
1. [validate] sid, tid = params; handle = registry.get((sid, tid)); 404 if None
|
||||
2. [not-started] IF handle.upstream_turn_id is None: del registry[(sid,tid)]; return 200 {"cancelled": false, "reason": "not_started"} # v0.16.0: upstream never opened
|
||||
3. [cancel] async with client_factory() as client:
|
||||
- try: await cancel_turn(client, sid, handle.upstream_turn_id) # v0.16.0: upstream id, not tid
|
||||
- return 200 {"cancelled": true}
|
||||
4. [race] EXCEPT CancelAlreadyCompleted / CancelTurnNotFound:
|
||||
- return 200 {"cancelled": false, "reason": "race_or_completed"}
|
||||
5. [cleanup] del registry[(sid, tid)]
|
||||
TESTS:
|
||||
happy [tracer]: registered turn (upstream_turn_id set) → POST cancel → 200, upstream cancel at the upstream id
|
||||
unknown_turn [error]: not in registry → 404
|
||||
cancel_before_started [v0.16.0]: upstream_turn_id None → 200 {cancelled:false, reason:not_started}, no upstream call
|
||||
cancel_targets_upstream_turn_id [v0.16.0]: local tid != upstream id → cancel URL uses upstream id
|
||||
already_completed [race]: respx cancel returns 409 → 200 with reason=race_or_completed
|
||||
cancel_failed [error]: respx returns 500 → 500 with cancel_failed envelope
|
||||
```
|
||||
|
||||
```contract
|
||||
FN root_endpoint(request: Request) -> FileResponse
|
||||
BRIEF: Serve the static index.html.
|
||||
POST: [POST-001 return_value] FileResponse for ratatoskr/web/static/index.html, status 200, content-type text/html
|
||||
STEPS:
|
||||
1. [resolve] path = importlib.resources.files("ratatoskr.web") / "static" / "index.html"
|
||||
2. [return] FileResponse(path, media_type="text/html")
|
||||
TESTS:
|
||||
happy [tracer]: GET / → 200, content-type text/html, body contains "<html"
|
||||
```
|
||||
|
||||
```contract
|
||||
FN lifespan_shutdown(app: Starlette) -> None
|
||||
BRIEF: On Ctrl-C / SIGTERM, drain the turn registry within 5s budget per INV-006.
|
||||
POST: [POST-001 side_effect] every in-flight upstream turn gets a cancel attempt within budget
|
||||
POST: [POST-002 side_effect] entries that don't ack in budget logged + abandoned
|
||||
STEPS:
|
||||
1. [collect] in_flight = [h for h in registry.values() if h.status == "streaming" and h.upstream_turn_id is not None] # v0.16.0: skip not-yet-started
|
||||
2. [cancel] async with client_factory() as client:
|
||||
- task_to_handle = {create_task(cancel_turn(client, h.session_id, h.upstream_turn_id)): h for h in in_flight} # v0.16.0: upstream id
|
||||
- done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
|
||||
3. [log] for each pending: cancel task + log {"kind": "shutdown", "event": "cleanup_timeout", "session_id": h.session_id, "upstream_turn_id": h.upstream_turn_id}
|
||||
4. [clear] registry.clear()
|
||||
TESTS:
|
||||
happy [tracer]: 2 in-flight turns + shutdown → both upstream cancels called, registry empty
|
||||
timeout [scenario]: 1 hanging cancel + 1 normal → normal succeeds, hanging logged as cleanup_timeout
|
||||
```
|
||||
@@ -0,0 +1,323 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
target_module: "ratatoskr.sessions"
|
||||
scope: "Startup agent picker for TUI mode when `--new` is passed without `--agent`. Small surface change distributed across three existing modules via in-place contract amendments: `ratatoskr.sessions` gains `list_agents()` (GET /agents) returning `list[AgentInfo]` (new frozen dataclass with omit-when-null defaults mirroring SessionInfo's INV-001/INV-002 pattern); `ratatoskr.cli` softens `--agent` requirement from absolute to mode-conditional (`--send --new` still requires it; bare `--new` accepts None; `--session` still forbids it); `ratatoskr.tui._resolve_then_run` gains a pre-create branch that, when `args.new and args.agent_id is None`, calls `list_agents(client)` then runs a dedicated tiny `AgentPickerApp` (separate Textual `App` instance, opens before the main `RatatoskrApp`) whose `run_async()` returns the chosen `agent_id` (or `None` on Esc/Ctrl-D for clean exit). No new files; no wire-level surface change beyond the new endpoint hit. Composes naturally with #5 (`--end-user-id`): both thread through `ParsedArgs` before any App opens."
|
||||
depends_on:
|
||||
- "httpx"
|
||||
- "textual"
|
||||
used_by:
|
||||
- "ratatoskr.cli"
|
||||
- "ratatoskr.tui"
|
||||
language: "python"
|
||||
complexity: "low"
|
||||
estimated_loc: 200
|
||||
confidence: 0.85
|
||||
assumptions:
|
||||
- "Worldtree spec pin (`docs/conversation-api-spec.md` v0.19.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) declares `GET /agents` at §832: returns 200 with a JSON array of agent objects. Three fields always present (`agent_id`, `name`, `description`); five optional with omit-when-null/omit-when-empty semantics (`version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). No pagination, no filters, no auth-scope requirement beyond bearer-authenticated (worldtree-dev confirmed 2026-05-23: Tier 1 `agent.list` baseline scope covers it; same auth posture as POST /sessions)."
|
||||
- "`AgentInfo` is a frozen dataclass on `ratatoskr.sessions` (alongside `SessionInfo` / `SessionPage`) carrying all 8 fields. Origin-conditional defaults mirror INV-001/INV-002 from issue #2: required fields (`agent_id`, `name`, `description`) take the response value verbatim; optional fields default to `None` (scalar `version`) or empty container (`capabilities=[]`, `supported_models=[]`, `persona_traits={}`, `ui_hints={}`) when omitted from the response. Empty containers (NOT `None`) for collection-shaped optionals so caller code can branch on truthiness without `is None` ceremony."
|
||||
- "`list_agents` uses the same caller-owned-client posture as `create_session` / `list_sessions`: takes `httpx.AsyncClient`, returns `list[AgentInfo]`, raises `SessionApiFailed(status, body)` on any non-200 response. No new exception type — list_agents' failure modes (auth, transport, server-side 5xx) all map cleanly to the existing `SessionApiFailed` shape. The module's posture against shared types with sse_client (`ratatoskr.sessions` issue #1 convention dependency) is preserved."
|
||||
- "**CLI surface change is mode-conditional, not flag-removal**: `--agent` becomes optional ONLY when `--new` is passed AND `--send` is NOT passed (i.e., bare TUI-mode create). `--send --new` still raises `UsageError('--agent is required when --new is passed in --send mode')` because non-interactive --send mode has no way to prompt. `--session <id>` still forbids `--agent` (preserves issue #3 INV-004 mutual-exclusion). The single existing line `if ns.new and not ns.agent: raise UsageError(...)` in `_parse_args` STEP 3 splits into two conditionals that distinguish on `ns.send`."
|
||||
- "**Picker is its own App, not a Screen within RatatoskrApp** (deliberate divergence from the issue body's 'pushed onto the App's screen stack' phrasing, which predated issue #6's refactor). Reason: issue #6's load-bearing invariant is that session-resolution + startup errors land on the operator's REAL stderr before any alt-screen opens. `list_agents` failures (network, auth, server error) need that same property. Doing it via a Screen inside RatatoskrApp re-introduces the alt-screen-eats-stderr problem #6 fixed. Doing it via a separate `AgentPickerApp` opened in `_resolve_then_run` (before `RatatoskrApp`) preserves #6's invariant: `list_agents` errors print to stderr and short-circuit BEFORE the picker's alt-screen opens; picker errors (which don't really exist — it's pure UI navigation) are bounded; chosen `agent_id` flows back through `app.run_async()`'s return value."
|
||||
- "**Two alt-screen cycles is acceptable** (picker opens + closes; main RatatoskrApp opens). Textual's `App.run_async()` handles alt-screen entry + restoration cleanly per-instance. The visible-flicker cost is one quick alt-screen flash between picker dismissal and main App mount; the architectural cost of avoiding this (Screen-within-App, breaking #6) is higher than the cosmetic cost. If empirical operator feedback indicates the flicker is jarring, follow-up issue collapses to one App with two Screens AFTER re-engineering the stderr-error path."
|
||||
- "**Picker Esc/Ctrl-D returns exit 0, NOT a `UsageError`**: when the operator dismisses the picker without choosing, the intent is 'never mind, exit cleanly' — same as Ctrl-D from the main chat pane in INV-002 of issue #4. `_resolve_then_run` returns 0 without calling `create_session` or `App.run_async()` on RatatoskrApp. No session is created server-side; no `agent_id` is required to satisfy this exit path."
|
||||
- "**Picker layout uses ListView, not DataTable**: design-brief §5 mentions `DataTable` for the session picker but ListView is the right primitive for agent picking — single-column, keyboard-navigable, one row per agent rendered as `agent_id · name — description`. v1 picker is a flat list per the issue's out-of-scope clause (search/filter/sort/ui_hints rendering all deferred). DataTable's column-header + sortable-column ergonomics are wasted on this surface."
|
||||
- "**Empty agent list is a clean exit, not an error**: if `GET /agents` returns `[]`, the picker writes `[no_agents] server returned empty agent list\\n` to stderr and `_resolve_then_run` returns exit code 13 (new — see ERROR_ROUTING below). The picker UI never opens in this case; no point showing an empty list with no actionable rows."
|
||||
- "**Single agent does NOT auto-select**: if `GET /agents` returns one agent, the picker still opens with one row. Auto-select would hide the choice (and the agent's description) from the operator. The cost is one keystroke; the benefit is transparency about what's about to happen."
|
||||
- "**`AgentInfo` field order in the dataclass matches the spec's column order** (`agent_id`, `name`, `description`, `version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). Mirrors how readers scanning the dataclass map mental model from spec → code."
|
||||
- "**`AgentInfo.persona_traits` / `ui_hints` are typed as `dict[str, Any]` not nested dataclasses**: v1 picker just displays `agent_id · name — description`; the inner shape (ocean object, icon, color_hint, vibe) is opaque to ratatoskr. Future polish that renders icon/color_hint would either parse on-demand or introduce nested dataclasses then. Keeping them as dict[str, Any] avoids paying a typing tax now for a display surface that's deferred."
|
||||
- "**`list_agents` does NOT pass query params**: spec §832 declares no pagination, no filters. The request is a bare `GET /agents` with the bearer header from the caller-owned client. If Worldtree later adds filters (e.g., `?capability=foo`), `list_agents` gains them via amendment then."
|
||||
open_questions:
|
||||
- "Should the picker display `version` when available (e.g., `mimir v0.2.0 — Keeper of the Well of Knowledge`)? Draft: no for v1 — the issue body specifies `agent_id · name — description` exactly. Add in a follow-up if operators report ambiguity (two `mimir` rows from different deployments). Drift-check evidence first."
|
||||
- "Should `AgentPickerApp.run_async()` return the chosen `AgentInfo` or just the `agent_id` string? Draft: just the `agent_id` string for v1 — that's all `create_session` needs. Returning the full `AgentInfo` would let `RatatoskrApp` show name/description in the identity widget without re-fetching, but the existing identity widget format is `<agent_id> · …<session_id>` so the extra metadata has no consumer yet. Defer until §5 side-panes work needs it."
|
||||
- "Should the picker show a loading spinner while `list_agents` is in flight? Draft: no for v1 — list_agents runs BEFORE the picker App opens (per the architectural decision above), so there's no in-app loading state to show. Operator sees stderr label on failure; on success the picker opens with the list already populated. If the request latency turns out to be noticeable (e.g., >300ms), reconsider."
|
||||
prd:
|
||||
issue: 8
|
||||
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/8"
|
||||
body_sha256_16: "c34f4878936a4edc"
|
||||
lock_in_comment_id: null
|
||||
lock_in_sha256_16: null
|
||||
lock_in_at: null
|
||||
pinned_at: "2026-05-24T00:49:03+00:00"
|
||||
dependencies:
|
||||
- issue: 2
|
||||
path: "src/ratatoskr/sessions.py"
|
||||
reason: "In-place contract amendment: new `list_agents()` function + new `AgentInfo` frozen dataclass added to the module's public surface. POST /sessions paths unchanged; no shared types with the new code beyond the module's existing `SessionApiFailed` exception (reused for non-200 responses)."
|
||||
- issue: 3
|
||||
path: "src/ratatoskr/cli.py"
|
||||
reason: "In-place contract amendment: `_parse_args` STEP 3 splits the single `if ns.new and not ns.agent` check into two conditionals — `--send --new` keeps the strict requirement; bare `--new` accepts `agent_id=None` for the TUI-picker case."
|
||||
- issue: 4
|
||||
path: "src/ratatoskr/tui.py"
|
||||
reason: "In-place contract amendment: new `AgentPickerApp(App[str | None])` class with ListView + Enter/Esc bindings; `_resolve_then_run` gains a pre-create branch that runs the picker when `args.new and args.agent_id is None`; on chosen `agent_id`, threads it into `create_session(client, chosen, end_user_id=args.end_user_id)`. RatatoskrApp itself is unchanged."
|
||||
---
|
||||
|
||||
# Startup agent picker — GET /agents when --new without --agent (TUI)
|
||||
|
||||
## Context
|
||||
|
||||
Today, `ratatoskr --new` requires `--agent <id>`. If omitted, `_parse_args`
|
||||
raises `UsageError("--agent is required when --new is passed")`. That's
|
||||
correct for `--send --new` (non-interactive — can't prompt) but wrong
|
||||
for the TUI (operator may not know which agents are available, would
|
||||
prefer to pick from a list at startup).
|
||||
|
||||
Worldtree's spec §832 exposes `GET /agents`. The endpoint returns a flat
|
||||
JSON array; required fields are `agent_id`, `name`, `description`;
|
||||
optional fields (`version`, `capabilities`, `supported_models`,
|
||||
`persona_traits`, `ui_hints`) follow omit-when-null/empty rules. No
|
||||
pagination, no filters, no special scope. Worldtree-dev confirmed
|
||||
2026-05-23: Tier 1 baseline auth covers it.
|
||||
|
||||
This issue threads a small surface change through three existing modules
|
||||
in-place — no new files apart from this contract.
|
||||
|
||||
## Data flow
|
||||
|
||||
**Input:**
|
||||
- `httpx.AsyncClient` (caller-owned, base_url + bearer auth on the client).
|
||||
- No request body, no query params.
|
||||
|
||||
**Output (`list_agents`):**
|
||||
- `list[AgentInfo]` — one entry per available agent, in server-declared order.
|
||||
|
||||
**Output (`AgentPickerApp.run_async()`):**
|
||||
- `str | None` — chosen `agent_id`, or `None` on Esc/Ctrl-D dismissal.
|
||||
|
||||
## Public surface (ratatoskr.sessions amendment)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AgentInfo:
|
||||
"""One agent's metadata from GET /agents.
|
||||
|
||||
INV-005: Required fields (`agent_id`, `name`, `description`) take the
|
||||
response value verbatim. Optional fields default to None (`version`)
|
||||
or an empty container (`capabilities`, `supported_models`,
|
||||
`persona_traits`, `ui_hints`) when omitted from the server response,
|
||||
mirroring SessionInfo's INV-001/INV-002 origin-conditional pattern.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
name: str
|
||||
description: str
|
||||
version: str | None
|
||||
capabilities: list[str]
|
||||
supported_models: list[str]
|
||||
persona_traits: dict[str, Any]
|
||||
ui_hints: dict[str, Any]
|
||||
|
||||
|
||||
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
|
||||
"""GET /agents → list of available agents. See contract FN list_agents."""
|
||||
```
|
||||
|
||||
## Functions
|
||||
|
||||
### FN list_agents
|
||||
|
||||
```
|
||||
FN list_agents(client: httpx.AsyncClient) -> list[AgentInfo]
|
||||
BRIEF: GET /agents → list of available agents. No pagination, no filters.
|
||||
|
||||
PRE-001: client is not None.
|
||||
|
||||
STEPS:
|
||||
1. resp = await client.get("/agents")
|
||||
2. IF resp.status_code != 200:
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
3. body = resp.json() # expected: list[dict]
|
||||
4. items = [
|
||||
AgentInfo(
|
||||
agent_id = item["agent_id"],
|
||||
name = item["name"],
|
||||
description = item["description"],
|
||||
version = item.get("version"),
|
||||
capabilities = item.get("capabilities") or [],
|
||||
supported_models = item.get("supported_models") or [],
|
||||
persona_traits = item.get("persona_traits") or {},
|
||||
ui_hints = item.get("ui_hints") or {},
|
||||
)
|
||||
for item in body
|
||||
]
|
||||
5. RETURN items
|
||||
|
||||
POST-001: every item in the return list has required fields populated.
|
||||
POST-002: optional fields default to None / [] / {} when absent from the response.
|
||||
|
||||
ERROR_ROUTING:
|
||||
- 200 with non-list body → KeyError / TypeError propagates (server bug; not handled here).
|
||||
- non-200 → SessionApiFailed(status=N, body=resp.content)
|
||||
- httpx.RequestError → propagates (network failure; caller handles).
|
||||
|
||||
TESTS:
|
||||
- test_happy_full_shape: 200 + spec's full-shape mimir example → AgentInfo with all fields populated.
|
||||
- test_happy_minimum_shape: 200 + spec's minimum-shape "minimal" example → AgentInfo with required + defaulted optional.
|
||||
- test_happy_multi_agent: 200 + array of 3 agents → list of 3 AgentInfo in order.
|
||||
- test_happy_empty: 200 + [] → empty list (no error).
|
||||
- test_omit_capabilities: 200 + agent missing capabilities → AgentInfo.capabilities == [].
|
||||
- test_omit_persona_traits: 200 + agent missing persona_traits → AgentInfo.persona_traits == {}.
|
||||
- test_omit_ui_hints: 200 + agent missing ui_hints → AgentInfo.ui_hints == {}.
|
||||
- test_500_raises: 500 → SessionApiFailed with status=500.
|
||||
- test_401_raises: 401 → SessionApiFailed with status=401.
|
||||
```
|
||||
|
||||
## CLI surface change (ratatoskr.cli amendment)
|
||||
|
||||
`_parse_args` STEP 3, currently:
|
||||
|
||||
```python
|
||||
if ns.new and not ns.agent:
|
||||
raise UsageError("--agent is required when --new is passed")
|
||||
```
|
||||
|
||||
becomes:
|
||||
|
||||
```python
|
||||
if ns.new and not ns.agent:
|
||||
if ns.send is not None:
|
||||
# --send --new mode: non-interactive, cannot prompt for choice.
|
||||
raise UsageError("--agent is required with --new in --send mode")
|
||||
# else: bare --new (TUI mode) — agent_id stays None, TUI runs picker.
|
||||
```
|
||||
|
||||
INV-004 (`--session` + `--agent` mutual exclusion) is unchanged. Existing
|
||||
`--send --new` smoke flows that pass `--agent` continue to work unchanged.
|
||||
|
||||
ERROR_ROUTING (cli):
|
||||
- `--send --new` without `--agent` → `UsageError` → exit 10 (unchanged from today).
|
||||
- bare `--new` without `--agent` → `ParsedArgs.agent_id=None`, TUI handles.
|
||||
|
||||
TESTS (additions to test_cli.py):
|
||||
- test_parse_send_new_without_agent_raises: `--send "hi" --new` (no --agent) → UsageError.
|
||||
- test_parse_bare_new_without_agent_accepted: `--new` (no --agent, no --send) → ParsedArgs with agent_id=None.
|
||||
- test_parse_bare_new_with_agent_accepted: `--new --agent mimir` → unchanged behavior, agent_id="mimir".
|
||||
|
||||
## TUI surface change (ratatoskr.tui amendment)
|
||||
|
||||
### New: AgentPickerApp
|
||||
|
||||
```python
|
||||
class AgentPickerApp(App[str | None]):
|
||||
"""Single-purpose picker App. Opens before RatatoskrApp.
|
||||
|
||||
`run_async()` returns the chosen agent_id (str) or None on Esc/Ctrl-D.
|
||||
"""
|
||||
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
Binding("enter", "pick", "Pick", priority=True),
|
||||
Binding("escape", "dismiss", "Cancel", priority=True),
|
||||
Binding("ctrl+d", "dismiss", "Cancel", priority=True),
|
||||
Binding("ctrl+c", "dismiss", "Cancel", priority=True),
|
||||
]
|
||||
|
||||
def __init__(self, agents: list[AgentInfo]) -> None:
|
||||
super().__init__()
|
||||
assert agents # PRE-002 — caller guarantees non-empty
|
||||
self.agents = agents
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Static("Pick an agent for the new session:", id="picker-prompt")
|
||||
yield ListView(
|
||||
*[
|
||||
ListItem(Label(f"{a.agent_id} · {a.name} — {a.description}"))
|
||||
for a in self.agents
|
||||
],
|
||||
id="agent-list",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.query_one("#agent-list", ListView).focus()
|
||||
|
||||
def action_pick(self) -> None:
|
||||
lv = self.query_one("#agent-list", ListView)
|
||||
idx = lv.index
|
||||
if idx is None:
|
||||
return # no row highlighted; ignore
|
||||
self.exit(self.agents[idx].agent_id)
|
||||
|
||||
def action_dismiss(self) -> None:
|
||||
self.exit(None)
|
||||
```
|
||||
|
||||
### Modified: _resolve_then_run
|
||||
|
||||
Insert a pre-create branch between the `async with httpx.AsyncClient(...)`
|
||||
and the existing `if args.new:` block:
|
||||
|
||||
```python
|
||||
async def _resolve_then_run(args: ParsedArgs) -> int:
|
||||
assert isinstance(args, ParsedArgs) and args.send_content is None
|
||||
async with httpx.AsyncClient(...) as client:
|
||||
# NEW (issue #8): startup agent picker when --new without --agent.
|
||||
chosen_agent_id: str | None = args.agent_id
|
||||
if args.new and args.agent_id is None:
|
||||
try:
|
||||
agents = await list_agents(client)
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(
|
||||
f"[session_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 not agents:
|
||||
sys.stderr.write("[no_agents] server returned empty agent list\n")
|
||||
return 13
|
||||
picker = AgentPickerApp(agents)
|
||||
chosen_agent_id = await picker.run_async()
|
||||
if chosen_agent_id is None:
|
||||
return 0 # Esc/Ctrl-D — clean exit, no session created
|
||||
# EXISTING: create_session OR re-use --session id
|
||||
if args.new:
|
||||
assert chosen_agent_id is not None
|
||||
try:
|
||||
info = await create_session(
|
||||
client, chosen_agent_id, end_user_id=args.end_user_id
|
||||
)
|
||||
...
|
||||
else:
|
||||
...
|
||||
app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
|
||||
...
|
||||
```
|
||||
|
||||
### ERROR_ROUTING (tui startup)
|
||||
|
||||
| Failure | Stderr label | Exit |
|
||||
|---------|--------------|------|
|
||||
| `list_agents` → `SessionApiFailed` | `[session_api_failed] status=N body=B` | 20 |
|
||||
| `list_agents` → network error | `[network_error] T: M` | 21 |
|
||||
| empty agent list (`GET /agents` returns `[]`) | `[no_agents] server returned empty agent list` | 13 (new) |
|
||||
| picker Esc/Ctrl-D dismissal | (none — clean exit) | 0 |
|
||||
| `create_session` post-pick → `AgentNotFound` | `[agent_not_found] agent_id=X` | 12 (unchanged from #4) |
|
||||
|
||||
INV: exit code 13 is new (no-agents). Previously unused — does not collide
|
||||
with any existing exit code in `_resolve_then_run` or `_amain`.
|
||||
|
||||
### TESTS (additions to test_tui.py)
|
||||
|
||||
```
|
||||
- test_picker_happy_path: list_agents returns 2 agents; AgentPickerApp opens; user picks index 0; chosen agent_id flows into create_session; main App opens.
|
||||
- test_picker_esc_clean_exit: list_agents returns 2 agents; picker opens; user presses Esc; _resolve_then_run returns 0; create_session NOT called; RatatoskrApp NOT opened.
|
||||
- test_picker_skipped_when_agent_id_provided: bare --new --agent mimir → list_agents NOT called; picker NOT opened; create_session called with "mimir".
|
||||
- test_picker_skipped_when_session_mode: --session s-1 → list_agents NOT called; picker NOT opened; no create_session.
|
||||
- test_picker_list_agents_session_api_failed: list_agents raises SessionApiFailed → stderr [session_api_failed]; exit 20; picker NOT opened; create_session NOT called.
|
||||
- test_picker_list_agents_network_error: list_agents raises ConnectError → stderr [network_error]; exit 21.
|
||||
- test_picker_empty_list: list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened; create_session NOT called.
|
||||
- test_agent_picker_app_renders_rows: AgentPickerApp with 3 agents → ListView has 3 ListItem children with expected text.
|
||||
- test_agent_picker_app_pick_returns_agent_id: simulate Enter on highlighted row → exit value == agents[idx].agent_id.
|
||||
- test_agent_picker_app_dismiss_returns_none: simulate Esc → exit value is None.
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-005**: `AgentInfo` field defaults are origin-conditional (mirrors INV-001/002 from #2). Required → verbatim; optional → None / [] / {}.
|
||||
- **INV-006**: `list_agents` failure modes route through `SessionApiFailed` only — no new exception type introduced.
|
||||
- **INV-007**: Picker is a separate App, opened by `_resolve_then_run` BEFORE `RatatoskrApp`. Preserves issue #6's stderr-error invariant for `list_agents` failures.
|
||||
- **INV-008**: `--agent` CLI requirement is mode-conditional: required only when `--send --new`; bare `--new` accepts None; `--session` always forbids it.
|
||||
- **INV-009**: Picker dismissal (Esc/Ctrl-D) returns clean exit 0; no session created server-side.
|
||||
- **INV-010**: Empty agent list is a clean stderr exit (code 13), not an open picker.
|
||||
- **INV-011**: Single-agent response still opens the picker — no auto-select.
|
||||
- **INV-012**: Two alt-screen cycles (picker + main App) is the explicit architectural tradeoff for preserving INV-007.
|
||||
@@ -55,6 +55,69 @@ conversation_api:
|
||||
|
||||
---
|
||||
|
||||
## Authorization model — agent invocation
|
||||
|
||||
When you call `POST /sessions` against an agent, the authorization check that fires depends on **which kind of agent** you target. There are two distinct scope namespaces — the spelling differs by one character (`agent` vs `agents`) and the granting mechanism differs entirely. Confusing the two is a common source of bug reports.
|
||||
|
||||
### Tier 1 — foundational agents (no `:` in agent_id)
|
||||
|
||||
Agents bundled with Worldtree: `mimir`, `lofn`, `soong`, `forseti`, `domari`, `vili`, `actor`, `saga`, `bragi`, `leif`, `troi`, `cara`, `glados`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon.
|
||||
|
||||
> **About tiers:** Your `tier` is set on the `users` table row your API key resolves to, assigned at key-mint time (see `POST /admin/keys`). Tiers are `anonymous` (dev-mode unauthenticated), `user` (default for newly-issued keys), `free`/`pro` (subscription-shaped, not actively differentiated), and `admin`. The tier you have is visible via `GET /me`'s `tier` field. Tier-derived scopes come from `config/policies.yaml > tiers.<tier>.scopes` — there is no per-key scope override.
|
||||
|
||||
**Authorization rule (singular `agent`):**
|
||||
|
||||
```yaml
|
||||
- id: agent-call-baseline-allow
|
||||
principal:
|
||||
tiers: ["anonymous", "user", "free", "pro", "admin"]
|
||||
action: "agent.call:*"
|
||||
resource: "*"
|
||||
effect: allow
|
||||
```
|
||||
|
||||
This baseline rule lives at `config/policies.yaml`. Every authenticated tier — including the `user` tier that newly-issued keys default to — already passes this check for every Tier 1 agent. **There is no per-agent scope you can add to "grant" Tier 1 access; it's covered by tier.**
|
||||
|
||||
If you get a 422 calling a Tier 1 agent (e.g., `lofn` rejecting with `end_user_id_required`), that's a **request-body validation**, not a scope denial. Check the `error_code` in the response detail — `END_USER_ID_REQUIRED` means the agent requires an `end_user_id` field in the request body; `AUTH_SCOPE_DENIED` (403) would be the actual scope problem. They're not interchangeable.
|
||||
|
||||
### Tier 3 — consumer-defined agents (`:` in agent_id)
|
||||
|
||||
Agents created at runtime via `POST /agents/define`. The agent_id is `<owner_user_id>:<agent_name>`, e.g., `acme:support-bot`. The `:` in the path is the trigger that switches the auth model.
|
||||
|
||||
**Authorization is DB-backed per-resource, NOT policy-driven (plural `agents`):**
|
||||
|
||||
```
|
||||
scope action checked: agents.call:<owner_user_id>:<agent_name>
|
||||
^^^^^^
|
||||
PLURAL — different namespace from Tier 1
|
||||
```
|
||||
|
||||
There is **no blanket allow rule** for `agents.call:*` in policy. The grant comes from the live `consumer_agents` table:
|
||||
|
||||
- A non-soft-deleted row in `consumer_agents` owned by `ctx.user_id` IS the grant.
|
||||
- Cascade soft-delete and owner-initiated `DELETE` revoke it.
|
||||
- Missing row → policy defaults to deny (403 `auth_scope_denied`).
|
||||
|
||||
To "add the scope" for a Tier 3 agent, you don't amend any config or call an admin endpoint — you `POST /agents/define` to register it under your `user_id`. Owning the row IS the grant. You cannot call another user's Tier 3 agent; ownership is checked at session-create (`row.user_id == ctx.user_id`).
|
||||
|
||||
### Common pitfalls
|
||||
|
||||
- **Singular vs plural.** Tier 1 uses `agent.call:*` (singular `agent`). Tier 3 uses `agents.call:<owner>:<name>` (plural `agents`). One character difference, two completely different mechanisms. There is no Tier 1 scope named `agent.call:mimir` or `agents.call:mimir` — Tier 1 is granted by baseline rule, not per-agent name.
|
||||
- **No scope-mutation API.** `POST /admin/keys` accepts `{user_id, label, tier}` only. There is no per-key scope override mechanism in the storage schema. To change a user's effective scopes, change their `tier`, not their key. Per-resource Tier 3 grants flow through `POST /agents/define` (and its DELETE counterpart), not through admin endpoints.
|
||||
- **422 vs 403.** A 422 is body-validation (e.g., `end_user_id_required`); a 403 is auth-policy denial (`auth_scope_denied`). Different fix paths. Read the `error_code` in `detail`.
|
||||
|
||||
### Quick decision table for consumers
|
||||
|
||||
| Target | Auth requirement |
|
||||
|---|---|
|
||||
| Tier 1 agent (e.g., `mimir`) | Authenticated tier ≥ `user`. No additional body requirements |
|
||||
| Tier 1 agent `lofn` (the default welcoming intermediary) | Authenticated tier ≥ `user` + `end_user_id` field required in request body. 422 `END_USER_ID_REQUIRED` if absent |
|
||||
| Tier 3 agent (any agent_id containing `:`) | `end_user_id` field required in body. AND the row must be owner-matched: `POST /agents/define` first to create a row under your `user_id`, then session-create works against your existing key. Cross-user Tier 3 invocation is rejected with 403 |
|
||||
|
||||
> **Programmatic discovery of `end_user_id` requirements:** as of v0.22.x there is no field on `GET /agents` indicating which agents require `end_user_id` — the spec line above (lofn + Tier 3) is the authoritative list, and 422 `END_USER_ID_REQUIRED` is the fallback signal at request time. Adding a discoverable `requires_end_user_id` field on `AgentInfoResponse` is on the table as a small future capability; ping if you want to drive it.
|
||||
|
||||
---
|
||||
|
||||
## GET /me
|
||||
|
||||
Returns the authenticated principal's identity and key metadata. Lets a client verify its key on boot without triggering agent-config-loading side effects.
|
||||
@@ -1878,6 +1941,65 @@ Tool-using turns cycle through `CallingLLM → ProcessingTools → CallingLLM
|
||||
|
||||
Clients that don't need phase events can filter on `event["type"] != "worker_phase"` client-side. Existing SSE consumers that switch on `event["type"]` ignore this event type without code changes.
|
||||
|
||||
### affect_update
|
||||
|
||||
Persona-state observability event (issue #204). Fires twice per turn for agents with `persona.enabled: true` on non-ephemeral sessions; suppressed entirely for persona-disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined agents (Phase 2.0), and ephemeral sessions.
|
||||
|
||||
**Start-of-turn — `status: "current"`:**
|
||||
|
||||
Emitted immediately at the start of each qualifying turn, before any `worker_phase` event. Carries the agent's current persona snapshot reflecting all prior turns' completed appraisals.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "affect_update",
|
||||
"status": "current",
|
||||
"turn_id": 42,
|
||||
"snapshot": {
|
||||
"agent_id": "mimir",
|
||||
"pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50},
|
||||
"dominant_emotion": "curiosity",
|
||||
"emotions_active": [
|
||||
{"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7}
|
||||
],
|
||||
"baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50},
|
||||
"mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07},
|
||||
"last_updated_at": "2026-05-25T22:30:18+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**End-of-turn — `status: "scheduled"`:**
|
||||
|
||||
Emitted after the post-turn appraisal task has been scheduled (per #177 Phase A's fire-and-forget discipline) and before `done`. Lightweight notification — no PAD numbers, since the appraisal is still running asynchronously. The result lands in the NEXT turn's `status: "current"` snapshot.
|
||||
|
||||
```json
|
||||
{"type": "affect_update", "status": "scheduled", "turn_id": 42}
|
||||
```
|
||||
|
||||
`scheduled` is skipped on turn failure/cancel paths (the appraisal was never reached); `current` still fires unconditionally for qualifying turns.
|
||||
|
||||
Bootstrap reads available via `GET /agents/{agent_id}/persona_state` (same `snapshot` shape, requires `persona.read` scope).
|
||||
|
||||
### awaiting_llm_first_token
|
||||
|
||||
Periodic heartbeat event (issue #201) emitted at a configurable interval during the gap between `worker_phase: phase="BuildingPrompt"` and `worker_phase: phase="CallingLLM"`. Solves the legitimate-slow first-token visibility gap: consumer TUIs can render a "thinking for Ns…" timer rather than a frozen line during heavy-CoT prompt warmup.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "awaiting_llm_first_token",
|
||||
"turn_id": 42,
|
||||
"elapsed_ms_since_building_prompt": 5012.3
|
||||
}
|
||||
```
|
||||
|
||||
`elapsed_ms_since_building_prompt` is the server-authoritative wall-clock milliseconds since `BuildingPrompt` was emitted. Independent of network latency or clock skew.
|
||||
|
||||
Heartbeats stop the moment the engine produces its first event (the `CallingLLM` marker). They do NOT re-fire during tool-roundtrip `CallingLLM` re-entries — the heartbeat is scoped to the FIRST `BuildingPrompt → CallingLLM` gap only.
|
||||
|
||||
**Configuration:** `conversation_api.awaiting_llm_first_token_heartbeat_s` (default `5.0`). Per-agent override via `agent.conversation.awaiting_llm_first_token_heartbeat_s`. Value `0.0` disables emission entirely.
|
||||
|
||||
Cancellation paths (stall watchdog, user-cancel) also stop the heartbeat — no `awaiting_llm_first_token` event appears after the terminal `cancelled` event.
|
||||
|
||||
### thinking
|
||||
|
||||
Incremental reasoning/thinking content (from thinking-enabled models).
|
||||
|
||||
@@ -1851,6 +1851,103 @@ SQLite `consumer_agents` table.
|
||||
before any other processing; non-slug user_ids return 403
|
||||
`tier3_user_id_unsupported`.
|
||||
|
||||
### Persona-state observability (issue #204)
|
||||
|
||||
- **INV-204-1 (affect_update event type)**: `affect_update` is a
|
||||
top-level SSE event `type` discriminator, sibling to `worker_phase`
|
||||
/ `tool_*` / `text` / `thinking` / `done`. Not a `worker_phase` sub-
|
||||
phase. INV-061's "BuildingPrompt is the FIRST event" property is
|
||||
scoped to `worker_phase` events only — `affect_update status="current"`
|
||||
may precede BuildingPrompt for persona-enabled agents.
|
||||
- **INV-204-2 (per-turn emission)**: For agents with persona enabled
|
||||
on non-ephemeral sessions, `stream_turn` emits `status="current"`
|
||||
before any other SSE event on a successful or failed turn, and
|
||||
`status="scheduled"` after `update_after_turn` schedules the
|
||||
appraisal task (success path only — skipped on cancel / error
|
||||
before update_after_turn was reached). See contract
|
||||
`docs/contracts/issues/204.contract.md`.
|
||||
- **INV-204-3 (emission suppression)**: Persona-disabled agents and
|
||||
ephemeral sessions emit ZERO `affect_update` events.
|
||||
- **INV-204-6 / INV-204-7 (persona_state endpoint)**: New
|
||||
`GET /agents/{agent_id}/persona_state` gated on Heimdall scope
|
||||
`persona.read`. Route ordering: auth → Tier 3 short-circuit (404
|
||||
`persona_not_configured`) → Tier 1/2 existence (404
|
||||
`agent_not_available`) → persona-enabled check (404
|
||||
`persona_not_configured`) → snapshot (200).
|
||||
- **INV-204-9 (read-only registry primitive)**: `PersonaRegistry.get_state`
|
||||
is mutex-free and never mutates `persona.emotions`. Eventual
|
||||
consistency under concurrent `_appraisal_wrapper` mutations.
|
||||
- **INV-204-14 (replay participation)**: `affect_update` events flow
|
||||
through `_publish`, so SSE resume / replay handles them with no
|
||||
special case.
|
||||
|
||||
## Amendment — AwaitingLLMFirstToken heartbeat (issue #201, INV-201-1..7)
|
||||
|
||||
Adds a periodic SSE heartbeat event during the gap between
|
||||
`BuildingPrompt` and `CallingLLM` so consumers can distinguish
|
||||
"engine is thinking" from "engine is wedged" without out-of-band
|
||||
server inspection. Filed by ratatoskr-dev; ships in v0.29.0.
|
||||
|
||||
- **INV-201-1 (new top-level event type)**: `awaiting_llm_first_token`
|
||||
is a new top-level SSE event type, sibling to `worker_phase` /
|
||||
`tool_*` / `text` / `thinking` / `debug` / `done` / `affect_update`.
|
||||
`_WORKER_PHASE_VOCAB` is NOT extended; INV-053 / INV-054 unchanged.
|
||||
Same precedent as #204's `affect_update`.
|
||||
|
||||
- **INV-201-2 (config-gated emission)**: Heartbeat emission requires
|
||||
`awaiting_llm_first_token_heartbeat_s > 0.0`. When the resolved
|
||||
value is `0.0`, the heartbeat task is never started and zero
|
||||
`awaiting_llm_first_token` events emit for the turn. When > 0.0,
|
||||
the task starts immediately after `_publish_phase("BuildingPrompt")`
|
||||
and emits an event every `interval` seconds until cancelled.
|
||||
|
||||
- **INV-201-3 (defense-in-depth cancellation)**: The heartbeat task
|
||||
is cancelled at three sites (idempotent via the `_cancel_heartbeat`
|
||||
helper): (a) immediately before `_publish_phase("CallingLLM")` on
|
||||
the engine-first-event path; (b) inside the `cancelled`/`error`
|
||||
handling that wraps `_handle_cancel` (covers stall + user-cancel
|
||||
paths); (c) in the outer `finally` block alongside
|
||||
`_clear_stall_timer`. After cancellation, no further
|
||||
`awaiting_llm_first_token` events emit.
|
||||
|
||||
- **INV-201-4 (wire shape)**: Payload is exactly `{type:
|
||||
"awaiting_llm_first_token", turn_id: <int>,
|
||||
elapsed_ms_since_building_prompt: <float>}` plus the composite `id:
|
||||
"<turn_id>:<seq>"` stamped by `_publish`. No additional fields.
|
||||
`elapsed_ms_since_building_prompt` is `(time.monotonic() -
|
||||
building_prompt_t) * 1000.0` where `building_prompt_t` is captured
|
||||
immediately before `BuildingPrompt` is published.
|
||||
|
||||
- **INV-201-5 (first-gap-only scope)**: Heartbeat is scoped to the
|
||||
FIRST `BuildingPrompt → CallingLLM` gap of the turn. Tool round-trip
|
||||
`CallingLLM` re-entries (INV-058) emit ZERO
|
||||
`awaiting_llm_first_token` events. Out-of-scope sub-phases
|
||||
(`AwaitingToolResult`, `AwaitingNextLLMCall`) would be separate
|
||||
follow-up features.
|
||||
|
||||
- **INV-201-6 (replay participation)**: Heartbeat events flow through
|
||||
`_publish → _replay_buffer + queue` per INV-060 — same replay
|
||||
semantics as worker_phase events. On `Last-Event-ID` reconnect,
|
||||
prior heartbeats replay identically.
|
||||
|
||||
- **INV-201-7 (config resolution precedence)**: Per-agent
|
||||
`agent.conversation.awaiting_llm_first_token_heartbeat_s` →
|
||||
`api_cfg.awaiting_llm_first_token_heartbeat_s` → built-in `5.0`.
|
||||
Negative values raise `ConfigurationError` at agent load; `0.0`
|
||||
is valid and means "disabled." Mirrors the `_resolve_stall_timeout_s`
|
||||
precedence pattern (INV-038).
|
||||
|
||||
### Mechanism note
|
||||
|
||||
The heartbeat task is a separate `asyncio.Task` (NOT `loop.call_later`,
|
||||
because heartbeats repeat at an interval rather than fire once at a
|
||||
timeout). An `asyncio.Queue` shared between the heartbeat task and the
|
||||
generator carries events; the generator uses
|
||||
`asyncio.wait(return_when=FIRST_COMPLETED)` to race the engine's
|
||||
`__anext__` against the heartbeat queue's `get` ONLY during the first
|
||||
iteration. After `CallingLLM` fires, the heartbeat task is cancelled
|
||||
and subsequent iterations use the original non-race pattern.
|
||||
|
||||
### Storage extension
|
||||
|
||||
The `consumer_agents` table lives in `core/heimdall/storage/sqlite.py`
|
||||
|
||||
+90
-57
@@ -1,6 +1,6 @@
|
||||
# Persistent memory — ratatoskr
|
||||
|
||||
_Last updated: 2026-05-23_
|
||||
_Last updated: 2026-05-24_
|
||||
|
||||
This file captures durable intent and supporting evidence (goals, decisions,
|
||||
foot-gun warnings, in-flight state) across context resets. Read it at session
|
||||
@@ -32,77 +32,107 @@ separate dev team rather than an in-tree Worldtree tool.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-05-23 (end of day, post-#12 implementation, pre-commit):_
|
||||
_As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_
|
||||
|
||||
**Status: issue #12 (presenter contract semantics amendment)
|
||||
TDD-complete, in working tree, awaiting commit.** Seven core issues
|
||||
complete (`sse_client` #1, `sessions` #2, `cli` #3, `tui` #4,
|
||||
`--end-user-id` #5, TUI startup error visibility #6, presenter
|
||||
contract semantics amendment #12) + robustness fix #7 (MalformedSseData
|
||||
+ empty-skip). 208/208 tests GREEN; ruff clean. pyproject.toml bumped
|
||||
to v0.2.0; `uv.lock` refreshed. Working tree has 9 modified files +
|
||||
the new `docs/contracts/issues/12.contract.md` (untracked); commit not
|
||||
yet authored.
|
||||
**Status: v0.8.2 shipped.** Eleven core features complete (`sse_client`
|
||||
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
||||
startup error visibility #6, presenter contract semantics amendment
|
||||
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
||||
+ robustness fix #7 (MalformedSseData + empty-skip) + v0.2.1 TUI
|
||||
layout fix. 236/236 tests GREEN; ruff clean.
|
||||
|
||||
**§5 v1 entry point shipped (issue #13).** TUI now Horizontal
|
||||
two-column: left = chat surface (transcript + thinking-current +
|
||||
prompt); right = TabbedContent with single Tools tab (RichLog
|
||||
receiving ToolStart/ToolResult events). Routing-not-duplication:
|
||||
tool events leave the main transcript entirely. Ctrl+1 activates
|
||||
Tools tab without losing Input focus (INV-016). New `pane-name`
|
||||
Static in the footer (static "Tools" v1; dynamic when more tabs
|
||||
land). CLI mode (--send) unaffected by design — INV-018.
|
||||
|
||||
Last commits on `main`:
|
||||
- `8282156` snapshot: persistent-memory Heimdall scope-model foot-gun (post-v0.1.0)
|
||||
- `804c2df` feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev consumer-API follow-up (tagged v0.1.0)
|
||||
- v0.8.2 fix(tui): drop post-Done Markdown body re-render (no double-print)
|
||||
- `11ef683` fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
|
||||
- `9fade55` feat(local_agents): JSON-backed local tier-3 index + picker merge (v0.8.0)
|
||||
- `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1)
|
||||
- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
|
||||
- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5)
|
||||
- `82437bd` style(tui): picker highlighted item → Aurora blue (v0.6.4)
|
||||
- `ac690c1` style(tui): restore Australis palette, only $background → pure black (v0.6.3)
|
||||
- `d845b20` style(tui): neutralize Australis dark palette (v0.6.2, reverted)
|
||||
- `8463eb2` style(tui): kill remaining blue + thinking-current into pane (v0.6.1)
|
||||
- `cfee89a` refactor(tui): streaming + turn headers + Thinking pane (v0.6.0)
|
||||
- `7106af5` style(tui): UI polish pass — terminal label colors, placeholders (v0.5.1)
|
||||
- `ffd22fb` refactor(tui): content-only main pane + Debug tab + chrome dark (v0.5.0)
|
||||
- `2756f5f` style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1)
|
||||
- `24e4371` feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
|
||||
- `d30be12` feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
|
||||
- `c85f6bd` fix(tui): anchor layout via dock so Input never moves (v0.2.1)
|
||||
- `3b9c610` feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
|
||||
- `8282156` snapshot: persistent-memory Heimdall scope-model foot-gun
|
||||
- `804c2df` feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev follow-up (v0.1.0)
|
||||
|
||||
`--send` validated end-to-end against personal Worldtree at v0.1.0
|
||||
(`http://10.250.50.152:8081`, mimir on qwen3.6-35-a3b, 2026-05-23 smoke
|
||||
returned `[done] turn_id=116 duration_ms=5467`). Lofn smoke is
|
||||
**auth-unblocked** as of 2026-05-23 — worldtree-dev confirmed our key
|
||||
(`c990f0be`) already covers Tier 1 agents via the `agent.call:*`
|
||||
baseline policy; the initial "scope-add needed" diagnosis was a phantom
|
||||
ask (see Tried-and-abandoned). The actual lofn fix shipped as issue #5
|
||||
(`--end-user-id` flag).
|
||||
|
||||
**Async cross-frontier activity in flight:**
|
||||
- Issue #12 code-review consult posted to volva 2026-05-23 (althing
|
||||
thread `01KSBH8GYH4G3H03T767X613W7`). Reply pending in inbox.
|
||||
**Smoke status:**
|
||||
- `--send --new --agent mimir` v0.3.0 smoke clean
|
||||
(`[done] turn_id=141 model=qwen3.6-35-a3b duration=2.2s`).
|
||||
- Live `list_agents` smoke against personal Worldtree returned 12
|
||||
agents (actor, bragi, cara, domari, forseti, glados, leif, lofn,
|
||||
mimir, soong, troi, saga).
|
||||
- Picker end-to-end smoke against live Worldtree: bare `--new` →
|
||||
list_agents → picker (auto-picked lofn programmatically since
|
||||
driving alt-screen interactively from CLI smoke isn't possible)
|
||||
→ POST /sessions with end_user_id="ratatoskr-tui" succeeded;
|
||||
RatatoskrApp constructed with agent_id="lofn".
|
||||
- TUI v0.2.0 was visually broken (Input pane bouncing with thinking
|
||||
runs); v0.2.1 fixed via dock-based layout. Operator confirmed
|
||||
"a lot better" interactively.
|
||||
|
||||
**Outstanding operator-side todos:**
|
||||
- **Commit issue #12 work** + tag v0.2.0 + push. 9 modified files +
|
||||
new `12.contract.md` ready.
|
||||
- **Post-v0.2.0 mimir smoke (the visual one)** — `source env.sh && uv
|
||||
run ratatoskr --new --agent mimir --send "test"` to eyeball the new
|
||||
rendering (`. thinking: ...` coalesce, `. worker_phase: ...` demotion,
|
||||
`duration=5.5s` formatting, `usage 6756 in -> 126 out (...)` shape).
|
||||
The v0.1.0 mimir smoke confirmed wire-level backwards compat but
|
||||
did NOT exercise the v0.2.0 rendering.
|
||||
- **Post-v0.2.0 lofn smoke** — `source env.sh && uv run ratatoskr
|
||||
--new --agent lofn --send "hello"` (env.sh ships
|
||||
`RATATOSKR_END_USER_ID="ratatoskr-tui"`). Now unblocked on auth.
|
||||
- **Interactive §5 layout eyeball** — `source env.sh && uv run
|
||||
ratatoskr --new --agent mimir`, ask a tool-using question
|
||||
("search your KB for X"). Confirm: left column shows chat /
|
||||
thinking; right column's Tools tab shows tool_start +
|
||||
tool_result with `· ` prefix; Ctrl+1 doesn't break input focus;
|
||||
no width-clamp issues on the operator's terminal. Programmatic
|
||||
smoke confirmed all the routing + binding; visual confirmation
|
||||
pending.
|
||||
- **Post-v0.2.1 TUI multi-turn eyeball** — confirm thinking-run
|
||||
bouncing is gone across multiple turns; the layout fix has only
|
||||
been confirmed for a single turn so far.
|
||||
|
||||
**Pending issues filed but not started:**
|
||||
- **Issue #8 (startup agent picker)** — filed but unscaffolded.
|
||||
`GET /agents` is free to call (worldtree-dev confirmed); auth side
|
||||
is unblocked. Depends on #5 composably (both thread through
|
||||
`ParsedArgs` → `_resolve_then_run`).
|
||||
- **Issue #9 (spec-pin refresh v0.19.0 → v0.22.1)** — filed
|
||||
2026-05-23. Documentation debt. None of the v0.20.0/v0.21.0/v0.22.0
|
||||
changes break ratatoskr's existing surface; the pin lies about
|
||||
what we've committed to.
|
||||
- **Issue #10 (subject:{type,id} migration)** — filed 2026-05-23 to
|
||||
track Worldtree #196's LOCKED-but-not-shipped breaking change.
|
||||
Don't pre-implement per worldtree-dev's explicit guidance.
|
||||
2026-05-23. Documentation debt; defer unless we need a v0.20.0+
|
||||
capability.
|
||||
- **Issue #10 (subject:{type,id} migration)** — filed 2026-05-23
|
||||
to track Worldtree #196. Don't pre-implement per worldtree-dev.
|
||||
- **Issue #11 (AdminEvents pane auth prerequisite)** — filed
|
||||
2026-05-23. Future side-pane requires `admin.events.read` scope.
|
||||
2026-05-23. Future side-pane needs `admin.events.read` scope.
|
||||
|
||||
Branch: `main` (dirty with #12 work pending commit). Remote:
|
||||
**Pending Worldtree-dev follow-up:**
|
||||
- worldtree-dev committed (althing `01KSBKTG096Q…`) to file a
|
||||
Worldtree-side issue for the stall-watchdog gap (cancel-check is
|
||||
inside the engine-event loop, so a never-yielding first-LLM-call
|
||||
bypasses the 300s watchdog). Will file after the immediate stall
|
||||
is cleared.
|
||||
- Ratatoskr-side companion (potential): a client-side stall watchdog
|
||||
(e.g., 90s-no-events → `[server_stalled]` stderr label, keep
|
||||
connection). Defer until recurrence; defense-in-depth regardless of
|
||||
whether Worldtree fixes its own.
|
||||
|
||||
Branch: `main` (clean). Remote:
|
||||
`origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||
|
||||
**Next natural moves:**
|
||||
|
||||
1. **Triage volva's #12 code-review** when the reply lands in the
|
||||
inbox; apply tactical fixes inline, surface architectural calls.
|
||||
2. **Commit + tag v0.2.0 + push.**
|
||||
3. **Post-v0.2.0 smokes** — mimir (visual), lofn (newly unblocked).
|
||||
4. **Issue #8 (startup agent picker)** — scaffold + contract, then
|
||||
TDD. Composes with the forward end_user_id direction (see Recent
|
||||
decisions).
|
||||
5. **Side-pane issues** — Persona pane first (file-tail, cheap).
|
||||
6. **Issue #9 (spec-pin refresh)** — defer unless we need a v0.20.0+
|
||||
1. **Interactive picker eyeball** — operator confirms the TUI
|
||||
picker UX (rendering, Enter pick, Esc dismiss) against personal
|
||||
Worldtree.
|
||||
2. **§5 side-panes work** — Persona pane first per design-brief; the
|
||||
collapsible Thinking pane + Debug pane proposals fold IN as
|
||||
additional `TabbedContent` tabs alongside Persona/Tools/AdminEvents.
|
||||
Reshapes layout from vertical-stack to Horizontal two-column.
|
||||
3. **Issue #9 (spec-pin refresh)** — defer unless we need a v0.20.0+
|
||||
capability (e.g., `memory_context` for Phase 2.1).
|
||||
|
||||
## Recent decisions
|
||||
@@ -134,6 +164,8 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
- `[2026-05-23]` **Issue #5 (`--end-user-id`) implemented via TDD.** Small surface change across three modules (sessions, cli, tui): `create_session(client, agent_id, *, end_user_id=None)` widens with optional kwarg; body conditionally adds the field when non-None (INV-002: omitting != sending empty); PRE-003 asserts non-empty. `ParsedArgs.end_user_id: str | None = None` field; `--end-user-id` CLI flag with non-empty validation (mirrors `--send` check). `_amain` and `_resolve_then_run` thread `end_user_id=args.end_user_id` to their `create_session` calls. Post-#6 adjustment: the contract originally named `on_mount` as the TUI threading site, but #6 had moved session resolution to `_resolve_then_run` — same shape, different function. 7 new tests across the 3 modules.
|
||||
- `[2026-05-23]` **Worldtree-dev consult landed authoritative consumer-API guidance** (althing thread `01KSBARG2B8M8C82H6AJGJWX1B`). Key takeaways shaped follow-on work: (1) `end_user_id` is a free-form partition key for long-term memory + persona/valence state; same value → same partition, different values → fully isolated. For Vuong-debugging-Worldtree the recommended posture is a project-stable default with `--end-user-id` override. (2) No programmatic `requires_end_user_id` discovery on `GET /agents` — "try and react to 422" remains the pattern. (3) Breaking-change #196 LOCKED but not shipped: `subject:{type,id}` replaces `end_user_id` at future v0.22.x or v0.23.0; don't pre-implement. (4) Spec pin (v0.19.0) is 3 minor versions stale (current v0.22.1); none of v0.20.0/v0.21.0/v0.22.0 break ratatoskr's surface but the pin lies about what we're committed to. (5) User-Agent header: send one (`ratatoskr/<version> (vh@phasefinal.com)`). (6) `agents.call:lofn` scope needed for lofn smoke. (7) `GET /agents` requires no special scope; issue #8 unblocked on auth.
|
||||
- `[2026-05-23]` **Follow-up acted on:** User-Agent header added to both `_amain` and `_resolve_then_run` httpx.AsyncClient constructions (with `importlib.metadata` version lookup + fallback to `0.0.0`); `RATATOSKR_END_USER_ID` env-var fallback added to `_parse_args` (resolution: flag > env > None); env.sh ships `RATATOSKR_END_USER_ID="ratatoskr-tui"` as project-stable default. Original issue #5 posture rejected env-var fallback as "papering over isolation"; revised after worldtree-dev's guidance that the realistic single-operator use case wants partition continuity. Issue #5 + #3 contracts amended in-place to document the env-var fallback. Infra-ops pinged via althing for `agents.call:lofn` scope (broker pattern; they forwarded to worldtree-dev). Three Gitea issues filed: #9 (spec-pin refresh), #10 (subject:{type,id} migration tracking), #11 (AdminEvents pane auth prereq).
|
||||
- `[2026-05-23]` **v0.2.1 layout fix: dock-anchored TUI chrome so Input never moves** (commit `c85f6bd`, tag `v0.2.1`). Reported during the v0.2.0 mimir TUI smoke: Input bouncing up/down throughout a turn, tokens landing at shifting screen positions. Cause: v0.2.0's `Static(id="thinking-current")` was yielded between `hint` and `Footer` in the auto-stacked vertical flow, so each `display=True/False` toggle per thinking-run shifted Input + identity + hint vertically; RichLog growth from streaming text also drifted Input downward. Fix: `RatatoskrApp.DEFAULT_CSS` docks the chrome to screen edges — `thinking-current` docks top under Header; `transcript` (RichLog) gets `height: 1fr` and absorbs all reflows internally via its scroll viewport; `prompt`, `identity`, `hint` all dock bottom (locked above Footer). Compose order moved `thinking-current` to position 2 (right after Header) so source-order matches the dock layout. **Operator-confirmed "a lot better"** interactively. Pure UI fix; no public API change; tests pass without modification. v0.2.0 → v0.2.1 (patch). I couldn't verify in a TTY from this non-interactive session — the design was sound enough to ship blind, with operator verification post-commit. Going forward: TUI-layout patches like this are "ship + operator verifies" since the TTY is the load-bearing test surface and respx + Pilot mocks can't catch screen-relative positioning bugs.
|
||||
- `[2026-05-23]` **Sequencing decision: design-brief §5 side-panes work absorbs the inline collapsible-Thinking-pane + Debug-pane proposals; do issue #8 (startup agent picker) BEFORE §5.** Surfaced during the v0.2.1 follow-up discussion. The operator's proposal — "create a collapsible pane for all thinking tokens; text_boundary goes to a debug pane" — is exactly §5-shaped work (the design-brief proposes a `Horizontal` two-column layout with `TabbedContent` for Persona/Tools/AdminEvents/BifrostState/ServerLog). Building inline-Collapsibles now and then rebuilding as `TabbedContent` panes at §5 would be wasted work. So: do #8 first (independent surface, no layout overlap), then §5 (which folds in Thinking + Debug panes alongside the design-brief's named §5 panes). Interim acceptance: v0.2.1 fixes the structural layout-bouncing pain; transcript-dominated-by-thinking is still real but doesn't degrade further — operator can scroll back, Input doesn't move, tokens land predictably. The interim "noisy transcript" pain is real but bounded; §5 work resolves it cleanly.
|
||||
- `[2026-05-23]` **Issue #12 (presenter contract semantics amendment) implemented via TDD.** Headline: thinking deltas render as ONE coalesced growing line (CLI) / one closed RichLog entry per run + live Static(id="thinking-current") widget per-delta (TUI), not 50 lines per turn. Introduced stateful per-turn presenters: `CliPresenterState` (cli.py) and `TuiPresenterState` (tui.py), both `@dataclass(slots=True)` with thinking_buffer + thinking_open (+ text_written_since_newline for CLI). Editorial promotion line settled: load-bearing = Text/Done/Error/Cancelled (no prefix); demoted telemetry = WorkerPhase/Thinking/TextBoundary/ToolStart/ToolResult (CLI `. ` ASCII prefix; TUI `· ` Unicode dim prefix). CLI stdout/stderr newline-boundary INV-005: when text was streamed mid-line, flush a `\n` to stdout before writing terminal labels to stderr; `text_written_since_newline = not event.content.endswith("\n")` per Volva F4 fix. Helpers `_format_duration_ms` (`347ms` / `5.5s` / `1.2m` autoscale) and `_format_usage` (`6756 in -> 126 out (6882 total, 0 cached)` with arrow="->" CLI or "→" TUI). Per Vor (eitri-smithy-dev cross-frontier consult, althing 01KSBE52YZR5) + Volva paraphrase (5 contract-text ambiguities all fixed in #12.contract.md). `[create_session]` lifecycle line demoted to `. create_session:` (written directly by `_amain`, bypasses state.render). Old `_render_event` / `_render_event_to_log` functions and their TestRenderEvent/TestRenderEventToLog classes removed (no-backwards-compat rule). Contracts amended: #3 (CliPresenterState block + `_run_turn` thread state + `_amain` create_session demotion + `_format_*` helper blocks), #4 (TuiPresenterState block + `_stream_turn_worker` state construction + `compose` Static widget addition). 39 new tests; 19 obsolete tests removed; net 208 GREEN. v0.1.0 → v0.2.0 (minor; pre-amendment output shape broken intentionally — scripts grepping `[thinking] '` no longer work; that's the intended cleanup). Cross-frontier design pass with eitri-smithy-dev returned 16-of-16 confirmed decisions + 4 material divergences applied (ASCII `· ` factual fix, RichLog-one-entry-per-run vs inline-mirror, presenter-state object vs stateless, "contract semantics amendment" framing not "polish"). Calibration note: eitri-smithy-dev's value here was *architectural* (state-object pattern + chronological-vs-live decoupling) not just *tactical*; the framing rename alone justified the consult. Volva paraphrase round added 5 prose-precision fixes (INV-001 "growing display" semantics, TUI hide mechanism unification, render_error security/readability tension, newline-tracking corner case, [create_session] integration path).
|
||||
- `[2026-05-23]` **Forward direction: Ratatoskr will require `end_user_id` for EVERY access before too long.** Operator's call. Reasoning: even Tier 1 foundational agents (mimir, all Asgardians) that don't *require* `end_user_id` server-side currently fall back to a `_no_end_user` sentinel substrate partition — effectively pollution from a single-operator-debug-tool's perspective. The right shape is "every conversation has an explicit partition key." `RATATOSKR_END_USER_ID="ratatoskr-tui"` env-default in env.sh is the first step toward that posture; once we've validated the partition-isolation experience, the next move is making `end_user_id` mandatory (probably remove the `None`-default in `_parse_args`, fail-closed with a UsageError if neither flag nor env provides it). Consequence for cross-project asks: declined worldtree-dev's offer to ship `requires_end_user_id: bool` on `AgentInfoResponse` because we'd treat every value as true regardless; the try-and-react-to-422 pattern goes away from our side because we never send a request without the field. File a ratatoskr issue when scheduling the change — touches `_parse_args` validation + `_resolve_then_run` + `_amain` + tests + contract amendments to #3 / #5. Treat as a v0.2.0 minor (breaking: existing `--new --agent mimir` without env or flag would start failing). **Cross-frontier alignment (worldtree-dev ack 2026-05-23, althing 01KSBD9FPMCWJMBXNNS4B3MYBS):** the platform side agrees with this framing — `_no_end_user` is a substrate accommodation for identity-less transports, NOT a consumer model. The fallback's `_is_fallback=True` trap door (#185 INV-185-5/8) "could become operator-controlled later" per worldtree-dev, meaning Worldtree itself may tighten the substrate-fallback path. Ratatoskr's forward posture pre-empts that tightening — moving from "we send end_user_id when set" to "we never send a request without end_user_id" stays consumer-correct regardless of what Worldtree does with the fallback knob.
|
||||
|
||||
@@ -153,4 +185,5 @@ defense against re-attempting the same cul-de-sac.
|
||||
- `[2026-05-21]` **TUI session-identity rendering via `self.sub_title` + `self.hint` plain attributes.** Stored state but never rendered to a visible widget. The contract's "session-identity-always-visible" invariant was satisfied at the state-attribute level but not the user-visible-widget level. Tests asserted the attributes (which passed); Volva code-review flagged the gap. Fix: dedicated `Static(id="identity")` + `Static(id="hint")` widgets in compose; `_set_hint()` helper mirrors state → widget. Calibration evidence for the "TDD catches state, code-review catches whether the user can see it" pattern.
|
||||
- `[2026-05-23]` **Using the cross-model review agent's name directly in composed prose.** The peer review agent's name (the `althing` handle starting with "V-o-l-v-a") is one letter from a body-part term. Anthropic's content classifier does fuzzy matching and intermittently blocks responses mid-stream when the name appears in composed prose sentences (especially in meta-commentary about the agent's work). Direct-quoted tool output (e.g., the `althing-cli thread` body) passes through fine. Mitigation: use role descriptions ("the cross-model reviewer," "the paraphrase peer") in prose rather than the name; quote content via tool output. Confirmed by switching to Sonnet 4.6 for a test read — same raw content read cleanly when fetched via Bash rather than composed into an LLM response. This is a persistent environmental constraint, not a one-off.
|
||||
- `[2026-05-22]` **`json.loads(sse.data)` unguarded against empty data.** `_iter_events` unconditionally called `json.loads` on every dispatched `ServerSentEvent`. When `httpx_sse` surfaced a frame with `id:` present but `data:` empty (a known library-vs-spec divergence — RFC says don't dispatch; httpx_sse is permissive), `json.loads('')` raised `JSONDecodeError` → propagated through Textual's worker → app crash. Crashed mimir conversation at turn 93/seq 1078 after 1077 successful events. Fix: `if sse.data == '': continue` BEFORE `_parse_sse_id` (empty-data event with a malformed id is still a keepalive — don't reorder). Non-empty malformed data raises new `MalformedSseData(raw[:200])`. Don't reintroduce unconditional `json.loads(sse.data)`; always pre-check for the empty case.
|
||||
- `[2026-05-23]` **Diagnostic shorthand: "2-events-then-silence" = Worldtree-side LLM-call wedge, not ratatoskr.** If a mimir `--send` smoke shows exactly two stderr events — `. create_session: ...` followed by `. worker_phase: phase=BuildingPrompt ...` — and then nothing for >60s, the root cause is upstream of ratatoskr. Worldtree's `service.py:2560` gates the `CallingLLM` event on the engine yielding its first LLM-provider chunk; if that provider connection is wedged at the TCP level, the `async for` never iterates and the SSE stream stays silent forever. ratatoskr's `read=None` httpx timeout (the issue #1 + #4 INV-007 fix for "5s default killed mid-stream during mimir's thinking") waits patiently as designed; there's no client-side stall watchdog above the read-timeout layer. Worldtree's OWN stall watchdog (300s `_start_stall_timer`) exists but its cancel-check is INSIDE the engine-event loop, so a never-yielding first-LLM-call bypasses it. Confirmed by worldtree-dev (althing thread `01KSBKTG096Q07JVRG41JXA1DD`). **Don't waste time bisecting ratatoskr code when this shape appears** — diagnose the LLM-provider connection state at Worldtree's host. Restarting the Worldtree service (`:8081` in our case) cleared a wedged llama-swap connection. Future ratatoskr issue worth filing if recurrence: client-side stall watchdog (e.g., 90s-no-events → `[server_stalled]` stderr label, keep connection open). Also worth knowing: 10.250.50.152 hosts 3 Worldtree instances (`:8080`, `:8081`, `:8082`) — each with its own DB and key namespace. Our key is valid only on `:8081`.
|
||||
- `[2026-05-23]` **Phantom "per-Tier-1-agent scope add" pattern.** Issue #5's lofn 422 was initially diagnosed (with worldtree-dev's first reply) as needing `agents.call:lofn` added to ratatoskr's existing key. Routed through infra-ops via althing per the credential-brokerage rule; infra-ops discovered no public scope-mutation endpoint on personal Worldtree, brokered to worldtree-dev for the actual mechanism. Worldtree-dev came back with a correction: their first answer conflated two distinct Heimdall scope namespaces. **Tier 1 foundational agents** (mimir, lofn, soong, all Asgardians) are covered by a blanket `agent.call:*` (singular) baseline rule in `config/policies.yaml > tiers.<tier>.scopes` for ALL authenticated tiers including `user`. There is no per-agent grant for Tier 1 — the baseline rule covers it. **Tier 3 consumer-defined agents** (IDs containing `:`, like `vh:custom-bot`) use the plural `agents.call:<owner>:<agent>` shape granted implicitly via owning a `consumer_agents` DB row, registered through `POST /agents/define`. The two notations differ by one letter and that was the source of the confusion. **The actual lofn fix was issue #5's `--end-user-id` flag — it was always a request-body validation, not an auth-scope gate.** Don't ping infra-ops for "per-Tier-1-agent scope adds" again; the pattern is a phantom ask. Real future infra-ops asks: admin-tier key for the AdminEvents pane (`admin.events.read` scope, different tier), and Tier 3 custom-agent registration (different flow entirely, requires `POST /agents/define`).
|
||||
|
||||
+16
-5
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.2.0"
|
||||
version = "0.17.0"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -21,6 +21,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
web = [
|
||||
"starlette>=0.40",
|
||||
"uvicorn[standard]>=0.30",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.24",
|
||||
@@ -29,10 +33,12 @@ dev = [
|
||||
"mypy>=1.11",
|
||||
"textual-dev>=1.5", # textual console + live reload during dev
|
||||
"pyyaml>=6", # used by docs/contracts/contract_parser.py and scripts/contract_drift_check.py
|
||||
"ratatoskr[web]", # web extras included in dev so test_web_* can import starlette
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ratatoskr = "ratatoskr.cli:main"
|
||||
ratatoskr = "ratatoskr.cli:main"
|
||||
ratatoskr-web = "ratatoskr.web.entrypoint:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
|
||||
@@ -42,13 +48,18 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
|
||||
# Ratatoskr is built against Worldtree at this commit; the vendored
|
||||
# spec snapshot in docs/ reflects that SHA.
|
||||
[tool.ratatoskr.spec-pin]
|
||||
worldtree-spec-rev = "55101e909abcd2219833266b6f905c5bc956e0f0"
|
||||
worldtree-version = "v0.19.0"
|
||||
pinned-on = "2026-05-20"
|
||||
worldtree-spec-rev = "562001af28d752c3a60d449c7ddd09f44fa9dc9a"
|
||||
worldtree-version = "v0.29.0"
|
||||
pinned-on = "2026-05-26"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/ratatoskr"]
|
||||
|
||||
# Issue #16: ship the web companion's static HTML in the wheel so
|
||||
# importlib.resources can locate it post-install.
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"src/ratatoskr/web/static" = "ratatoskr/web/static"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
+30
-2
@@ -18,6 +18,8 @@ import httpx
|
||||
|
||||
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
AwaitingLlmFirstToken,
|
||||
CancelAlreadyCompleted,
|
||||
CancelFailed,
|
||||
Cancelled,
|
||||
@@ -124,8 +126,11 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
||||
raise UsageError("pass exactly one of --session or --new")
|
||||
if ns.session and ns.agent:
|
||||
raise UsageError("--agent is required with --new and forbidden with --session")
|
||||
if ns.new and not ns.agent:
|
||||
raise UsageError("--agent is required when --new is passed")
|
||||
if ns.new and not ns.agent and ns.send is not None:
|
||||
# Issue #8: --agent stays required for --send --new (non-interactive,
|
||||
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
|
||||
# the choice via list_agents in _resolve_then_run.
|
||||
raise UsageError("--agent is required when --new is passed in --send mode")
|
||||
|
||||
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
|
||||
if not api_key:
|
||||
@@ -202,6 +207,7 @@ class CliPresenterState:
|
||||
(
|
||||
WorkerPhase, Thinking, Text, TextBoundary,
|
||||
ToolStart, ToolResult, Done, Error, Cancelled,
|
||||
AffectUpdate, AwaitingLlmFirstToken,
|
||||
),
|
||||
)
|
||||
# Thinking events accumulate into the open run.
|
||||
@@ -272,6 +278,28 @@ class CliPresenterState:
|
||||
f". text_boundary: kind={event.kind} char_offset={event.char_offset}\n"
|
||||
)
|
||||
return
|
||||
if isinstance(event, AffectUpdate):
|
||||
# Worldtree #204 / v0.28.0. CLI surface is debug telemetry —
|
||||
# one line to stderr with status + (for current) dominant_emotion.
|
||||
if event.snapshot is not None:
|
||||
dom = event.snapshot.get("dominant_emotion")
|
||||
stderr.write(
|
||||
f". affect_update: status={event.status} turn_id={event.turn_id} "
|
||||
f"dominant_emotion={dom!r}\n"
|
||||
)
|
||||
else:
|
||||
stderr.write(
|
||||
f". affect_update: status={event.status} turn_id={event.turn_id}\n"
|
||||
)
|
||||
return
|
||||
if isinstance(event, AwaitingLlmFirstToken):
|
||||
# Worldtree #201 / v0.29.0. Heartbeat during BuildingPrompt →
|
||||
# CallingLLM gap. Stderr surface, one line per heartbeat.
|
||||
secs = event.elapsed_ms_since_building_prompt / 1000.0
|
||||
stderr.write(
|
||||
f". awaiting_llm_first_token: turn_id={event.turn_id} elapsed={secs:.1f}s\n"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def _cancel_and_log(
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Local index of tier-3 agents defined via `python -m ratatoskr.tier3`.
|
||||
|
||||
Workaround for Worldtree's ``GET /agents`` not returning consumer-defined
|
||||
agents (the public list excludes tier-3 per-spec; see issue #15 smoke
|
||||
findings). Local file maintains a list of agent_ids + display metadata so
|
||||
the picker can show them alongside foundational agents.
|
||||
|
||||
Storage shape: JSON at ``$XDG_CONFIG_HOME/ratatoskr/local_agents.json``
|
||||
(default ``~/.config/ratatoskr/local_agents.json``). Override via
|
||||
``$RATATOSKR_LOCAL_AGENTS`` env var for tests / per-machine isolation.
|
||||
|
||||
If Worldtree later starts returning tier-3 agents in ``GET /agents``, this
|
||||
module's role narrows to redundant local cache; can be removed cleanly
|
||||
since the picker's dedup-by-agent-id keeps remote-wins behavior.
|
||||
|
||||
Failure modes are lenient: missing file → empty index; corrupt JSON or
|
||||
schema mismatch → empty index (no crash). The picker continues to show
|
||||
foundational agents either way; the local-tier-3 surface degrades to
|
||||
"operator passes --agent ratatoskr:<name> explicitly" — the
|
||||
pre-v0.8.0 workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalAgentEntry:
|
||||
"""One row in the local tier-3 agent index.
|
||||
|
||||
Schema:
|
||||
- ``agent_id``: full "user_id:agent_name" string (Worldtree-owned).
|
||||
- ``agent_name``: slug from define (display name).
|
||||
- ``model``: provider model ID at last define/patch.
|
||||
- ``description``: synthetic display string (typically derived from
|
||||
the system_prompt's first line + a "(tier 3)" prefix; the picker
|
||||
uses this in its ``{id} · {name} — {description}`` rendering).
|
||||
- ``defined_at``: ISO-8601 timestamp from the Tier3AgentInfo response.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
model: str
|
||||
description: str
|
||||
defined_at: str
|
||||
|
||||
|
||||
def _local_agents_path() -> Path:
|
||||
"""Resolve the local index file path with XDG + env-var override."""
|
||||
override = os.environ.get("RATATOSKR_LOCAL_AGENTS")
|
||||
if override:
|
||||
return Path(override)
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else (Path.home() / ".config")
|
||||
return base / "ratatoskr" / "local_agents.json"
|
||||
|
||||
|
||||
def load_local_agents() -> list[LocalAgentEntry]:
|
||||
"""Read the local index. Returns ``[]`` on missing file, corrupt JSON,
|
||||
schema mismatch, or any read error — never raises.
|
||||
"""
|
||||
path = _local_agents_path()
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
if not isinstance(raw, dict) or raw.get("version") != _SCHEMA_VERSION:
|
||||
return []
|
||||
agents = raw.get("agents", [])
|
||||
if not isinstance(agents, list):
|
||||
return []
|
||||
out: list[LocalAgentEntry] = []
|
||||
for item in agents:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
out.append(LocalAgentEntry(**item))
|
||||
except TypeError:
|
||||
# Malformed row (missing/extra fields) — skip silently.
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _save_local_agents(agents: list[LocalAgentEntry]) -> None:
|
||||
"""Persist the index. Creates parent dir as needed."""
|
||||
path = _local_agents_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"version": _SCHEMA_VERSION, "agents": [asdict(a) for a in agents]}
|
||||
path.write_text(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
def add_local_agent(entry: LocalAgentEntry) -> None:
|
||||
"""Add (or replace) an agent in the local index. agent_id is the key."""
|
||||
agents = [a for a in load_local_agents() if a.agent_id != entry.agent_id]
|
||||
agents.append(entry)
|
||||
_save_local_agents(agents)
|
||||
|
||||
|
||||
def update_local_agent(entry: LocalAgentEntry) -> None:
|
||||
"""Update an existing entry. Identical semantics to ``add_local_agent``
|
||||
(agent_id is the dedup key), exposed separately so callers can
|
||||
self-document intent.
|
||||
"""
|
||||
add_local_agent(entry)
|
||||
|
||||
|
||||
def remove_local_agent(agent_id: str) -> None:
|
||||
"""Remove an entry by agent_id. No-op if absent (idempotent)."""
|
||||
agents = [a for a in load_local_agents() if a.agent_id != agent_id]
|
||||
_save_local_agents(agents)
|
||||
|
||||
|
||||
def make_description(system_prompt: str) -> str:
|
||||
"""Synthesize a one-line description for the picker from a system prompt.
|
||||
|
||||
Strategy: first non-empty line, stripped of leading markdown heading
|
||||
markers and whitespace, prefixed with "(tier 3) ", truncated to 80
|
||||
chars. Falls back to "(tier 3) custom system prompt" if the prompt is
|
||||
empty (defensive — define rejects empty prompts at PRE-002).
|
||||
"""
|
||||
for line in system_prompt.splitlines():
|
||||
stripped = line.lstrip("# ").strip()
|
||||
if stripped:
|
||||
label = f"(tier 3) {stripped}"
|
||||
return label[:80] + ("…" if len(label) > 80 else "")
|
||||
return "(tier 3) custom system prompt"
|
||||
@@ -39,6 +39,26 @@ class SessionPage:
|
||||
next_cursor: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentInfo:
|
||||
"""Worldtree agent envelope from GET /agents (issue #8).
|
||||
|
||||
INV-005: required fields (`agent_id`, `name`, `description`) take the
|
||||
response value verbatim. Optional fields default to None / [] / {} when
|
||||
omitted by the server, mirroring SessionInfo's INV-001/INV-002
|
||||
origin-conditional defaulting.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
name: str
|
||||
description: str
|
||||
version: str | None
|
||||
capabilities: list[str]
|
||||
supported_models: list[str]
|
||||
persona_traits: dict[str, Any]
|
||||
ui_hints: dict[str, Any]
|
||||
|
||||
|
||||
class AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 from POST /sessions — unknown agent_id."""
|
||||
|
||||
@@ -69,6 +89,46 @@ class SessionApiFailed(Exception):
|
||||
self.body = body
|
||||
|
||||
|
||||
# Worldtree #204 / v0.28.0 — persona_state endpoint failure modes.
|
||||
class PersonaNotConfigured(Exception):
|
||||
"""Raised on HTTP 404 `persona_not_configured` from GET persona_state.
|
||||
|
||||
Agent exists but has no persona surface: persona-disabled Tier 1/2
|
||||
agents (e.g. `domari`, `muninn`) and all Tier 3 consumer-defined
|
||||
agents (Phase 2.0). Distinct from `AgentNotAvailable` which means the
|
||||
agent_id is unknown entirely.
|
||||
"""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"persona not configured for agent_id: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
class AgentNotAvailable(Exception):
|
||||
"""Raised on HTTP 404 `agent_not_available` from GET persona_state.
|
||||
|
||||
The agent_id is unknown to the server. Distinct from
|
||||
`PersonaNotConfigured` (agent exists but has no persona).
|
||||
"""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"agent not available: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
class AuthScopeDenied(Exception):
|
||||
"""Raised on HTTP 403 `auth_scope_denied` from a Heimdall-scoped endpoint.
|
||||
|
||||
The API key lacks the required scope (e.g. `persona.read` for
|
||||
GET /agents/{id}/persona_state). User-tier keys carry `persona.read`
|
||||
by default; this surfaces when a narrower key is in use.
|
||||
"""
|
||||
|
||||
def __init__(self, *, scope: str) -> None:
|
||||
super().__init__(f"auth scope denied: required={scope!r}")
|
||||
self.scope = scope
|
||||
|
||||
|
||||
async def list_sessions(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
@@ -153,3 +213,81 @@ async def create_session(
|
||||
archived=False,
|
||||
tags=[],
|
||||
)
|
||||
|
||||
|
||||
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
|
||||
"""GET /agents — list available agents. See contract FN list_agents (issue #8).
|
||||
|
||||
No request params, no pagination. Returns server-ordered list. Optional
|
||||
fields are defaulted to None / [] / {} per INV-005.
|
||||
"""
|
||||
assert client is not None
|
||||
|
||||
resp = await client.get("/agents")
|
||||
if resp.status_code != 200:
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
body = resp.json()
|
||||
return [
|
||||
AgentInfo(
|
||||
agent_id=item["agent_id"],
|
||||
name=item["name"],
|
||||
description=item["description"],
|
||||
version=item.get("version"),
|
||||
capabilities=item.get("capabilities") or [],
|
||||
supported_models=item.get("supported_models") or [],
|
||||
persona_traits=item.get("persona_traits") or {},
|
||||
ui_hints=item.get("ui_hints") or {},
|
||||
)
|
||||
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. Worldtree returns errors as either flat `{"error_code": …}`
|
||||
# OR FastAPI-default `{"detail": {"error_code": …}}` depending on
|
||||
# which handler raised — unwrap both shapes (real wire observed
|
||||
# 2026-05-28 returning the detail-nested form for auth_scope_denied
|
||||
# from /agents/{id}/persona_state).
|
||||
try:
|
||||
err = resp.json()
|
||||
except ValueError:
|
||||
err = None
|
||||
error_code: str | None = None
|
||||
if isinstance(err, dict):
|
||||
error_code = err.get("error_code")
|
||||
if error_code is None and isinstance(err.get("detail"), dict):
|
||||
error_code = err["detail"].get("error_code")
|
||||
if resp.status_code == 404 and error_code == "persona_not_configured":
|
||||
raise PersonaNotConfigured(agent_id=agent_id)
|
||||
if resp.status_code == 404 and error_code == "agent_not_available":
|
||||
raise AgentNotAvailable(agent_id=agent_id)
|
||||
if resp.status_code == 403 and error_code == "auth_scope_denied":
|
||||
raise AuthScopeDenied(scope="persona.read")
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
@@ -111,6 +111,55 @@ class Cancelled:
|
||||
partial_message_id: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AwaitingLlmFirstToken:
|
||||
"""SSE event `awaiting_llm_first_token`: heartbeat during slow first-token.
|
||||
|
||||
Fires at the configured interval (default 5s) during the gap between
|
||||
`worker_phase` phase=BuildingPrompt and phase=CallingLLM. Lets clients
|
||||
render a live "thinking for Ns…" indicator instead of a frozen line
|
||||
during legitimate-slow first-token latency. Stops the moment CallingLLM
|
||||
fires (defense-in-depth at three sites); no heartbeat after Cancelled
|
||||
or stalled terminal events. Tool round-trip re-entries do NOT re-fire
|
||||
heartbeats — INV-201-5 scopes the mechanism to the FIRST gap only.
|
||||
|
||||
`elapsed_ms_since_building_prompt` is server-authoritative
|
||||
`time.monotonic()`-based — independent of network latency or clock
|
||||
skew, monotonically increasing across the heartbeat sequence.
|
||||
|
||||
See docs/conversation-api-spec.md § awaiting_llm_first_token
|
||||
(Worldtree #201, v0.29.0).
|
||||
"""
|
||||
|
||||
sse_id: SseId
|
||||
turn_id: int
|
||||
elapsed_ms_since_building_prompt: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AffectUpdate:
|
||||
"""SSE event `affect_update`: persona-state observability snapshot.
|
||||
|
||||
Two emissions per qualifying turn (persona-enabled agent on non-
|
||||
ephemeral session): `status="current"` at turn start carrying the full
|
||||
snapshot, `status="scheduled"` after post-turn appraisal kicks off
|
||||
(lightweight — `snapshot` is None). Suppressed entirely for persona-
|
||||
disabled agents (e.g. `domari`, `muninn`), Tier 3 consumer-defined
|
||||
agents (Phase 2.0), and ephemeral sessions.
|
||||
|
||||
Bootstrap reads available via `GET /agents/{agent_id}/persona_state`
|
||||
(same `snapshot` shape, requires `persona.read` scope).
|
||||
|
||||
See docs/conversation-api-spec.md § affect_update (Worldtree #204,
|
||||
v0.28.0).
|
||||
"""
|
||||
|
||||
sse_id: SseId
|
||||
status: str # "current" | "scheduled"
|
||||
turn_id: int
|
||||
snapshot: dict[str, Any] | None # None when status="scheduled"
|
||||
|
||||
|
||||
Event = (
|
||||
WorkerPhase
|
||||
| Thinking
|
||||
@@ -121,6 +170,8 @@ Event = (
|
||||
| Done
|
||||
| Error
|
||||
| Cancelled
|
||||
| AffectUpdate
|
||||
| AwaitingLlmFirstToken
|
||||
)
|
||||
|
||||
|
||||
@@ -284,6 +335,26 @@ def _envelope_for_type(body: dict[str, Any], sse_id: SseId) -> Event:
|
||||
reason=body.get("reason"),
|
||||
partial_message_id=body.get("partial_message_id"),
|
||||
)
|
||||
if t == "awaiting_llm_first_token":
|
||||
# Worldtree #201 / v0.29.0: top-level heartbeat during BuildingPrompt
|
||||
# → CallingLLM gap. Lets clients render live elapsed-time indicators
|
||||
# instead of frozen lines on legitimate-slow first-token latency.
|
||||
return AwaitingLlmFirstToken(
|
||||
sse_id=sse_id,
|
||||
turn_id=body["turn_id"],
|
||||
elapsed_ms_since_building_prompt=body["elapsed_ms_since_building_prompt"],
|
||||
)
|
||||
if t == "affect_update":
|
||||
# Worldtree #204 / v0.28.0: persona-state observability event.
|
||||
# status="current" carries full snapshot at turn start;
|
||||
# status="scheduled" omits snapshot (lightweight post-appraisal-
|
||||
# kickoff notification).
|
||||
return AffectUpdate(
|
||||
sse_id=sse_id,
|
||||
status=body["status"],
|
||||
turn_id=body["turn_id"],
|
||||
snapshot=body.get("snapshot"),
|
||||
)
|
||||
raise ValueError(f"unknown SSE event type: {t!r}")
|
||||
|
||||
|
||||
@@ -309,6 +380,14 @@ async def _iter_events(
|
||||
# with a bad id is still a keepalive). Don't reorder.
|
||||
if sse.data == "":
|
||||
continue
|
||||
# v0.8.1: empty-id frames are also treated as keepalives. Worldtree
|
||||
# SOMETIMES emits events without an `id:` line (observed mid-stream
|
||||
# on the qwen3.6-35-a3b-heretic provider, 2026-05-25). Per the SSE
|
||||
# RFC, events without ids are legitimate (they just don't update
|
||||
# Last-Event-ID); the previous strict behavior crashed every turn
|
||||
# on the offending agent. Treat same as empty-data: skip silently.
|
||||
if sse.id == "":
|
||||
continue
|
||||
try:
|
||||
sse_id = _parse_sse_id(sse.id)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Worldtree Tier 3 (consumer-defined) agent lifecycle client.
|
||||
|
||||
Implements docs/contracts/issues/15.contract.md. Caller-owned httpx.AsyncClient
|
||||
posture (same as ratatoskr.sessions). Exposes three lifecycle operations:
|
||||
|
||||
- ``define_agent`` — POST /agents/define
|
||||
- ``patch_agent`` — PATCH /agents/<id>
|
||||
- ``delete_agent`` — DELETE /agents/<id>
|
||||
|
||||
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
|
||||
already handles colon-containing agent_ids generically (issue #8); session
|
||||
creation works unchanged via ``ratatoskr.sessions.create_session``.
|
||||
|
||||
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
|
||||
# Per spec §2627: agent_name + user_id slugs are `[a-z][a-z0-9-]{2,63}`.
|
||||
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tier3AgentInfo:
|
||||
"""Worldtree Tier 3 agent envelope returned by define / patch.
|
||||
|
||||
INV-001: ``agent_id`` is always shape ``"<user_id>:<agent_name>"`` —
|
||||
constructed server-side from the auth's user_id + the supplied agent_name.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
user_id: str
|
||||
agent_name: str
|
||||
system_prompt: str
|
||||
model: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class Tier3QuotaExceeded(Exception):
|
||||
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
|
||||
on the Heimdall key. ``retry_after`` captures the Retry-After header
|
||||
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero)."""
|
||||
|
||||
def __init__(self, *, retry_after: int) -> None:
|
||||
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class Tier3UserIdUnsupported(Exception):
|
||||
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
|
||||
is not slug-safe per Phase 2.0 gate (spec §2626)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("tier3 caller user_id is not slug-safe")
|
||||
|
||||
|
||||
class Tier3FieldNotMutable(Exception):
|
||||
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
|
||||
carried a key that's immutable post-define (``agent_name``, ``user_id``,
|
||||
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3LayerDeferred(Exception):
|
||||
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
|
||||
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
|
||||
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
|
||||
|
||||
Note: ``define_agent`` never sends layer fields, so this exception is
|
||||
defense-against-server-side-changes / forward-compat. INV-001 in the
|
||||
request body construction is the first line of defense.
|
||||
"""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"tier3 layer field deferred: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
|
||||
(spec §2634 + §2641)."""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"tier3 agent not found: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
def _extract_error_code(resp: httpx.Response) -> str | None:
|
||||
"""Pluck the ``detail.error_code`` from a Worldtree error envelope.
|
||||
|
||||
Worldtree wraps API errors in ``{"detail": {"error_code": "...", ...}}``
|
||||
per the spec. Returns None on shape mismatch (so callers fall through
|
||||
to the generic ``SessionApiFailed`` branch).
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
code = detail.get("error_code")
|
||||
if isinstance(code, str):
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
def _extract_error_field(resp: httpx.Response) -> str | None:
|
||||
"""Pluck ``detail.field`` from a Worldtree error envelope (used for
|
||||
``field_not_mutable`` and ``layer_deferred`` to surface which field
|
||||
triggered the rejection). Returns None on shape mismatch.
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
field = detail.get("field")
|
||||
if isinstance(field, str):
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def _parse_tier3_agent_info(body: dict) -> Tier3AgentInfo:
|
||||
"""Parse a Worldtree Tier 3 agent JSON body into the frozen dataclass."""
|
||||
return Tier3AgentInfo(
|
||||
agent_id=body["agent_id"],
|
||||
user_id=body["user_id"],
|
||||
agent_name=body["agent_name"],
|
||||
system_prompt=body["system_prompt"],
|
||||
model=body["model"],
|
||||
created_at=body["created_at"],
|
||||
updated_at=body["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
async def define_agent(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
agent_name: str,
|
||||
system_prompt: str,
|
||||
model: str,
|
||||
) -> Tier3AgentInfo:
|
||||
"""POST /agents/define — create a Tier 3 agent.
|
||||
|
||||
See contract FN define_agent. Validates the agent_name slug client-side
|
||||
before the network round-trip; server-side validation is the safety net.
|
||||
Returns a fully populated Tier3AgentInfo on 201. Routes documented error
|
||||
codes to typed exceptions; unknown non-2xx → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert _SLUG_RE.match(agent_name), (
|
||||
f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
|
||||
)
|
||||
assert system_prompt, "system_prompt must be non-empty"
|
||||
assert model, "model must be non-empty"
|
||||
|
||||
body = {
|
||||
"agent_name": agent_name,
|
||||
"system_prompt": system_prompt,
|
||||
"model": model,
|
||||
}
|
||||
resp = await client.post("/agents/define", json=body)
|
||||
|
||||
if resp.status_code == 201:
|
||||
return _parse_tier3_agent_info(resp.json())
|
||||
if resp.status_code == 429:
|
||||
# Spec §2675: 51st define → 429 with Retry-After: 0.
|
||||
try:
|
||||
retry_after = int(resp.headers.get("Retry-After", "0"))
|
||||
except (TypeError, ValueError):
|
||||
retry_after = 0
|
||||
raise Tier3QuotaExceeded(retry_after=retry_after)
|
||||
if resp.status_code == 403:
|
||||
if _extract_error_code(resp) == "tier3_user_id_unsupported":
|
||||
raise Tier3UserIdUnsupported()
|
||||
if resp.status_code == 422:
|
||||
code = _extract_error_code(resp)
|
||||
if code == "layer_deferred":
|
||||
raise Tier3LayerDeferred(field=_extract_error_field(resp))
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def patch_agent(
|
||||
client: httpx.AsyncClient,
|
||||
agent_id: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> Tier3AgentInfo:
|
||||
"""PATCH /agents/<id> — mutate system_prompt and/or model.
|
||||
|
||||
See contract FN patch_agent. Per spec §2641: only system_prompt + model
|
||||
are mutable in Phase 2.0; any other key returns 422 field_not_mutable.
|
||||
"""
|
||||
assert client is not None
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
assert system_prompt is not None or model is not None, (
|
||||
"patch requires at least one of system_prompt or model"
|
||||
)
|
||||
|
||||
body: dict[str, str] = {}
|
||||
if system_prompt is not None:
|
||||
body["system_prompt"] = system_prompt
|
||||
if model is not None:
|
||||
body["model"] = model
|
||||
resp = await client.patch(f"/agents/{agent_id}", json=body)
|
||||
|
||||
if resp.status_code == 200:
|
||||
return _parse_tier3_agent_info(resp.json())
|
||||
if resp.status_code == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
if resp.status_code == 422:
|
||||
code = _extract_error_code(resp)
|
||||
if code == "field_not_mutable":
|
||||
raise Tier3FieldNotMutable(field=_extract_error_field(resp))
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
|
||||
"""DELETE /agents/<id> — owner hard-delete (cancels active sessions
|
||||
server-side per spec §2636).
|
||||
|
||||
See contract FN delete_agent. 204 on success; 404 if the agent_id
|
||||
doesn't exist; other non-2xx → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
|
||||
resp = await client.delete(f"/agents/{agent_id}")
|
||||
if resp.status_code == 204:
|
||||
return
|
||||
if resp.status_code == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
# ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------
|
||||
#
|
||||
# Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes
|
||||
# mirror ratatoskr.cli: 0 happy / 10 usage / 11 auth / 20 api-failure /
|
||||
# 21 network. Outbound requests carry the same User-Agent string.
|
||||
|
||||
|
||||
class _Tier3UsageError(Exception):
|
||||
"""Argparse usage violation → exit 10."""
|
||||
|
||||
|
||||
class _Tier3AuthError(Exception):
|
||||
"""No API key resolvable → exit 11."""
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m ratatoskr.tier3",
|
||||
description="Worldtree Tier 3 (consumer-defined) agent lifecycle.",
|
||||
)
|
||||
parser.add_argument("--api-key", dest="api_key", default=None)
|
||||
parser.add_argument("--server", dest="server", default=None)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_define = sub.add_parser("define", help="Create a Tier 3 agent.")
|
||||
p_define.add_argument("--name", required=True, help="agent_name (slug).")
|
||||
p_define.add_argument(
|
||||
"--system-prompt", dest="system_prompt", required=True,
|
||||
help="System prompt the agent ships with.",
|
||||
)
|
||||
p_define.add_argument(
|
||||
"--model", required=True,
|
||||
help="Provider model ID (NOT a profile alias; e.g., qwen3.6-35-a3b).",
|
||||
)
|
||||
|
||||
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or model.")
|
||||
p_patch.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
|
||||
p_patch.add_argument("--system-prompt", dest="system_prompt", default=None)
|
||||
p_patch.add_argument("--model", default=None)
|
||||
|
||||
p_delete = sub.add_parser("delete", help="Hard-delete a Tier 3 agent.")
|
||||
p_delete.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _resolve_auth(ns: argparse.Namespace) -> tuple[str, str]:
|
||||
"""Resolve API key + server URL with the same env-var fallback as cli.py."""
|
||||
import os
|
||||
|
||||
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
|
||||
if not api_key:
|
||||
raise _Tier3AuthError("no API key (set --api-key or WORLDTREE_API_KEY)")
|
||||
server_url = (
|
||||
ns.server or os.environ.get("WORLDTREE_API_URL") or "http://localhost:8000"
|
||||
)
|
||||
return api_key, server_url
|
||||
|
||||
|
||||
async def _run_define(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
add_local_agent,
|
||||
make_description,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
info = await define_agent(
|
||||
client,
|
||||
agent_name=ns.name,
|
||||
system_prompt=ns.system_prompt,
|
||||
model=ns.model,
|
||||
)
|
||||
# v0.8.0: persist to local index so the picker can show it.
|
||||
add_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=info.agent_id,
|
||||
agent_name=info.agent_name,
|
||||
model=info.model,
|
||||
description=make_description(info.system_prompt),
|
||||
defined_at=info.created_at,
|
||||
)
|
||||
)
|
||||
print(f"defined {info.agent_id} ({info.model})")
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_patch(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
make_description,
|
||||
update_local_agent,
|
||||
)
|
||||
|
||||
if ns.system_prompt is None and ns.model is None:
|
||||
raise _Tier3UsageError(
|
||||
"patch requires at least one of --system-prompt or --model"
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
info = await patch_agent(
|
||||
client,
|
||||
ns.agent_id,
|
||||
system_prompt=ns.system_prompt,
|
||||
model=ns.model,
|
||||
)
|
||||
# v0.8.0: refresh local index with the post-patch state.
|
||||
update_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=info.agent_id,
|
||||
agent_name=info.agent_name,
|
||||
model=info.model,
|
||||
description=make_description(info.system_prompt),
|
||||
defined_at=info.updated_at,
|
||||
)
|
||||
)
|
||||
print(f"patched {info.agent_id}")
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_delete(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr.local_agents import remove_local_agent
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
await delete_agent(client, ns.agent_id)
|
||||
# v0.8.0: drop from local index so the picker stops listing it.
|
||||
remove_local_agent(ns.agent_id)
|
||||
print(f"deleted {ns.agent_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Sync entry point — argparse + dispatch + error → exit-code mapping.
|
||||
|
||||
Mirrors ratatoskr.cli.main()'s error-routing matrix:
|
||||
0 happy
|
||||
10 usage error
|
||||
11 auth error
|
||||
20 api-failure (typed exception or generic SessionApiFailed)
|
||||
21 network error
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
parser = _build_parser()
|
||||
try:
|
||||
ns = parser.parse_args(argv)
|
||||
except SystemExit as exc:
|
||||
return int(exc.code) if exc.code is not None else 0
|
||||
|
||||
handler = {
|
||||
"define": _run_define,
|
||||
"patch": _run_patch,
|
||||
"delete": _run_delete,
|
||||
}[ns.cmd]
|
||||
|
||||
try:
|
||||
return asyncio.run(handler(ns))
|
||||
except _Tier3UsageError as exc:
|
||||
sys.stderr.write(f"[usage_error] {exc}\n")
|
||||
return 10
|
||||
except _Tier3AuthError as exc:
|
||||
sys.stderr.write(f"[auth_error] {exc}\n")
|
||||
return 11
|
||||
except AssertionError as exc:
|
||||
sys.stderr.write(f"[usage_error] {exc}\n")
|
||||
return 10
|
||||
except Tier3QuotaExceeded as exc:
|
||||
sys.stderr.write(f"[quota_exceeded] retry_after={exc.retry_after}\n")
|
||||
return 20
|
||||
except Tier3UserIdUnsupported:
|
||||
sys.stderr.write("[user_id_unsupported]\n")
|
||||
return 20
|
||||
except Tier3AgentNotFound as exc:
|
||||
sys.stderr.write(f"[agent_not_found] {exc.agent_id}\n")
|
||||
return 20
|
||||
except Tier3FieldNotMutable as exc:
|
||||
sys.stderr.write(f"[field_not_mutable] field={exc.field}\n")
|
||||
return 20
|
||||
except Tier3LayerDeferred as exc:
|
||||
sys.stderr.write(f"[layer_deferred] field={exc.field}\n")
|
||||
return 20
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(
|
||||
f"[api_failed] status={exc.status} body={exc.body!r}\n"
|
||||
)
|
||||
return 20
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||
return 21
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
+1214
-97
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
"""ratatoskr.web — browser debug companion to the Ratatoskr TUI.
|
||||
|
||||
Per issue #16 INV-001: this module MUST NOT import starlette or
|
||||
uvicorn at module top. Both live behind the optional `[web]` extras
|
||||
group; importing them eagerly here would defeat the lazy-import
|
||||
discipline that gives users without the extras a clean install hint
|
||||
instead of a naked ImportError.
|
||||
"""
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Console-script entrypoint for `ratatoskr-web`.
|
||||
|
||||
Per docs/contracts/issues/16.contract.md FN main and INV-001:
|
||||
- MUST NOT import starlette / uvicorn at module top
|
||||
- Imports happen INSIDE main() after argparse, with ImportError caught
|
||||
and converted to a clean `pip install ratatoskr[web]` exit
|
||||
- Users without the [web] extras installed get a readable hint instead
|
||||
of a naked ImportError traceback
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import webbrowser
|
||||
from importlib.metadata import version as _pkg_version
|
||||
|
||||
|
||||
def _build_arg_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="ratatoskr-web",
|
||||
description="Browser-based debug companion to ratatoskr.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--host", default="0.0.0.0",
|
||||
help="Bind address. Default: 0.0.0.0 (LAN-accessible). "
|
||||
"Use 127.0.0.1 to restrict to localhost.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--port", type=int, default=8765,
|
||||
help="Listen port. Default 8765. Use 0 for random free.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--open", action="store_true",
|
||||
help="Auto-open the URL in the system browser.",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Console-script entry. Per FN main.
|
||||
|
||||
Returns:
|
||||
0 on clean shutdown
|
||||
11 on missing WORLDTREE_API_KEY ([auth_error])
|
||||
12 on missing [web] extras ([missing_extras])
|
||||
"""
|
||||
args = _build_arg_parser().parse_args(argv)
|
||||
|
||||
# Validate env BEFORE importing starlette so missing env shows the
|
||||
# right error regardless of extras-install state.
|
||||
api_key = os.environ.get("WORLDTREE_API_KEY")
|
||||
if not api_key:
|
||||
sys.stderr.write(
|
||||
"[auth_error] WORLDTREE_API_KEY env var required. "
|
||||
"Source env.sh in your project root.\n"
|
||||
)
|
||||
return 11
|
||||
server_url = os.environ.get("WORLDTREE_API_URL", "http://localhost:8000")
|
||||
end_user_id = os.environ.get("RATATOSKR_END_USER_ID")
|
||||
|
||||
# INV-001: lazy import. Users without [web] extras get a clean hint
|
||||
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
|
||||
# extras (starlette / uvicorn) so a real import bug inside a
|
||||
# production module (ratatoskr.web.server, ratatoskr.cli, httpx —
|
||||
# all baseline deps) propagates as a true traceback rather than
|
||||
# being masked as "install ratatoskr[web]".
|
||||
try:
|
||||
import starlette # noqa: F401 (extras-presence probe)
|
||||
import uvicorn
|
||||
except ImportError as exc:
|
||||
sys.stderr.write(
|
||||
f"[missing_extras] {exc}\n"
|
||||
f"ratatoskr-web requires the [web] optional dependencies.\n"
|
||||
f"Install with: pip install ratatoskr[web]\n"
|
||||
)
|
||||
return 12
|
||||
|
||||
# Baseline deps + own modules — a failure here is a real bug, not a
|
||||
# missing-extras condition; let it propagate.
|
||||
import httpx
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
def client_factory() -> "httpx.AsyncClient":
|
||||
return httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
|
||||
)
|
||||
|
||||
app = create_app(client_factory, end_user_id=end_user_id)
|
||||
|
||||
# Boot banner to stderr (so stdout stays clean for piping).
|
||||
version = _pkg_version("ratatoskr")
|
||||
host = args.host
|
||||
port = args.port
|
||||
display_host = "localhost" if host == "0.0.0.0" else host
|
||||
sys.stderr.write(
|
||||
f"ratatoskr-web v{version}\n"
|
||||
f"Listening on http://{host}:{port}/\n"
|
||||
f"Connect from this device: http://{display_host}:{port}/\n"
|
||||
)
|
||||
if host == "0.0.0.0":
|
||||
sys.stderr.write(
|
||||
f"Connect from LAN: http://<host-ip>:{port}/\n"
|
||||
)
|
||||
sys.stderr.write("Ctrl-C to stop.\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
if args.open:
|
||||
webbrowser.open(f"http://{display_host}:{port}/")
|
||||
|
||||
uvicorn.run(app, host=host, port=port, log_config=None)
|
||||
return 0
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Starlette app factory + endpoint handlers for ratatoskr.web.
|
||||
|
||||
Per docs/contracts/issues/16.contract.md. INV-002: create_app accepts
|
||||
a client_factory callable; the factory produces a configured
|
||||
httpx.AsyncClient. Tests pass a respx-mocked factory; production
|
||||
passes a factory that bakes in WORLDTREE_API_URL + WORLDTREE_API_KEY.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import asdict, dataclass, is_dataclass
|
||||
from importlib.metadata import version as _pkg_version
|
||||
|
||||
import httpx
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from ratatoskr import local_agents as _local_agents
|
||||
from ratatoskr.sessions import (
|
||||
AgentNotAvailable,
|
||||
AgentNotFound,
|
||||
AuthScopeDenied,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
create_session,
|
||||
get_persona_state,
|
||||
list_agents,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
CancelAlreadyCompleted,
|
||||
Cancelled,
|
||||
CancelFailed,
|
||||
CancelTurnNotFound,
|
||||
Done,
|
||||
Error,
|
||||
MalformedSseData,
|
||||
MalformedSseId,
|
||||
SseConnectFailed,
|
||||
SseConnectionDropped,
|
||||
TurnIdFlip,
|
||||
cancel_turn,
|
||||
stream_turn,
|
||||
)
|
||||
|
||||
|
||||
def _static_dir() -> str:
|
||||
"""Locate the bundled static/ directory inside the installed package.
|
||||
|
||||
Uses importlib.resources so the lookup works for editable installs,
|
||||
wheel installs, and uvicorn's worker reload. Per INV-009 packaging:
|
||||
static/index.html ships in the wheel.
|
||||
"""
|
||||
from importlib.resources import files
|
||||
return str(files("ratatoskr.web") / "static")
|
||||
|
||||
|
||||
def _root_endpoint(request: Request) -> FileResponse:
|
||||
"""GET / → index.html. Per FN root_endpoint POST-001."""
|
||||
from pathlib import Path
|
||||
return FileResponse(
|
||||
Path(_static_dir()) / "index.html",
|
||||
media_type="text/html",
|
||||
)
|
||||
|
||||
|
||||
def _version_endpoint(request: Request) -> JSONResponse:
|
||||
"""GET /version → {"ratatoskr": "<version>"}.
|
||||
|
||||
Per FN version_endpoint POST-001.
|
||||
"""
|
||||
return JSONResponse({"ratatoskr": _pkg_version("ratatoskr")}, status_code=200)
|
||||
|
||||
|
||||
def _as_dict(obj: object) -> dict:
|
||||
"""Best-effort dataclass-to-dict for AgentInfo / LocalAgentEntry."""
|
||||
if is_dataclass(obj):
|
||||
return asdict(obj)
|
||||
return dict(obj) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def _agents_endpoint(request: Request) -> JSONResponse:
|
||||
"""GET /api/agents → upstream /agents + local Tier 3 index merge.
|
||||
|
||||
Per FN agents_endpoint POST-001 + ERRORS table.
|
||||
"""
|
||||
client_factory = request.app.state.client_factory
|
||||
try:
|
||||
async with client_factory() as client:
|
||||
upstream = await list_agents(client)
|
||||
except SessionApiFailed as exc:
|
||||
return JSONResponse(
|
||||
{"error_code": "session_api_failed", "status": exc.status},
|
||||
status_code=exc.status,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
return JSONResponse(
|
||||
{"error_code": "network_error", "message": str(exc)},
|
||||
status_code=502,
|
||||
)
|
||||
upstream_ids = {a.agent_id for a in upstream}
|
||||
local = _local_agents.load_local_agents()
|
||||
merged = [_as_dict(a) for a in upstream] + [
|
||||
_as_dict(le) for le in local if le.agent_id not in upstream_ids
|
||||
]
|
||||
return JSONResponse(merged, status_code=200)
|
||||
|
||||
|
||||
async def _create_session_endpoint(request: Request) -> JSONResponse:
|
||||
"""POST /api/sessions → upstream POST /sessions. Per FN create_session_endpoint.
|
||||
|
||||
v0.16.0: end_user_id is SERVER-configured (app.state.end_user_id from
|
||||
RATATOSKR_END_USER_ID), never read from the browser body. A client
|
||||
cannot impersonate an arbitrary end-user partition.
|
||||
"""
|
||||
body = await request.json()
|
||||
agent_id = body.get("agent_id") if isinstance(body, dict) else None
|
||||
if not agent_id:
|
||||
return JSONResponse({"error_code": "missing_agent_id"}, status_code=400)
|
||||
end_user_id = request.app.state.end_user_id
|
||||
client_factory = request.app.state.client_factory
|
||||
try:
|
||||
async with client_factory() as client:
|
||||
info = await create_session(client, agent_id, end_user_id=end_user_id)
|
||||
except AgentNotFound:
|
||||
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
|
||||
except SessionApiFailed as exc:
|
||||
return JSONResponse(
|
||||
{"error_code": "session_api_failed", "status": exc.status},
|
||||
status_code=exc.status,
|
||||
)
|
||||
return JSONResponse(_as_dict(info), status_code=201)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnHandle:
|
||||
"""In-flight turn record stored in app.state.turn_registry.
|
||||
|
||||
Per FN submit_turn_endpoint + INV-005/006/007.
|
||||
|
||||
v0.16.0: `upstream_turn_id` captures Worldtree's server-assigned
|
||||
turn_id (from the first SSE event's sse_id.turn_id) once the stream
|
||||
opens. Cancel paths target THIS, not the browser-local `turn_id` —
|
||||
the local counter is only a registry key. None until the first
|
||||
upstream event arrives; cancel before then is a no-op (nothing to
|
||||
cancel upstream yet).
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
turn_id: int
|
||||
content: str
|
||||
status: str = "queued" # queued | streaming | done | error | cancelled
|
||||
upstream_turn_id: int | None = None
|
||||
|
||||
|
||||
# Process-local monotonic turn_id counter. Per FN submit_turn_endpoint
|
||||
# STEPS 2: turn_id is opaque to the upstream Worldtree (whose own
|
||||
# turn_ids come back via SSE); the registry's key uses our own counter
|
||||
# so cancel/stream lookups don't need upstream-issued ids.
|
||||
_TURN_COUNTER = itertools.count(1)
|
||||
|
||||
|
||||
async def _submit_turn_endpoint(request: Request) -> JSONResponse:
|
||||
"""POST /api/turns/{session_id} → allocate turn_id + register handle.
|
||||
|
||||
Per FN submit_turn_endpoint. Does NOT open the upstream stream here;
|
||||
the subsequent GET /api/turns/{sid}/stream does that.
|
||||
"""
|
||||
body = await request.json()
|
||||
content = body.get("content") if isinstance(body, dict) else None
|
||||
if not content:
|
||||
return JSONResponse({"error_code": "missing_content"}, status_code=400)
|
||||
session_id = request.path_params["session_id"]
|
||||
turn_id = next(_TURN_COUNTER)
|
||||
request.app.state.turn_registry[(session_id, turn_id)] = TurnHandle(
|
||||
session_id=session_id, turn_id=turn_id, content=content,
|
||||
)
|
||||
return JSONResponse({"turn_id": turn_id}, status_code=200)
|
||||
|
||||
|
||||
def _event_to_browser_payload(event: object) -> tuple[str, dict]:
|
||||
"""Serialize an upstream Event dataclass to (browser_event_type, json_dict).
|
||||
|
||||
Per INV-008 + FN stream_turn_endpoint STEP 3. The dict shape is
|
||||
locked by tests/fixtures/presentation_contract.json — one entry per
|
||||
Event type. Implementation: snake_case class name as event_type;
|
||||
asdict(event) with sse_id flattened to "T:S" string.
|
||||
"""
|
||||
type_name = type(event).__name__
|
||||
# CamelCase → snake_case
|
||||
browser_type = "".join(
|
||||
("_" + c.lower() if c.isupper() and i else c.lower())
|
||||
for i, c in enumerate(type_name)
|
||||
)
|
||||
data = asdict(event) # type: ignore[arg-type]
|
||||
sse_id = data.get("sse_id")
|
||||
if isinstance(sse_id, (list, tuple)) and len(sse_id) == 2:
|
||||
data["sse_id"] = f"{sse_id[0]}:{sse_id[1]}"
|
||||
elif isinstance(sse_id, dict) and "turn_id" in sse_id and "seq" in sse_id:
|
||||
data["sse_id"] = f"{sse_id['turn_id']}:{sse_id['seq']}"
|
||||
return browser_type, data
|
||||
|
||||
|
||||
def _format_sse(event_type: str, data: dict) -> bytes:
|
||||
"""Format a browser-facing SSE event with `event:` + `data:`.
|
||||
|
||||
Two-newline terminator per the SSE spec.
|
||||
"""
|
||||
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n".encode()
|
||||
|
||||
|
||||
async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
|
||||
"""GET /api/turns/{session_id}/stream?turn_id=N → proxy upstream SSE.
|
||||
|
||||
Per FN stream_turn_endpoint. Handles browser-disconnect cleanup
|
||||
(INV-005) and synthesizes `event: error` for upstream typed
|
||||
exceptions.
|
||||
"""
|
||||
session_id = request.path_params["session_id"]
|
||||
try:
|
||||
turn_id = int(request.query_params["turn_id"])
|
||||
except (KeyError, ValueError):
|
||||
return JSONResponse({"error_code": "missing_turn_id"}, status_code=400)
|
||||
registry = request.app.state.turn_registry
|
||||
handle = registry.get((session_id, turn_id))
|
||||
if handle is None:
|
||||
return JSONResponse({"error_code": "turn_not_found"}, status_code=404)
|
||||
|
||||
client_factory = request.app.state.client_factory
|
||||
|
||||
async def gen() -> AsyncIterator[bytes]:
|
||||
client = client_factory()
|
||||
try:
|
||||
handle.status = "streaming"
|
||||
try:
|
||||
async for event in stream_turn(client, session_id, handle.content):
|
||||
# v0.16.0: capture the upstream (Worldtree-assigned)
|
||||
# turn_id from the first event so cancel paths target
|
||||
# the real upstream turn, not our local counter.
|
||||
if handle.upstream_turn_id is None:
|
||||
sse_id = getattr(event, "sse_id", None)
|
||||
if sse_id is not None:
|
||||
handle.upstream_turn_id = sse_id.turn_id
|
||||
event_type, data = _event_to_browser_payload(event)
|
||||
yield _format_sse(event_type, data)
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
handle.status = type(event).__name__.lower()
|
||||
break
|
||||
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
|
||||
MalformedSseData, TurnIdFlip) as exc:
|
||||
yield _format_sse(
|
||||
"error",
|
||||
{"exception": type(exc).__name__, "message": str(exc)},
|
||||
)
|
||||
handle.status = "error"
|
||||
except asyncio.CancelledError:
|
||||
# Browser disconnect path (INV-005). Cancel the UPSTREAM
|
||||
# turn (if it started) — never the local turn_id.
|
||||
if handle.status == "streaming" and handle.upstream_turn_id is not None:
|
||||
try:
|
||||
await cancel_turn(client, session_id, handle.upstream_turn_id)
|
||||
except (CancelAlreadyCompleted, CancelTurnNotFound):
|
||||
pass # cooperative race — turn already terminal upstream
|
||||
except Exception as exc:
|
||||
# v0.16.1: unexpected cancel failure during disconnect
|
||||
# cleanup (e.g. CancelFailed, transport error) — log for
|
||||
# diagnosability instead of silently swallowing. Never
|
||||
# re-raise: we're already unwinding the cancelled
|
||||
# generator and must not mask the CancelledError.
|
||||
import sys as _sys
|
||||
_sys.stderr.write(
|
||||
f'{{"kind":"disconnect_cancel","event":"cancel_failed",'
|
||||
f'"session_id":"{session_id}",'
|
||||
f'"upstream_turn_id":{handle.upstream_turn_id},'
|
||||
f'"exc":"{type(exc).__name__}"}}\n'
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
registry.pop((session_id, turn_id), None)
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
async def _cancel_turn_endpoint(request: Request) -> JSONResponse:
|
||||
"""POST /api/turns/{session_id}/cancel?turn_id=N. Per FN cancel_turn_endpoint."""
|
||||
session_id = request.path_params["session_id"]
|
||||
try:
|
||||
turn_id = int(request.query_params["turn_id"])
|
||||
except (KeyError, ValueError):
|
||||
return JSONResponse({"error_code": "missing_turn_id"}, status_code=400)
|
||||
registry = request.app.state.turn_registry
|
||||
handle = registry.get((session_id, turn_id))
|
||||
if handle is None:
|
||||
return JSONResponse({"error_code": "turn_not_found"}, status_code=404)
|
||||
# v0.16.0: cancel targets the UPSTREAM turn_id captured during
|
||||
# streaming, not the browser-local turn_id. If the upstream stream
|
||||
# never started (upstream_turn_id is None), there's nothing to
|
||||
# cancel — clean up and report a no-op.
|
||||
if handle.upstream_turn_id is None:
|
||||
registry.pop((session_id, turn_id), None)
|
||||
return JSONResponse(
|
||||
{"cancelled": False, "reason": "not_started"}, status_code=200
|
||||
)
|
||||
client_factory = request.app.state.client_factory
|
||||
try:
|
||||
async with client_factory() as client:
|
||||
await cancel_turn(client, session_id, handle.upstream_turn_id)
|
||||
body = {"cancelled": True}
|
||||
except (CancelAlreadyCompleted, CancelTurnNotFound):
|
||||
body = {"cancelled": False, "reason": "race_or_completed"}
|
||||
except CancelFailed as exc:
|
||||
registry.pop((session_id, turn_id), None)
|
||||
return JSONResponse(
|
||||
{"error_code": "cancel_failed", "status": exc.status},
|
||||
status_code=exc.status,
|
||||
)
|
||||
registry.pop((session_id, turn_id), None)
|
||||
return JSONResponse(body, status_code=200)
|
||||
|
||||
|
||||
async def _persona_state_endpoint(request: Request) -> JSONResponse:
|
||||
"""GET /api/agents/{agent_id}/persona_state. Per FN persona_state_endpoint."""
|
||||
agent_id = request.path_params["agent_id"]
|
||||
client_factory = request.app.state.client_factory
|
||||
try:
|
||||
async with client_factory() as client:
|
||||
snap = await get_persona_state(client, agent_id)
|
||||
except PersonaNotConfigured:
|
||||
return JSONResponse({"error_code": "persona_not_configured"}, status_code=404)
|
||||
except AgentNotAvailable:
|
||||
return JSONResponse({"error_code": "agent_not_available"}, status_code=404)
|
||||
except AuthScopeDenied:
|
||||
return JSONResponse({"error_code": "auth_scope_denied"}, status_code=403)
|
||||
return JSONResponse(snap, status_code=200)
|
||||
|
||||
|
||||
def create_app(
|
||||
client_factory: Callable[[], httpx.AsyncClient],
|
||||
*,
|
||||
end_user_id: str | None = None,
|
||||
) -> Starlette:
|
||||
"""Construct the Starlette app — wire routes + state per FN create_app.
|
||||
|
||||
INV-002: app MUST NOT construct httpx.AsyncClient at module top;
|
||||
everything HTTP-bound goes through client_factory.
|
||||
INV-006: lifespan shutdown drains the turn registry within a 5s
|
||||
budget — every in-flight turn gets a best-effort upstream cancel.
|
||||
|
||||
v0.16.0: `end_user_id` is the server-configured Worldtree end-user
|
||||
partition (from RATATOSKR_END_USER_ID). Threaded into POST /sessions
|
||||
server-side; never accepted from the browser.
|
||||
"""
|
||||
assert callable(client_factory)
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Starlette):
|
||||
yield
|
||||
# Shutdown path — drain in-flight turns per INV-006. Cancel the
|
||||
# UPSTREAM turn_id (v0.16.0); skip handles whose upstream stream
|
||||
# never started (upstream_turn_id is None — nothing to cancel).
|
||||
import sys as _sys
|
||||
|
||||
registry: dict[tuple[str, int], TurnHandle] = app.state.turn_registry
|
||||
in_flight = [
|
||||
h for h in registry.values()
|
||||
if h.status == "streaming" and h.upstream_turn_id is not None
|
||||
]
|
||||
if in_flight:
|
||||
client = client_factory()
|
||||
try:
|
||||
task_to_handle = {
|
||||
asyncio.create_task(
|
||||
cancel_turn(client, h.session_id, h.upstream_turn_id)
|
||||
): h
|
||||
for h in in_flight
|
||||
}
|
||||
done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
|
||||
# Per-pending session/turn detail (INV-006 logging fidelity).
|
||||
for task in pending:
|
||||
h = task_to_handle[task]
|
||||
task.cancel()
|
||||
_sys.stderr.write(
|
||||
f'{{"kind":"shutdown","event":"cleanup_timeout",'
|
||||
f'"session_id":"{h.session_id}",'
|
||||
f'"upstream_turn_id":{h.upstream_turn_id}}}\n'
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
registry.clear()
|
||||
|
||||
routes = [
|
||||
Route("/", _root_endpoint),
|
||||
Mount("/static", app=StaticFiles(directory=_static_dir()), name="static"),
|
||||
Route("/version", _version_endpoint),
|
||||
Route("/api/agents", _agents_endpoint),
|
||||
Route("/api/sessions", _create_session_endpoint, methods=["POST"]),
|
||||
Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint),
|
||||
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
|
||||
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
|
||||
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
|
||||
]
|
||||
app = Starlette(routes=routes, lifespan=lifespan)
|
||||
app.state.client_factory = client_factory
|
||||
app.state.end_user_id = end_user_id
|
||||
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
|
||||
app.state.turn_registry = {}
|
||||
return app
|
||||
@@ -0,0 +1,983 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ratatoskr · wire monitor</title>
|
||||
<style>
|
||||
/* ============================================================
|
||||
ratatoskr-web — Aurora telemetry instrument
|
||||
A precision wire-monitoring console for the Worldtree
|
||||
Conversation API. All-monospace by intent; Australis dark
|
||||
cool-tone palette with aurora-borealis accents on LIVE
|
||||
surfaces. Single file, no build, no CDN.
|
||||
============================================================ */
|
||||
:root {
|
||||
/* layered voids */
|
||||
--void: #000000;
|
||||
--surface-1: #07090c;
|
||||
--surface-2: #0d1117;
|
||||
--surface-3: #141a22;
|
||||
--line: #1b212a;
|
||||
--line-2: #283039;
|
||||
|
||||
/* Australis Sea — text ramp */
|
||||
--fg: #b9c8ce;
|
||||
--fg-2: #8a97a0;
|
||||
--fg-dim: #6e7882;
|
||||
--fg-faint: #49525c;
|
||||
|
||||
/* Aurora accents */
|
||||
--cyan: #42dcd1;
|
||||
--blue: #a4c4ff;
|
||||
--green: #51e08a;
|
||||
--green-deep: #16b866;
|
||||
--red: #ff5a36;
|
||||
--amber: #e1c631;
|
||||
|
||||
--glow-cyan: rgba(66, 220, 209, 0.14);
|
||||
|
||||
--mono: "Berkeley Mono", "JetBrains Mono", "IBM Plex Mono",
|
||||
"SFMono-Regular", "Cascadia Code", "Roboto Mono",
|
||||
ui-monospace, Menlo, Consolas, monospace;
|
||||
|
||||
--row: 30px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
|
||||
body {
|
||||
background: var(--void);
|
||||
color: var(--fg);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
/* faint cyan glow at top + grain */
|
||||
background-image:
|
||||
radial-gradient(120% 60% at 50% -10%, var(--glow-cyan), transparent 60%),
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.025'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* selection */
|
||||
::selection { background: rgba(66,220,209,0.25); color: #fff; }
|
||||
|
||||
/* scrollbars */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 0; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--fg-faint); }
|
||||
|
||||
/* ---- aurora signature band ---- */
|
||||
.aurora {
|
||||
height: 2px;
|
||||
flex: 0 0 auto;
|
||||
background: linear-gradient(90deg,
|
||||
transparent, var(--cyan), var(--blue), var(--green), var(--cyan), transparent);
|
||||
background-size: 300% 100%;
|
||||
animation: aurora-drift 14s linear infinite;
|
||||
opacity: 0.85;
|
||||
}
|
||||
@keyframes aurora-drift {
|
||||
0% { background-position: 0% 0; }
|
||||
100% { background-position: 300% 0; }
|
||||
}
|
||||
|
||||
/* ---- top command bar ---- */
|
||||
#topbar {
|
||||
flex: 0 0 auto;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 0 16px;
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: baseline; gap: 9px; flex: 0 0 auto; }
|
||||
.brand .glyph {
|
||||
font-size: 16px; color: var(--cyan);
|
||||
text-shadow: 0 0 12px var(--glow-cyan);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.brand .name {
|
||||
font-size: 14px; font-weight: 600; letter-spacing: 0.04em; color: var(--fg);
|
||||
}
|
||||
.brand .tag {
|
||||
font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
|
||||
.conn {
|
||||
display: flex; align-items: center; gap: 7px; flex: 0 0 auto;
|
||||
font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.conn .dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--fg-faint);
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
.conn[data-state="idle"] .dot { background: var(--green-deep); }
|
||||
.conn[data-state="streaming"] .dot {
|
||||
background: var(--cyan);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
.conn[data-state="error"] .dot { background: var(--red); }
|
||||
.conn[data-state="streaming"] { color: var(--cyan); }
|
||||
.conn[data-state="error"] { color: var(--red); }
|
||||
@keyframes pulse {
|
||||
0%,100% { box-shadow: 0 0 0 0 rgba(66,220,209,0.55); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(66,220,209,0); }
|
||||
}
|
||||
|
||||
/* persona strip — lives in the top bar, hidden until hydrated */
|
||||
#persona-strip {
|
||||
display: none;
|
||||
align-items: center; gap: 14px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding-left: 18px;
|
||||
border-left: 1px solid var(--line);
|
||||
height: 26px;
|
||||
}
|
||||
#persona-strip.show { display: flex; }
|
||||
#persona-strip .emo {
|
||||
font-size: 11px; color: var(--blue); letter-spacing: 0.02em;
|
||||
white-space: nowrap; flex: 0 0 auto;
|
||||
}
|
||||
#persona-strip .emo b { color: var(--cyan); font-weight: 600; }
|
||||
.pad-bars { display: flex; gap: 12px; flex: 0 0 auto; }
|
||||
.pad { display: flex; align-items: center; gap: 5px; }
|
||||
.pad .k { font-size: 9px; color: var(--fg-faint); width: 8px; }
|
||||
.pad .track {
|
||||
width: 46px; height: 4px; background: var(--line-2);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.pad .track::before { /* center baseline tick */
|
||||
content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
|
||||
width: 1px; background: var(--fg-faint); opacity: 0.5;
|
||||
}
|
||||
.pad .fill {
|
||||
position: absolute; top: 0; bottom: 0; left: 50%;
|
||||
background: linear-gradient(90deg, var(--cyan), var(--blue));
|
||||
transition: width 0.4s ease, left 0.4s ease;
|
||||
}
|
||||
|
||||
.spacer { flex: 1 1 auto; }
|
||||
#identity {
|
||||
font-size: 11px; color: var(--fg-dim); letter-spacing: 0.02em;
|
||||
white-space: nowrap; flex: 0 0 auto;
|
||||
}
|
||||
#identity .a { color: var(--blue); }
|
||||
|
||||
/* ---- workspace split ---- */
|
||||
#workspace {
|
||||
display: none;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
grid-template-columns: 1.85fr 1fr;
|
||||
}
|
||||
#workspace.live { display: grid; }
|
||||
|
||||
.conversation, .telemetry { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
.conversation { border-right: 1px solid var(--line); }
|
||||
|
||||
/* transcript */
|
||||
#transcript {
|
||||
flex: 1 1 auto; overflow-y: auto; padding: 18px 22px 28px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.turn-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 18px 0 10px; color: var(--fg-faint);
|
||||
font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
}
|
||||
.turn-header::before, .turn-header::after {
|
||||
content: ""; height: 1px; background: var(--line); flex: 1 1 auto;
|
||||
}
|
||||
.turn-header:first-child { margin-top: 0; }
|
||||
|
||||
.prompt-echo {
|
||||
color: var(--cyan); font-weight: 500; margin: 4px 0 10px;
|
||||
display: flex; gap: 9px; align-items: baseline;
|
||||
animation: rise 0.3s ease both;
|
||||
}
|
||||
.prompt-echo::before {
|
||||
content: "❯"; color: var(--cyan); font-weight: 700;
|
||||
text-shadow: 0 0 10px var(--glow-cyan);
|
||||
}
|
||||
.response {
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
color: var(--fg); margin: 0 0 6px;
|
||||
padding-left: 18px; border-left: 2px solid var(--line-2);
|
||||
}
|
||||
.response.live { border-left-color: var(--cyan); }
|
||||
|
||||
.awaiting {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
color: var(--fg-dim); font-style: italic; font-size: 12px;
|
||||
margin: 6px 0; padding-left: 18px;
|
||||
}
|
||||
.awaiting::after {
|
||||
content: ""; width: 16px; text-align: left;
|
||||
animation: dots 1.4s steps(4, end) infinite;
|
||||
}
|
||||
@keyframes dots {
|
||||
0% { content: ""; } 25% { content: "·"; }
|
||||
50% { content: "··"; } 75% { content: "···"; }
|
||||
}
|
||||
|
||||
/* terminal status chips */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
margin: 8px 0 4px; padding: 3px 10px;
|
||||
font-size: 10px; letter-spacing: 0.08em;
|
||||
border: 1px solid currentColor; border-radius: 2px;
|
||||
animation: rise 0.3s ease both;
|
||||
}
|
||||
.chip .lbl { text-transform: uppercase; font-weight: 600; }
|
||||
.chip .meta { color: var(--fg-dim); border: 0; letter-spacing: 0; }
|
||||
.chip.done { color: var(--green); }
|
||||
.chip.error { color: var(--red); }
|
||||
.chip.cancelled { color: var(--amber); }
|
||||
.chip.wire { color: var(--red); }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* composer */
|
||||
.composer {
|
||||
flex: 0 0 auto; border-top: 1px solid var(--line);
|
||||
background: var(--surface-1);
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 14px;
|
||||
}
|
||||
.composer .prompt-mark { color: var(--cyan); font-weight: 700; }
|
||||
#prompt-input {
|
||||
flex: 1 1 auto; background: transparent; border: 0; outline: none;
|
||||
color: var(--fg); font-family: var(--mono); font-size: 13px;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
#prompt-input::placeholder { color: var(--fg-faint); }
|
||||
#send-btn {
|
||||
flex: 0 0 auto; cursor: pointer;
|
||||
background: transparent; color: var(--cyan);
|
||||
border: 1px solid var(--line-2); border-radius: 2px;
|
||||
font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; padding: 6px 12px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
#send-btn:hover { border-color: var(--cyan); background: rgba(66,220,209,0.08); }
|
||||
.composer.streaming #send-btn { display: none; }
|
||||
#cancel-btn {
|
||||
display: none; flex: 0 0 auto; cursor: pointer;
|
||||
background: transparent; color: var(--amber);
|
||||
border: 1px solid rgba(225,198,49,0.4); border-radius: 2px;
|
||||
font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; padding: 6px 12px;
|
||||
}
|
||||
.composer.streaming #cancel-btn { display: inline-block; }
|
||||
#cancel-btn:hover { border-color: var(--amber); background: rgba(225,198,49,0.08); }
|
||||
|
||||
/* ---- telemetry column ---- */
|
||||
.telemetry { background: var(--surface-1); }
|
||||
.tabs {
|
||||
flex: 0 0 auto; display: flex; border-bottom: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.tab {
|
||||
flex: 1 1 0; cursor: pointer; user-select: none;
|
||||
background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||
color: var(--fg-dim); font-family: var(--mono);
|
||||
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
padding: 11px 6px 9px; display: flex; align-items: center;
|
||||
justify-content: center; gap: 6px; transition: color 0.15s ease;
|
||||
}
|
||||
.tab:hover { color: var(--fg-2); }
|
||||
.tab.active { color: var(--cyan); border-bottom-color: var(--cyan); }
|
||||
.tab .kbd { color: var(--fg-faint); font-size: 9px; }
|
||||
.tab .badge {
|
||||
min-width: 16px; padding: 0 4px; height: 14px; line-height: 14px;
|
||||
font-size: 9px; text-align: center; border-radius: 7px;
|
||||
background: var(--line-2); color: var(--fg-dim);
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.tab.active .badge { background: rgba(66,220,209,0.16); color: var(--cyan); }
|
||||
.tab .badge.flash { background: var(--cyan); color: var(--void); }
|
||||
|
||||
.pane-head {
|
||||
flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 7px 12px; border-bottom: 1px solid var(--line);
|
||||
font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--fg-dim); background: var(--surface-1);
|
||||
}
|
||||
.pane-head .copy {
|
||||
cursor: pointer; background: transparent; border: 1px solid var(--line-2);
|
||||
color: var(--fg-dim); border-radius: 2px; font-family: var(--mono);
|
||||
font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
padding: 2px 8px; transition: all 0.15s ease;
|
||||
}
|
||||
.pane-head .copy:hover { border-color: var(--cyan); color: var(--cyan); }
|
||||
.pane-head .copy.copied { border-color: var(--green); color: var(--green); }
|
||||
|
||||
.pane-wrap { flex: 1 1 auto; min-height: 0; position: relative; }
|
||||
.pane {
|
||||
position: absolute; inset: 0; overflow-y: auto;
|
||||
padding: 10px 12px; display: none;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
font-size: 12px; color: var(--fg-2);
|
||||
}
|
||||
.pane.active { display: block; }
|
||||
.pane > div { padding: 1px 0; }
|
||||
.pane .empty {
|
||||
color: var(--fg-faint); font-style: italic;
|
||||
}
|
||||
.pane .rule { color: var(--fg-faint); }
|
||||
/* new-line flash */
|
||||
.pane > div.fresh { animation: flash 0.9s ease; }
|
||||
@keyframes flash { from { background: rgba(66,220,209,0.12); } to { background: transparent; } }
|
||||
|
||||
/* persona pane structured render */
|
||||
#pane-persona .pk { color: var(--fg-dim); }
|
||||
#pane-persona .pv { color: var(--blue); }
|
||||
#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; }
|
||||
|
||||
/* thinking-pane per-turn dividers */
|
||||
.pane-turn {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 14px 0 8px; color: var(--fg-faint);
|
||||
font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
}
|
||||
.pane-turn:first-child { margin-top: 0; }
|
||||
.pane-turn::before, .pane-turn::after {
|
||||
content: ""; height: 1px; background: var(--line); flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* ---- safe live Markdown (transcript response + thinking) ---- */
|
||||
/* Renderer escapes first, then applies a whitelist subset; no raw HTML
|
||||
passthrough, link schemes restricted to http(s). See markdownSafe(). */
|
||||
.md-body { white-space: normal; }
|
||||
.md-body .md-p { margin: 0 0 7px; white-space: pre-wrap; }
|
||||
.md-body .md-p:last-child { margin-bottom: 0; }
|
||||
.md-body .md-h { margin: 10px 0 5px; color: var(--blue); font-weight: 600; letter-spacing: 0.01em; }
|
||||
.md-body .md-h1 { font-size: 15px; } .md-body .md-h2 { font-size: 14px; }
|
||||
.md-body .md-h3, .md-body .md-h4, .md-body .md-h5, .md-body .md-h6 { font-size: 13px; }
|
||||
.md-body strong { color: var(--fg); font-weight: 700; }
|
||||
.md-body em { color: var(--blue); font-style: italic; }
|
||||
.md-body code {
|
||||
background: var(--surface-3); color: var(--cyan);
|
||||
padding: 0 4px; border-radius: 2px; font-size: 0.92em;
|
||||
}
|
||||
.md-body pre.md-code {
|
||||
background: var(--surface-2); border: 1px solid var(--line);
|
||||
border-radius: 3px; padding: 8px 10px; margin: 7px 0;
|
||||
white-space: pre-wrap; word-break: break-word; color: var(--fg-2);
|
||||
}
|
||||
.md-body ul, .md-body ol { margin: 5px 0; padding-left: 20px; }
|
||||
.md-body li { margin: 2px 0; }
|
||||
.md-body .md-quote {
|
||||
border-left: 2px solid var(--line-2); padding-left: 10px;
|
||||
margin: 5px 0; color: var(--fg-dim);
|
||||
}
|
||||
.md-body a { color: var(--cyan); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.md-body a:hover { color: var(--blue); }
|
||||
|
||||
/* ---- status line ---- */
|
||||
#statusline {
|
||||
flex: 0 0 auto; height: 24px; display: flex; align-items: center;
|
||||
gap: 16px; padding: 0 14px; background: var(--surface-2);
|
||||
border-top: 1px solid var(--line); font-size: 10px; color: var(--fg-faint);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
#statusline .legend { display: flex; gap: 14px; flex: 1 1 auto; min-width: 0; overflow: hidden; }
|
||||
#statusline kbd {
|
||||
font-family: var(--mono); color: var(--fg-dim);
|
||||
border: 1px solid var(--line-2); border-radius: 2px;
|
||||
padding: 0 4px; font-size: 9px;
|
||||
}
|
||||
#version { flex: 0 0 auto; color: var(--fg-dim); }
|
||||
|
||||
/* ---- setup overlay ---- */
|
||||
#setup {
|
||||
position: fixed; inset: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: radial-gradient(80% 80% at 50% 30%, rgba(13,17,23,0.6), var(--void) 80%);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.setup-card {
|
||||
width: 440px; max-width: 90vw;
|
||||
background: linear-gradient(180deg, var(--surface-3), var(--surface-1));
|
||||
border: 1px solid var(--line-2); border-radius: 4px;
|
||||
padding: 30px 30px 26px; position: relative; overflow: hidden;
|
||||
animation: rise 0.4s ease both;
|
||||
}
|
||||
.setup-card .aurora { position: absolute; top: 0; left: 0; right: 0; }
|
||||
.setup-card h1 {
|
||||
margin: 6px 0 4px; font-size: 18px; font-weight: 600; letter-spacing: 0.02em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.setup-card h1 .glyph { color: var(--cyan); text-shadow: 0 0 14px var(--glow-cyan); }
|
||||
.setup-card .sub {
|
||||
margin: 0 0 22px; font-size: 11px; color: var(--fg-dim);
|
||||
letter-spacing: 0.04em; line-height: 1.6;
|
||||
}
|
||||
.field-label {
|
||||
display: block; font-size: 10px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--fg-dim); margin: 0 0 7px;
|
||||
}
|
||||
.select-wrap { position: relative; margin-bottom: 22px; }
|
||||
.select-wrap::after {
|
||||
content: "▾"; position: absolute; right: 12px; top: 50%;
|
||||
transform: translateY(-50%); color: var(--cyan); pointer-events: none; font-size: 11px;
|
||||
}
|
||||
#agent-picker {
|
||||
width: 100%; appearance: none; -webkit-appearance: none;
|
||||
background: var(--surface-1); color: var(--fg);
|
||||
border: 1px solid var(--line-2); border-radius: 3px;
|
||||
font-family: var(--mono); font-size: 13px; padding: 10px 34px 10px 12px;
|
||||
cursor: pointer; outline: none; transition: border-color 0.15s ease;
|
||||
}
|
||||
#agent-picker:focus { border-color: var(--cyan); }
|
||||
#start-btn {
|
||||
width: 100%; cursor: pointer;
|
||||
background: linear-gradient(180deg, rgba(66,220,209,0.16), rgba(66,220,209,0.06));
|
||||
color: var(--cyan); border: 1px solid var(--cyan); border-radius: 3px;
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; padding: 11px; font-weight: 600;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
#start-btn:hover { background: rgba(66,220,209,0.2); box-shadow: 0 0 22px var(--glow-cyan); }
|
||||
#start-btn:disabled { opacity: 0.4; cursor: wait; }
|
||||
.setup-err { color: var(--red); font-size: 11px; margin-top: 12px; min-height: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="aurora"></div>
|
||||
|
||||
<header id="topbar">
|
||||
<div class="brand">
|
||||
<span class="glyph">ᛯ</span>
|
||||
<span class="name">ratatoskr</span>
|
||||
<span class="tag">wire monitor</span>
|
||||
</div>
|
||||
<div class="conn" id="conn" data-state="">
|
||||
<span class="dot"></span><span id="conn-label">offline</span>
|
||||
</div>
|
||||
<div id="persona-strip"></div>
|
||||
<div class="spacer"></div>
|
||||
<div id="identity">—</div>
|
||||
</header>
|
||||
|
||||
<main id="workspace">
|
||||
<section class="conversation">
|
||||
<div id="transcript"></div>
|
||||
<div class="composer" id="composer">
|
||||
<span class="prompt-mark">❯</span>
|
||||
<input id="prompt-input" type="text" autocomplete="off"
|
||||
placeholder="Message — Enter sends, Shift+Enter newline" />
|
||||
<button id="send-btn">send</button>
|
||||
<button id="cancel-btn">cancel ⌃C</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="telemetry">
|
||||
<nav class="tabs" id="tabs">
|
||||
<button class="tab active" data-pane="tools">tools <span class="kbd">⌃1</span><span class="badge">0</span></button>
|
||||
<button class="tab" data-pane="debug">debug <span class="kbd">⌃2</span><span class="badge">0</span></button>
|
||||
<button class="tab" data-pane="thinking">think <span class="kbd">⌃3</span><span class="badge">0</span></button>
|
||||
<button class="tab" data-pane="persona">persona <span class="kbd">⌃4</span></button>
|
||||
</nav>
|
||||
<div class="pane-head">
|
||||
<span id="pane-name">tools</span>
|
||||
<button class="copy" id="copy-btn">copy</button>
|
||||
</div>
|
||||
<div class="pane-wrap">
|
||||
<div class="pane active" id="pane-tools"><div class="empty">no tool events yet</div></div>
|
||||
<div class="pane" id="pane-debug"><div class="empty">waiting for wire telemetry…</div></div>
|
||||
<div class="pane" id="pane-thinking"><div class="empty">no chain-of-thought captured yet</div></div>
|
||||
<div class="pane" id="pane-persona"><div class="empty">persona state loads on session open</div></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="setup">
|
||||
<div class="setup-card">
|
||||
<div class="aurora"></div>
|
||||
<h1><span class="glyph">ᛯ</span> ratatoskr-web</h1>
|
||||
<p class="sub">Internal-LAN debug companion to the Worldtree Conversation API. Pick an agent and open a session to begin watching the wire.</p>
|
||||
<label class="field-label" for="agent-picker">Agent</label>
|
||||
<div class="select-wrap">
|
||||
<select id="agent-picker"><option>loading…</option></select>
|
||||
</div>
|
||||
<button id="start-btn">open session</button>
|
||||
<div class="setup-err" id="setup-err"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer id="statusline">
|
||||
<div class="legend">
|
||||
<span><kbd>Enter</kbd> send</span>
|
||||
<span><kbd>⇧Enter</kbd> newline</span>
|
||||
<span><kbd>⌃1</kbd>–<kbd>⌃4</kbd> panes</span>
|
||||
<span><kbd>⌃C</kbd> cancel turn</span>
|
||||
</div>
|
||||
<div id="version">ratatoskr —</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// ratatoskr-web — vanilla JS client.
|
||||
// Five-pane debug surface. HTML-escapes ALL model/tool output (INV-004 —
|
||||
// model output is untrusted text; adversarial HTML must not execute).
|
||||
// Drift-detection contract: tests/fixtures/presentation_contract.json
|
||||
"use strict";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const state = { sessionId: null, agentId: null, turnId: null, eventSource: null };
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement("div");
|
||||
d.appendChild(document.createTextNode(String(s)));
|
||||
return d.innerHTML;
|
||||
}
|
||||
function ts() {
|
||||
const d = new Date();
|
||||
return d.toTimeString().slice(0, 8) + "." + String(d.getMilliseconds()).padStart(3, "0");
|
||||
}
|
||||
|
||||
// ---- safe live Markdown ------------------------------------------------
|
||||
// Model output is UNTRUSTED (INV-004). Strategy: escape EVERYTHING first
|
||||
// (esc neutralizes < > &), THEN apply a whitelist of markdown transforms
|
||||
// on the already-escaped text. No raw HTML ever passes through; link
|
||||
// hrefs are restricted to http(s) and a conservative charset so a
|
||||
// crafted URL can't break out of the attribute. Hand-rolled (no CDN,
|
||||
// no library) — a deliberately small subset for a debug surface.
|
||||
function mdInline(s) {
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
||||
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
||||
s = s.replace(/(^|[^_\w])_([^_\n]+)_/g, "$1<em>$2</em>");
|
||||
// [text](url) — http(s) only, conservative charset (no quotes/brackets/ws)
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, text, url) =>
|
||||
/^https?:\/\/[^\s"'<>)]+$/.test(url)
|
||||
? `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`
|
||||
: text);
|
||||
return s;
|
||||
}
|
||||
function markdownSafe(raw) {
|
||||
let s = esc(raw); // escape < > & FIRST
|
||||
const cb = [], ic = [];
|
||||
// fenced code blocks (no inline transforms inside)
|
||||
s = s.replace(/```[^\n`]*\n([\s\S]*?)```/g, (m, body) => { cb.push(body); return ` | ||||