diff --git a/agent/agent_init.py b/agent/agent_init.py index 0c700c279b980..89ed00700c012 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -273,6 +273,32 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An agent.request_overrides = overrides +def _bedrock_invokemodel_guardrail_headers(gr: Dict[str, Any]) -> Optional[Dict[str, str]]: + """Build the ``X-Amzn-Bedrock-Guardrail*`` InvokeModel headers from the + raw ``bedrock.guardrail`` config dict, or ``None`` if incomplete. + + Extracted as a pure function so the trace-enum handling is unit + testable without spinning up a full ``init_agent`` (the Converse path + builds its ``guardrailConfig`` body param the same way, just without a + header — see the ``bedrock_converse`` branch below). + """ + if not (gr.get("guardrail_identifier") and gr.get("guardrail_version")): + return None + headers = { + "X-Amzn-Bedrock-GuardrailIdentifier": gr["guardrail_identifier"], + "X-Amzn-Bedrock-GuardrailVersion": str(gr["guardrail_version"]), + } + trace = gr.get("trace") + if trace: + # Preserve the configured enum verbatim (uppercased to match the + # header's expected casing) — "disabled" and "enabled_full" are + # documented values too, not just "enabled"; collapsing all of them + # to "ENABLED" would unexpectedly turn tracing on for "disabled" and + # lose the "enabled_full" verbosity level. + headers["X-Amzn-Bedrock-Trace"] = str(trace).upper() + return headers + + def init_agent( agent, base_url: str = None, @@ -787,8 +813,20 @@ def init_agent( agent.api_key = "aws-sdk" agent.client = None agent._client_kwargs = {} + # Guardrail config for Bedrock Claude via InvokeModel headers. + # The Converse API uses guardrailConfig body param; InvokeModel uses + # X-Amzn-Bedrock-Guardrail* HTTP headers — same enforcement, same + # guarantee, preserves prompt caching / thinking / 1M context. + agent._bedrock_guardrail_headers = None + try: + from hermes_cli.config import load_config as _load_gr_cfg + _gr = _load_gr_cfg().get("bedrock", {}).get("guardrail", {}) + agent._bedrock_guardrail_headers = _bedrock_invokemodel_guardrail_headers(_gr) + except Exception: + pass if not agent.quiet_mode: - print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock + AnthropicBedrock SDK, {_br_region})") + _gr_label = " + Guardrails" if agent._bedrock_guardrail_headers else "" + print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock + AnthropicBedrock SDK, {_br_region}{_gr_label})") else: # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own API key. diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 689d01010ad60..113e6e034b68c 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2466,6 +2466,7 @@ def build_anthropic_kwargs( base_url: str | None = None, fast_mode: bool = False, drop_context_1m_beta: bool = False, + bedrock_guardrail_headers: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: """Build kwargs for anthropic.messages.create(). @@ -2694,6 +2695,15 @@ def _to_oauth_wire_name(name: str) -> str: betas.append(_FAST_MODE_BETA) kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)} + if bedrock_guardrail_headers: + # Merge Bedrock guardrail headers without overwriting an existing + # extra_headers dict (fast_mode may have already set anthropic-beta). + # Header keys are disjoint: X-Amzn-Bedrock-Guardrail* vs anthropic-beta. + kwargs["extra_headers"] = { + **kwargs.get("extra_headers", {}), + **bedrock_guardrail_headers, + } + return kwargs diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 45a88f8decf0d..3f3cd8cb04d70 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -840,6 +840,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: base_url=getattr(agent, "_anthropic_base_url", None), fast_mode=(agent.request_overrides or {}).get("speed") == "fast", drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False)), + bedrock_guardrail_headers=getattr(agent, "_bedrock_guardrail_headers", None), ) # AWS Bedrock native Converse API — bypasses the OpenAI client entirely. diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py index 98721f7c5e638..c1b798eeee634 100644 --- a/agent/transports/anthropic.py +++ b/agent/transports/anthropic.py @@ -75,6 +75,7 @@ def build_kwargs( base_url=params.get("base_url"), fast_mode=params.get("fast_mode", False), drop_context_1m_beta=params.get("drop_context_1m_beta", False), + bedrock_guardrail_headers=params.get("bedrock_guardrail_headers"), ) def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: @@ -216,7 +217,9 @@ def validate_response(self, response: Any) -> bool: if not isinstance(content_blocks, list): return False if not content_blocks: - return getattr(response, "stop_reason", None) in {"end_turn", "refusal"} + return getattr(response, "stop_reason", None) in { + "end_turn", "refusal", "guardrail_intervened", + } return True def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]: @@ -238,6 +241,8 @@ def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]: "stop_sequence": "stop", "refusal": "content_filter", "model_context_window_exceeded": "length", + # Bedrock guardrail blocked the request via InvokeModel headers + "guardrail_intervened": "content_filter", } def map_finish_reason(self, raw_reason: str) -> str: diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index a3a26b268d069..98dbb59849a45 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1972,9 +1972,12 @@ def resolve_runtime_provider( guardrail_config["streamProcessingMode"] = _gr["stream_processing_mode"] if _gr.get("trace"): guardrail_config["trace"] = _gr["trace"] - # Dual-path routing: Claude models use AnthropicBedrock SDK for full - # feature parity (prompt caching, thinking budgets, adaptive thinking). - # Non-Claude models use the Converse API for multi-model support. + # Dual-path routing: + # - Claude models → AnthropicBedrock SDK (InvokeModel) → anthropic_messages path. + # Full feature parity: prompt caching, thinking budgets, 1M context. + # Guardrails are enforced via X-Amzn-Bedrock-Guardrail* HTTP headers injected + # into every InvokeModel request (see agent_init.py + anthropic_adapter.py). + # - Non-Claude models → boto3 Converse API → bedrock_converse path. _current_model = str(target_model or model_cfg.get("default") or "").strip() if is_anthropic_bedrock_model(_current_model): # Claude on Bedrock → AnthropicBedrock SDK → anthropic_messages path @@ -1989,7 +1992,7 @@ def resolve_runtime_provider( "requested_provider": requested_provider, } else: - # Non-Claude (Nova, DeepSeek, Llama, etc.) → Converse API + # Non-Claude (Nova, DeepSeek, Llama, etc.) → Converse API. runtime = { "provider": "bedrock", "api_mode": "bedrock_converse", @@ -1999,8 +2002,6 @@ def resolve_runtime_provider( "region": region, "requested_provider": requested_provider, } - if guardrail_config: - runtime["guardrail_config"] = guardrail_config return runtime # API-key providers (z.ai/GLM, Kimi, MiniMax, MiniMax-CN) diff --git a/hermes_logging.py b/hermes_logging.py index fb5065e87cee5..c819ced9778ee 100644 --- a/hermes_logging.py +++ b/hermes_logging.py @@ -62,9 +62,17 @@ # module (class declaration, ``isinstance`` checks, docstring) working # unchanged. See #44873. if sys.platform == "win32": - from concurrent_log_handler import ( # noqa: E402 - ConcurrentRotatingFileHandler as RotatingFileHandler, - ) + try: + from concurrent_log_handler import ( # noqa: E402 + ConcurrentRotatingFileHandler as RotatingFileHandler, + ) + except ImportError: + # concurrent-log-handler is a declared core dependency on Windows + # (see pyproject.toml #44873). If it is somehow absent (e.g. a + # partial install or a stripped dev environment), fall back to the + # stdlib handler. Log rotation may fail with WinError 32 under + # concurrent writers, but the rest of the application stays functional. + from logging.handlers import RotatingFileHandler # noqa: E402 else: from logging.handlers import RotatingFileHandler # noqa: E402 diff --git a/tests/agent/test_bedrock_integration.py b/tests/agent/test_bedrock_integration.py index d8840bc979e4f..6f9b3390b8296 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -680,3 +680,344 @@ def test_bedrock_converse_shim_stream_returns_complete_response(self, monkeypatc # got-final-object downgrade path handles the rest. assert resp is sentinel assert mock_converse.call_count == 1 + + +# --------------------------------------------------------------------------- +# Guardrail-aware dual-path routing (PR #50773 fix) +# --------------------------------------------------------------------------- + +class TestBedrockGuardrailRouting: + """Verify Claude models always use anthropic_messages (AnthropicBedrock SDK) + regardless of guardrail configuration. + + Option B architecture: guardrails for Claude+Bedrock are enforced via + X-Amzn-Bedrock-Guardrail* HTTP headers injected into every InvokeModel + request, NOT by rerouting to the Converse API. This preserves all Claude + features: prompt caching, thinking budgets, 1M context. + + Non-Claude models continue to use the Converse API (bedrock_converse). + """ + + _GUARDRAIL_CFG = { + "bedrock": { + "guardrail": { + "guardrail_identifier": "gr-abc123", + "guardrail_version": "1", + } + } + } + + def _resolve(self, monkeypatch, model: str, config: dict): + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_provider", + lambda *a, **k: "bedrock", + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider._get_model_config", + lambda: {"provider": "bedrock", "default": model}, + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.load_config", + lambda: config, + ) + monkeypatch.setattr( + "agent.bedrock_adapter.has_aws_credentials", + lambda **_: True, + ) + from hermes_cli.runtime_provider import resolve_runtime_provider + return resolve_runtime_provider(requested="bedrock") + + def test_claude_without_guardrail_uses_anthropic_messages(self, monkeypatch): + """Claude + no guardrail → AnthropicBedrock SDK path (full feature parity).""" + resolved = self._resolve( + monkeypatch, + model="us.anthropic.claude-sonnet-4-6", + config={"bedrock": {}}, + ) + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["bedrock_anthropic"] is True + + def test_claude_with_guardrail_stays_on_anthropic_messages(self, monkeypatch): + """Claude + guardrail → still uses anthropic_messages (Option B: header injection). + + Guardrails are enforced via X-Amzn-Bedrock-Guardrail* headers in InvokeModel, + NOT by rerouting to Converse. This preserves prompt caching / thinking / 1M ctx. + """ + resolved = self._resolve( + monkeypatch, + model="us.anthropic.claude-sonnet-4-6", + config=self._GUARDRAIL_CFG, + ) + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["bedrock_anthropic"] is True + + def test_global_inference_profile_with_guardrail_stays_on_anthropic_messages( + self, monkeypatch + ): + """global.anthropic.* inference profile + guardrail also stays on anthropic_messages.""" + resolved = self._resolve( + monkeypatch, + model="global.anthropic.claude-opus-4-7", + config=self._GUARDRAIL_CFG, + ) + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["bedrock_anthropic"] is True + + def test_non_claude_with_guardrail_stays_on_bedrock_converse(self, monkeypatch): + """Non-Claude model + guardrail → Converse (existing behaviour, no regression).""" + resolved = self._resolve( + monkeypatch, + model="amazon.nova-pro-v1:0", + config=self._GUARDRAIL_CFG, + ) + assert resolved["api_mode"] == "bedrock_converse" + + def test_non_claude_without_guardrail_stays_on_bedrock_converse(self, monkeypatch): + """Non-Claude model + no guardrail → Converse (unchanged behaviour).""" + resolved = self._resolve( + monkeypatch, + model="amazon.nova-lite-v1:0", + config={"bedrock": {}}, + ) + assert resolved["api_mode"] == "bedrock_converse" + + def test_incomplete_guardrail_config_does_not_trigger_reroute(self, monkeypatch): + """Guardrail with identifier but no version is incomplete → stays on anthropic_messages.""" + resolved = self._resolve( + monkeypatch, + model="us.anthropic.claude-sonnet-4-6", + config={"bedrock": {"guardrail": {"guardrail_identifier": "gr-abc123"}}}, + ) + assert resolved["api_mode"] == "anthropic_messages" + + +class TestBedrockInvokeModelGuardrailTraceEnum: + """``_bedrock_invokemodel_guardrail_headers`` must preserve the configured + ``bedrock.guardrail.trace`` enum verbatim (just uppercased), not collapse + every truthy value to "ENABLED". "enabled", "disabled", and "enabled_full" + are all documented values (website/docs/guides/aws-bedrock.md).""" + + _BASE_CFG = { + "guardrail_identifier": "gr-abc123", + "guardrail_version": "1", + } + + @pytest.mark.parametrize("configured,expected_header", [ + ("enabled", "ENABLED"), + ("disabled", "DISABLED"), + ("enabled_full", "ENABLED_FULL"), + ]) + def test_preserves_each_documented_trace_value(self, configured, expected_header): + from agent.agent_init import _bedrock_invokemodel_guardrail_headers + + headers = _bedrock_invokemodel_guardrail_headers( + {**self._BASE_CFG, "trace": configured} + ) + assert headers["X-Amzn-Bedrock-Trace"] == expected_header + + def test_no_trace_configured_omits_header(self): + from agent.agent_init import _bedrock_invokemodel_guardrail_headers + + headers = _bedrock_invokemodel_guardrail_headers(dict(self._BASE_CFG)) + assert "X-Amzn-Bedrock-Trace" not in headers + + def test_incomplete_config_returns_none(self): + from agent.agent_init import _bedrock_invokemodel_guardrail_headers + + assert _bedrock_invokemodel_guardrail_headers( + {"guardrail_identifier": "gr-abc123"} + ) is None + assert _bedrock_invokemodel_guardrail_headers({}) is None + + +class TestBedrockGuardrailConfigToClient: + """End-to-end: a real config.yaml under a temp HERMES_HOME, read through + the real config-loading pipeline, must produce the correct + X-Amzn-Bedrock-Trace value in the kwargs handed to the AnthropicBedrock + SDK call. Hermes routes both streaming and non-streaming Claude/Bedrock + turns through the same ``build_api_kwargs`` -> ``build_anthropic_kwargs`` + -> single ``messages.stream(**api_kwargs)`` call site (see + agent/chat_completion_helpers.py), so one kwargs dict covers both.""" + + @pytest.fixture + def isolated_home(self, monkeypatch, tmp_path): + import os + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + for k in list(os.environ.keys()): + if k.endswith("_API_KEY") or k.endswith("_TOKEN"): + monkeypatch.delenv(k, raising=False) + return hermes_home + + def _write_config(self, home, trace_value: str) -> None: + (home / "config.yaml").write_text(f""" +bedrock: + guardrail: + guardrail_identifier: gr-abc123 + guardrail_version: "1" + trace: "{trace_value}" +""") + + @pytest.mark.parametrize("configured,expected_header", [ + ("enabled", "ENABLED"), + ("disabled", "DISABLED"), + ("enabled_full", "ENABLED_FULL"), + ]) + def test_config_file_trace_value_reaches_invocation_kwargs( + self, isolated_home, configured, expected_header + ): + from hermes_cli.config import load_config + from agent.agent_init import _bedrock_invokemodel_guardrail_headers + from agent.anthropic_adapter import build_anthropic_kwargs + + self._write_config(isolated_home, configured) + gr = load_config().get("bedrock", {}).get("guardrail", {}) + headers = _bedrock_invokemodel_guardrail_headers(gr) + + kwargs = build_anthropic_kwargs( + model="anthropic.claude-sonnet-4-6", + messages=[{"role": "user", "content": "hi"}], + tools=None, + max_tokens=1024, + reasoning_config=None, + bedrock_guardrail_headers=headers, + ) + assert kwargs["extra_headers"]["X-Amzn-Bedrock-Trace"] == expected_header + + +class TestBedrockGuardrailHeaderInjection: + """Verify guardrail headers are injected correctly into build_anthropic_kwargs. + + These tests exercise the transport layer (Option B architecture): + X-Amzn-Bedrock-GuardrailIdentifier and X-Amzn-Bedrock-GuardrailVersion + are passed as extra_headers in the AnthropicBedrock SDK call. + """ + + def test_guardrail_headers_appear_in_extra_headers(self): + """build_anthropic_kwargs merges guardrail headers into extra_headers.""" + from agent.anthropic_adapter import build_anthropic_kwargs + + headers = { + "X-Amzn-Bedrock-GuardrailIdentifier": "gr-abc123", + "X-Amzn-Bedrock-GuardrailVersion": "1", + } + kwargs = build_anthropic_kwargs( + model="anthropic.claude-sonnet-4-6", + messages=[{"role": "user", "content": "hi"}], + tools=None, + max_tokens=1024, + reasoning_config=None, + bedrock_guardrail_headers=headers, + ) + assert "extra_headers" in kwargs + assert kwargs["extra_headers"]["X-Amzn-Bedrock-GuardrailIdentifier"] == "gr-abc123" + assert kwargs["extra_headers"]["X-Amzn-Bedrock-GuardrailVersion"] == "1" + + def test_guardrail_trace_header_included_when_set(self): + """X-Amzn-Bedrock-Trace header is included when trace is enabled.""" + from agent.anthropic_adapter import build_anthropic_kwargs + + headers = { + "X-Amzn-Bedrock-GuardrailIdentifier": "gr-abc123", + "X-Amzn-Bedrock-GuardrailVersion": "2", + "X-Amzn-Bedrock-Trace": "ENABLED", + } + kwargs = build_anthropic_kwargs( + model="anthropic.claude-sonnet-4-6", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=512, + reasoning_config=None, + bedrock_guardrail_headers=headers, + ) + assert kwargs["extra_headers"]["X-Amzn-Bedrock-Trace"] == "ENABLED" + + def test_no_guardrail_headers_produces_no_extra_headers(self): + """Without guardrail headers, extra_headers is absent from kwargs.""" + from agent.anthropic_adapter import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="anthropic.claude-sonnet-4-6", + messages=[{"role": "user", "content": "hi"}], + tools=None, + max_tokens=1024, + reasoning_config=None, + bedrock_guardrail_headers=None, + ) + assert "extra_headers" not in kwargs or not kwargs.get("extra_headers") + + def test_guardrail_headers_do_not_overwrite_existing_extra_headers(self): + """Guardrail headers are merged with pre-existing extra_headers (e.g. fast_mode). + + fast_mode is only supported on opus-4-6; use that model to trigger + the anthropic-beta header so we can verify the two sets coexist. + """ + from agent.anthropic_adapter import build_anthropic_kwargs + + headers = { + "X-Amzn-Bedrock-GuardrailIdentifier": "gr-xyz", + "X-Amzn-Bedrock-GuardrailVersion": "3", + } + # fast_mode on opus-4-6 adds extra_headers with anthropic-beta + kwargs = build_anthropic_kwargs( + model="claude-opus-4-6", + messages=[{"role": "user", "content": "hi"}], + tools=None, + max_tokens=1024, + reasoning_config=None, + fast_mode=True, + bedrock_guardrail_headers=headers, + ) + assert "extra_headers" in kwargs + # Guardrail headers are present + assert kwargs["extra_headers"]["X-Amzn-Bedrock-GuardrailIdentifier"] == "gr-xyz" + # fast_mode beta header must also still be present (not overwritten) + assert "anthropic-beta" in kwargs["extra_headers"] + + def test_transport_build_kwargs_passes_guardrail_headers(self): + """AnthropicTransport.build_kwargs correctly forwards bedrock_guardrail_headers.""" + from agent.transports.anthropic import AnthropicTransport + + transport = AnthropicTransport() + headers = { + "X-Amzn-Bedrock-GuardrailIdentifier": "gr-transport-test", + "X-Amzn-Bedrock-GuardrailVersion": "1", + } + kwargs = transport.build_kwargs( + model="anthropic.claude-haiku-4-5", + messages=[{"role": "user", "content": "test"}], + tools=None, + max_tokens=256, + bedrock_guardrail_headers=headers, + ) + assert kwargs["extra_headers"]["X-Amzn-Bedrock-GuardrailIdentifier"] == "gr-transport-test" + + +class TestBedrockGuardrailStopReason: + """Verify guardrail_intervened stop_reason is handled correctly.""" + + def test_guardrail_intervened_maps_to_content_filter(self): + """guardrail_intervened stop_reason → content_filter finish_reason.""" + from agent.transports.anthropic import AnthropicTransport + + transport = AnthropicTransport() + assert transport.map_finish_reason("guardrail_intervened") == "content_filter" + + def test_validate_response_accepts_empty_content_on_guardrail_intervened(self): + """Empty content with guardrail_intervened is a valid (blocked) response.""" + from types import SimpleNamespace + from agent.transports.anthropic import AnthropicTransport + + transport = AnthropicTransport() + response = SimpleNamespace(content=[], stop_reason="guardrail_intervened") + assert transport.validate_response(response) is True + + def test_validate_response_rejects_empty_content_on_unknown_stop_reason(self): + """Empty content without a known terminal stop_reason is invalid.""" + from types import SimpleNamespace + from agent.transports.anthropic import AnthropicTransport + + transport = AnthropicTransport() + response = SimpleNamespace(content=[], stop_reason="unknown_reason") + assert transport.validate_response(response) is False diff --git a/tests/agent/test_pet_generate.py b/tests/agent/test_pet_generate.py index 17d8b24104b5d..a4d00d2971cbc 100644 --- a/tests/agent/test_pet_generate.py +++ b/tests/agent/test_pet_generate.py @@ -431,14 +431,15 @@ def test_hatch_pet_end_to_end(monkeypatch, tmp_path): from agent.pet.generate import imagegen, orchestrate base = tmp_path / "base.png" - _strip(1).save(base) + _strip(1, size=(64, 64)).save(base) def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"): # Return a synthetic row strip; frame count is inferable from the spec. + # Use small cells (64×64) to keep PIL BFS fast under the CI 140s limit. state = prefix.replace("pet_row_", "") count = atlas_mod.FRAME_COUNTS.get(state, 6) p = tmp_path / f"{prefix}.png" - _strip(count).save(p) + _strip(count, size=(64, 64)).save(p) return [p] monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object()) @@ -468,7 +469,7 @@ def test_hatch_pet_idle_fallback_when_row_fails(monkeypatch, tmp_path): from agent.pet.generate.imagegen import GenerationError base = tmp_path / "base.png" - _strip(1).save(base) + _strip(1, size=(64, 64)).save(base) def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"): if prefix == "pet_row_idle": @@ -476,7 +477,7 @@ def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix=" state = prefix.replace("pet_row_", "") count = atlas_mod.FRAME_COUNTS.get(state, 6) p = tmp_path / f"{prefix}.png" - _strip(count).save(p) + _strip(count, size=(64, 64)).save(p) return [p] monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object()) @@ -492,7 +493,7 @@ def test_hatch_pet_rejects_missing_required_animation_rows(monkeypatch, tmp_path from agent.pet.generate.imagegen import GenerationError base = tmp_path / "base.png" - _strip(1).save(base) + _strip(1, size=(64, 64)).save(base) def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet", aspect_ratio="square"): if prefix == "pet_row_running-right": @@ -500,7 +501,7 @@ def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix=" state = prefix.replace("pet_row_", "") count = atlas_mod.FRAME_COUNTS.get(state, 6) p = tmp_path / f"{prefix}.png" - _strip(count).save(p) + _strip(count, size=(64, 64)).save(p) return [p] monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())