From 25da489dea8282a9877644f2ec21a2a4dfcf89eb Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:04:30 +0800 Subject: [PATCH 01/19] =?UTF-8?q?=E2=9C=A8=20(tests):=20Create=20OpenAI=20?= =?UTF-8?q?Agents=20adapter=20unit=20and=20integration=20test=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/integration/openai_agents/__init__.py | 0 test/unit/adapters/openai_agents/__init__.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/integration/openai_agents/__init__.py create mode 100644 test/unit/adapters/openai_agents/__init__.py diff --git a/test/integration/openai_agents/__init__.py b/test/integration/openai_agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/adapters/openai_agents/__init__.py b/test/unit/adapters/openai_agents/__init__.py new file mode 100644 index 0000000..e69de29 From abef6056cfabdd99b1652e50396bc56d3b43e361 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:05:10 +0800 Subject: [PATCH 02/19] =?UTF-8?q?=E2=9C=85=20(tests):=20Add=20initial=20fa?= =?UTF-8?q?iling=20coverage=20for=20FunctionTool=20call=20patching=20idemp?= =?UTF-8?q?otency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/adapters/openai_agents/test_patch.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/unit/adapters/openai_agents/test_patch.py diff --git a/test/unit/adapters/openai_agents/test_patch.py b/test/unit/adapters/openai_agents/test_patch.py new file mode 100644 index 0000000..7cb9224 --- /dev/null +++ b/test/unit/adapters/openai_agents/test_patch.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from agent_assembly.adapters.openai_agents import patch as openai_patch + + +def _install_fake_openai_agents_module(monkeypatch: pytest.MonkeyPatch) -> type[Any]: + class FakeFunctionTool: + async def __call__(self, ctx: Any, tool_input: Any) -> dict[str, Any]: + return {"ctx": ctx, "tool_input": tool_input} + + fake_module = SimpleNamespace(FunctionTool=FakeFunctionTool) + + def fake_import_module(module_name: str) -> object: + if module_name == "openai.agents": + return fake_module + raise ImportError(module_name) + + monkeypatch.setattr(openai_patch.importlib, "import_module", fake_import_module) + monkeypatch.setattr(openai_patch.importlib.util, "find_spec", lambda package: object()) + return FakeFunctionTool + + +def test_apply_patches_functiontool_call_and_is_idempotent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls = _install_fake_openai_agents_module(monkeypatch) + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=object()) + + original_call = function_tool_cls.__call__ + assert patcher.apply() is True + + patched_call = function_tool_cls.__call__ + assert patched_call is not original_call + + assert patcher.apply() is True + assert function_tool_cls.__call__ is patched_call From 16fe81696da3b7636376401f8b62a29e1f646620 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:05:36 +0800 Subject: [PATCH 03/19] =?UTF-8?q?=E2=9C=A8=20(openai=5Fagents):=20Add=20pa?= =?UTF-8?q?tch=20state=20constants=20and=20process=20agent=20context=20hel?= =?UTF-8?q?pers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/openai_agents/patch.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 65b8372..8fd541e 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -3,23 +3,42 @@ from __future__ import annotations from dataclasses import dataclass +import importlib import importlib.util from typing import Any +_ORIGINAL_FUNCTION_TOOL_CALL = "_agent_assembly_original_openai_agents_function_tool_call" +_PATCHED_FLAG = "_agent_assembly_openai_agents_function_tool_patched" +_PROCESS_AGENT_ID: str | None = None + @dataclass(slots=True) class OpenAIAgentsPatch: """Patch placeholder for OpenAI Agents SDK interception.""" callback_handler: Any + process_agent_id: str | None = None def apply(self) -> bool: + set_process_agent_id(self.process_agent_id) _ = self.callback_handler return _is_openai_agents_available() def revert(self) -> None: + set_process_agent_id(None) return None def _is_openai_agents_available() -> bool: return importlib.util.find_spec("openai.agents") is not None + + +def set_process_agent_id(agent_id: str | None) -> None: + global _PROCESS_AGENT_ID + _PROCESS_AGENT_ID = agent_id + + +def _get_process_agent_id() -> str | None: + if isinstance(_PROCESS_AGENT_ID, str) and _PROCESS_AGENT_ID: + return _PROCESS_AGENT_ID + return None From 8c0ec2f301c137c88de1732642ee0c9d6fdd77d1 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:05:57 +0800 Subject: [PATCH 04/19] =?UTF-8?q?=E2=9C=A8=20(openai=5Fagents):=20Add=20Fu?= =?UTF-8?q?nctionTool=20class=20loader=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/openai_agents/patch.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 8fd541e..1651548 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -42,3 +42,15 @@ def _get_process_agent_id() -> str | None: if isinstance(_PROCESS_AGENT_ID, str) and _PROCESS_AGENT_ID: return _PROCESS_AGENT_ID return None + + +def _load_openai_agents_function_tool_class() -> type[Any] | None: + try: + module = importlib.import_module("openai.agents") + except ImportError: + return None + + function_tool_cls = getattr(module, "FunctionTool", None) + if isinstance(function_tool_cls, type): + return function_tool_cls + return None From 838d2c597120765d2a0113ba5e0d791f77f7cba3 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:06:16 +0800 Subject: [PATCH 05/19] =?UTF-8?q?=E2=9C=A8=20(openai=5Fagents):=20Add=20ct?= =?UTF-8?q?x-based=20agent=5Fid=20resolver=20with=20process=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/openai_agents/patch.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 1651548..c8f3793 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -54,3 +54,10 @@ def _load_openai_agents_function_tool_class() -> type[Any] | None: if isinstance(function_tool_cls, type): return function_tool_cls return None + + +def _resolve_agent_id(ctx: Any) -> str | None: + candidate = getattr(ctx, "agent_id", None) + if isinstance(candidate, str) and candidate: + return candidate + return _get_process_agent_id() From 74cc5b7ab57de71057e654cb9b575b9130d5a96b Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:06:40 +0800 Subject: [PATCH 06/19] =?UTF-8?q?=E2=9C=A8=20(openai=5Fagents):=20Add=20go?= =?UTF-8?q?vernance=20decision=20normalization=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/openai_agents/patch.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index c8f3793..c09bbb7 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -5,7 +5,9 @@ from dataclasses import dataclass import importlib import importlib.util -from typing import Any +from typing import Any, Literal + +from agent_assembly.adapters.crewai.patch import _normalize_decision as _normalize_governance_decision _ORIGINAL_FUNCTION_TOOL_CALL = "_agent_assembly_original_openai_agents_function_tool_call" _PATCHED_FLAG = "_agent_assembly_openai_agents_function_tool_patched" @@ -61,3 +63,9 @@ def _resolve_agent_id(ctx: Any) -> str | None: if isinstance(candidate, str) and candidate: return candidate return _get_process_agent_id() + + +def _normalize_decision( + decision: object, +) -> tuple[Literal["allow", "deny", "pending"], str | None]: + return _normalize_governance_decision(decision) From a4134af5b862e819deff40d42032cb43c4a555b6 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:07:05 +0800 Subject: [PATCH 07/19] =?UTF-8?q?=E2=9C=A8=20(openai=5Fagents):=20Add=20as?= =?UTF-8?q?ync=20governance=20pre-check=20helper=20for=20FunctionTool=20ca?= =?UTF-8?q?lls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/openai_agents/patch.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index c09bbb7..086e792 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import importlib import importlib.util +import inspect from typing import Any, Literal from agent_assembly.adapters.crewai.patch import _normalize_decision as _normalize_governance_decision @@ -69,3 +70,36 @@ def _normalize_decision( decision: object, ) -> tuple[Literal["allow", "deny", "pending"], str | None]: return _normalize_governance_decision(decision) + + +def _resolve_governance_target(callback_handler: Any) -> Any: + target = getattr(callback_handler, "_interceptor", None) + if target is not None: + return target + return callback_handler + + +async def _invoke_async_tool_check( + callback_handler: Any, + *, + tool_name: str, + tool_input: Any, + agent_id: str | None, + ctx: Any, +) -> object: + target = _resolve_governance_target(callback_handler) + method = getattr(target, "check_tool_start", None) + if not callable(method): + return {"status": "allow"} + + result = method( + serialized={"name": tool_name}, + input_str=str(tool_input), + tool_name=tool_name, + args=tool_input, + agent_id=agent_id, + run_context=ctx, + ) + if inspect.isawaitable(result): + return await result + return result From f617b59dfb99ec378d8c0f2b30bdea4739e85269 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:07:31 +0800 Subject: [PATCH 08/19] =?UTF-8?q?=E2=9C=A8=20(openai=5Fagents):=20Add=20pe?= =?UTF-8?q?nding=20approval=20wait=20helper=20with=20timeout=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/openai_agents/patch.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 086e792..c8c8bc1 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -8,6 +8,9 @@ import inspect from typing import Any, Literal +from agent_assembly.adapters.crewai.patch import ( + _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, +) from agent_assembly.adapters.crewai.patch import _normalize_decision as _normalize_governance_decision _ORIGINAL_FUNCTION_TOOL_CALL = "_agent_assembly_original_openai_agents_function_tool_call" @@ -103,3 +106,35 @@ async def _invoke_async_tool_check( if inspect.isawaitable(result): return await result return result + + +def _get_pending_tool_approval_timeout_seconds(callback_handler: Any) -> int: + return _resolve_pending_timeout_seconds(callback_handler) + + +async def _wait_for_async_tool_approval( + callback_handler: Any, + *, + tool_name: str, + timeout_seconds: int, + tool_input: Any, + agent_id: str | None, + ctx: Any, +) -> object: + target = _resolve_governance_target(callback_handler) + method = getattr(target, "wait_for_tool_approval", None) + if not callable(method): + return {"status": "deny", "reason": "Approval handler is unavailable."} + + result = method( + serialized={"name": tool_name}, + input_str=str(tool_input), + tool_name=tool_name, + timeout_seconds=timeout_seconds, + args=tool_input, + agent_id=agent_id, + run_context=ctx, + ) + if inspect.isawaitable(result): + return await result + return result From 95f56ce422105b867919a4b4b6aaa870249250d5 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:08:08 +0800 Subject: [PATCH 09/19] =?UTF-8?q?=E2=9C=A8=20(openai=5Fagents):=20Add=20To?= =?UTF-8?q?olResult=20error-construction=20helper=20for=20deny=20outcomes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/openai_agents/patch.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index c8c8bc1..0c4ceec 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -138,3 +138,30 @@ async def _wait_for_async_tool_approval( if inspect.isawaitable(result): return await result return result + + +def _build_tool_result_error( + *, + tool_name: str, + reason: str | None, + is_pending_rejection: bool, +) -> object: + try: + module = importlib.import_module("openai.agents") + except ImportError: + module = None + + tool_result_cls = getattr(module, "ToolResult", None) if module is not None else None + reason_text = reason or "No reason provided." + if is_pending_rejection: + error_message = f"Approval denied for tool '{tool_name}': {reason_text}" + else: + error_message = f"Action blocked by governance policy for tool '{tool_name}': {reason_text}" + + if isinstance(tool_result_cls, type): + try: + return tool_result_cls(error=error_message) + except Exception: + pass + + return {"error": error_message} From 4c8cdd693f4bda5f2b448f74180e03972f3c73a4 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:08:42 +0800 Subject: [PATCH 10/19] =?UTF-8?q?=E2=9C=A8=20(openai=5Fagents):=20Add=20as?= =?UTF-8?q?ync=20audit=20result=20recording=20helper=20for=20tool=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/openai_agents/patch.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 0c4ceec..23dbb3c 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -16,6 +16,7 @@ _ORIGINAL_FUNCTION_TOOL_CALL = "_agent_assembly_original_openai_agents_function_tool_call" _PATCHED_FLAG = "_agent_assembly_openai_agents_function_tool_patched" _PROCESS_AGENT_ID: str | None = None +_MAX_AUDIT_RESULT_CHARS = 2000 @dataclass(slots=True) @@ -165,3 +166,43 @@ def _build_tool_result_error( pass return {"error": error_message} + + +def _truncate_result_for_audit(result: object) -> str: + return str(result)[:_MAX_AUDIT_RESULT_CHARS] + + +async def _record_async_tool_result( + callback_handler: Any, + *, + tool_name: str, + tool_input: Any, + result: object, + agent_id: str | None, + ctx: Any, +) -> None: + target = _resolve_governance_target(callback_handler) + + record_method = getattr(target, "record_result", None) + if callable(record_method): + recorded = record_method( + tool_name=tool_name, + args=tool_input, + result=_truncate_result_for_audit(result), + agent_id=agent_id, + run_context=ctx, + ) + if inspect.isawaitable(recorded): + await recorded + return None + + tool_end_method = getattr(target, "on_tool_end", None) + if callable(tool_end_method): + recorded = tool_end_method( + output=_truncate_result_for_audit(result), + tool_name=tool_name, + agent_id=agent_id, + run_context=ctx, + ) + if inspect.isawaitable(recorded): + await recorded From 9fd1696e14a57d04064b1ec848a8ec8157b78a65 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:09:04 +0800 Subject: [PATCH 11/19] =?UTF-8?q?=E2=9C=A8=20(openai=5Fagents):=20Add=20go?= =?UTF-8?q?vernance=20error=20classifier=20helper=20for=20fail-open=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/openai_agents/patch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 23dbb3c..ef8d87f 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -194,6 +194,10 @@ async def _record_async_tool_result( ) if inspect.isawaitable(recorded): await recorded + + +def _is_governance_error(error: Exception) -> bool: + return True return None tool_end_method = getattr(target, "on_tool_end", None) From f9d36d60ac878635a6b182dd2d3ddd350c22c931 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:09:45 +0800 Subject: [PATCH 12/19] =?UTF-8?q?=E2=9C=A8=20(openai=5Fagents):=20Implemen?= =?UTF-8?q?t=20FunctionTool=20=5F=5Fcall=5F=5F=20patch=20apply=20and=20rev?= =?UTF-8?q?ert=20flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/openai_agents/patch.py | 104 +++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index ef8d87f..aa3a687 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from functools import wraps import importlib import importlib.util import inspect @@ -28,10 +29,16 @@ class OpenAIAgentsPatch: def apply(self) -> bool: set_process_agent_id(self.process_agent_id) - _ = self.callback_handler - return _is_openai_agents_available() + function_tool_cls = _load_openai_agents_function_tool_class() + if function_tool_cls is None: + return False + _apply_function_tool_call_patch(function_tool_cls, self.callback_handler) + return True def revert(self) -> None: + function_tool_cls = _load_openai_agents_function_tool_class() + if function_tool_cls is not None: + _revert_function_tool_call_patch(function_tool_cls) set_process_agent_id(None) return None @@ -197,7 +204,100 @@ async def _record_async_tool_result( def _is_governance_error(error: Exception) -> bool: + del error return True + + +def _apply_function_tool_call_patch(function_tool_cls: type[Any], callback_handler: Any) -> None: + if getattr(function_tool_cls, _PATCHED_FLAG, False): + return None + + original_call = getattr(function_tool_cls, "__call__", None) + if not callable(original_call): + return None + + @wraps(original_call) + async def patched_call(self: Any, ctx: Any, tool_input: Any, *args: Any, **kwargs: Any) -> Any: + tool_name = str(getattr(self, "name", self.__class__.__name__)) + agent_id = _resolve_agent_id(ctx) + + decision: object = {"status": "allow"} + governance_failed = False + try: + decision = await _invoke_async_tool_check( + callback_handler, + tool_name=tool_name, + tool_input=tool_input, + agent_id=agent_id, + ctx=ctx, + ) + status, reason = _normalize_decision(decision) + is_pending_flow = False + if status == "pending": + is_pending_flow = True + timeout_seconds = _get_pending_tool_approval_timeout_seconds(callback_handler) + final_decision = await _wait_for_async_tool_approval( + callback_handler, + tool_name=tool_name, + timeout_seconds=timeout_seconds, + tool_input=tool_input, + agent_id=agent_id, + ctx=ctx, + ) + status, reason = _normalize_decision(final_decision) + + if status == "deny": + blocked_result = _build_tool_result_error( + tool_name=tool_name, + reason=reason, + is_pending_rejection=is_pending_flow, + ) + await _record_async_tool_result( + callback_handler, + tool_name=tool_name, + tool_input=tool_input, + result=blocked_result, + agent_id=agent_id, + ctx=ctx, + ) + return blocked_result + except Exception as error: + governance_failed = _is_governance_error(error) + if not governance_failed: + raise + + result = original_call(self, ctx, tool_input, *args, **kwargs) + if inspect.isawaitable(result): + result = await result + + if not governance_failed: + await _record_async_tool_result( + callback_handler, + tool_name=tool_name, + tool_input=tool_input, + result=result, + agent_id=agent_id, + ctx=ctx, + ) + return result + + setattr(function_tool_cls, _ORIGINAL_FUNCTION_TOOL_CALL, original_call) + setattr(function_tool_cls, "__call__", patched_call) + setattr(function_tool_cls, _PATCHED_FLAG, True) + + +def _revert_function_tool_call_patch(function_tool_cls: type[Any]) -> None: + if not getattr(function_tool_cls, _PATCHED_FLAG, False): + return None + + original_call = getattr(function_tool_cls, _ORIGINAL_FUNCTION_TOOL_CALL, None) + if callable(original_call): + setattr(function_tool_cls, "__call__", original_call) + + if hasattr(function_tool_cls, _ORIGINAL_FUNCTION_TOOL_CALL): + delattr(function_tool_cls, _ORIGINAL_FUNCTION_TOOL_CALL) + if hasattr(function_tool_cls, _PATCHED_FLAG): + delattr(function_tool_cls, _PATCHED_FLAG) return None tool_end_method = getattr(target, "on_tool_end", None) From 7c04c6f6ffef041b7c7be621f66788a6f9252331 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:10:07 +0800 Subject: [PATCH 13/19] =?UTF-8?q?=E2=9C=A8=20(assembly):=20Pass=20process?= =?UTF-8?q?=5Fagent=5Fid=20into=20OpenAIAgentsPatch=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/core/assembly.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/agent_assembly/core/assembly.py b/agent_assembly/core/assembly.py index 60b1fb5..90a8cbe 100644 --- a/agent_assembly/core/assembly.py +++ b/agent_assembly/core/assembly.py @@ -186,7 +186,12 @@ def _build_patch_plan(client: GatewayClient, process_agent_id: str) -> list[Runt if _is_installed("pydantic_ai"): patch_plan.append(PydanticAIPatch(callback_target)) if _is_installed("openai") and _has_agents_sdk(): - patch_plan.append(OpenAIAgentsPatch(callback_target)) + patch_plan.append( + OpenAIAgentsPatch( + callback_handler=callback_target, + process_agent_id=process_agent_id, + ) + ) if _is_installed("mcp"): # Keep MCP patch last as fallback for remaining tool dispatch paths. patch_plan.append( From cd3320ac717a4b792fe8938491bae4a235d26524 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:11:52 +0800 Subject: [PATCH 14/19] =?UTF-8?q?=E2=9C=85=20(tests):=20Add=20comprehensiv?= =?UTF-8?q?e=20OpenAI=20Agents=20patch=20unit=20coverage=20and=20retire=20?= =?UTF-8?q?optional=20placeholder=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/adapters/openai_agents/test_patch.py | 219 +++++++++++++++++- test/unit/adapters/test_optional_patches.py | 20 -- 2 files changed, 213 insertions(+), 26 deletions(-) delete mode 100644 test/unit/adapters/test_optional_patches.py diff --git a/test/unit/adapters/openai_agents/test_patch.py b/test/unit/adapters/openai_agents/test_patch.py index 7cb9224..cf26df1 100644 --- a/test/unit/adapters/openai_agents/test_patch.py +++ b/test/unit/adapters/openai_agents/test_patch.py @@ -8,12 +8,20 @@ from agent_assembly.adapters.openai_agents import patch as openai_patch -def _install_fake_openai_agents_module(monkeypatch: pytest.MonkeyPatch) -> type[Any]: +def _install_fake_openai_agents_module(monkeypatch: pytest.MonkeyPatch) -> tuple[type[Any], type[Any]]: + class FakeToolResult: + def __init__(self, *, error: str | None = None, output: Any = None) -> None: + self.error = error + self.output = output + class FakeFunctionTool: + def __init__(self, name: str = "fake_openai_tool") -> None: + self.name = name + async def __call__(self, ctx: Any, tool_input: Any) -> dict[str, Any]: - return {"ctx": ctx, "tool_input": tool_input} + return {"ctx": ctx, "tool_input": tool_input, "name": self.name} - fake_module = SimpleNamespace(FunctionTool=FakeFunctionTool) + fake_module = SimpleNamespace(FunctionTool=FakeFunctionTool, ToolResult=FakeToolResult) def fake_import_module(module_name: str) -> object: if module_name == "openai.agents": @@ -22,20 +30,219 @@ def fake_import_module(module_name: str) -> object: monkeypatch.setattr(openai_patch.importlib, "import_module", fake_import_module) monkeypatch.setattr(openai_patch.importlib.util, "find_spec", lambda package: object()) - return FakeFunctionTool + return FakeFunctionTool, FakeToolResult def test_apply_patches_functiontool_call_and_is_idempotent( monkeypatch: pytest.MonkeyPatch, ) -> None: - function_tool_cls = _install_fake_openai_agents_module(monkeypatch) - patcher = openai_patch.OpenAIAgentsPatch(callback_handler=object()) + function_tool_cls, _ = _install_fake_openai_agents_module(monkeypatch) + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=object(), process_agent_id="agent-1") original_call = function_tool_cls.__call__ assert patcher.apply() is True patched_call = function_tool_cls.__call__ assert patched_call is not original_call + assert openai_patch._get_process_agent_id() == "agent-1" assert patcher.apply() is True assert function_tool_cls.__call__ is patched_call + + +def test_revert_restores_original_functiontool_call_and_clears_process_agent_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls, _ = _install_fake_openai_agents_module(monkeypatch) + original_call = function_tool_cls.__call__ + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=object()) + + assert patcher.apply() is True + assert function_tool_cls.__call__ is not original_call + patcher.revert() + + assert function_tool_cls.__call__ is original_call + assert getattr(function_tool_cls, openai_patch._PATCHED_FLAG, False) is False + assert openai_patch._get_process_agent_id() is None + + +def test_loader_edge_cases_and_apply_false_when_functiontool_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + openai_patch.importlib, + "import_module", + lambda _: (_ for _ in ()).throw(ImportError("openai.agents")), + ) + monkeypatch.setattr(openai_patch.importlib.util, "find_spec", lambda package: None) + + assert openai_patch._is_openai_agents_available() is False + assert openai_patch._load_openai_agents_function_tool_class() is None + assert openai_patch.OpenAIAgentsPatch(callback_handler=object()).apply() is False + + fake_module = SimpleNamespace(FunctionTool=object()) + monkeypatch.setattr( + openai_patch.importlib, + "import_module", + lambda name: fake_module + if name == "openai.agents" + else (_ for _ in ()).throw(ImportError(name)), + ) + monkeypatch.setattr(openai_patch.importlib.util, "find_spec", lambda package: object()) + assert openai_patch._is_openai_agents_available() is True + assert openai_patch._load_openai_agents_function_tool_class() is None + + +@pytest.mark.asyncio +async def test_deny_returns_toolresult_error_without_raising( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls, tool_result_cls = _install_fake_openai_agents_module(monkeypatch) + + class Interceptor: + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + assert kwargs["tool_name"] == "blocked_tool" + return {"status": "deny", "reason": "blocked by policy"} + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=Interceptor()) + assert patcher.apply() is True + + tool = function_tool_cls(name="blocked_tool") + ctx = SimpleNamespace(agent_id="agent-deny") + result = await tool(ctx, {"topic": "finance"}) + + assert isinstance(result, tool_result_cls) + assert isinstance(result.error, str) + assert "blocked by policy" in result.error + + +@pytest.mark.asyncio +async def test_pending_then_approved_executes_tool_and_records_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls, _ = _install_fake_openai_agents_module(monkeypatch) + wait_calls: list[dict[str, object]] = [] + recorded_results: list[dict[str, object]] = [] + + class Interceptor: + pending_tool_approval_timeout_seconds = 23 + + 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]: + wait_calls.append(dict(kwargs)) + return {"status": "allow"} + + async def record_result(self, **kwargs: object) -> None: + recorded_results.append(dict(kwargs)) + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=Interceptor()) + assert patcher.apply() is True + + tool = function_tool_cls(name="approved_tool") + ctx = SimpleNamespace(agent_id="agent-allow") + result = await tool(ctx, {"q": "hello"}) + + assert result["name"] == "approved_tool" + assert len(wait_calls) == 1 + assert wait_calls[0]["timeout_seconds"] == 23 + assert len(recorded_results) == 1 + assert recorded_results[0]["tool_name"] == "approved_tool" + assert recorded_results[0]["agent_id"] == "agent-allow" + + +@pytest.mark.asyncio +async def test_pending_then_denied_returns_toolresult_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls, tool_result_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": "deny", "reason": "approval rejected"} + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=Interceptor()) + assert patcher.apply() is True + + tool = function_tool_cls(name="rejected_tool") + ctx = SimpleNamespace(agent_id="agent-pending-deny") + result = await tool(ctx, {"q": "secret"}) + + assert isinstance(result, tool_result_cls) + assert isinstance(result.error, str) + assert "approval rejected" in result.error + + +@pytest.mark.asyncio +async def test_extracts_agent_id_from_ctx_and_falls_back_to_process_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls, _ = _install_fake_openai_agents_module(monkeypatch) + seen_agent_ids: list[str | None] = [] + + class Interceptor: + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + seen_agent_ids.append(kwargs.get("agent_id")) # type: ignore[arg-type] + return {"status": "allow"} + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=Interceptor(), process_agent_id="process-agent") + assert patcher.apply() is True + tool = function_tool_cls(name="agent_id_tool") + + await tool(SimpleNamespace(agent_id="ctx-agent"), {"step": 1}) + await tool(SimpleNamespace(), {"step": 2}) + + assert seen_agent_ids == ["ctx-agent", "process-agent"] + + +@pytest.mark.asyncio +async def test_records_tool_result_after_successful_execution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls, _ = _install_fake_openai_agents_module(monkeypatch) + observed_results: list[str] = [] + + class Interceptor: + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + async def record_result(self, **kwargs: object) -> None: + observed_results.append(str(kwargs["result"])) + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=Interceptor()) + assert patcher.apply() is True + + tool = function_tool_cls(name="record_tool") + result = await tool(SimpleNamespace(agent_id="agent-rec"), {"x": 1}) + + assert result["name"] == "record_tool" + assert len(observed_results) == 1 + assert "record_tool" in observed_results[0] + + +@pytest.mark.asyncio +async def test_gateway_error_uses_fail_open_and_executes_original_tool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls, _ = _install_fake_openai_agents_module(monkeypatch) + + class Interceptor: + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + raise RuntimeError("gateway unavailable") + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=Interceptor()) + assert patcher.apply() is True + + tool = function_tool_cls(name="fail_open_tool") + result = await tool(SimpleNamespace(agent_id="agent-fail-open"), {"x": 2}) + + assert result["name"] == "fail_open_tool" diff --git a/test/unit/adapters/test_optional_patches.py b/test/unit/adapters/test_optional_patches.py deleted file mode 100644 index 84fe1bc..0000000 --- a/test/unit/adapters/test_optional_patches.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -import pytest - -from agent_assembly.adapters.openai_agents.patch import OpenAIAgentsPatch -from agent_assembly.adapters.openai_agents import patch as openai_patch - -def test_openai_agents_patch_apply_and_revert(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(openai_patch.importlib.util, "find_spec", lambda package: object()) - patcher = OpenAIAgentsPatch(callback_handler=object()) - assert patcher.apply() is True - patcher.revert() - - -def test_openai_agents_patch_apply_returns_false_when_module_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(openai_patch.importlib.util, "find_spec", lambda package: None) - patcher = OpenAIAgentsPatch(callback_handler=object()) - assert patcher.apply() is False From d94da17969da68aea7d6a65a2886bf36d7096f80 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:12:51 +0800 Subject: [PATCH 15/19] =?UTF-8?q?=E2=9C=85=20(integration):=20Add=20direct?= =?UTF-8?q?=20and=20MCP-coexistence=20coverage=20for=20OpenAI=20Agents=20a?= =?UTF-8?q?dapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_direct_functiontool_integration.py | 55 ++++++++++++ ...test_openai_mcp_coexistence_integration.py | 87 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 test/integration/openai_agents/test_direct_functiontool_integration.py create mode 100644 test/integration/openai_agents/test_openai_mcp_coexistence_integration.py diff --git a/test/integration/openai_agents/test_direct_functiontool_integration.py b/test/integration/openai_agents/test_direct_functiontool_integration.py new file mode 100644 index 0000000..901dff0 --- /dev/null +++ b/test/integration/openai_agents/test_direct_functiontool_integration.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from agent_assembly.adapters.openai_agents import patch as openai_patch + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_direct_openai_agents_functiontool_blocks_then_allows_followup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeToolResult: + def __init__(self, *, error: str | None = None, output: Any = None) -> None: + self.error = error + self.output = output + + class FakeFunctionTool: + def __init__(self, name: str) -> None: + self.name = name + + async def __call__(self, ctx: Any, tool_input: Any) -> dict[str, Any]: + return {"name": self.name, "ctx": ctx, "tool_input": tool_input} + + fake_openai_agents_module = SimpleNamespace( + FunctionTool=FakeFunctionTool, + ToolResult=FakeToolResult, + ) + monkeypatch.setattr( + openai_patch.importlib, + "import_module", + lambda name: fake_openai_agents_module + if name == "openai.agents" + else (_ for _ in ()).throw(ImportError(name)), + ) + + class Interceptor: + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + if kwargs.get("tool_name") == "blocked_tool": + return {"status": "deny", "reason": "blocked by policy"} + return {"status": "allow"} + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=Interceptor(), process_agent_id="agent-oa") + assert patcher.apply() is True + + blocked = await FakeFunctionTool("blocked_tool")(SimpleNamespace(agent_id="agent-oa"), {"step": 1}) + safe = await FakeFunctionTool("safe_tool")(SimpleNamespace(agent_id="agent-oa"), {"step": 2}) + + assert isinstance(blocked, FakeToolResult) + assert isinstance(blocked.error, str) + assert "blocked by policy" in blocked.error + assert safe["name"] == "safe_tool" diff --git a/test/integration/openai_agents/test_openai_mcp_coexistence_integration.py b/test/integration/openai_agents/test_openai_mcp_coexistence_integration.py new file mode 100644 index 0000000..7a040c6 --- /dev/null +++ b/test/integration/openai_agents/test_openai_mcp_coexistence_integration.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from agent_assembly.adapters.mcp import patch as mcp_patch +from agent_assembly.adapters.openai_agents import patch as openai_patch + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_openai_agents_and_mcp_layers_both_emit_governance_events( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeMCPClientSession: + def __init__(self) -> None: + self._server_name = "mcp-from-openai-agent" + + async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> str: + payload = arguments or {} + return f"mcp:{name}:{payload.get('q', '')}" + + class FakeToolResult: + def __init__(self, *, error: str | None = None, output: Any = None) -> None: + self.error = error + self.output = output + + class FakeFunctionTool: + def __init__(self, name: str) -> None: + self.name = name + + async def __call__(self, ctx: Any, tool_input: Any) -> dict[str, Any]: + mcp_result = await FakeMCPClientSession().call_tool("mcp_tool", {"q": "hello"}) + return {"name": self.name, "ctx": ctx, "tool_input": tool_input, "mcp_result": mcp_result} + + fake_openai_agents_module = SimpleNamespace( + FunctionTool=FakeFunctionTool, + ToolResult=FakeToolResult, + ) + fake_mcp_module = SimpleNamespace(ClientSession=FakeMCPClientSession) + + monkeypatch.setattr( + openai_patch.importlib, + "import_module", + lambda name: fake_openai_agents_module + if name == "openai.agents" + else (_ for _ in ()).throw(ImportError(name)), + ) + monkeypatch.setattr( + mcp_patch.importlib, + "import_module", + lambda name: fake_mcp_module + if name == "mcp" + else (_ for _ in ()).throw(ImportError(name)), + ) + + checks: list[dict[str, object]] = [] + records: list[dict[str, object]] = [] + + class Interceptor: + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + checks.append(dict(kwargs)) + return {"status": "allow"} + + async def record_result(self, **kwargs: object) -> None: + records.append(dict(kwargs)) + + interceptor = Interceptor() + assert openai_patch.OpenAIAgentsPatch( + callback_handler=interceptor, + process_agent_id="agent-openai", + ).apply() is True + assert mcp_patch.MCPClientPatch( + callback_handler=interceptor, + process_agent_id="agent-openai", + ).apply() is True + + result = await FakeFunctionTool("openai_tool")(SimpleNamespace(agent_id="agent-openai"), {"step": "run"}) + + assert result["name"] == "openai_tool" + assert result["mcp_result"] == "mcp:mcp_tool:hello" + assert len(checks) == 2 + assert checks[0]["serialized"] == {"name": "openai_tool"} + assert checks[1]["serialized"] == {"name": "mcp_tool"} + assert len(records) == 2 From cdd007138a52035f1a9c95825bcce38368b04549 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:14:02 +0800 Subject: [PATCH 16/19] =?UTF-8?q?=F0=9F=A9=B9=20(openai=5Fagents):=20Fix?= =?UTF-8?q?=20async=20result=20recording=20fallback=20block=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/openai_agents/patch.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index aa3a687..0624eb8 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -201,6 +201,18 @@ async def _record_async_tool_result( ) if inspect.isawaitable(recorded): await recorded + return None + + tool_end_method = getattr(target, "on_tool_end", None) + if callable(tool_end_method): + recorded = tool_end_method( + output=_truncate_result_for_audit(result), + tool_name=tool_name, + agent_id=agent_id, + run_context=ctx, + ) + if inspect.isawaitable(recorded): + await recorded def _is_governance_error(error: Exception) -> bool: @@ -298,15 +310,3 @@ def _revert_function_tool_call_patch(function_tool_cls: type[Any]) -> None: delattr(function_tool_cls, _ORIGINAL_FUNCTION_TOOL_CALL) if hasattr(function_tool_cls, _PATCHED_FLAG): delattr(function_tool_cls, _PATCHED_FLAG) - return None - - tool_end_method = getattr(target, "on_tool_end", None) - if callable(tool_end_method): - recorded = tool_end_method( - output=_truncate_result_for_audit(result), - tool_name=tool_name, - agent_id=agent_id, - run_context=ctx, - ) - if inspect.isawaitable(recorded): - await recorded From 67dc91c1c04c4435e672b870405c990fa8d9f775 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 16:15:13 +0800 Subject: [PATCH 17/19] =?UTF-8?q?=F0=9F=A9=B9=20(integration):=20Use=20sha?= =?UTF-8?q?red=20import=20dispatcher=20for=20OpenAI=20and=20MCP=20coexiste?= =?UTF-8?q?nce=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...test_openai_mcp_coexistence_integration.py | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/test/integration/openai_agents/test_openai_mcp_coexistence_integration.py b/test/integration/openai_agents/test_openai_mcp_coexistence_integration.py index 7a040c6..f53d8c1 100644 --- a/test/integration/openai_agents/test_openai_mcp_coexistence_integration.py +++ b/test/integration/openai_agents/test_openai_mcp_coexistence_integration.py @@ -41,20 +41,15 @@ async def __call__(self, ctx: Any, tool_input: Any) -> dict[str, Any]: ) fake_mcp_module = SimpleNamespace(ClientSession=FakeMCPClientSession) - monkeypatch.setattr( - openai_patch.importlib, - "import_module", - lambda name: fake_openai_agents_module - if name == "openai.agents" - else (_ for _ in ()).throw(ImportError(name)), - ) - monkeypatch.setattr( - mcp_patch.importlib, - "import_module", - lambda name: fake_mcp_module - if name == "mcp" - else (_ for _ in ()).throw(ImportError(name)), - ) + def fake_import_module(name: str) -> object: + if name == "openai.agents": + return fake_openai_agents_module + if name == "mcp": + return fake_mcp_module + raise ImportError(name) + + monkeypatch.setattr(openai_patch.importlib, "import_module", fake_import_module) + monkeypatch.setattr(mcp_patch.importlib, "import_module", fake_import_module) checks: list[dict[str, object]] = [] records: list[dict[str, object]] = [] From c0b67845bb0a92cdf4b324e2e54b691ea3fcb7e0 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 18:29:58 +0800 Subject: [PATCH 18/19] =?UTF-8?q?=E2=9C=85=20(tests):=20Cover=20OpenAI=20a?= =?UTF-8?q?dapter=20fallback=20branches=20to=20resolve=20patch=20coverage?= =?UTF-8?q?=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/adapters/openai_agents/test_patch.py | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/test/unit/adapters/openai_agents/test_patch.py b/test/unit/adapters/openai_agents/test_patch.py index cf26df1..1dafda7 100644 --- a/test/unit/adapters/openai_agents/test_patch.py +++ b/test/unit/adapters/openai_agents/test_patch.py @@ -246,3 +246,146 @@ async def check_tool_start(self, **kwargs: object) -> dict[str, str]: result = await tool(SimpleNamespace(agent_id="agent-fail-open"), {"x": 2}) assert result["name"] == "fail_open_tool" + + +@pytest.mark.asyncio +async def test_non_governance_error_is_reraised( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls, _ = _install_fake_openai_agents_module(monkeypatch) + + class Interceptor: + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + raise ValueError("unexpected") + + monkeypatch.setattr(openai_patch, "_is_governance_error", lambda error: False) + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=Interceptor()) + assert patcher.apply() is True + + tool = function_tool_cls(name="strict_tool") + with pytest.raises(ValueError, match="unexpected"): + await tool(SimpleNamespace(agent_id="agent-strict"), {"x": 3}) + + +@pytest.mark.asyncio +async def test_helper_fallback_branches_for_check_wait_record_and_result_build( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_fake_openai_agents_module(monkeypatch) + + class Wrapper: + def __init__(self) -> None: + self._interceptor = SimpleNamespace() + + # _resolve_governance_target with wrapper + assert openai_patch._resolve_governance_target(Wrapper()) is not None + + # _invoke_async_tool_check fallback when check method missing + fallback_check = await openai_patch._invoke_async_tool_check( + object(), + tool_name="x", + tool_input={}, + agent_id=None, + ctx=SimpleNamespace(), + ) + assert fallback_check == {"status": "allow"} + + class SyncCheck: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + # _invoke_async_tool_check non-awaitable branch + sync_check = await openai_patch._invoke_async_tool_check( + SyncCheck(), + tool_name="x", + tool_input={}, + agent_id=None, + ctx=SimpleNamespace(), + ) + assert sync_check == {"status": "allow"} + + # _wait_for_async_tool_approval fallback when wait method missing + fallback_wait = await openai_patch._wait_for_async_tool_approval( + object(), + tool_name="x", + timeout_seconds=1, + tool_input={}, + agent_id=None, + ctx=SimpleNamespace(), + ) + assert fallback_wait["status"] == "deny" + + class SyncWait: + def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + # _wait_for_async_tool_approval non-awaitable branch + sync_wait = await openai_patch._wait_for_async_tool_approval( + SyncWait(), + tool_name="x", + timeout_seconds=1, + tool_input={}, + agent_id=None, + ctx=SimpleNamespace(), + ) + assert sync_wait == {"status": "allow"} + + observed_tool_end: list[str] = [] + + class OnToolEndOnly: + def on_tool_end(self, **kwargs: object) -> None: + observed_tool_end.append(str(kwargs["output"])) + + # _record_async_tool_result on_tool_end fallback branch + await openai_patch._record_async_tool_result( + OnToolEndOnly(), + tool_name="x", + tool_input={}, + result={"value": "ok"}, + agent_id=None, + ctx=SimpleNamespace(), + ) + assert observed_tool_end + + class FailingToolResult: + def __init__(self, **kwargs: object) -> None: + del kwargs + raise RuntimeError("cannot build") + + fake_module = SimpleNamespace(FunctionTool=object(), ToolResult=FailingToolResult) + monkeypatch.setattr( + openai_patch.importlib, + "import_module", + lambda name: fake_module + if name == "openai.agents" + else (_ for _ in ()).throw(ImportError(name)), + ) + + # _build_tool_result_error fallback dict branch + fallback_result = openai_patch._build_tool_result_error( + tool_name="x", + reason="nope", + is_pending_rejection=False, + ) + assert isinstance(fallback_result, dict) + assert "error" in fallback_result + + +def test_apply_and_revert_helpers_cover_non_callable_and_unpatched_branches() -> None: + class NoCallable: + __call__ = None + + class NotPatched: + async def __call__(self, ctx: Any, tool_input: Any) -> None: + del ctx, tool_input + return None + + # _apply_function_tool_call_patch early return when __call__ is not callable + openai_patch._apply_function_tool_call_patch(NoCallable, callback_handler=object()) + + # _revert_function_tool_call_patch early return when patch flag absent + openai_patch._revert_function_tool_call_patch(NotPatched) From 5c092308dbaf0e52403467d7937dc4da58450f32 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Thu, 30 Apr 2026 18:31:31 +0800 Subject: [PATCH 19/19] =?UTF-8?q?=E2=9C=85=20(tests):=20Cover=20async=20on?= =?UTF-8?q?=5Ftool=5Fend=20fallback=20await=20branch=20for=20OpenAI=20adap?= =?UTF-8?q?ter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/adapters/openai_agents/test_patch.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/unit/adapters/openai_agents/test_patch.py b/test/unit/adapters/openai_agents/test_patch.py index 1dafda7..2808977 100644 --- a/test/unit/adapters/openai_agents/test_patch.py +++ b/test/unit/adapters/openai_agents/test_patch.py @@ -389,3 +389,23 @@ async def __call__(self, ctx: Any, tool_input: Any) -> None: # _revert_function_tool_call_patch early return when patch flag absent openai_patch._revert_function_tool_call_patch(NotPatched) + + +@pytest.mark.asyncio +async def test_record_result_fallback_awaits_async_on_tool_end() -> None: + observed: list[str] = [] + + class AsyncOnToolEndOnly: + async def on_tool_end(self, **kwargs: object) -> None: + observed.append(str(kwargs["output"])) + + await openai_patch._record_async_tool_result( + AsyncOnToolEndOnly(), + tool_name="x", + tool_input={}, + result={"value": "ok"}, + agent_id=None, + ctx=SimpleNamespace(), + ) + + assert observed