From 36dcc36062a1a131b0b3849c877e6d362227ce4f Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 20 Jul 2026 11:38:28 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20(adapters):=20Require=20stat?= =?UTF-8?q?us=3D=3Dallow=20to=20run=20tool=20across=207=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep the pending-approval fail-open (AAASM-4898 sibling) across the remaining adapters. smolagents, llamaindex, openai_agents, mcp, microsoft_agent_framework, haystack and agno gated tool execution on `status == "deny"` only, so a terminal `"pending"` (approval timed out or the resolver returned pending again) fell through and ran the tool. Change each terminal gate to `status != "allow"`, matching the LangChain handler and the fixed crewai/_shared gates: only an explicit allow may proceed; any other terminal verdict fails closed via the already-present is_pending_flow branch. Refs AAASM-4906 --- agent_assembly/adapters/agno/patch.py | 12 ++++++++++-- agent_assembly/adapters/haystack/patch.py | 6 +++++- agent_assembly/adapters/llamaindex/patch.py | 12 ++++++++++-- agent_assembly/adapters/mcp/patch.py | 6 +++++- .../adapters/microsoft_agent_framework/patch.py | 6 +++++- agent_assembly/adapters/openai_agents/patch.py | 6 +++++- agent_assembly/adapters/smolagents/patch.py | 6 +++++- 7 files changed, 45 insertions(+), 9 deletions(-) diff --git a/agent_assembly/adapters/agno/patch.py b/agent_assembly/adapters/agno/patch.py index 3e8cda20..451088d3 100644 --- a/agent_assembly/adapters/agno/patch.py +++ b/agent_assembly/adapters/agno/patch.py @@ -194,7 +194,11 @@ def patched_execute(self: Any, *args: Any, **kwargs: Any) -> Any: tool_args=tool_args, enforce=enforce, ) - if status == "deny": + # Fail closed: only an explicit "allow" may proceed. A terminal "pending" + # (approval timed out or the resolver returned pending again) is a + # non-decision, not a grant — blocking it here stops it from falling + # through and running the tool, matching the LangChain handler. + if status != "allow": message = _format_approval_rejected(reason) if is_pending_flow else _format_blocked(reason) return _build_denied_result(message) @@ -217,7 +221,11 @@ async def patched_aexecute(self: Any, *args: Any, **kwargs: Any) -> Any: tool_args=tool_args, enforce=enforce, ) - if status == "deny": + # Fail closed: only an explicit "allow" may proceed. A terminal + # "pending" (approval timed out or the resolver returned pending + # again) is a non-decision, not a grant — blocking it here stops it + # from falling through and running the tool, matching LangChain. + if status != "allow": message = _format_approval_rejected(reason) if is_pending_flow else _format_blocked(reason) return _build_denied_result(message) diff --git a/agent_assembly/adapters/haystack/patch.py b/agent_assembly/adapters/haystack/patch.py index 192ff609..ce9a8108 100644 --- a/agent_assembly/adapters/haystack/patch.py +++ b/agent_assembly/adapters/haystack/patch.py @@ -259,7 +259,11 @@ def patched_invoke(self: Any, *args: Any, **kwargs: Any) -> Any: ) status, reason = _normalize_decision(final_decision, enforce=enforce) - if status == "deny": + # Fail closed: only an explicit "allow" may proceed. A terminal "pending" + # (approval timed out or the resolver returned pending again) is a + # non-decision, not a grant — blocking it here stops it from falling + # through and running the tool, matching the LangChain handler. + if status != "allow": if is_pending_flow: return _format_approval_rejected_message(reason) return _format_blocked_message(reason) diff --git a/agent_assembly/adapters/llamaindex/patch.py b/agent_assembly/adapters/llamaindex/patch.py index f1aca023..2943210e 100644 --- a/agent_assembly/adapters/llamaindex/patch.py +++ b/agent_assembly/adapters/llamaindex/patch.py @@ -285,7 +285,11 @@ def patched_call(self: Any, *args: Any, **kwargs: Any) -> Any: agent_id=agent_id, enforce=enforce, ) - if status == "deny": + # Fail closed: only an explicit "allow" may proceed. A terminal "pending" + # (approval timed out or the resolver returned pending again) is a + # non-decision, not a grant — blocking it here stops it from falling + # through and running the tool, matching the LangChain handler. + if status != "allow": message = _format_approval_rejected_message(reason) if is_pending_flow else _format_blocked_message(reason) return _denied_tool_output(self, tool_name=tool_name, message=message) @@ -327,7 +331,11 @@ async def patched_acall(self: Any, *args: Any, **kwargs: Any) -> Any: agent_id=agent_id, enforce=enforce, ) - if status == "deny": + # Fail closed: only an explicit "allow" may proceed. A terminal "pending" + # (approval timed out or the resolver returned pending again) is a + # non-decision, not a grant — blocking it here stops it from falling + # through and running the tool, matching the LangChain handler. + if status != "allow": message = _format_approval_rejected_message(reason) if is_pending_flow else _format_blocked_message(reason) return _denied_tool_output(self, tool_name=tool_name, message=message) diff --git a/agent_assembly/adapters/mcp/patch.py b/agent_assembly/adapters/mcp/patch.py index fac10f77..c8dcdda3 100644 --- a/agent_assembly/adapters/mcp/patch.py +++ b/agent_assembly/adapters/mcp/patch.py @@ -284,7 +284,11 @@ async def patched_call_tool(self: Any, *args: Any, **kwargs: Any) -> Any: ) status, reason = _normalize_decision(final_decision, enforce=enforce) - if status == "deny": + # Fail closed: only an explicit "allow" may proceed. A terminal "pending" + # (approval timed out or the resolver returned pending again) is a + # non-decision, not a grant — blocking it here stops it from falling + # through and running the tool, matching the LangChain handler. + if status != "allow": raise _build_blocked_error( tool_name=tool_name, server_identifier=server_identifier, diff --git a/agent_assembly/adapters/microsoft_agent_framework/patch.py b/agent_assembly/adapters/microsoft_agent_framework/patch.py index 806a025f..ebfbe485 100644 --- a/agent_assembly/adapters/microsoft_agent_framework/patch.py +++ b/agent_assembly/adapters/microsoft_agent_framework/patch.py @@ -329,7 +329,11 @@ async def patched_invoke(self: Any, *args: Any, **kwargs: Any) -> Any: ) status, reason = _normalize_decision(final_decision, enforce=enforce) - if status == "deny": + # Fail closed: only an explicit "allow" may proceed. A terminal "pending" + # (approval timed out or the resolver returned pending again) is a + # non-decision, not a grant — blocking it here stops it from falling + # through and running the tool, matching the LangChain handler. + if status != "allow": if is_pending_flow: raise _build_pending_rejected_error(tool_name, reason) raise _build_denied_error(tool_name, reason) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 5789d2c8..a0decd48 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -549,7 +549,11 @@ async def governed_invoke(ctx: Any, tool_input: Any) -> Any: ) status, reason = _normalize_decision(final_decision, enforce=enforce) - if status == "deny": + # Fail closed: only an explicit "allow" may proceed. A terminal + # "pending" (approval timed out or the resolver returned pending + # again) is a non-decision, not a grant — blocking it here stops it + # from falling through and running the tool, matching LangChain. + if status != "allow": blocked_result = _build_tool_deny_error( tool_name=tool_name, reason=reason, diff --git a/agent_assembly/adapters/smolagents/patch.py b/agent_assembly/adapters/smolagents/patch.py index 0c6e9904..12fc0d4f 100644 --- a/agent_assembly/adapters/smolagents/patch.py +++ b/agent_assembly/adapters/smolagents/patch.py @@ -153,7 +153,11 @@ def patched_call(self: Any, *args: Any, **kwargs: Any) -> Any: ) status, reason = _normalize_decision(final_decision, enforce=enforce) - if status == "deny": + # Fail closed: only an explicit "allow" may proceed. A terminal "pending" + # (approval timed out or the resolver returned pending again) is a + # non-decision, not a grant — blocking it here stops it from falling + # through and running the tool, matching the LangChain handler. + if status != "allow": if is_pending_flow: return _format_approval_rejected_message(reason) return _format_blocked_message(reason) From d228bfcd57659c1bb9b8ce6f7010efdd6fcbe641 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 20 Jul 2026 11:38:57 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85=20(test):=20Assert=20terminal=20p?= =?UTF-8?q?ending=20blocks=20tool=20across=207=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression test per adapter proving a still-"pending" verdict after the approval round-trip fails closed and never invokes the wrapped tool body, locking in the AAASM-4906 fix. llamaindex and agno cover both the sync and async gate. Refs AAASM-4906 --- test/unit/adapters/agno/test_patch.py | 45 ++++++++++++++++++ test/unit/adapters/haystack/test_patch.py | 23 ++++++++++ test/unit/adapters/llamaindex/test_patch.py | 46 +++++++++++++++++++ test/unit/adapters/mcp/test_patch.py | 26 +++++++++++ .../test_microsoft_agent_framework_patch.py | 31 +++++++++++++ .../unit/adapters/openai_agents/test_patch.py | 30 ++++++++++++ test/unit/adapters/smolagents/test_patch.py | 31 +++++++++++++ 7 files changed, 232 insertions(+) diff --git a/test/unit/adapters/agno/test_patch.py b/test/unit/adapters/agno/test_patch.py index 9333b804..23b60d72 100644 --- a/test/unit/adapters/agno/test_patch.py +++ b/test/unit/adapters/agno/test_patch.py @@ -297,3 +297,48 @@ def test_resolve_tool_name_falls_back_to_class_name() -> None: def test_resolve_tool_args_handles_non_dict() -> None: assert agno_patch._resolve_tool_args(SimpleNamespace(arguments=None)) == {} assert agno_patch._resolve_tool_args(SimpleNamespace(arguments={"a": 1})) == {"a": 1} + + +class _TerminalPendingInterceptor: + """Returns "pending" at check AND again at approval — a terminal non-decision.""" + + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "needs approval"} + + def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "still pending"} + + +def test_terminal_pending_blocks_sync_body(monkeypatch: pytest.MonkeyPatch) -> None: + """AAASM-4906: a still-"pending" verdict after the approval round-trip must + fail closed on ``execute`` — only an explicit allow may run the tool.""" + ran: list[object] = [] + cls = _make_fake_function_call_cls(_recording_body(ran, "ran")) + _install_fake_agno(monkeypatch, cls) + + patcher = agno_patch.AgnoPatch(_TerminalPendingInterceptor()) + assert patcher.apply() is True + + result = cls("deploy", {}).execute() + + assert ran == [] + assert result.status == "failure" + assert result.error.startswith("[APPROVAL REJECTED]") + + +def test_terminal_pending_blocks_async_body(monkeypatch: pytest.MonkeyPatch) -> None: + """AAASM-4906: the same fail-closed guarantee on the ``aexecute`` gate.""" + ran: list[object] = [] + cls = _make_fake_function_call_cls(_recording_body(ran, "ran")) + _install_fake_agno(monkeypatch, cls) + + patcher = agno_patch.AgnoPatch(_TerminalPendingInterceptor()) + assert patcher.apply() is True + + result = asyncio.run(cls("deploy", {}).aexecute()) + + assert ran == [] + assert result.status == "failure" + assert result.error.startswith("[APPROVAL REJECTED]") diff --git a/test/unit/adapters/haystack/test_patch.py b/test/unit/adapters/haystack/test_patch.py index f9f17d7d..d71c1e7b 100644 --- a/test/unit/adapters/haystack/test_patch.py +++ b/test/unit/adapters/haystack/test_patch.py @@ -426,3 +426,26 @@ def record_result(self, **kwargs: object) -> None: assert recorded == ["hello world"] finally: patcher.revert() + + +def test_terminal_pending_blocks_tool_and_does_not_run(monkeypatch: pytest.MonkeyPatch) -> None: + """AAASM-4906: a still-"pending" verdict after the approval round-trip must + fail closed — only an explicit allow may run the tool, matching LangChain.""" + FakeTool = _install_fake_haystack_module(monkeypatch) + + class TerminalPendingInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "needs approval"} + + def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "still pending"} + + patcher = haystack_patch.HaystackPatch(TerminalPendingInterceptor()) + assert patcher.apply() is True + + result = FakeTool().invoke(param="value") + + assert isinstance(result, str) + assert result.startswith("[APPROVAL REJECTED]") diff --git a/test/unit/adapters/llamaindex/test_patch.py b/test/unit/adapters/llamaindex/test_patch.py index 459b8663..ff0e6ae7 100644 --- a/test/unit/adapters/llamaindex/test_patch.py +++ b/test/unit/adapters/llamaindex/test_patch.py @@ -261,3 +261,49 @@ def test_negative_control_noop_patch_would_pass_through(monkeypatch: pytest.Monk result = tool_cls().call(param="value") assert body == ["call"] assert result == {"args": (), "kwargs": {"param": "value"}} + + +class _TerminalPendingInterceptor: + """Returns "pending" at check AND again at approval — a terminal non-decision.""" + + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "needs review"} + + def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "still pending"} + + +def test_terminal_pending_blocks_sync_body(monkeypatch: pytest.MonkeyPatch) -> None: + """AAASM-4906: a still-"pending" verdict after the approval round-trip must + fail closed on ``call`` — only an explicit allow may run the tool.""" + body: list[str] = [] + tool_cls = _make_fake_function_tool_class(body) + _install_fake_llamaindex(monkeypatch, tool_cls) + + patcher = li_patch.LlamaIndexPatch(_TerminalPendingInterceptor()) + assert patcher.apply() is True + + result = tool_cls().call(param="value") + + assert body == [] + assert isinstance(result, _FakeToolOutput) + assert "[APPROVAL REJECTED]" in result.content + + +@pytest.mark.asyncio +async def test_terminal_pending_blocks_async_body(monkeypatch: pytest.MonkeyPatch) -> None: + """AAASM-4906: the same fail-closed guarantee on the ``acall`` gate.""" + body: list[str] = [] + tool_cls = _make_fake_function_tool_class(body) + _install_fake_llamaindex(monkeypatch, tool_cls) + + patcher = li_patch.LlamaIndexPatch(_TerminalPendingInterceptor()) + assert patcher.apply() is True + + result = await tool_cls().acall(param="value") + + assert body == [] + assert isinstance(result, _FakeToolOutput) + assert "[APPROVAL REJECTED]" in result.content diff --git a/test/unit/adapters/mcp/test_patch.py b/test/unit/adapters/mcp/test_patch.py index 151e3715..c10255dd 100644 --- a/test/unit/adapters/mcp/test_patch.py +++ b/test/unit/adapters/mcp/test_patch.py @@ -318,3 +318,29 @@ async def test_missing_check_tool_start_blocks_tool_under_enforce( session = FakeClientSession() with pytest.raises(MCPToolBlockedError): await session.call_tool("some_tool", {"q": "x"}) + + +@pytest.mark.asyncio +async def test_terminal_pending_blocks_tool_and_does_not_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """AAASM-4906: a still-"pending" verdict after the approval round-trip must + fail closed — only an explicit allow may run the tool, matching LangChain.""" + FakeClientSession = _install_fake_mcp_module(monkeypatch) + + class Interceptor: + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "approval required"} + + async def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "still pending"} + + patcher = mcp_patch.MCPClientPatch(Interceptor()) + assert patcher.apply() is True + + session = FakeClientSession() + session._ws_url = "wss://tools.mcp.test" + with pytest.raises(MCPToolBlockedError, match="rejected during approval"): + await session.call_tool("deploy", {"service": "api"}) diff --git a/test/unit/adapters/microsoft_agent_framework/test_microsoft_agent_framework_patch.py b/test/unit/adapters/microsoft_agent_framework/test_microsoft_agent_framework_patch.py index f7dc4be3..d20bf1a4 100644 --- a/test/unit/adapters/microsoft_agent_framework/test_microsoft_agent_framework_patch.py +++ b/test/unit/adapters/microsoft_agent_framework/test_microsoft_agent_framework_patch.py @@ -292,3 +292,34 @@ def write_note(path: str, content: str) -> str: assert interceptor.recorded # and recorded the result finally: adapter.unregister_hooks() + + +class _TerminalPendingInterceptor: + _enforce = True + + def check_tool_start(self, **kwargs: Any) -> dict[str, str]: + del kwargs + return {"status": "pending"} + + def wait_for_tool_approval(self, **kwargs: Any) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "still pending"} + + +@pytest.mark.asyncio +async def test_terminal_pending_blocks_tool_and_does_not_run(monkeypatch: pytest.MonkeyPatch) -> None: + """AAASM-4906: a still-"pending" verdict after the approval round-trip must + fail closed — only an explicit allow may run the tool, matching LangChain.""" + side_effects: list[Any] = [] + FakeTool = _install_fake_agent_framework(monkeypatch, side_effects) + + patcher = maf_patch.MicrosoftAgentFrameworkPatch(_TerminalPendingInterceptor()) + assert patcher.apply() is True + + tool = FakeTool() + with pytest.raises(PolicyViolationError, match="rejected during approval"): + await tool.invoke(arguments={"x": 1}) + + assert side_effects == [] + + patcher.revert() diff --git a/test/unit/adapters/openai_agents/test_patch.py b/test/unit/adapters/openai_agents/test_patch.py index 6f1ca5b9..c23ffb9d 100644 --- a/test/unit/adapters/openai_agents/test_patch.py +++ b/test/unit/adapters/openai_agents/test_patch.py @@ -643,3 +643,33 @@ async def record_result(self, **kwargs: object) -> None: assert isinstance(result, str) assert "policy deny" in result assert "blocked by governance policy" in result + + +@pytest.mark.asyncio +async def test_terminal_pending_blocks_tool_and_does_not_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """AAASM-4906: a still-"pending" verdict after the approval round-trip must + fail closed — only an explicit allow may run the tool, matching LangChain.""" + function_tool_cls = _install_fake_openai_agents_module(monkeypatch) + + class Interceptor: + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "needs approval"} + + async def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "still pending"} + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=Interceptor()) + assert patcher.apply() is True + + tool = function_tool_cls(name="terminal_pending_tool") + ctx = SimpleNamespace(agent_id="agent-terminal-pending") + result = await tool.on_invoke_tool(ctx, '{"q": "secret"}') + + # A string governance error (not the tool's real dict output) proves the + # body never executed under a terminal "pending". + assert isinstance(result, str) + assert "Approval denied for tool 'terminal_pending_tool'" in result diff --git a/test/unit/adapters/smolagents/test_patch.py b/test/unit/adapters/smolagents/test_patch.py index 20b4663e..9b3af64f 100644 --- a/test/unit/adapters/smolagents/test_patch.py +++ b/test/unit/adapters/smolagents/test_patch.py @@ -184,3 +184,34 @@ def raise_import_error(module_name: str) -> object: monkeypatch.setattr(smol_patch.importlib, "import_module", raise_import_error) assert smol_patch._load_smolagents_tool_class() is None assert smol_patch.SmolagentsPatch(_RecordingInterceptor()).apply() is False + + +class _TerminalPendingInterceptor: + """Returns "pending" at check AND again at approval — a terminal non-decision.""" + + def check_tool_start(self, **kwargs: Any) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "needs approval"} + + def wait_for_tool_approval(self, **kwargs: Any) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "still pending"} + + +def test_terminal_pending_blocks_tool_and_does_not_run(monkeypatch: pytest.MonkeyPatch) -> None: + """AAASM-4906: a still-"pending" verdict after the approval round-trip must + fail closed — only an explicit allow may run the tool, matching LangChain.""" + FakeTool = _install_fake_smolagents(monkeypatch) + + patcher = smol_patch.SmolagentsPatch(_TerminalPendingInterceptor()) + assert patcher.apply() is True + try: + tool = FakeTool() + result = tool(text="hello") + finally: + patcher.revert() + + assert isinstance(result, str) + assert result.startswith("[APPROVAL REJECTED]") + # The body returns a dict on success; a string proves it never executed. + assert not isinstance(result, dict) From fb7634878a0dbbddd660d21380ab6020821abfab Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 20 Jul 2026 11:39:11 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=93=9D=20(docs):=20Correct=20formatte?= =?UTF-8?q?r=20gate=20from=20ruff=20format=20to=20black?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The formatter gate is black (.pre-commit-config.yaml: isort + autoflake + black + mypy; there is no ruff-format hook — ruff is the linter only). CONTRIBUTING.md and the authoring-adapters checklist still referenced `ruff format`; point them at black to match reality. Refs AAASM-4906 --- CONTRIBUTING.md | 2 +- docs/guides/authoring-adapters.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65aad34b..6e518733 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,7 +130,7 @@ cargo test --manifest-path native/aa-ffi-python/Cargo.toml ```bash uv run ruff check . # lint (config: ruff.toml; line length 120, target py312) -uv run ruff format . # auto-format +uv run black . # auto-format (formatter gate; see .pre-commit-config.yaml) uv run mypy agent_assembly # type check (mypy.ini; strict on adapters.base/registry) uv run pre-commit run --all-files # full pre-commit suite (isort + black + autoflake + mypy) ``` diff --git a/docs/guides/authoring-adapters.md b/docs/guides/authoring-adapters.md index c9dc42e4..b0b17b2f 100644 --- a/docs/guides/authoring-adapters.md +++ b/docs/guides/authoring-adapters.md @@ -327,7 +327,7 @@ repos but should still meet this bar), confirm: lifecycle (framework mocked). - [ ] Integration test under `test/integration/adapters//` exercises a minimal real flow. -- [ ] `uv run ruff check .`, `uv run ruff format --check .`, and `uv run mypy agent_assembly` +- [ ] `uv run ruff check .`, `uv run black --check .`, and `uv run mypy agent_assembly` are clean. - [ ] Entry point declared in `pyproject.toml` under `[project.entry-points."agent_assembly.adapters"]` (for standalone packages). From cf585be3a5fd8ce62f98468dba19ca51141a9d8d Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 20 Jul 2026 11:39:35 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=85=20(test):=20Force=20pure-Python?= =?UTF-8?q?=20registration=20in=20native-sensitive=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the AAASM-4898 hermeticity pattern to the remaining files whose init_assembly() tests dial a real gateway when a native _core.so is present (they stub GatewayClient/adapters/network but not the native connect_runtime_client/register_agent path). Add an autouse fixture pinning _native_core_available() to False so the full suite is deterministic in both native-present and native-absent modes. Refs AAASM-4906 --- test/bench/test_init_assembly_coldstart.py | 14 ++++++++++++++ test/bench/test_latency_contracts.py | 16 ++++++++++++++++ test/integration/test_assembly_integration.py | 15 +++++++++++++++ .../test_spawn_lineage_integration.py | 16 ++++++++++++++++ test/unit/adapters/langchain/test_runtime.py | 14 ++++++++++++++ 5 files changed, 75 insertions(+) diff --git a/test/bench/test_init_assembly_coldstart.py b/test/bench/test_init_assembly_coldstart.py index df725c8b..6834a83e 100644 --- a/test/bench/test_init_assembly_coldstart.py +++ b/test/bench/test_init_assembly_coldstart.py @@ -18,6 +18,20 @@ from agent_assembly.core.assembly import init_assembly +@pytest.fixture(autouse=True) +def _force_pure_python_registration(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep init_assembly() hermetic whether or not the native ``_core`` extension + is built (AAASM-4906, sibling of AAASM-4898). + + With a native ``.so`` present, ``_native_core_available()`` returns True and + init dials a real gateway over gRPC (``connect_runtime_client`` / + ``register_agent``); this benchmark stubs neither, so it would hang/fail. + Forcing the pure-Python path (the CI default) keeps the measurement + deterministic in both build modes. + """ + monkeypatch.setattr(assembly_mod, "_native_core_available", lambda: False) + + @pytest.mark.benchmark(group="init") def test_init_assembly_coldstart(benchmark: Any) -> None: def cold_start() -> None: diff --git a/test/bench/test_latency_contracts.py b/test/bench/test_latency_contracts.py index 66cdae26..3b9adbce 100644 --- a/test/bench/test_latency_contracts.py +++ b/test/bench/test_latency_contracts.py @@ -20,6 +20,8 @@ from typing import Any from uuid import uuid4 +import pytest + import agent_assembly.core.assembly as assembly_mod from agent_assembly.adapters.crewai.patch import ( _apply_basetool_run_patch, @@ -49,6 +51,20 @@ _ITERATIONS = 100 +@pytest.fixture(autouse=True) +def _force_pure_python_registration(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep init_assembly() hermetic whether or not the native ``_core`` extension + is built (AAASM-4906, sibling of AAASM-4898). + + With a native ``.so`` present, ``_native_core_available()`` returns True and + init dials a real gateway over gRPC (``connect_runtime_client`` / + ``register_agent``); the cold-start contract stubs neither, so it would + hang/fail. Forcing the pure-Python path (the CI default) keeps the latency + measurement deterministic in both build modes. + """ + monkeypatch.setattr(assembly_mod, "_native_core_available", lambda: False) + + def _percentiles(samples: list[int]) -> tuple[float, float, float]: """Return (P50, P95, P99) from a list of nanosecond measurements.""" sorted_samples = sorted(samples) diff --git a/test/integration/test_assembly_integration.py b/test/integration/test_assembly_integration.py index 5d64d52e..15ecc4d9 100644 --- a/test/integration/test_assembly_integration.py +++ b/test/integration/test_assembly_integration.py @@ -8,9 +8,24 @@ import pytest from agent_assembly import init_assembly +from agent_assembly.core import assembly as core_assembly from agent_assembly.exceptions import ConfigurationError +@pytest.fixture(autouse=True) +def _force_pure_python_registration(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep init_assembly() hermetic whether or not the native ``_core`` extension + is built (AAASM-4906, sibling of AAASM-4898). + + With a native ``.so`` present, ``_native_core_available()`` returns True and + init dials a real gateway over gRPC (``connect_runtime_client`` / + ``register_agent``). These tests exercise SDK wiring without a live gateway, + so forcing the pure-Python path (the CI default) keeps them deterministic in + both build modes. + """ + monkeypatch.setattr(core_assembly, "_native_core_available", lambda: False) + + @pytest.mark.integration def test_init_assembly_with_valid_config() -> None: """Test that assembly initialization works with valid configuration.""" diff --git a/test/integration/test_spawn_lineage_integration.py b/test/integration/test_spawn_lineage_integration.py index 1384df2b..76ae38c8 100644 --- a/test/integration/test_spawn_lineage_integration.py +++ b/test/integration/test_spawn_lineage_integration.py @@ -16,9 +16,25 @@ import pytest +from agent_assembly.core import assembly as core_assembly from agent_assembly.core.assembly import init_assembly from agent_assembly.core.spawn import _SPAWN_CTX, SpawnContext, spawn_context_scope + +@pytest.fixture(autouse=True) +def _force_pure_python_registration(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep init_assembly() hermetic whether or not the native ``_core`` extension + is built (AAASM-4906, sibling of AAASM-4898). + + ``_call_init_assembly`` mocks GatewayClient / adapter / network layers but not + the native gRPC registration path (``connect_runtime_client`` / + ``register_agent``), so a stray native ``.so`` made init dial a real gateway. + Forcing the pure-Python path (the CI default) keeps these tests deterministic + in both build modes. + """ + monkeypatch.setattr(core_assembly, "_native_core_available", lambda: False) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/test/unit/adapters/langchain/test_runtime.py b/test/unit/adapters/langchain/test_runtime.py index 41e54bd0..a19056fa 100644 --- a/test/unit/adapters/langchain/test_runtime.py +++ b/test/unit/adapters/langchain/test_runtime.py @@ -19,6 +19,20 @@ def _reset_assembly_state() -> None: core_assembly._ACTIVE_CONTEXT = None +@pytest.fixture(autouse=True) +def _force_pure_python_registration(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep init_assembly() hermetic whether or not the native ``_core`` extension + is built (AAASM-4906, sibling of AAASM-4898). + + The init_assembly tests below stub ``_register_adapters`` / ``_start_network_layer`` + but not the native gRPC registration path (``connect_runtime_client`` / + ``register_agent``), so a stray native ``.so`` made init dial a real gateway. + Forcing the pure-Python path (the CI default) keeps them deterministic in both + build modes. + """ + monkeypatch.setattr(core_assembly, "_native_core_available", lambda: False) + + def test_auto_inject_callback_handler_is_idempotent() -> None: _reset_runtime_state_for_tests() _reset_assembly_state()