mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[chore] mcp-servers: normalize exception bodies to Envelope + lift task_id/correlation_id on circuit_open (#232 #359 #57)
flow_server/do_server: the non-404 JSON path returned exception-handler bodies raw (dict `error` from roboco/generic/http exception handlers, or a 422 `detail` list) — neither is the Envelope wire format the agent is prompted to trust (string error kind + message + remediate + missing), so on any service/validation failure the agent got no remediate and flailed until the breaker tripped. _normalize_exception_envelope lifts the body into a real Envelope (code -> counted string kind via _classify_dict_error_code, NOT_FOUND -> not_found, message lifted, remediate synthesized, missing=[]; 422 -> incomplete_input with the validation detail preserved). The synthesized envelope still flows through the breaker so a 500/422 storm trips it. _record_and_check_circuit: the circuit_open substitution dropped task_id / correlation_id from the top level (the SDK's envelope omits them); lift them from the original rejection so the agent's envelope contract and ops audit-join of the trip event still work, not just nested in inner. intake_server._post_event: capture the relay response body under `detail` on non-success so the grok intake agent gets the real reason (e.g. 'session not in MegaTask scope' on a 422) instead of an opaque http_422 token with no remediation. TDD red->green; ruff + mypy clean; 157 mcp/SDK-breaker tests pass.
This commit is contained in:
@@ -147,6 +147,76 @@ def _classify_rejection(payload: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _remediate_for_kind(kind: str, verb: str) -> str:
|
||||
"""A directed recovery hint for a synthesized Envelope kind.
|
||||
|
||||
Mirrors flow_server: the orchestrator's exception handlers return a dict
|
||||
`error` with no `remediate`; without a hint the agent has no directed next
|
||||
action and flails until the breaker trips.
|
||||
"""
|
||||
if kind == "not_found":
|
||||
return (
|
||||
"the call targeted a resource that does not exist; re-fetch state"
|
||||
" and retry on a current id; do not retry the same id."
|
||||
)
|
||||
if kind == "incomplete_input":
|
||||
return (
|
||||
f"the {verb} call was rejected as incomplete input — re-issue it"
|
||||
f" with the missing/invalid fields (see `detail` for the exact"
|
||||
f" validation errors); do not retry blindly."
|
||||
)
|
||||
if kind == "not_authorized":
|
||||
return (
|
||||
f"you are not authorized for this {verb} action; use delegate /"
|
||||
f" escalate_up, or call i_am_blocked(reason=...) if the gate is"
|
||||
f" genuinely wrong; do not retry the same action."
|
||||
)
|
||||
return (
|
||||
f"service error on {verb} — re-fetch state and re-issue; call"
|
||||
f" i_am_blocked or i_am_idle if it persists; do not retry blindly."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_exception_envelope(
|
||||
payload: dict[str, Any], path: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Synthesize an Envelope-wire-format dict from an exception-handler body.
|
||||
|
||||
Mirrors flow_server (#232): FastAPI's exception handlers return ``error``
|
||||
as a DICT (``{code, message, details?}``) or a bare ``detail`` list (422) —
|
||||
neither is the Envelope wire format the agent trusts (string ``error`` +
|
||||
``message`` + ``remediate`` + ``missing``). This lifts the body into a real
|
||||
Envelope so the agent gets a directed remediate instead of flailing until
|
||||
the breaker trips. Returns None for a real Envelope (string ``error`` or
|
||||
success) so those pass through unchanged.
|
||||
"""
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
code = str(error.get("code") or "")
|
||||
kind = _classify_dict_error_code(code)
|
||||
if kind is None:
|
||||
kind = "not_found"
|
||||
verb = _verb_from_path(path)
|
||||
message = str(error.get("message") or "") or f"orchestrator error ({code})"
|
||||
return {
|
||||
"error": kind,
|
||||
"message": message,
|
||||
"remediate": _remediate_for_kind(kind, verb),
|
||||
"missing": [],
|
||||
"details": error,
|
||||
}
|
||||
if "detail" in payload and "error" not in payload:
|
||||
verb = _verb_from_path(path)
|
||||
return {
|
||||
"error": "incomplete_input",
|
||||
"message": f"the {verb} call was rejected as incomplete input",
|
||||
"remediate": _remediate_for_kind("incomplete_input", verb),
|
||||
"missing": [],
|
||||
"detail": payload.get("detail"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
mcp = FastMCP("roboco-do")
|
||||
log = structlog.get_logger()
|
||||
|
||||
@@ -255,6 +325,14 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
"missing": [],
|
||||
}
|
||||
# Outside the orchestrator client so the SDK call is its own connection.
|
||||
# A non-404 JSON body that is NOT a real Envelope (dict `error` from an
|
||||
# exception handler, or a 422 `detail` list) is normalized to the Envelope
|
||||
# wire format so the agent gets a string `error` kind + remediate (#232).
|
||||
# Mirrors flow_server; the synthesized Envelope still flows through the
|
||||
# breaker below — its string kind is in the counted set.
|
||||
normalized = _normalize_exception_envelope(payload, path)
|
||||
if normalized is not None:
|
||||
payload = normalized
|
||||
return _record_and_check_circuit(path, body, payload)
|
||||
|
||||
|
||||
@@ -326,6 +404,11 @@ def _record_and_check_circuit(
|
||||
# breaker tripped, not WHY the verb failed (#60). Mirrors flow_server.
|
||||
circuit_env: dict[str, Any] = dict(status["circuit_envelope"])
|
||||
circuit_env["inner"] = payload
|
||||
# Lift task_id/correlation_id from the original rejection to the top
|
||||
# level (the SDK's envelope omits them) so the agent's envelope contract
|
||||
# and ops audit-join still work — not just nested in `inner` (#359).
|
||||
circuit_env["task_id"] = payload.get("task_id")
|
||||
circuit_env["correlation_id"] = payload.get("correlation_id")
|
||||
log.info(
|
||||
"do_server: circuit_open substituted for rejection",
|
||||
verb=verb,
|
||||
|
||||
@@ -127,6 +127,92 @@ def _classify_dict_error_code(code: str) -> str | None:
|
||||
return "invalid_state"
|
||||
|
||||
|
||||
def _remediate_for_kind(kind: str, verb: str) -> str:
|
||||
"""A directed recovery hint for a synthesized Envelope kind.
|
||||
|
||||
The orchestrator's exception handlers return a dict `error` with no
|
||||
`remediate`; without a hint the agent has no directed next action and
|
||||
flails/respawn-loops until the breaker trips. This gives each counted kind
|
||||
(and not_found) a one-line remedy that mirrors the string-kind envelopes.
|
||||
"""
|
||||
if kind == "not_found":
|
||||
return (
|
||||
"the call targeted a resource that does not exist; re-fetch the"
|
||||
" task state (give_me_work / resume) and retry on a current id;"
|
||||
" do not retry the same id."
|
||||
)
|
||||
if kind == "incomplete_input":
|
||||
return (
|
||||
f"the {verb} call was rejected as incomplete input — re-issue it"
|
||||
f" with the missing/invalid fields (see `detail` for the exact"
|
||||
f" validation errors); do not retry blindly."
|
||||
)
|
||||
if kind == "not_authorized":
|
||||
return (
|
||||
f"you are not authorized for this {verb} action; use delegate /"
|
||||
f" escalate_up, or call i_am_blocked(reason=...) if the gate is"
|
||||
f" genuinely wrong; do not retry the same action."
|
||||
)
|
||||
# invalid_state + any fallback (service/INTERNAL_ERROR).
|
||||
return (
|
||||
f"service error on {verb} — the task may be in the wrong state for"
|
||||
f" this verb. Re-fetch state (resume / give_me_work) and re-issue;"
|
||||
f" call i_am_blocked or i_am_idle if it persists; do not retry blindly."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_exception_envelope(
|
||||
payload: dict[str, Any], path: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Synthesize an Envelope-wire-format dict from an exception-handler body.
|
||||
|
||||
FastAPI's exception handlers return ``error`` as a DICT
|
||||
(``{code, message, details?}`` from ``roboco_exception_handler`` /
|
||||
``http_exception_handler`` / ``generic_exception_handler``) or a bare
|
||||
``detail`` list (``request_validation_handler``, 422) — neither is the
|
||||
Envelope wire format the agent is prompted to trust (string ``error`` kind
|
||||
+ ``message`` + ``remediate`` + ``missing``). Returning either raw violates
|
||||
the contract: the agent has no ``remediate``/``next`` and flails until the
|
||||
breaker trips (#232). This lifts the dict/422 body into a real Envelope:
|
||||
|
||||
- dict ``error`` → map ``code`` via :func:`_classify_dict_error_code` to a
|
||||
counted string kind (NOT_FOUND → ``not_found``), lift ``message``, synthesize
|
||||
a ``remediate``, ``missing=[]``, and tuck the original body under
|
||||
``details`` for correlation traceability.
|
||||
- 422 ``detail`` (no ``error``) → ``error='incomplete_input'`` with the
|
||||
validation ``detail`` preserved so the agent sees WHICH fields failed.
|
||||
|
||||
Returns None when ``payload`` is already a real Envelope (string ``error``
|
||||
or success) so successful/string-kind rejections pass through unchanged.
|
||||
"""
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
code = str(error.get("code") or "")
|
||||
kind = _classify_dict_error_code(code)
|
||||
if kind is None:
|
||||
kind = "not_found"
|
||||
verb = _verb_from_path(path)
|
||||
message = str(error.get("message") or "") or f"orchestrator error ({code})"
|
||||
return {
|
||||
"error": kind,
|
||||
"message": message,
|
||||
"remediate": _remediate_for_kind(kind, verb),
|
||||
"missing": [],
|
||||
"details": error,
|
||||
}
|
||||
if "detail" in payload and "error" not in payload:
|
||||
# 422 request-validation body ({"detail": [...], "body": ...}).
|
||||
verb = _verb_from_path(path)
|
||||
return {
|
||||
"error": "incomplete_input",
|
||||
"message": f"the {verb} call was rejected as incomplete input",
|
||||
"remediate": _remediate_for_kind("incomplete_input", verb),
|
||||
"missing": [],
|
||||
"detail": payload.get("detail"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _classify_rejection(payload: dict[str, Any]) -> str | None:
|
||||
"""Return the breaker kind to forward for this payload, or None.
|
||||
|
||||
@@ -284,6 +370,14 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
# Outside the orchestrator client context so the SDK call is its own
|
||||
# connection — keeps semantics independent and timeouts separated.
|
||||
# A non-404 JSON body that is NOT a real Envelope (dict `error` from an
|
||||
# exception handler, or a 422 `detail` list) is normalized to the Envelope
|
||||
# wire format so the agent gets a string `error` kind + remediate (#232).
|
||||
# The synthesized Envelope still flows through the breaker below — its
|
||||
# string kind is in the counted set, so a 500/422 storm trips it.
|
||||
normalized = _normalize_exception_envelope(payload, path)
|
||||
if normalized is not None:
|
||||
payload = normalized
|
||||
return _record_and_check_circuit(path, body, payload)
|
||||
|
||||
|
||||
@@ -358,6 +452,12 @@ def _record_and_check_circuit(
|
||||
# verb failed, and the agent still needs the underlying hint (#60).
|
||||
circuit_env: dict[str, Any] = dict(status["circuit_envelope"])
|
||||
circuit_env["inner"] = payload
|
||||
# The SDK's circuit_envelope omits task_id/correlation_id; lift them
|
||||
# from the original rejection to the top level so the agent's envelope
|
||||
# contract (read top-level task_id/correlation_id) and ops audit-join of
|
||||
# the trip event still work — not just nested in `inner` (#359).
|
||||
circuit_env["task_id"] = payload.get("task_id")
|
||||
circuit_env["correlation_id"] = payload.get("correlation_id")
|
||||
log.info(
|
||||
"flow_server: circuit_open substituted for rejection",
|
||||
verb=verb,
|
||||
|
||||
@@ -20,6 +20,7 @@ provides ``ROBOCO_API_URL`` + ``ROBOCO_PROMPTER_SESSION_ID``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
@@ -55,7 +56,15 @@ async def _post_event(
|
||||
if owns:
|
||||
await http.aclose()
|
||||
if not resp.is_success:
|
||||
return {"error": f"http_{resp.status_code}"}
|
||||
# Capture the relay's body so the caller can surface the real reason
|
||||
# (e.g. 'session not in MegaTask scope' on a 422) instead of an opaque
|
||||
# `http_422` token with no remediation (#57). None when the body is
|
||||
# absent or not JSON.
|
||||
try:
|
||||
body = resp.json()
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
body = None
|
||||
return {"error": f"http_{resp.status_code}", "detail": body}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
@@ -273,13 +273,15 @@ def test_verb_extracted_from_path(do_module: types.ModuleType) -> None:
|
||||
assert sdk_body["verb"] == "commit"
|
||||
|
||||
|
||||
def test_dict_shaped_error_does_not_crash(do_module: types.ModuleType) -> None:
|
||||
def test_dict_shaped_error_normalized_to_envelope(do_module: types.ModuleType) -> None:
|
||||
"""A RobocoError.to_dict()-shaped response must not TypeError the breaker.
|
||||
|
||||
A dict-shaped `error` is a retry-storm-worthy rejection (the orchestrator's
|
||||
exception handlers surface this shape on 4xx/5xx), so the breaker must count
|
||||
it via the classifier rather than passing it through silently. The original
|
||||
dict payload still reaches the agent (the breaker only substitutes when open).
|
||||
it via the classifier. The dict body is normalized to an Envelope wire format
|
||||
(#232): the agent receives a string `error` kind (not the dict, which violates
|
||||
the Envelope contract) with the message lifted and a remediate. The breaker
|
||||
still counts it (the synthesized string kind is in the counted set).
|
||||
"""
|
||||
factory, captured = _make_client(
|
||||
orchestrator_response={
|
||||
@@ -299,13 +301,16 @@ def test_dict_shaped_error_does_not_crash(do_module: types.ModuleType) -> None:
|
||||
"circuit_envelope": None,
|
||||
},
|
||||
)
|
||||
# No TypeError; the dict-shaped rejection is forwarded to the SDK.
|
||||
# No TypeError; the dict-shaped rejection is normalized + forwarded to the SDK.
|
||||
with patch("httpx.Client", side_effect=factory):
|
||||
result = do_module.dm(recipient="qa-all", text="x")
|
||||
# Original dict payload still reaches the agent (breaker not open).
|
||||
assert isinstance(result["error"], dict)
|
||||
assert result["error"]["code"] == "A2A_ACCESS_DENIED"
|
||||
# SDK breaker MUST now be called so a storm of these counts.
|
||||
# Normalized to a string-kind Envelope — the dict `error` never reaches the
|
||||
# agent (#232). A2A_ACCESS_DENIED maps to not_authorized (exact-code, #161).
|
||||
assert result["error"] == "not_authorized"
|
||||
assert result["message"] == "be-qa cannot A2A with qa-all"
|
||||
assert isinstance(result["remediate"], str) and result["remediate"]
|
||||
assert result["missing"] == []
|
||||
# SDK breaker MUST still be called so a storm of these counts.
|
||||
sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url]
|
||||
assert len(sdk_calls) == 1
|
||||
# ACCESS_DENIED maps to the not_authorized counted kind.
|
||||
@@ -460,3 +465,123 @@ def test_missing_route_404_returns_envelope_and_counts(
|
||||
_, sdk_body = sdk_calls[0]
|
||||
assert sdk_body is not None
|
||||
assert sdk_body["rejection_kind"] == "invalid_state"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #232: a non-404 JSON exception-handler body (dict `error` / 422 `detail`)
|
||||
# must be normalized to an Envelope wire format before reaching the agent.
|
||||
# Mirrors flow_server.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_422_detail_normalized_to_incomplete_input_envelope(
|
||||
do_module: types.ModuleType,
|
||||
) -> None:
|
||||
"""A 422 body (`{"detail": [...]}`, no `error`) is normalized to an Envelope
|
||||
with `error='incomplete_input'`, a non-empty `remediate`, `missing=[]`, and
|
||||
the raw validation `detail` preserved. The breaker still counts it. Mirrors
|
||||
flow_server (#232)."""
|
||||
detail_body = [
|
||||
{"loc": ["body", "text"], "msg": "field required", "type": "missing"}
|
||||
]
|
||||
factory, captured = _make_client(
|
||||
orchestrator_response={"detail": detail_body, "body": None},
|
||||
sdk_response={
|
||||
"verb": "note",
|
||||
"task_id": None,
|
||||
"attempts": 1,
|
||||
"limit": 3,
|
||||
"window_seconds": 60,
|
||||
"open": False,
|
||||
"circuit_envelope": None,
|
||||
},
|
||||
)
|
||||
with patch("httpx.Client", side_effect=factory):
|
||||
result = do_module.note(text="")
|
||||
assert result["error"] == "incomplete_input"
|
||||
assert isinstance(result["remediate"], str) and result["remediate"]
|
||||
assert result["missing"] == []
|
||||
assert result["detail"] == detail_body
|
||||
sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url]
|
||||
assert len(sdk_calls) == 1
|
||||
assert sdk_calls[0][1]["rejection_kind"] == "incomplete_input"
|
||||
|
||||
|
||||
def test_dict_internal_error_normalized_to_invalid_state_envelope(
|
||||
do_module: types.ModuleType,
|
||||
) -> None:
|
||||
"""A dict-shaped INTERNAL_ERROR (generic_exception_handler) is normalized to
|
||||
`error='invalid_state'` with the message lifted + a remediate. Mirrors
|
||||
flow_server (#232)."""
|
||||
factory, captured = _make_client(
|
||||
orchestrator_response={
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "An internal error occurred",
|
||||
"details": {"correlation_id": "abc"},
|
||||
}
|
||||
},
|
||||
sdk_response={
|
||||
"verb": "commit",
|
||||
"task_id": None,
|
||||
"attempts": 1,
|
||||
"limit": 3,
|
||||
"window_seconds": 60,
|
||||
"open": False,
|
||||
"circuit_envelope": None,
|
||||
},
|
||||
)
|
||||
with patch("httpx.Client", side_effect=factory):
|
||||
result = do_module.commit(message="[abc12345] a valid commit message here")
|
||||
assert result["error"] == "invalid_state"
|
||||
assert result["message"] == "An internal error occurred"
|
||||
assert isinstance(result["remediate"], str) and result["remediate"]
|
||||
assert result["missing"] == []
|
||||
sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url]
|
||||
assert len(sdk_calls) == 1
|
||||
assert sdk_calls[0][1]["rejection_kind"] == "invalid_state"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #359: the circuit_open substitution lifts task_id/correlation_id from the
|
||||
# original rejection to the top-level envelope. Mirrors flow_server.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_circuit_open_lifts_task_id_and_correlation_id(
|
||||
do_module: types.ModuleType,
|
||||
) -> None:
|
||||
"""The SDK's circuit_envelope omits task_id/correlation_id; the substitution
|
||||
lifts them from the original rejection to the top level so the agent's
|
||||
envelope contract and ops audit-join still work. Mirrors flow_server (#359)."""
|
||||
circuit_env = {
|
||||
"error": "circuit_open",
|
||||
"message": "verb 'note' rejected too often (3 in 60s)",
|
||||
"remediate": "fix the missing fields once, then retry",
|
||||
"missing": [],
|
||||
}
|
||||
factory, _ = _make_client(
|
||||
orchestrator_response={
|
||||
"error": "incomplete_input",
|
||||
"missing": ["context"],
|
||||
"remediate": "fill context",
|
||||
"task_id": "T7",
|
||||
"correlation_id": "C7",
|
||||
},
|
||||
sdk_response={
|
||||
"verb": "note",
|
||||
"task_id": "T7",
|
||||
"attempts": 3,
|
||||
"limit": 3,
|
||||
"window_seconds": 60,
|
||||
"open": True,
|
||||
"circuit_envelope": circuit_env,
|
||||
},
|
||||
)
|
||||
with patch("httpx.Client", side_effect=factory):
|
||||
result = do_module.note(text="x", scope="decision")
|
||||
assert result["error"] == "circuit_open"
|
||||
assert result["task_id"] == "T7"
|
||||
assert result["correlation_id"] == "C7"
|
||||
assert result["inner"]["error"] == "incomplete_input"
|
||||
assert result["inner"]["task_id"] == "T7"
|
||||
|
||||
@@ -547,11 +547,13 @@ def test_dict_shaped_internal_error_counts_as_invalid_state(
|
||||
assert sdk_calls[0][1]["rejection_kind"] == "invalid_state"
|
||||
|
||||
|
||||
def test_dict_shaped_not_found_does_not_count(
|
||||
def test_dict_shaped_not_found_normalized_to_envelope_and_not_counted(
|
||||
flow_module: types.ModuleType,
|
||||
) -> None:
|
||||
"""A dict-shaped NOT_FOUND (404 family) does NOT count — parity with the
|
||||
string-error contract that a `not_found` rejection isn't counted.
|
||||
"""A dict-shaped NOT_FOUND (404 family) is normalized to an Envelope
|
||||
(`error="not_found"` string + lifted message + remediate) — the agent
|
||||
never sees a dict `error`, which violates the Envelope wire format — and
|
||||
the breaker is NOT touched (parity with the string `not_found` contract).
|
||||
"""
|
||||
factory, captured = _make_client(
|
||||
orchestrator_response={
|
||||
@@ -561,8 +563,13 @@ def test_dict_shaped_not_found_does_not_count(
|
||||
)
|
||||
with patch("httpx.Client", side_effect=factory):
|
||||
result = flow_module.i_am_done("task-A")
|
||||
assert isinstance(result["error"], dict)
|
||||
assert result["error"]["code"] == "TASK_NOT_FOUND"
|
||||
# Normalized to a string-kind Envelope — the dict `error` never reaches
|
||||
# the agent (#232).
|
||||
assert result["error"] == "not_found"
|
||||
assert result["message"] == "no such task"
|
||||
assert isinstance(result["remediate"], str) and result["remediate"]
|
||||
assert result["missing"] == []
|
||||
# NOT_FOUND still does not count toward the breaker.
|
||||
assert all("test-sdk" not in url for url, _ in captured)
|
||||
|
||||
|
||||
@@ -630,3 +637,165 @@ def test_missing_route_404_returns_envelope_and_counts(
|
||||
_, sdk_body = sdk_calls[0]
|
||||
assert sdk_body is not None
|
||||
assert sdk_body["rejection_kind"] == "invalid_state"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #232: a non-404 JSON exception-handler body (dict `error` / 422 `detail`)
|
||||
# must be normalized to an Envelope wire format before reaching the agent.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_exception_dict_500_normalized_to_invalid_state_envelope(
|
||||
flow_module: types.ModuleType,
|
||||
) -> None:
|
||||
"""A 500 with a dict-shaped error (generic_exception_handler) is normalized
|
||||
to an Envelope: top-level `error='invalid_state'` (string kind), the dict's
|
||||
`message` lifted to top-level `message`, a non-empty `remediate`, `missing=[]`.
|
||||
The agent never receives a dict `error` (which violates the Envelope contract
|
||||
it's prompted to trust). The breaker still counts it as `invalid_state`."""
|
||||
factory, captured = _make_client(
|
||||
orchestrator_response={
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "An internal error occurred",
|
||||
"details": {"correlation_id": "abc"},
|
||||
}
|
||||
},
|
||||
sdk_response={
|
||||
"verb": "i_am_done",
|
||||
"task_id": "task-A",
|
||||
"attempts": 1,
|
||||
"limit": 3,
|
||||
"window_seconds": 60,
|
||||
"open": False,
|
||||
"circuit_envelope": None,
|
||||
},
|
||||
)
|
||||
with patch("httpx.Client", side_effect=factory):
|
||||
result = flow_module.i_am_done("task-A")
|
||||
assert result["error"] == "invalid_state"
|
||||
assert result["message"] == "An internal error occurred"
|
||||
assert isinstance(result["remediate"], str) and result["remediate"]
|
||||
assert result["missing"] == []
|
||||
# The synthesized Envelope still flows through the breaker (string kind in
|
||||
# the counted set) so a 500 storm trips it.
|
||||
sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url]
|
||||
assert len(sdk_calls) == 1
|
||||
assert sdk_calls[0][1]["rejection_kind"] == "invalid_state"
|
||||
|
||||
|
||||
def test_422_detail_normalized_to_incomplete_input_envelope(
|
||||
flow_module: types.ModuleType,
|
||||
) -> None:
|
||||
"""A 422 request-validation body (`{"detail": [...]}`, no `error`) is
|
||||
normalized to an Envelope with `error='incomplete_input'`, a non-empty
|
||||
`remediate`, and `missing=[]`; the raw validation `detail` is preserved so
|
||||
the agent can see WHICH fields failed. The breaker still counts it."""
|
||||
detail_body = [
|
||||
{"loc": ["body", "task_id"], "msg": "field required", "type": "missing"}
|
||||
]
|
||||
factory, captured = _make_client(
|
||||
orchestrator_response={"detail": detail_body, "body": None},
|
||||
sdk_response={
|
||||
"verb": "i_am_done",
|
||||
"task_id": "task-A",
|
||||
"attempts": 1,
|
||||
"limit": 3,
|
||||
"window_seconds": 60,
|
||||
"open": False,
|
||||
"circuit_envelope": None,
|
||||
},
|
||||
)
|
||||
with patch("httpx.Client", side_effect=factory):
|
||||
result = flow_module.i_am_done("task-A")
|
||||
assert result["error"] == "incomplete_input"
|
||||
assert isinstance(result["remediate"], str) and result["remediate"]
|
||||
assert result["missing"] == []
|
||||
# The validation detail survives so the agent knows which fields to fix.
|
||||
assert result["detail"] == detail_body
|
||||
sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url]
|
||||
assert len(sdk_calls) == 1
|
||||
assert sdk_calls[0][1]["rejection_kind"] == "incomplete_input"
|
||||
|
||||
|
||||
def test_dict_authorized_normalized_to_not_authorized_envelope(
|
||||
flow_module: types.ModuleType,
|
||||
) -> None:
|
||||
"""A dict-shaped PERMISSION_DENIED maps to `error='not_authorized'` in the
|
||||
normalized Envelope (exact-code map, not a substring accident — #161), with
|
||||
the message lifted and a remediate. The breaker counts it as not_authorized."""
|
||||
factory, captured = _make_client(
|
||||
orchestrator_response={
|
||||
"error": {
|
||||
"code": "PERMISSION_DENIED",
|
||||
"message": "you may not merge that PR",
|
||||
"details": {},
|
||||
}
|
||||
},
|
||||
sdk_response={
|
||||
"verb": "i_am_done",
|
||||
"task_id": "task-A",
|
||||
"attempts": 1,
|
||||
"limit": 3,
|
||||
"window_seconds": 60,
|
||||
"open": False,
|
||||
"circuit_envelope": None,
|
||||
},
|
||||
)
|
||||
with patch("httpx.Client", side_effect=factory):
|
||||
result = flow_module.i_am_done("task-A")
|
||||
assert result["error"] == "not_authorized"
|
||||
assert result["message"] == "you may not merge that PR"
|
||||
assert isinstance(result["remediate"], str) and result["remediate"]
|
||||
assert result["missing"] == []
|
||||
sdk_calls = [(url, body) for url, body in captured if "test-sdk" in url]
|
||||
assert len(sdk_calls) == 1
|
||||
assert sdk_calls[0][1]["rejection_kind"] == "not_authorized"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #359: the circuit_open substitution lifts task_id/correlation_id from the
|
||||
# original rejection to the top-level envelope (the SDK's envelope omits them).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_circuit_open_lifts_task_id_and_correlation_id(
|
||||
flow_module: types.ModuleType,
|
||||
) -> None:
|
||||
"""When the breaker trips, the SDK's circuit_envelope carries no task_id /
|
||||
correlation_id, but the original rejection payload does (the orchestrator
|
||||
stamps them on every envelope). The substitution must lift them to the
|
||||
top-level so the agent's envelope contract (read top-level task_id /
|
||||
correlation_id) and ops audit-join still work — not leave them only nested
|
||||
in `inner`."""
|
||||
circuit_env: dict[str, Any] = {
|
||||
"error": "circuit_open",
|
||||
"message": "verb 'i_am_done' rejected 3 times in 60s — breaker open",
|
||||
"remediate": "call i_am_blocked or i_am_idle",
|
||||
}
|
||||
factory, _ = _make_client(
|
||||
orchestrator_response={
|
||||
"error": "tracing_gap",
|
||||
"remediate": "open the PR",
|
||||
"task_id": "T1",
|
||||
"correlation_id": "C1",
|
||||
},
|
||||
sdk_response={
|
||||
"verb": "i_am_done",
|
||||
"task_id": "T1",
|
||||
"attempts": 3,
|
||||
"limit": 3,
|
||||
"window_seconds": 60,
|
||||
"open": True,
|
||||
"circuit_envelope": circuit_env,
|
||||
},
|
||||
)
|
||||
with patch("httpx.Client", side_effect=factory):
|
||||
result = flow_module.i_am_done("task-A")
|
||||
assert result["error"] == "circuit_open"
|
||||
# Lifted from the original rejection to the top level (not just in inner).
|
||||
assert result["task_id"] == "T1"
|
||||
assert result["correlation_id"] == "C1"
|
||||
# The original rejection still survives nested.
|
||||
assert result["inner"]["error"] == "tracing_gap"
|
||||
assert result["inner"]["task_id"] == "T1"
|
||||
|
||||
@@ -70,7 +70,49 @@ async def test_post_draft_reports_http_error() -> None:
|
||||
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.post_draft("s", {}, client=client)
|
||||
assert result == {"error": "http_503"}
|
||||
# A no-body failure still surfaces the status; ``detail`` is None when the
|
||||
# body is absent/unparseable (#57 — the body is now captured, not dropped).
|
||||
assert result == {"error": "http_503", "detail": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_event_captures_body_on_non_success() -> None:
|
||||
"""#57: on a non-success response the relay's body carries the real reason
|
||||
(e.g. 'session not in MegaTask scope' on a 422); _post_event must capture it
|
||||
under ``detail`` so the intake agent gets actionable remediation instead of an
|
||||
opaque ``http_422`` token."""
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(422, json={"detail": "session not in MegaTask scope"})
|
||||
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.post_draft("s", {}, client=client)
|
||||
assert result["error"] == "http_422"
|
||||
assert result["detail"] == {"detail": "session not in MegaTask scope"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_batch_result_string_includes_relay_detail(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The propose_batch failure string surfaces the captured relay body so the
|
||||
grok intake agent can tell whether to re-shape the draft or surface to the
|
||||
CEO (#57). ``post_batch`` is stubbed to the dict _post_event now returns when
|
||||
the relay rejects with a body."""
|
||||
|
||||
async def _relay_rejected(
|
||||
_session_id: str, _payload: dict[str, Any], **_kw: Any
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"error": "http_422",
|
||||
"detail": {"detail": "session not in MegaTask scope"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(intake_server, "post_batch", _relay_rejected)
|
||||
monkeypatch.setenv("ROBOCO_PROMPTER_SESSION_ID", "sess-1")
|
||||
msg = await intake_server.propose_batch([{"title": "A"}], "MegaTask")
|
||||
assert "Could not submit the MegaTask to the panel" in msg
|
||||
assert "session not in MegaTask scope" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user