diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 9fdbfee193..ecffcabcb6 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -416,14 +416,17 @@ def build_anthropic_bedrock_client(region: str): """AnthropicBedrock client for Bedrock Claude models (boto3 default credential chain). The SDK's native Bedrock adapter gives full Claude feature parity (prompt caching, thinking budgets, adaptive thinking, fast mode) that Converse lacks. The common betas plus - ``context-1m-2025-08-07`` are attached: without the latter Bedrock caps Opus 4.6/4.7 at 200K.""" + ``context-1m-2025-08-07`` are attached: without the latter Bedrock caps Opus 4.6/4.7 at 200K. + A configured ``bedrock.guardrail`` rides as InvokeModel headers so every client built here + (primary, auxiliary, per-request rebuild) enforces it.""" + from agent.bedrock_adapter import bedrock_guardrail_headers sdk = _require_sdk("the Bedrock provider") if not hasattr(sdk, "AnthropicBedrock"): raise ImportError("anthropic.AnthropicBedrock not available. Upgrade with: pip install 'anthropic>=0.39.0'") return sdk.AnthropicBedrock( aws_region=region, timeout=_client_timeout(None), max_retries=0, # retry belongs to hermes's outer loop (honors Retry-After) - default_headers=_beta_header([*_COMMON_BETAS, _CONTEXT_1M_BETA]), + default_headers={**_beta_header([*_COMMON_BETAS, _CONTEXT_1M_BETA]), **bedrock_guardrail_headers()}, ) diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index cae396f266..0424a6321b 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -321,6 +321,33 @@ def bedrock_guardrail_config(config: Optional[Dict[str, Any]] = None) -> Optiona return out +def bedrock_guardrail_headers(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]: + """InvokeModel/Messages-wire form of the configured guardrail. The AnthropicBedrock SDK speaks + InvokeModel, which has no ``guardrailConfig`` body field; Bedrock reads the guardrail from these + headers instead (same enforcement, keeps prompt caching / thinking / 1M context).""" + gr = bedrock_guardrail_config(config) + if not gr: + return {} + headers = { + "X-Amzn-Bedrock-GuardrailIdentifier": str(gr["guardrailIdentifier"]), + "X-Amzn-Bedrock-GuardrailVersion": str(gr["guardrailVersion"]), + } + if str(gr.get("trace", "")).lower() in {"enabled", "enabled_full", "true"}: + headers["X-Amzn-Bedrock-Trace"] = "ENABLED" + return headers + + +GUARDRAIL_ACTION_FIELD = "amazon-bedrock-guardrailAction" + + +def anthropic_response_guardrail_intervened(response: Any) -> bool: + """True when Bedrock substituted the InvokeModel reply with guardrail messaging. Unlike Converse + (``stopReason=guardrail_intervened``), InvokeModel keeps ``stop_reason=end_turn`` and signals the + block only via an unmodelled body field the Anthropic SDK keeps in ``model_extra``.""" + extra = getattr(response, "model_extra", None) or {} + return str(extra.get(GUARDRAIL_ACTION_FIELD, "")).upper() == "INTERVENED" + + def bind_bedrock_runtime(agent, base_url: str, api_mode: str) -> None: """Point *agent* at a non-Mantle Bedrock wire: ``bedrock_converse`` (boto3 direct, no SDK client) or ``anthropic_messages`` (AnthropicBedrock SDK, SigV4 via the boto3 chain). ``aws-sdk`` is a sentinel, diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py index 978a0d4af2..ee39e1dfa3 100644 --- a/agent/transports/anthropic.py +++ b/agent/transports/anthropic.py @@ -97,11 +97,20 @@ class AnthropicTransport(ProviderTransport): provider_data["anthropic_content_blocks"] = ordered_blocks return NormalizedResponse( content="\n".join(text_parts) if text_parts else None, tool_calls=tool_calls or None, - finish_reason=self.map_finish_reason(response.stop_reason), + finish_reason=self.response_finish_reason(response), reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None, usage=None, provider_data=provider_data or None, ) + def response_finish_reason(self, response: Any) -> str: + """``stop_reason`` mapped to the OpenAI vocabulary. Bedrock InvokeModel guardrail blocks keep + ``stop_reason=end_turn`` and hand back the guardrail's canned text as an ordinary reply; they + must surface as ``content_filter`` so the loop treats them as a refusal, not model output.""" + from agent.bedrock_adapter import anthropic_response_guardrail_intervened + if anthropic_response_guardrail_intervened(response): + return "content_filter" + return self.map_finish_reason(response.stop_reason) + def validate_response(self, response: Any) -> bool: """Structural check; empty content is legitimate for ``end_turn``/``refusal`` (retrying either would loop forever).""" diff --git a/agent/turn_response_check.py b/agent/turn_response_check.py index 07dab60ceb..a0847ae1cb 100644 --- a/agent/turn_response_check.py +++ b/agent/turn_response_check.py @@ -67,7 +67,7 @@ def _derive_finish_reason(agent: Any, response: Any, messages: Any) -> str: return _codex_finish_reason(response) transport = agent._get_transport() if agent.api_mode == "anthropic_messages": - return transport.map_finish_reason(response.stop_reason) + return transport.response_finish_reason(response) normalized = transport.normalize_response(response) # Bedrock already normalized at dispatch finish_reason = normalized.finish_reason if agent.api_mode != "bedrock_converse" and agent._should_treat_stop_as_truncated( diff --git a/tests/agent/test_bedrock_guardrail_claude_route.py b/tests/agent/test_bedrock_guardrail_claude_route.py new file mode 100644 index 0000000000..4b5dfaa8d3 --- /dev/null +++ b/tests/agent/test_bedrock_guardrail_claude_route.py @@ -0,0 +1,32 @@ +"""``bedrock.guardrail`` must be enforced on the Claude route, not only Converse. The AnthropicBedrock +SDK speaks InvokeModel, whose body has no ``guardrailConfig``; Bedrock reads the guardrail from +``X-Amzn-Bedrock-Guardrail*`` headers. Blocks come back with ``stop_reason=end_turn`` and the +guardrail's canned text, flagged only by ``amazon-bedrock-guardrailAction`` in the body.""" + +from types import SimpleNamespace +from unittest.mock import patch + +CFG = {"bedrock": {"guardrail": {"guardrail_identifier": "gr-1", "guardrail_version": "DRAFT", "trace": "enabled"}}} + + +def test_anthropic_bedrock_client_carries_configured_guardrail_headers(): + with patch("hermes_cli.config.load_config_readonly", return_value=CFG): + from agent.anthropic_adapter import build_anthropic_bedrock_client + client = build_anthropic_bedrock_client("us-east-2") + headers = {k.lower(): v for k, v in client.default_headers.items()} + assert headers["x-amzn-bedrock-guardrailidentifier"] == "gr-1" + assert headers["x-amzn-bedrock-guardrailversion"] == "DRAFT" + assert headers["x-amzn-bedrock-trace"] == "ENABLED" + assert "anthropic-beta" in headers, "guardrail headers must not displace the beta header" + + +def test_guardrail_intervened_invokemodel_reply_is_content_filter_not_model_text(): + from agent.transports.anthropic import AnthropicTransport + transport = AnthropicTransport() + blocked = SimpleNamespace( + stop_reason="end_turn", content=[SimpleNamespace(type="text", text="BLOCKED_BY_GUARDRAIL_INPUT")], + model_extra={"amazon-bedrock-guardrailAction": "INTERVENED"}, + ) + clean = SimpleNamespace(stop_reason="end_turn", content=[SimpleNamespace(type="text", text="hi")], model_extra={}) + assert transport.response_finish_reason(blocked) == "content_filter" + assert transport.response_finish_reason(clean) == "stop" diff --git a/website/docs/guides/aws-bedrock.md b/website/docs/guides/aws-bedrock.md index 4aed045cef..348dd17306 100644 --- a/website/docs/guides/aws-bedrock.md +++ b/website/docs/guides/aws-bedrock.md @@ -86,6 +86,10 @@ bedrock: trace: "disabled" # "enabled", "disabled", or "enabled_full" ``` +The guardrail is attached on the Converse route (`guardrailConfig`) and on the Claude route (InvokeModel headers via the Anthropic Bedrock SDK, so prompt caching and thinking are kept). A blocked request surfaces as a content-filter refusal rather than as model text. `stream_processing_mode` only applies to Converse. + +AWS does not apply Guardrails to the Mantle Responses endpoint used by `openai.gpt-5.x` models ([AWS docs](https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html)); use a Converse-served model when a guardrail is required. + ### Model Discovery Hermes auto-discovers available models via the Bedrock control plane. You can customize discovery: