T1a (Story-2840) shipped POST /app/agents/{agent_key} with a
static mira-converse mapping only: router_agents.py rejected
every key except "mira", and
MiraAgentLaunchRequest required matterId +
message with a hardcoded kind="converse". Three
of the four app rail builders — wizard intake, dock chat, mira command —
had no HTTP launch path, so T1b (Story-2842, app rail rewire) could not
migrate without breaking live surfaces. This Story extends the adapter so
all four builders launch over HTTP, keeping the frozen broker contract
clean: server-derived firm/user (RFD-AGT-013 D4), a static key mapping (no
generic registry), per-builder request DTOs with explicit validation, and
unknown keys still rejected with the typed 404.
| Route | Request DTO | Enqueued envelope |
|---|---|---|
POST /app/agents/atlas_intake_review |
AtlasIntakeReviewLaunchRequest: formId + structured
input (AtlasIntakeInput) + optional history/message. No matterId —
no matter exists at intake time. |
agent_key="atlas_intake_review", kind="intake",
form_id + input, no matter/chat session. |
POST /app/agents/atlas_dock_chat |
AtlasDockChatLaunchRequest: the dockChat
context envelope (DockChatContext — surface key + counsel
message + history; snake_case inner block, the same shape the app sends
on the WS rail). |
agent_key="atlas_dock_chat", kind="dock_chat",
dock_chat context verbatim, no matter/form/input. |
POST /app/agents/mira (converse) |
MiraAgentLaunchRequest, intent="converse"
(default) — today's shape, byte-compatible: the four pre-existing
integration cases stay green. |
agent_key="mira_matter_chat", kind="converse"
— unchanged. |
POST /app/agents/mira (command) |
MiraAgentLaunchRequest, intent="command" +
command + scope. No message, no frame. |
agent_key="mira_matter_chat", kind="mira_command",
matter + resolved chat session, input={matter_id, scope, command}. |
The WS broker rejects the command route even today
(pramaan.websocket.mira_command_unsupported,
router_ws.py). The HTTP endpoint deliberately does
not inherit that refusal — the command route is wired end to end:
land_mira_command_frame): the run needs
matter_id, input.scope,
input.command, and input.base_frame_version;
the agent_run_scope_present CHECK needs a chat session for
mira_matter_chat runs. So the HTTP adapter accepts
matterId + command + scope, resolves the matter's default
Mira session server-side, and enqueues exactly that.
current_frame/base_frame_version — tenant data
the client cannot be trusted to assert. The clerk worker's new
_stamp_command_input (mirroring the converse route's §5.1
ingest boundary) loads the matter's LATEST frame and pins
base_frame_version to it, so the landing's stale-base CAS
arbitrates against the version the command was actually built on. Empty
first run → the well-formed empty frame, proposal lands as v1.
intent and fails closed
without it (agents/mira/main.py); the worker's
agent_invoke.v1 build for kind="mira_command"
now sets intent="command" + agent_run_id, the
same pair the converse invoke sets. Without this the route would have
been "wired" into a runtime that rejects it — a vacuous smoke.
AgentCommand (the frozen queue contract) gained a
mira_command validator branch: requires
mira_matter_chat + matter + session +
input.{scope,command}; forbids form scope. Purely additive —
the combination was previously inexpressible, so no existing valid
envelope changes.
| Surface | Change |
|---|---|
src/app_api/router_agents.py |
Four static routes (mira, intake, dock, typed-404 catch-all) sharing
one launch tail: firm context, socket attach, prepare_launch
(run-id idempotency, input ceiling, rate limit, circuit breaker,
consent, blocked-run truth), clerk-rail enqueue. |
src/app_api/agent_launch_dtos.py |
Shared base (requestId/socketId/surfaceContext) + per-builder DTOs;
Mira DTO gains the converse/command intent matrix;
AgentLaunchResponse.chatSessionId becomes nullable (intake
and dock have no chat session). |
src/app_api/agent_launch_core.py |
attach_launch_socket — the Story-2896 attach seam as one
helper every route shares. |
src/shared/agent_commands.py |
mira_command launch validator branch (additive). |
src/clerk_worker/runner.py |
_load_matter_frame (shared by both stampers),
_stamp_command_input, and the
intent/agent_run_id routing pair on the command invoke. |
openapi/app-api.json |
Regenerated: three concrete paths added, catch-all retained. |
Every rail rides the shared prepare_launch: run-id
idempotency (agents.agent_run_key), input ceiling, per-firm/user
rate limit, circuit breaker, egress consent, and blocked-run truth writes.
Identity/firm/matter are server-derived only — no
firm_id/user_id in any request body.
| Layer | Coverage |
|---|---|
Integration (tests/integration/test_app_agents_launch.py) |
Per rail: intake happy path + deduped relaunch; dock happy path +
deduped relaunch + consent-blocked terminal truth (the
atlas_dock_chat scope-CHECK path); mira command happy
path (envelope shape, no client frame, default session resolved) +
deduped relaunch + cross-firm session refusal; unknown key → typed
404. The four pre-existing mira-converse cases stay green unchanged.
|
Unit (tests/unit/test_router_agents.py,
test_agent_launch_core.py,
test_agent_commands.py) |
Per-route envelope mapping; intent-matrix 422s; the
attach_launch_socket seam (explicit vs derived vs none);
the AgentCommand mira_command matrix.
|
Worker unit (tests/unit/clerk_worker/test_runner_mira_command.py) |
The command invoke carries intent="command" +
agent_run_id; the broker stamps the matter's latest frame
+ base version into the input and the landing receives the same
stamped input; empty-first-run degrades to the well-formed empty
frame.
|
_CLERK_RAIL_AGENT_KEYS).