mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
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:
parent
90ce019b8e
commit
e6130a7b19
1 changed files with 82 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue