From 90e358c751efa0f596b94b5373675a1db71ae790 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:49:14 +0800 Subject: [PATCH 01/36] =?UTF-8?q?=F0=9F=93=A6=20(adapters):=20Create=20emp?= =?UTF-8?q?ty=20CrewAI=20adapter=20package=20scaffold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 agent_assembly/adapters/crewai/__init__.py diff --git a/agent_assembly/adapters/crewai/__init__.py b/agent_assembly/adapters/crewai/__init__.py new file mode 100644 index 0000000..e69de29 From 6d8c5ab6058b98922939e76e5fd1cdf5720694f2 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:49:18 +0800 Subject: [PATCH 02/36] =?UTF-8?q?=F0=9F=93=A6=20(adapters):=20Add=20empty?= =?UTF-8?q?=20CrewAI=20patch=20module=20scaffold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 agent_assembly/adapters/crewai/patch.py diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py new file mode 100644 index 0000000..e69de29 From 698c9fbeccd145843c288d769e4e09fe70fefdea Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:49:25 +0800 Subject: [PATCH 03/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20CrewAIPatch?= =?UTF-8?q?=20class=20skeleton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index e69de29..3d0192f 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -0,0 +1,13 @@ +"""CrewAI patch module.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(slots=True) +class CrewAIPatch: + """Applies CrewAI runtime monkey-patching hooks.""" + + callback_handler: Any From 13fe05de112425a2bf4cc5599c5b57ea9275eae9 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:49:35 +0800 Subject: [PATCH 04/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20apply=20she?= =?UTF-8?q?ll=20for=20CrewAI=20patch=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 3d0192f..ac69559 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -11,3 +11,8 @@ class CrewAIPatch: """Applies CrewAI runtime monkey-patching hooks.""" callback_handler: Any + + def apply(self) -> bool: + """Apply patch wiring and return whether CrewAI is available.""" + del self + return False From 4c188a79871d83687af998db1fc52a859629bce1 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:49:42 +0800 Subject: [PATCH 05/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20idempotent?= =?UTF-8?q?=20patch=20guard=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index ac69559..8d58063 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -5,6 +5,11 @@ from dataclasses import dataclass from typing import Any +_TOOLS_PATCHED_FLAG = "_agent_assembly_crewai_tools_patched" +_TASK_PATCHED_FLAG = "_agent_assembly_crewai_task_patched" +_ORIGINAL_TOOL_RUN = "_agent_assembly_original_crewai_tool_run" +_ORIGINAL_TASK_EXECUTE_SYNC = "_agent_assembly_original_crewai_task_execute_sync" + @dataclass(slots=True) class CrewAIPatch: From 3b412eafb948d1135347dacb9a35b5df3266f706 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:49:49 +0800 Subject: [PATCH 06/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20CrewAI=20cl?= =?UTF-8?q?ass=20loader=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 8d58063..ca899cb 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +import importlib from typing import Any _TOOLS_PATCHED_FLAG = "_agent_assembly_crewai_tools_patched" @@ -21,3 +22,27 @@ def apply(self) -> bool: """Apply patch wiring and return whether CrewAI is available.""" del self return False + + +def _load_crewai_basetool_class() -> type[Any] | None: + try: + module = importlib.import_module("crewai.tools") + except ImportError: + return None + + base_tool_cls = getattr(module, "BaseTool", None) + if isinstance(base_tool_cls, type): + return base_tool_cls + return None + + +def _load_crewai_task_class() -> type[Any] | None: + try: + module = importlib.import_module("crewai") + except ImportError: + return None + + task_cls = getattr(module, "Task", None) + if isinstance(task_cls, type): + return task_cls + return None From 9077f2cc443c3518e29b86e403ccdbe1e827cc25 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:50:05 +0800 Subject: [PATCH 07/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20thread-loca?= =?UTF-8?q?l=20agent=20context=20storage=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index ca899cb..21348f4 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -4,12 +4,14 @@ from dataclasses import dataclass import importlib +from threading import local from typing import Any _TOOLS_PATCHED_FLAG = "_agent_assembly_crewai_tools_patched" _TASK_PATCHED_FLAG = "_agent_assembly_crewai_task_patched" _ORIGINAL_TOOL_RUN = "_agent_assembly_original_crewai_tool_run" _ORIGINAL_TASK_EXECUTE_SYNC = "_agent_assembly_original_crewai_task_execute_sync" +_AGENT_CONTEXT = local() @dataclass(slots=True) @@ -46,3 +48,7 @@ def _load_crewai_task_class() -> type[Any] | None: if isinstance(task_cls, type): return task_cls return None + + +def _set_thread_local_agent_id(agent_id: str | None) -> None: + _AGENT_CONTEXT.agent_id = agent_id From 109ab2511caa9c4bd75e28fe970734f81cb213cc Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:50:10 +0800 Subject: [PATCH 08/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20thread-loca?= =?UTF-8?q?l=20agent=20ID=20getter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 21348f4..54b39f5 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -52,3 +52,10 @@ def _load_crewai_task_class() -> type[Any] | None: def _set_thread_local_agent_id(agent_id: str | None) -> None: _AGENT_CONTEXT.agent_id = agent_id + + +def _get_thread_local_agent_id() -> str | None: + agent_id = getattr(_AGENT_CONTEXT, "agent_id", None) + if isinstance(agent_id, str) and agent_id: + return agent_id + return None From f8f922ace6f634b6868f53f5b3e97717b169ff51 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:50:17 +0800 Subject: [PATCH 09/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20blocked=20p?= =?UTF-8?q?olicy=20response=20string=20formatter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 54b39f5..52bb844 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -59,3 +59,11 @@ def _get_thread_local_agent_id() -> str | None: if isinstance(agent_id, str) and agent_id: return agent_id return None + + +def _format_blocked_message(reason: str | None) -> str: + reason_text = reason or "No reason provided." + return ( + f"[BLOCKED by governance policy] {reason_text}. " + "Please choose a different approach to accomplish this task." + ) From 2e04a7f44f19d66ebb5cecae976d1dfc62b9a8db Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:50:24 +0800 Subject: [PATCH 10/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20approval=20?= =?UTF-8?q?rejected=20response=20string=20formatter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 52bb844..0fdef3c 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -67,3 +67,8 @@ def _format_blocked_message(reason: str | None) -> str: f"[BLOCKED by governance policy] {reason_text}. " "Please choose a different approach to accomplish this task." ) + + +def _format_approval_rejected_message(reason: str | None) -> str: + reason_text = reason or "No reason provided." + return f"[APPROVAL REJECTED] Action was reviewed and denied: {reason_text}" From 47f67fbdf13fc5395eebc718b4eaa31d8bc41178 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:50:36 +0800 Subject: [PATCH 11/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20governance?= =?UTF-8?q?=20decision=20normalization=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 0fdef3c..ba2c2ac 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -5,7 +5,7 @@ from dataclasses import dataclass import importlib from threading import local -from typing import Any +from typing import Any, Literal, Mapping _TOOLS_PATCHED_FLAG = "_agent_assembly_crewai_tools_patched" _TASK_PATCHED_FLAG = "_agent_assembly_crewai_task_patched" @@ -72,3 +72,30 @@ def _format_blocked_message(reason: str | None) -> str: def _format_approval_rejected_message(reason: str | None) -> str: reason_text = reason or "No reason provided." return f"[APPROVAL REJECTED] Action was reviewed and denied: {reason_text}" + + +def _normalize_decision( + decision: object, +) -> tuple[Literal["allow", "deny", "pending"], str | None]: + if isinstance(decision, str): + normalized = decision.strip().lower() + if normalized == "deny": + return "deny", None + if normalized == "pending": + return "pending", None + return "allow", None + + if isinstance(decision, Mapping): + raw_status = str(decision.get("status", "allow")).strip().lower() + if raw_status == "deny": + status: Literal["allow", "deny", "pending"] = "deny" + elif raw_status == "pending": + status = "pending" + else: + status = "allow" + + reason_value = decision.get("reason") + reason = str(reason_value) if reason_value is not None else None + return status, reason + + return "allow", None From 1a71261bc143824a04040a27e4414c91016b227e Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:50:48 +0800 Subject: [PATCH 12/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20sync=20gove?= =?UTF-8?q?rnance=20tool-check=20invocation=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index ba2c2ac..063c568 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -99,3 +99,23 @@ def _normalize_decision( return status, reason return "allow", None + + +def _invoke_sync_tool_check( + callback_handler: Any, + *, + tool_name: str, + tool_args: dict[str, Any], + agent_id: str | None, +) -> object: + method = getattr(callback_handler, "check_tool_start", None) + if callable(method): + return method( + serialized={"name": tool_name}, + input_str=str(tool_args), + tool_name=tool_name, + args=tool_args, + agent_id=agent_id, + ) + + return {"status": "allow"} From 03bd6b551d8a245a984d9d10b419bbc360845a7b Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:50:58 +0800 Subject: [PATCH 13/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20sync=20pend?= =?UTF-8?q?ing-approval=20wait=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 063c568..188d186 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -119,3 +119,23 @@ def _invoke_sync_tool_check( ) return {"status": "allow"} + + +def _wait_for_sync_tool_approval( + callback_handler: Any, + *, + tool_name: str, + timeout_seconds: int, + tool_args: dict[str, Any], + agent_id: str | None, +) -> object: + method = getattr(callback_handler, "wait_for_tool_approval", None) + if callable(method): + return method( + tool_name=tool_name, + timeout_seconds=timeout_seconds, + args=tool_args, + agent_id=agent_id, + ) + + return {"status": "deny", "reason": "Approval handler is unavailable."} From 8821e2201ea8f0ef2b634c623bc1a3ad75dc7709 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:51:11 +0800 Subject: [PATCH 14/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Patch=20BaseTool.?= =?UTF-8?q?run=20with=20governance=20pre-check=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 46 +++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 188d186..d9ea922 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -22,8 +22,12 @@ class CrewAIPatch: def apply(self) -> bool: """Apply patch wiring and return whether CrewAI is available.""" - del self - return False + base_tool_cls = _load_crewai_basetool_class() + if base_tool_cls is None: + return False + + _apply_basetool_run_patch(base_tool_cls, self.callback_handler) + return True def _load_crewai_basetool_class() -> type[Any] | None: @@ -139,3 +143,41 @@ def _wait_for_sync_tool_approval( ) return {"status": "deny", "reason": "Approval handler is unavailable."} + + +def _apply_basetool_run_patch(base_tool_cls: type[Any], callback_handler: Any) -> None: + if getattr(base_tool_cls, _TOOLS_PATCHED_FLAG, False): + return None + + original_run = base_tool_cls.run + + def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any: + tool_name = getattr(self, "name", self.__class__.__name__) + tool_args = dict(kwargs) + agent_id = _get_thread_local_agent_id() + decision = _invoke_sync_tool_check( + callback_handler, + tool_name=str(tool_name), + tool_args=tool_args, + agent_id=agent_id, + ) + status, reason = _normalize_decision(decision) + if status == "pending": + final_decision = _wait_for_sync_tool_approval( + callback_handler, + tool_name=str(tool_name), + timeout_seconds=300, + tool_args=tool_args, + agent_id=agent_id, + ) + status, reason = _normalize_decision(final_decision) + + if status == "deny": + del reason + return "" + + return original_run(self, *args, **kwargs) + + setattr(base_tool_cls, _ORIGINAL_TOOL_RUN, original_run) + setattr(base_tool_cls, "run", patched_run) + setattr(base_tool_cls, _TOOLS_PATCHED_FLAG, True) From 6dba34576dc37d8f416161e46a2c4bdc11a2a644 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:51:23 +0800 Subject: [PATCH 15/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Return=20denied?= =?UTF-8?q?=20governance=20decisions=20as=20CrewAI=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index d9ea922..7d6bdb4 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -162,7 +162,9 @@ def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any: agent_id=agent_id, ) status, reason = _normalize_decision(decision) + is_pending_flow = False if status == "pending": + is_pending_flow = True final_decision = _wait_for_sync_tool_approval( callback_handler, tool_name=str(tool_name), @@ -173,8 +175,9 @@ def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any: status, reason = _normalize_decision(final_decision) if status == "deny": - del reason - return "" + if is_pending_flow: + return _format_approval_rejected_message(reason) + return _format_blocked_message(reason) return original_run(self, *args, **kwargs) From ba4ea1859f05f78a0593139078bceecfcd1aaa26 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:51:30 +0800 Subject: [PATCH 16/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Record=20allowed?= =?UTF-8?q?=20tool=20results=20after=20run=20passthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 7d6bdb4..9fec45c 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -145,6 +145,23 @@ def _wait_for_sync_tool_approval( return {"status": "deny", "reason": "Approval handler is unavailable."} +def _record_sync_tool_result( + callback_handler: Any, + *, + tool_name: str, + result: object, +) -> None: + record_method = getattr(callback_handler, "record_result", None) + if callable(record_method): + record_method(tool_name=tool_name, result=result) + return None + + tool_end_method = getattr(callback_handler, "on_tool_end", None) + if callable(tool_end_method): + tool_end_method(output=result, tool_name=tool_name) + return None + + def _apply_basetool_run_patch(base_tool_cls: type[Any], callback_handler: Any) -> None: if getattr(base_tool_cls, _TOOLS_PATCHED_FLAG, False): return None @@ -179,7 +196,9 @@ def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any: return _format_approval_rejected_message(reason) return _format_blocked_message(reason) - return original_run(self, *args, **kwargs) + result = original_run(self, *args, **kwargs) + _record_sync_tool_result(callback_handler, tool_name=str(tool_name), result=result) + return result setattr(base_tool_cls, _ORIGINAL_TOOL_RUN, original_run) setattr(base_tool_cls, "run", patched_run) From 25dfc63e6b5fc4eb6d955934fb819609b1c543bc Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:51:56 +0800 Subject: [PATCH 17/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Patch=20Task.exec?= =?UTF-8?q?ute=5Fsync=20with=20task-start=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 9fec45c..d5c7845 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -27,6 +27,9 @@ def apply(self) -> bool: return False _apply_basetool_run_patch(base_tool_cls, self.callback_handler) + task_cls = _load_crewai_task_class() + if task_cls is not None: + _apply_task_execute_sync_patch(task_cls, self.callback_handler) return True @@ -203,3 +206,34 @@ def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any: setattr(base_tool_cls, _ORIGINAL_TOOL_RUN, original_run) setattr(base_tool_cls, "run", patched_run) setattr(base_tool_cls, _TOOLS_PATCHED_FLAG, True) + + +def _record_task_start(callback_handler: Any, task: Any) -> None: + method = getattr(callback_handler, "record", None) + if callable(method): + method( + action="task_start", + task_description=str(getattr(task, "description", ""))[:200], + expected_output=getattr(task, "expected_output", None), + ) + return None + + fallback = getattr(callback_handler, "on_task_start", None) + if callable(fallback): + fallback(task=task) + return None + + +def _apply_task_execute_sync_patch(task_cls: type[Any], callback_handler: Any) -> None: + if getattr(task_cls, _TASK_PATCHED_FLAG, False): + return None + + original_execute_sync = task_cls.execute_sync + + def patched_execute_sync(self: Any, *args: Any, **kwargs: Any) -> Any: + _record_task_start(callback_handler, self) + return original_execute_sync(self, *args, **kwargs) + + setattr(task_cls, _ORIGINAL_TASK_EXECUTE_SYNC, original_execute_sync) + setattr(task_cls, "execute_sync", patched_execute_sync) + setattr(task_cls, _TASK_PATCHED_FLAG, True) From e909518e96a57a2dab3713c30d87d871d06b4b32 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:52:08 +0800 Subject: [PATCH 18/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Add=20task-comple?= =?UTF-8?q?te=20hook=20recording=20for=20execute=5Fsync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index d5c7845..74d1806 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -224,6 +224,18 @@ def _record_task_start(callback_handler: Any, task: Any) -> None: return None +def _record_task_complete(callback_handler: Any, result: object) -> None: + method = getattr(callback_handler, "record", None) + if callable(method): + method(action="task_complete", output_preview=str(result)[:500]) + return None + + fallback = getattr(callback_handler, "on_task_complete", None) + if callable(fallback): + fallback(result=result) + return None + + def _apply_task_execute_sync_patch(task_cls: type[Any], callback_handler: Any) -> None: if getattr(task_cls, _TASK_PATCHED_FLAG, False): return None @@ -232,7 +244,9 @@ def _apply_task_execute_sync_patch(task_cls: type[Any], callback_handler: Any) - def patched_execute_sync(self: Any, *args: Any, **kwargs: Any) -> Any: _record_task_start(callback_handler, self) - return original_execute_sync(self, *args, **kwargs) + result = original_execute_sync(self, *args, **kwargs) + _record_task_complete(callback_handler, result) + return result setattr(task_cls, _ORIGINAL_TASK_EXECUTE_SYNC, original_execute_sync) setattr(task_cls, "execute_sync", patched_execute_sync) From 2a28dc76e97a1c122073bf97faefcd02cce3ae52 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:52:19 +0800 Subject: [PATCH 19/36] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(crewai):=20Preserve?= =?UTF-8?q?=20original=20callable=20metadata=20in=20patched=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 74d1806..9eb406c 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from functools import wraps import importlib from threading import local from typing import Any, Literal, Mapping @@ -171,6 +172,7 @@ def _apply_basetool_run_patch(base_tool_cls: type[Any], callback_handler: Any) - original_run = base_tool_cls.run + @wraps(original_run) def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any: tool_name = getattr(self, "name", self.__class__.__name__) tool_args = dict(kwargs) @@ -242,6 +244,7 @@ def _apply_task_execute_sync_patch(task_cls: type[Any], callback_handler: Any) - original_execute_sync = task_cls.execute_sync + @wraps(original_execute_sync) def patched_execute_sync(self: Any, *args: Any, **kwargs: Any) -> Any: _record_task_start(callback_handler, self) result = original_execute_sync(self, *args, **kwargs) From b11386f1729a532a51d4049f14d0dccdf7339a0a Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:52:30 +0800 Subject: [PATCH 20/36] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(runtime):=20Apply?= =?UTF-8?q?=20CrewAIPatch=20during=20assembly=20runtime=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/langchain/runtime.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agent_assembly/adapters/langchain/runtime.py b/agent_assembly/adapters/langchain/runtime.py index 50bb20f..aca6841 100644 --- a/agent_assembly/adapters/langchain/runtime.py +++ b/agent_assembly/adapters/langchain/runtime.py @@ -5,6 +5,7 @@ from threading import Lock from typing import Any +from agent_assembly.adapters.crewai.patch import CrewAIPatch from agent_assembly.adapters.langchain.callback_handler import AssemblyCallbackHandler from agent_assembly.adapters.langgraph import LangGraphPatch @@ -19,11 +20,13 @@ def auto_inject_callback_handler(interceptor: Any) -> AssemblyCallbackHandler: with _RUNTIME_LOCK: if _ACTIVE_CALLBACK_HANDLER is not None: LangGraphPatch(_ACTIVE_CALLBACK_HANDLER).apply() + CrewAIPatch(interceptor).apply() return _ACTIVE_CALLBACK_HANDLER handler = AssemblyCallbackHandler(interceptor) _ACTIVE_CALLBACK_HANDLER = handler LangGraphPatch(handler).apply() + CrewAIPatch(interceptor).apply() return handler From f61dc364ce1a7afe1423613ea3ceb9e433cfb662 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:53:03 +0800 Subject: [PATCH 21/36] =?UTF-8?q?=E2=9C=85=20(crewai):=20Add=20unit=20test?= =?UTF-8?q?=20for=20patch=20apply=20idempotency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/crewai/test_patch.py | 82 +++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 test/unit/adapters/crewai/test_patch.py diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py new file mode 100644 index 0000000..cf75952 --- /dev/null +++ b/test/unit/adapters/crewai/test_patch.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from agent_assembly.adapters.crewai import patch as crewai_patch + + +class _RecordingInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + +def _install_fake_crewai_modules(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeBaseTool: + name = "fake_tool" + + def run(self, *args: Any, **kwargs: Any) -> dict[str, object]: + return {"args": args, "kwargs": kwargs} + + class FakeTask: + description = "fake task" + expected_output = "fake output" + + def execute_sync(self, *args: Any, **kwargs: Any) -> dict[str, object]: + return {"args": args, "kwargs": kwargs} + + fake_crewai_tools = SimpleNamespace(BaseTool=FakeBaseTool) + fake_crewai_module = SimpleNamespace(Task=FakeTask) + + def fake_import_module(module_name: str) -> object: + if module_name == "crewai.tools": + return fake_crewai_tools + if module_name == "crewai": + return fake_crewai_module + raise ImportError(module_name) + + monkeypatch.setattr(crewai_patch.importlib, "import_module", fake_import_module) + + +def test_apply_patches_crewai_run_and_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_crewai_modules(monkeypatch) + + class FakeBaseTool: + name = "fake_tool" + + def run(self, *args: Any, **kwargs: Any) -> dict[str, object]: + return {"args": args, "kwargs": kwargs} + + class FakeTask: + description = "fake task" + expected_output = "fake output" + + def execute_sync(self, *args: Any, **kwargs: Any) -> dict[str, object]: + return {"args": args, "kwargs": kwargs} + + fake_crewai_tools = SimpleNamespace(BaseTool=FakeBaseTool) + fake_crewai_module = SimpleNamespace(Task=FakeTask) + + def fake_import_module(module_name: str) -> object: + if module_name == "crewai.tools": + return fake_crewai_tools + if module_name == "crewai": + return fake_crewai_module + raise ImportError(module_name) + + monkeypatch.setattr(crewai_patch.importlib, "import_module", fake_import_module) + + patcher = crewai_patch.CrewAIPatch(_RecordingInterceptor()) + assert patcher.apply() is True + first_run_ref = FakeBaseTool.run + first_task_ref = FakeTask.execute_sync + + assert getattr(FakeBaseTool, crewai_patch._TOOLS_PATCHED_FLAG, False) is True + assert getattr(FakeTask, crewai_patch._TASK_PATCHED_FLAG, False) is True + + assert patcher.apply() is True + assert FakeBaseTool.run is first_run_ref + assert FakeTask.execute_sync is first_task_ref From e42b308929509d8e079900fe04368166130f6874 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:53:31 +0800 Subject: [PATCH 22/36] =?UTF-8?q?=E2=9C=85=20(crewai):=20Add=20unit=20test?= =?UTF-8?q?=20for=20blocked=20tool=20return-string=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/crewai/test_patch.py | 51 ++++++++++++------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index cf75952..ff48979 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -14,7 +14,9 @@ def check_tool_start(self, **kwargs: object) -> dict[str, str]: return {"status": "allow"} -def _install_fake_crewai_modules(monkeypatch: pytest.MonkeyPatch) -> None: +def _install_fake_crewai_modules( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[type[Any], type[Any]]: class FakeBaseTool: name = "fake_tool" @@ -39,35 +41,11 @@ def fake_import_module(module_name: str) -> object: raise ImportError(module_name) monkeypatch.setattr(crewai_patch.importlib, "import_module", fake_import_module) + return FakeBaseTool, FakeTask def test_apply_patches_crewai_run_and_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: - _install_fake_crewai_modules(monkeypatch) - - class FakeBaseTool: - name = "fake_tool" - - def run(self, *args: Any, **kwargs: Any) -> dict[str, object]: - return {"args": args, "kwargs": kwargs} - - class FakeTask: - description = "fake task" - expected_output = "fake output" - - def execute_sync(self, *args: Any, **kwargs: Any) -> dict[str, object]: - return {"args": args, "kwargs": kwargs} - - fake_crewai_tools = SimpleNamespace(BaseTool=FakeBaseTool) - fake_crewai_module = SimpleNamespace(Task=FakeTask) - - def fake_import_module(module_name: str) -> object: - if module_name == "crewai.tools": - return fake_crewai_tools - if module_name == "crewai": - return fake_crewai_module - raise ImportError(module_name) - - monkeypatch.setattr(crewai_patch.importlib, "import_module", fake_import_module) + FakeBaseTool, FakeTask = _install_fake_crewai_modules(monkeypatch) patcher = crewai_patch.CrewAIPatch(_RecordingInterceptor()) assert patcher.apply() is True @@ -80,3 +58,22 @@ def fake_import_module(module_name: str) -> object: assert patcher.apply() is True assert FakeBaseTool.run is first_run_ref assert FakeTask.execute_sync is first_task_ref + + +def test_blocked_tool_returns_policy_string(monkeypatch: pytest.MonkeyPatch) -> None: + FakeBaseTool, _ = _install_fake_crewai_modules(monkeypatch) + + class BlockInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "blocked for safety"} + + patcher = crewai_patch.CrewAIPatch(BlockInterceptor()) + assert patcher.apply() is True + + tool = FakeBaseTool() + result = tool.run(param="value") + + assert isinstance(result, str) + assert "[BLOCKED by governance policy]" in result + assert "blocked for safety" in result From 559d4f0d80e9535cdb38f159aabbf7c29da12f8e Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:53:47 +0800 Subject: [PATCH 23/36] =?UTF-8?q?=E2=9C=85=20(crewai):=20Add=20unit=20test?= =?UTF-8?q?=20for=20allowed=20tool=20result=20recording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/crewai/test_patch.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index ff48979..2f6835c 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -77,3 +77,26 @@ def check_tool_start(self, **kwargs: object) -> dict[str, str]: assert isinstance(result, str) assert "[BLOCKED by governance policy]" in result assert "blocked for safety" in result + + +def test_allowed_tool_runs_and_records_result(monkeypatch: pytest.MonkeyPatch) -> None: + FakeBaseTool, _ = _install_fake_crewai_modules(monkeypatch) + observed: list[object] = [] + + class AllowInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + def on_tool_end(self, *, output: object, **kwargs: object) -> None: + del kwargs + observed.append(output) + + patcher = crewai_patch.CrewAIPatch(AllowInterceptor()) + assert patcher.apply() is True + + tool = FakeBaseTool() + result = tool.run(param="value") + + assert result == {"args": (), "kwargs": {"param": "value"}} + assert observed == [result] From ec36b32af478f63df084616173310dbfde6a0367 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:54:01 +0800 Subject: [PATCH 24/36] =?UTF-8?q?=E2=9C=85=20(crewai):=20Add=20unit=20test?= =?UTF-8?q?=20for=20pending=20approval=20allow=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/crewai/test_patch.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index 2f6835c..b1df46e 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -100,3 +100,28 @@ def on_tool_end(self, *, output: object, **kwargs: object) -> None: assert result == {"args": (), "kwargs": {"param": "value"}} assert observed == [result] + + +def test_pending_tool_waits_and_allows_when_approved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + FakeBaseTool, _ = _install_fake_crewai_modules(monkeypatch) + wait_calls: list[dict[str, object]] = [] + + class PendingThenApproveInterceptor: + 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]: + wait_calls.append(dict(kwargs)) + return {"status": "allow"} + + patcher = crewai_patch.CrewAIPatch(PendingThenApproveInterceptor()) + assert patcher.apply() is True + + tool = FakeBaseTool() + result = tool.run(param="value") + + assert result == {"args": (), "kwargs": {"param": "value"}} + assert len(wait_calls) == 1 From aefe158a7e35ebc24473c97964c93026314427dc Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:54:19 +0800 Subject: [PATCH 25/36] =?UTF-8?q?=E2=9C=85=20(crewai):=20Add=20unit=20test?= =?UTF-8?q?=20for=20pending=20approval=20timeout=20denial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/crewai/test_patch.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index b1df46e..9ee18e4 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -125,3 +125,26 @@ def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: assert result == {"args": (), "kwargs": {"param": "value"}} assert len(wait_calls) == 1 + + +def test_pending_timeout_returns_denied_string(monkeypatch: pytest.MonkeyPatch) -> None: + FakeBaseTool, _ = _install_fake_crewai_modules(monkeypatch) + + class PendingTimeoutInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "requires manual approval"} + + def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "approval timeout"} + + patcher = crewai_patch.CrewAIPatch(PendingTimeoutInterceptor()) + assert patcher.apply() is True + + tool = FakeBaseTool() + result = tool.run(param="value") + + assert isinstance(result, str) + assert result.startswith("[APPROVAL REJECTED]") + assert "approval timeout" in result From 8a8ae5106368778a082bf3fb682605edcfc43c82 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:54:34 +0800 Subject: [PATCH 26/36] =?UTF-8?q?=E2=9C=85=20(crewai):=20Add=20unit=20test?= =?UTF-8?q?=20for=20task=20lifecycle=20audit=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/crewai/test_patch.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index 9ee18e4..0134cac 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -148,3 +148,29 @@ def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: assert isinstance(result, str) assert result.startswith("[APPROVAL REJECTED]") assert "approval timeout" in result + + +def test_task_start_and_complete_events_are_recorded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _, FakeTask = _install_fake_crewai_modules(monkeypatch) + recorded: list[dict[str, object]] = [] + + class TaskRecordInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + def record(self, **kwargs: object) -> None: + recorded.append(dict(kwargs)) + + patcher = crewai_patch.CrewAIPatch(TaskRecordInterceptor()) + assert patcher.apply() is True + + task = FakeTask() + result = task.execute_sync(input_text="hello") + + assert result == {"args": (), "kwargs": {"input_text": "hello"}} + assert len(recorded) == 2 + assert recorded[0]["action"] == "task_start" + assert recorded[1]["action"] == "task_complete" From bd2e4af71fba7170c35f502f5b4a3bffb35d3374 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:54:53 +0800 Subject: [PATCH 27/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Propagate=20task?= =?UTF-8?q?=20agent=20ID=20into=20thread-local=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 35 ++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 9eb406c..9987354 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -69,6 +69,33 @@ def _get_thread_local_agent_id() -> str | None: return None +def _extract_agent_id_from_inputs(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str | None: + direct_agent_id = kwargs.get("agent_id") + if isinstance(direct_agent_id, str) and direct_agent_id: + return direct_agent_id + + config = kwargs.get("config") + if isinstance(config, dict): + configurable = config.get("configurable") + if isinstance(configurable, dict): + configurable_agent_id = configurable.get("agent_id") + if isinstance(configurable_agent_id, str) and configurable_agent_id: + return configurable_agent_id + + metadata = config.get("metadata") + if isinstance(metadata, dict): + metadata_agent_id = metadata.get("agent_id") + if isinstance(metadata_agent_id, str) and metadata_agent_id: + return metadata_agent_id + + if args and isinstance(args[0], dict): + state_agent_id = args[0].get("agent_id") + if isinstance(state_agent_id, str) and state_agent_id: + return state_agent_id + + return None + + def _format_blocked_message(reason: str | None) -> str: reason_text = reason or "No reason provided." return ( @@ -246,8 +273,14 @@ def _apply_task_execute_sync_patch(task_cls: type[Any], callback_handler: Any) - @wraps(original_execute_sync) def patched_execute_sync(self: Any, *args: Any, **kwargs: Any) -> Any: + previous_agent_id = _get_thread_local_agent_id() + _set_thread_local_agent_id(_extract_agent_id_from_inputs(args, kwargs)) _record_task_start(callback_handler, self) - result = original_execute_sync(self, *args, **kwargs) + try: + result = original_execute_sync(self, *args, **kwargs) + finally: + _set_thread_local_agent_id(previous_agent_id) + _record_task_complete(callback_handler, result) return result From 25d3ca8233db6e38fef520fabeaaae80f7c18a0a Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:55:18 +0800 Subject: [PATCH 28/36] =?UTF-8?q?=E2=9C=85=20(crewai):=20Add=20unit=20test?= =?UTF-8?q?=20for=20thread-local=20agent=20ID=20concurrency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/crewai/test_patch.py | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index 0134cac..a2717f8 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -1,5 +1,6 @@ from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor from types import SimpleNamespace from typing import Any @@ -174,3 +175,54 @@ def record(self, **kwargs: object) -> None: assert len(recorded) == 2 assert recorded[0]["action"] == "task_start" assert recorded[1]["action"] == "task_complete" + + +def test_thread_local_agent_id_isolated_across_concurrent_tasks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeBaseTool: + name = "thread_tool" + + def run(self, *args: Any, **kwargs: Any) -> dict[str, object]: + return {"args": args, "kwargs": kwargs} + + class FakeTask: + description = "thread task" + expected_output = "thread output" + + def execute_sync(self, *args: Any, **kwargs: Any) -> object: + tool = kwargs["tool"] + return tool.run() + + fake_crewai_tools = SimpleNamespace(BaseTool=FakeBaseTool) + fake_crewai_module = SimpleNamespace(Task=FakeTask) + + def fake_import_module(module_name: str) -> object: + if module_name == "crewai.tools": + return fake_crewai_tools + if module_name == "crewai": + return fake_crewai_module + raise ImportError(module_name) + + monkeypatch.setattr(crewai_patch.importlib, "import_module", fake_import_module) + + observed_agent_ids: list[str | None] = [] + + class ConcurrencyInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + observed_agent_ids.append( + str(kwargs.get("agent_id")) if kwargs.get("agent_id") is not None else None + ) + return {"status": "allow"} + + patcher = crewai_patch.CrewAIPatch(ConcurrencyInterceptor()) + assert patcher.apply() is True + + task = FakeTask() + with ThreadPoolExecutor(max_workers=2) as pool: + future_a = pool.submit(task.execute_sync, tool=FakeBaseTool(), agent_id="agent-A") + future_b = pool.submit(task.execute_sync, tool=FakeBaseTool(), agent_id="agent-B") + assert future_a.result() == {"args": (), "kwargs": {}} + assert future_b.result() == {"args": (), "kwargs": {}} + + assert sorted(observed_agent_ids) == ["agent-A", "agent-B"] From 8e104419d88a1bf2138213c0e760168ac6f304d8 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:55:41 +0800 Subject: [PATCH 29/36] =?UTF-8?q?=E2=9C=85=20(integration):=20Add=20CrewAI?= =?UTF-8?q?=20blocked-tool=20continuation=20scenario=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_crewai_interception_integration.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 test/integration/test_crewai_interception_integration.py diff --git a/test/integration/test_crewai_interception_integration.py b/test/integration/test_crewai_interception_integration.py new file mode 100644 index 0000000..01a7864 --- /dev/null +++ b/test/integration/test_crewai_interception_integration.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from agent_assembly.adapters.crewai import patch as crewai_patch + + +@pytest.mark.integration +def test_crewai_two_task_flow_continues_after_blocked_tool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeBaseTool: + def __init__(self, name: str) -> None: + self.name = name + + def run(self, *args: Any, **kwargs: Any) -> str: + del args, kwargs + return f"ok:{self.name}" + + class FakeTask: + def __init__(self, tool: FakeBaseTool, description: str, expected_output: str) -> None: + self.tool = tool + self.description = description + self.expected_output = expected_output + + def execute_sync(self, *args: Any, **kwargs: Any) -> str: + del args, kwargs + return self.tool.run() + + fake_crewai_tools = SimpleNamespace(BaseTool=FakeBaseTool) + fake_crewai_module = SimpleNamespace(Task=FakeTask) + + def fake_import_module(module_name: str) -> object: + if module_name == "crewai.tools": + return fake_crewai_tools + if module_name == "crewai": + return fake_crewai_module + raise ImportError(module_name) + + monkeypatch.setattr(crewai_patch.importlib, "import_module", fake_import_module) + + class Interceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + tool_name = kwargs.get("tool_name") + if tool_name == "blocked_tool": + return {"status": "deny", "reason": "blocked by policy"} + return {"status": "allow"} + + patcher = crewai_patch.CrewAIPatch(Interceptor()) + assert patcher.apply() is True + + blocked_task = FakeTask( + tool=FakeBaseTool("blocked_tool"), + description="task1", + expected_output="blocked string", + ) + safe_task = FakeTask( + tool=FakeBaseTool("safe_tool"), + description="task2", + expected_output="normal result", + ) + + results = [ + blocked_task.execute_sync(agent_id="agent-1"), + safe_task.execute_sync(agent_id="agent-2"), + ] + + assert isinstance(results[0], str) + assert "[BLOCKED by governance policy]" in results[0] + assert "blocked by policy" in results[0] + assert results[1] == "ok:safe_tool" From 8d0381f3b7660baa094339313ee7db5bae54f454 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:56:41 +0800 Subject: [PATCH 30/36] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20Export?= =?UTF-8?q?=20CrewAIPatch=20from=20crewai=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agent_assembly/adapters/crewai/__init__.py b/agent_assembly/adapters/crewai/__init__.py index e69de29..e050dae 100644 --- a/agent_assembly/adapters/crewai/__init__.py +++ b/agent_assembly/adapters/crewai/__init__.py @@ -0,0 +1,5 @@ +"""CrewAI adapter package.""" + +from agent_assembly.adapters.crewai.patch import CrewAIPatch + +__all__ = ["CrewAIPatch"] From 2d8e9eae7bb1cbc9b580f3b7cf8d34747170d3ad Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Tue, 28 Apr 2026 22:57:01 +0800 Subject: [PATCH 31/36] =?UTF-8?q?=F0=9F=93=9D=20(docs):=20Add=20CrewAI=20r?= =?UTF-8?q?untime=20interception=20behavior=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/contents/document/api-references/index.mdx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/contents/document/api-references/index.mdx b/docs/contents/document/api-references/index.mdx index 8ed37d7..e757ff1 100644 --- a/docs/contents/document/api-references/index.mdx +++ b/docs/contents/document/api-references/index.mdx @@ -72,3 +72,12 @@ from agent_assembly.exceptions import ( - `deny` (or unresolved `pending`) raises `ToolExecutionBlockedError`. - LLM start interception is scan-only and does not mutate prompt content. - LangGraph `StateGraph.compile()` is patched to add pre/post invocation governance hooks. + +## CrewAI runtime interception + +`init_assembly(...)` also applies CrewAI runtime patches when CrewAI is installed. + +- `BaseTool.run()` is patched with synchronous governance checks. +- Blocked tool calls return policy message strings (instead of raising exceptions). +- `pending` approval decisions block synchronously and return denial strings if not approved. +- `Task.execute_sync()` emits task start/complete audit events. From e0f91ad9afbdfda88462c2610ae24d537bd28a3a Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Wed, 29 Apr 2026 07:40:07 +0800 Subject: [PATCH 32/36] =?UTF-8?q?=E2=9C=85=20(crewai):=20Add=20unit=20test?= =?UTF-8?q?=20for=20loader=20edge=20cases=20and=20apply=20false=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/crewai/test_patch.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index a2717f8..99f5882 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -61,6 +61,32 @@ def test_apply_patches_crewai_run_and_is_idempotent(monkeypatch: pytest.MonkeyPa assert FakeTask.execute_sync is first_task_ref +def test_loader_edge_cases_and_apply_false_without_basetool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_import_error(module_name: str) -> object: + raise ImportError(module_name) + + monkeypatch.setattr(crewai_patch.importlib, "import_module", raise_import_error) + assert crewai_patch._load_crewai_basetool_class() is None + assert crewai_patch._load_crewai_task_class() is None + assert crewai_patch.CrewAIPatch(_RecordingInterceptor()).apply() is False + + fake_crewai_tools = SimpleNamespace(BaseTool=object()) + fake_crewai_module = SimpleNamespace(Task=object()) + + def return_non_type(module_name: str) -> object: + if module_name == "crewai.tools": + return fake_crewai_tools + if module_name == "crewai": + return fake_crewai_module + raise ImportError(module_name) + + monkeypatch.setattr(crewai_patch.importlib, "import_module", return_non_type) + assert crewai_patch._load_crewai_basetool_class() is None + assert crewai_patch._load_crewai_task_class() is None + + def test_blocked_tool_returns_policy_string(monkeypatch: pytest.MonkeyPatch) -> None: FakeBaseTool, _ = _install_fake_crewai_modules(monkeypatch) From e1a4238191461d7750ad2d592c6dc18a887d39a6 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Wed, 29 Apr 2026 07:40:31 +0800 Subject: [PATCH 33/36] =?UTF-8?q?=E2=9C=85=20(crewai):=20Add=20unit=20test?= =?UTF-8?q?=20for=20decision=20and=20agent-ID=20helper=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/crewai/test_patch.py | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index 99f5882..aa7ea97 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -87,6 +87,53 @@ def return_non_type(module_name: str) -> object: assert crewai_patch._load_crewai_task_class() is None +def test_helper_branch_coverage_for_decision_and_agent_extraction() -> None: + assert crewai_patch._normalize_decision("deny") == ("deny", None) + assert crewai_patch._normalize_decision("pending") == ("pending", None) + assert crewai_patch._normalize_decision("allow") == ("allow", None) + assert crewai_patch._normalize_decision(12345) == ("allow", None) + + assert ( + crewai_patch._extract_agent_id_from_inputs((), {"agent_id": "agent-direct"}) == "agent-direct" + ) + assert ( + crewai_patch._extract_agent_id_from_inputs( + (), + {"config": {"configurable": {"agent_id": "agent-configurable"}}}, + ) + == "agent-configurable" + ) + assert ( + crewai_patch._extract_agent_id_from_inputs( + (), + {"config": {"metadata": {"agent_id": "agent-metadata"}}}, + ) + == "agent-metadata" + ) + assert crewai_patch._extract_agent_id_from_inputs(({"agent_id": "agent-state"},), {}) == "agent-state" + assert crewai_patch._extract_agent_id_from_inputs((), {}) is None + + class NoHandlers: + pass + + fallback_check = crewai_patch._invoke_sync_tool_check( + NoHandlers(), + tool_name="x", + tool_args={}, + agent_id=None, + ) + assert fallback_check == {"status": "allow"} + + fallback_wait = crewai_patch._wait_for_sync_tool_approval( + NoHandlers(), + tool_name="x", + timeout_seconds=1, + tool_args={}, + agent_id=None, + ) + assert fallback_wait == {"status": "deny", "reason": "Approval handler is unavailable."} + + def test_blocked_tool_returns_policy_string(monkeypatch: pytest.MonkeyPatch) -> None: FakeBaseTool, _ = _install_fake_crewai_modules(monkeypatch) From 6baa20ce38ce5f06225c5ff3aaf93da1819d6878 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Wed, 29 Apr 2026 07:41:02 +0800 Subject: [PATCH 34/36] =?UTF-8?q?=E2=9C=85=20(crewai):=20Add=20unit=20test?= =?UTF-8?q?=20for=20result=20and=20task=20fallback=20callback=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/adapters/crewai/test_patch.py | 60 +++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index aa7ea97..cb0b0e7 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -134,6 +134,66 @@ class NoHandlers: assert fallback_wait == {"status": "deny", "reason": "Approval handler is unavailable."} +def test_record_result_and_task_fallback_handlers_are_used( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeBaseTool: + name = "record_result_tool" + + def run(self, *args: Any, **kwargs: Any) -> dict[str, object]: + return {"args": args, "kwargs": kwargs} + + class FakeTask: + description = "fallback task" + expected_output = "fallback output" + + def execute_sync(self, *args: Any, **kwargs: Any) -> str: + del args, kwargs + return "task-result" + + fake_crewai_tools = SimpleNamespace(BaseTool=FakeBaseTool) + fake_crewai_module = SimpleNamespace(Task=FakeTask) + + def fake_import_module(module_name: str) -> object: + if module_name == "crewai.tools": + return fake_crewai_tools + if module_name == "crewai": + return fake_crewai_module + raise ImportError(module_name) + + monkeypatch.setattr(crewai_patch.importlib, "import_module", fake_import_module) + + seen_results: list[object] = [] + lifecycle_events: list[str] = [] + + class FallbackInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + def record_result(self, **kwargs: object) -> None: + seen_results.append(kwargs["result"]) + + def on_task_start(self, **kwargs: object) -> None: + del kwargs + lifecycle_events.append("start") + + def on_task_complete(self, **kwargs: object) -> None: + del kwargs + lifecycle_events.append("complete") + + patcher = crewai_patch.CrewAIPatch(FallbackInterceptor()) + assert patcher.apply() is True + + tool_result = FakeBaseTool().run(alpha=1) + task_result = FakeTask().execute_sync() + + assert tool_result == {"args": (), "kwargs": {"alpha": 1}} + assert seen_results == [tool_result] + assert task_result == "task-result" + assert lifecycle_events == ["start", "complete"] + + def test_blocked_tool_returns_policy_string(monkeypatch: pytest.MonkeyPatch) -> None: FakeBaseTool, _ = _install_fake_crewai_modules(monkeypatch) From 3289efac0acfdb4b4f0b968c4fe48fd105d5a7d6 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Wed, 29 Apr 2026 08:30:15 +0800 Subject: [PATCH 35/36] =?UTF-8?q?=E2=9C=A8=20(crewai):=20Make=20pending=20?= =?UTF-8?q?approval=20timeout=20configurable=20in=20runtime=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_assembly/adapters/crewai/patch.py | 28 +++++++++++++++++- test/unit/adapters/crewai/test_patch.py | 39 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/crewai/patch.py b/agent_assembly/adapters/crewai/patch.py index 9987354..261a7ec 100644 --- a/agent_assembly/adapters/crewai/patch.py +++ b/agent_assembly/adapters/crewai/patch.py @@ -13,6 +13,7 @@ _ORIGINAL_TOOL_RUN = "_agent_assembly_original_crewai_tool_run" _ORIGINAL_TASK_EXECUTE_SYNC = "_agent_assembly_original_crewai_task_execute_sync" _AGENT_CONTEXT = local() +_DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS = 300 @dataclass(slots=True) @@ -176,6 +177,30 @@ def _wait_for_sync_tool_approval( return {"status": "deny", "reason": "Approval handler is unavailable."} +def _get_pending_tool_approval_timeout_seconds(callback_handler: Any) -> int: + provider = getattr(callback_handler, "get_pending_tool_approval_timeout_seconds", None) + if callable(provider): + configured = provider() + else: + configured = getattr(callback_handler, "pending_tool_approval_timeout_seconds", None) + + if isinstance(configured, str): + stripped = configured.strip() + if stripped.isdigit(): + parsed = int(stripped) + if parsed > 0: + return parsed + return _DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS + + if isinstance(configured, bool): + return _DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS + + if isinstance(configured, int) and configured > 0: + return configured + + return _DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS + + def _record_sync_tool_result( callback_handler: Any, *, @@ -214,10 +239,11 @@ def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any: is_pending_flow = False if status == "pending": is_pending_flow = True + timeout_seconds = _get_pending_tool_approval_timeout_seconds(callback_handler) final_decision = _wait_for_sync_tool_approval( callback_handler, tool_name=str(tool_name), - timeout_seconds=300, + timeout_seconds=timeout_seconds, tool_args=tool_args, agent_id=agent_id, ) diff --git a/test/unit/adapters/crewai/test_patch.py b/test/unit/adapters/crewai/test_patch.py index cb0b0e7..92e048e 100644 --- a/test/unit/adapters/crewai/test_patch.py +++ b/test/unit/adapters/crewai/test_patch.py @@ -133,6 +133,18 @@ class NoHandlers: ) assert fallback_wait == {"status": "deny", "reason": "Approval handler is unavailable."} + class TimeoutProvider: + def get_pending_tool_approval_timeout_seconds(self) -> str: + return "42" + + assert crewai_patch._get_pending_tool_approval_timeout_seconds(TimeoutProvider()) == 42 + assert crewai_patch._get_pending_tool_approval_timeout_seconds( + SimpleNamespace(pending_tool_approval_timeout_seconds=0) + ) == 300 + assert crewai_patch._get_pending_tool_approval_timeout_seconds( + SimpleNamespace(pending_tool_approval_timeout_seconds=True) + ) == 300 + def test_record_result_and_task_fallback_handlers_are_used( monkeypatch: pytest.MonkeyPatch, @@ -259,6 +271,33 @@ def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: assert result == {"args": (), "kwargs": {"param": "value"}} assert len(wait_calls) == 1 + assert wait_calls[0]["timeout_seconds"] == 300 + + +def test_pending_tool_uses_configurable_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + FakeBaseTool, _ = _install_fake_crewai_modules(monkeypatch) + wait_calls: list[dict[str, object]] = [] + + class PendingWithConfigurableTimeoutInterceptor: + pending_tool_approval_timeout_seconds = 37 + + 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]: + wait_calls.append(dict(kwargs)) + return {"status": "allow"} + + patcher = crewai_patch.CrewAIPatch(PendingWithConfigurableTimeoutInterceptor()) + assert patcher.apply() is True + + tool = FakeBaseTool() + result = tool.run(param="value") + + assert result == {"args": (), "kwargs": {"param": "value"}} + assert len(wait_calls) == 1 + assert wait_calls[0]["timeout_seconds"] == 37 def test_pending_timeout_returns_denied_string(monkeypatch: pytest.MonkeyPatch) -> None: From 6f9798fc9a09f16ec85a161aa1361c3d9125097f Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Wed, 29 Apr 2026 08:30:20 +0800 Subject: [PATCH 36/36] =?UTF-8?q?=E2=9C=85=20(integration):=20Add=20real?= =?UTF-8?q?=20CrewAI=20class-path=20blocked=20tool=20flow=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_crewai_interception_integration.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/test/integration/test_crewai_interception_integration.py b/test/integration/test_crewai_interception_integration.py index 01a7864..b3a297a 100644 --- a/test/integration/test_crewai_interception_integration.py +++ b/test/integration/test_crewai_interception_integration.py @@ -72,3 +72,79 @@ def check_tool_start(self, **kwargs: object) -> dict[str, str]: assert "[BLOCKED by governance policy]" in results[0] assert "blocked by policy" in results[0] assert results[1] == "ok:safe_tool" + + +@pytest.mark.integration +def test_crewai_real_task_and_tool_classes_flow_when_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + crewai = pytest.importorskip("crewai") + crewai_tools = pytest.importorskip("crewai.tools") + + BaseTool = crewai_tools.BaseTool + Task = crewai.Task + Agent = crewai.Agent + Crew = crewai.Crew + + class BlockedTool(BaseTool): + name: str = "blocked_tool" + description: str = "Tool that should be blocked by governance." + + def _run(self, **kwargs: object) -> str: + del kwargs + return "should-not-run" + + class SafeTool(BaseTool): + name: str = "safe_tool" + description: str = "Tool that should remain allowed." + + def _run(self, **kwargs: object) -> str: + del kwargs + return "ok:safe_tool" + + def fake_execute_sync(self: object, *args: object, **kwargs: object) -> str: + del args, kwargs + task_tools = getattr(self, "tools", None) or [] + if task_tools: + return str(task_tools[0].run()) + return "no-tool" + + monkeypatch.setattr(Task, "execute_sync", fake_execute_sync, raising=True) + + class Interceptor: + 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 = crewai_patch.CrewAIPatch(Interceptor()) + assert patcher.apply() is True + + blocked_agent = Agent(role="blocked", goal="run blocked task", backstory="blocked") + safe_agent = Agent(role="safe", goal="run safe task", backstory="safe") + + blocked_task = Task( + description="blocked task", + expected_output="blocked string", + agent=blocked_agent, + tools=[BlockedTool()], + ) + safe_task = Task( + description="safe task", + expected_output="safe string", + agent=safe_agent, + tools=[SafeTool()], + ) + + # Build a real CrewAI Crew object to validate object wiring and two-agent task setup. + Crew(agents=[blocked_agent, safe_agent], tasks=[blocked_task, safe_task], verbose=False) + + results = [ + blocked_task.execute_sync(agent_id="agent-1"), + safe_task.execute_sync(agent_id="agent-2"), + ] + + assert isinstance(results[0], str) + assert "[BLOCKED by governance policy]" in results[0] + assert "blocked by policy" in results[0] + assert results[1] == "ok:safe_tool"