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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user