feat(gateway): send_run_message and run_message_supported for the live-message route

Add the transport half of the fifth nanny verb to the Claudexor gateway:
POST /v2/runs/:id/messages with a REQUIRED Idempotency-Key (the caller's
message identity), an optional expectedAttemptId and an optional per-call
timeout. Like answer_interaction it never goes through _request: any body
carrying a typed outcome (LIVE_MESSAGE_OUTCOMES) is returned as the answer
whatever the HTTP status; every untyped refusal (the daemon's 404, the 409
idempotency problems, 400, 501, 5xx) is typed through _problem with its
status code so the verb classifies by code AND status; transport death is
daemon_unreachable; a 2xx without a typed outcome is malformed_response.

run_message_supported negotiates the route structurally from the engine's
/v2/operations catalog (Express-style template, like the control and
interaction-answer rows), so an engine older than the live-message release
never receives a POST whose 404 would read as "no such run".

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-26 08:10:21 +03:00
parent 90ce019b8e
commit e6130a7b19

View file

@ -358,6 +358,31 @@ def model_failure_evidence_supported(operations: list[dict]) -> bool:
name="captureFailureEvidence", value="true")
# The engine's typed live-message outcomes (``LiveMessageOutcome``): the two
# positive boundaries plus the four typed non-deliveries. Mirrored 1:1 by
# ``delegate_message``; the host adds only its own ``not_found`` vocabulary.
LIVE_MESSAGE_OUTCOMES = frozenset({
"delivered", "accepted", "rejected", "not_active", "unsupported", "delivery_unknown",
})
# The catalog spelling of the live-message route (Express-style template, the
# same shape as ``/v2/runs/:id/control`` and the interaction-answer row).
RUN_MESSAGE_OPERATION = ("POST", "/v2/runs/:id/messages")
def run_message_supported(operations: list[dict]) -> bool:
"""Does this engine's own route catalog list ``POST /v2/runs/:id/messages``?
Presence is negotiated structurally, like every other route here: an engine
older than the live-message release answers a route 404, which the verb must
never reach (a 404 is otherwise the daemon's "no such run").
"""
method, path = RUN_MESSAGE_OPERATION
return any(
operation.get("method") == method and operation.get("path") == path
for operation in operations if isinstance(operation, dict)
)
class ClaudexorGateway:
"""Thin typed client over the Claudexor ``/v2`` control API."""
@ -1000,6 +1025,63 @@ class ClaudexorGateway:
f"interaction answer returned no typed status (HTTP {response.status_code})",
)
def send_run_message(self, run_id: str, text: str, *, idempotency_key: str,
expected_attempt_id: str = "",
timeout_sec: Optional[float] = None) -> Dict[str, Any]:
"""POST /v2/runs/:id/messages — one live message into a running run.
``idempotency_key`` is REQUIRED and is the caller's message identity: the
engine serves the route through its idempotent-delivery store, so a replay
under the same key returns the stored receipt instead of delivering twice
(``delegate_message`` mints it as ``message_id`` and hands it back).
``expected_attempt_id`` pins the live attempt when a caller holds one.
Like ``answer_interaction`` this is transport, not translation, and it never
goes through ``_request`` (which raises on every status >= 400): any body
carrying a typed ``outcome`` (``LIVE_MESSAGE_OUTCOMES``) is returned as the
ANSWER it is, whatever the HTTP status. What raises ``ClaudexorUnavailable``:
transport failures (``daemon_unreachable``), and every refusal without a
typed outcome — the daemon's 404 ``no such run``, the 409 idempotency
problems (``idempotency_conflict`` / ``delivery_in_progress`` /
``delivery_interrupted``), 400 (malformed, secret, too long), 501 (no
service), 5xx — each typed through ``_problem`` with its status code, so the
verb classifies by code AND status. A 2xx without a typed outcome is
``malformed_response``.
"""
from urllib.parse import quote
path = f"/v2/runs/{quote(str(run_id), safe='')}/messages"
payload: Dict[str, Any] = {"text": str(text)}
if expected_attempt_id:
payload["expectedAttemptId"] = str(expected_attempt_id)
bound: Dict[str, Any] = {}
if timeout_sec is not None:
bounded = max(0.000001, float(timeout_sec))
bound = {"timeout": httpx.Timeout(bounded, connect=min(_CONNECT_TIMEOUT_SEC, bounded))}
try:
response = self._client.request(
"POST", path, json=payload,
headers={"Idempotency-Key": str(idempotency_key)}, **bound)
except httpx.HTTPError as exc:
raise ClaudexorUnavailable(
"daemon_unreachable",
f"Claudexor daemon unreachable: {type(exc).__name__}: {exc}",
) from exc
body: Any = None
if response.content:
try:
body = response.json()
except ValueError:
body = None
if isinstance(body, dict) and str(body.get("outcome") or "") in LIVE_MESSAGE_OUTCOMES:
return body
if response.status_code >= 400:
raise self._problem(response)
raise ClaudexorUnavailable(
"malformed_response",
f"run message returned no typed outcome (HTTP {response.status_code})",
)
def cancel_run(self, run_id: str, *, reason: str = "") -> Dict[str, Any]:
control: Dict[str, Any] = {"kind": "cancel"}
if reason: