Preserve bio policy errors as a distinct non-retryable error (#46306)

## Why

Streaming `bio_policy` failures were classified as generic invalid requests, losing their policy-specific classification.

## What changed

- Add `BioPolicy` errors across the API and core protocol, recognizing streaming failures and HTTP 400 responses, including wrapped WebSocket errors.
- Preserve server messages and use a biological-risk fallback when the message is missing or blank.
- Treat bio policy errors as non-retryable in core and guardian handling, and classify them in diagnostics and telemetry.
- Map `BioPolicy` to `other` in the app-server v2 protocol.

## Testing

Add coverage for error classification, message preservation and fallbacks, HTTP and wrapped WebSocket responses, guardian retry decisions, and app-server conversion. Extend the core integration test to verify that bio policy failures emit a typed error and complete the turn after a single request.

GitOrigin-RevId: 78c2647e8fc8f80297cb8a23fff06ab141099632
This commit is contained in:
Rennie
2026-09-17 21:11:09 +00:00
committed by copyberry
parent 55db7e8c88
commit fa8cf44985
16 changed files with 146 additions and 24 deletions

View File

@@ -56,6 +56,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
ApiError::CyberPolicy { message } => {
CodexErr::new(CodexErrorDetails::CyberPolicy { message })
}
ApiError::BioPolicy { message } => CodexErr::new(CodexErrorDetails::BioPolicy { message }),
ApiError::MisalignmentPolicyViolation {
message,
misalignment,
@@ -117,16 +118,25 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
if status == http::StatusCode::BAD_REQUEST {
if let Ok(parsed) = serde_json::from_str::<Value>(&body_text)
&& let Some(error) = parsed.get("error")
&& error.get("code").and_then(Value::as_str)
== Some(CYBER_POLICY_ERROR_CODE)
&& let Some(code @ (CYBER_POLICY_ERROR_CODE | BIO_POLICY_ERROR_CODE)) =
error.get("code").and_then(Value::as_str)
{
let fallback_message = if code == BIO_POLICY_ERROR_CODE {
BIO_POLICY_FALLBACK_MESSAGE
} else {
CYBER_POLICY_FALLBACK_MESSAGE
};
let message = error
.get("message")
.and_then(Value::as_str)
.filter(|message| !message.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| CYBER_POLICY_FALLBACK_MESSAGE.to_string());
CodexErr::new(CodexErrorDetails::CyberPolicy { message })
.unwrap_or_else(|| fallback_message.to_string());
if code == BIO_POLICY_ERROR_CODE {
CodexErr::new(CodexErrorDetails::BioPolicy { message })
} else {
CodexErr::new(CodexErrorDetails::CyberPolicy { message })
}
} else if body_text
.contains("The image data you provided does not represent a valid image")
{
@@ -227,6 +237,8 @@ const X_ERROR_JSON_HEADER: &str = "x-error-json";
const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy";
const CYBER_POLICY_FALLBACK_MESSAGE: &str =
"This request has been flagged for possible cybersecurity risk.";
const BIO_POLICY_ERROR_CODE: &str = "bio_policy";
const BIO_POLICY_FALLBACK_MESSAGE: &str = "This content was flagged for possible biological risk.";
const MISALIGNMENT_POLICY_VIOLATION_ERROR_CODE: &str = "misalignment_policy_violation";
const MISALIGNMENT_POLICY_VIOLATION_FALLBACK_MESSAGE: &str =
"This request was blocked due to a misalignment policy violation.";

View File

@@ -181,6 +181,53 @@ fn map_api_error_uses_cyber_policy_fallback_for_missing_message() {
);
}
#[test]
fn map_api_error_preserves_bio_policy() {
let err = map_api_error(ApiError::BioPolicy {
message: "This request was blocked by bio policy.".to_string(),
});
assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::BioPolicy);
assert_eq!(err.to_string(), "This request was blocked by bio policy.");
assert!(!err.is_retryable());
}
#[test]
fn map_api_error_maps_http_and_wrapped_websocket_bio_policy() {
for wrapped in [false, true] {
for message in [
Some("This request was blocked by bio policy."),
None,
Some(""),
Some(" "),
] {
let mut body = serde_json::json!({"error": {"code": "bio_policy"}});
if let Some(message) = message {
body["error"]["message"] = serde_json::json!(message);
}
if wrapped {
body["type"] = serde_json::json!("error");
body["status"] = serde_json::json!(400);
}
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::BAD_REQUEST,
url: None,
headers: None,
body: Some(body.to_string()),
}));
let expected = message
.filter(|message| !message.trim().is_empty())
.unwrap_or("This content was flagged for possible biological risk.");
let CodexErrorDetails::BioPolicy { message } = err.details() else {
panic!("expected CodexErrorDetails::BioPolicy, got {err:?}");
};
assert_eq!(message, expected);
assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::BioPolicy);
assert!(!err.is_retryable());
}
}
}
#[test]
fn map_api_error_maps_misalignment_policy_violation_from_400_body() {
assert_misalignment_policy_violation_from_http_body(http::StatusCode::BAD_REQUEST);

View File

@@ -35,6 +35,8 @@ pub enum ApiError {
InvalidRequest { message: String },
#[error("cyber policy: {message}")]
CyberPolicy { message: String },
#[error("bio policy: {message}")]
BioPolicy { message: String },
#[error("misalignment policy violation: {message}")]
MisalignmentPolicyViolation {
message: String,

View File

@@ -429,6 +429,14 @@ pub fn process_responses_event(
} else if is_cyber_policy_error(&error) {
let message = cyber_policy_message(error.message);
response_error = ApiError::CyberPolicy { message };
} else if error.code.as_deref() == Some("bio_policy") {
let message = error
.message
.filter(|message| !message.trim().is_empty())
.unwrap_or_else(|| {
"This content was flagged for possible biological risk.".to_string()
});
response_error = ApiError::BioPolicy { message };
} else if error.code.as_deref() == Some("misalignment_policy_violation") {
let message = error
.message
@@ -443,8 +451,7 @@ pub fn process_responses_event(
serde_json::from_value::<MisalignmentErrorDetails>(details).ok()
}),
};
} else if matches!(error.code.as_deref(), Some("invalid_prompt" | "bio_policy"))
{
} else if error.code.as_deref() == Some("invalid_prompt") {
let message = error
.message
.unwrap_or_else(|| "Invalid request.".to_string());
@@ -1366,7 +1373,7 @@ mod tests {
}
#[tokio::test]
async fn content_policy_errors_without_type_are_invalid_requests() {
async fn content_policy_errors_without_type_preserve_their_classification() {
for (code, expected_message) in [
(
"invalid_prompt",
@@ -1396,8 +1403,9 @@ mod tests {
let events = collect_events(&[sse1.as_bytes()]).await;
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::InvalidRequest { message }) => {
match (code, &events[0]) {
("invalid_prompt", Err(ApiError::InvalidRequest { message }))
| ("bio_policy", Err(ApiError::BioPolicy { message })) => {
assert_eq!(message, expected_message);
}
other => panic!("unexpected event for {code}: {other:?}"),
@@ -1405,6 +1413,28 @@ mod tests {
}
}
#[tokio::test]
async fn bio_policy_error_uses_fallback_for_missing_or_blank_message() {
for message in [None, Some(""), Some(" ")] {
let mut event = json!({
"type": "response.failed",
"response": { "error": { "code": "bio_policy" } },
});
if let Some(message) = message {
event["response"]["error"]["message"] = json!(message);
}
let sse = format!("event: response.failed\ndata: {event}\n\n");
let events = collect_events(&[sse.as_bytes()]).await;
match events.as_slice() {
[Err(ApiError::BioPolicy { message })] => assert_eq!(
message,
"This content was flagged for possible biological risk."
),
other => panic!("unexpected events: {other:?}"),
}
}
}
#[tokio::test]
async fn table_driven_event_kinds() {
struct TestCase {