fix: store FileArtifact returned by after_tool_call hooks

store_if_artifact ran before the after_tool_call hooks, so a hook that
replaced the result with a FileArtifact put raw bytes / a dataclass repr
into the tool message and events. Re-run store_if_artifact on the final
result after the hook loop in all three native tool paths (no-op for the
normal string case).
This commit is contained in:
Matt Aitchison
2026-06-04 20:06:41 -05:00
parent 000dd41fc3
commit 8d2ca5ef4c
4 changed files with 45 additions and 0 deletions

View File

@@ -1030,6 +1030,10 @@ class CrewAgentExecutor(BaseAgentExecutor):
color="red",
)
# An after_tool_call hook may have replaced the result with a
# FileArtifact; keep those bytes out of the message and events too.
result = store_if_artifact(result, scope_id)
if not error_event_emitted:
crewai_event_bus.emit(
self,

View File

@@ -1939,6 +1939,10 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
color="red",
)
# An after_tool_call hook may have replaced the result with a
# FileArtifact; keep those bytes out of the message and events too.
result = store_if_artifact(result, scope_id)
if not error_event_emitted:
crewai_event_bus.emit(
self,

View File

@@ -1541,6 +1541,10 @@ def execute_single_native_tool_call(
except Exception: # noqa: S110
pass
# An after_tool_call hook may have replaced the result with a FileArtifact;
# keep those bytes out of the message and events too.
result = store_if_artifact(result, scope_id)
if not error_event_emitted:
crewai_event_bus.emit(
event_source,

View File

@@ -314,6 +314,39 @@ class TestNativeExecutorWiring:
assert base64.b64decode(captured["content"]) == payload
class TestAfterHookArtifact:
"""An after_tool_call hook that returns a FileArtifact must still be stored."""
def test_hook_returned_artifact_is_replaced_by_handle(self) -> None:
from crewai.hooks.tool_hooks import (
register_after_tool_call_hook,
unregister_after_tool_call_hook,
)
from crewai.tools import BaseTool, FileArtifact
payload = bytes(range(256)) * 50
class Echo(BaseTool):
name: str = "echo"
description: str = "Echo"
def _run(self) -> str:
return "plain text"
def hook(_context):
return FileArtifact(data=payload, filename="hook.bin")
register_after_tool_call_hook(hook)
try:
run = _experimental_executor_runner([Echo()])
result = run("echo", "{}")["result"]
finally:
unregister_after_tool_call_hook(hook)
assert base64.b64encode(payload).decode() not in result
assert _HANDLE.search(result) is not None
class TestTtlPrune:
@staticmethod
def _expire(handle: str) -> None: