Skip to content

Commit c78bcc4

Browse files
committed
fix(agent): keep guardrail retries on the async path in kickoff_async
1 parent 34199c2 commit c78bcc4

2 files changed

Lines changed: 210 additions & 18 deletions

File tree

lib/crewai/src/crewai/agent/core.py

Lines changed: 125 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1803,6 +1803,42 @@ def _finalize_kickoff(
18031803

18041804
return output
18051805

1806+
async def _finalize_kickoff_async(
1807+
self,
1808+
output: LiteAgentOutput,
1809+
executor: AgentExecutor,
1810+
inputs: dict[str, str],
1811+
response_format: type[Any] | None,
1812+
messages: str | list[LLMMessage],
1813+
agent_info: dict[str, Any],
1814+
usage_baseline: UsageMetrics | None = None,
1815+
) -> LiteAgentOutput:
1816+
"""Async variant of _finalize_kickoff for kickoff_async.
1817+
1818+
Identical except guardrail retries stay on the async execution path;
1819+
see _process_kickoff_guardrail_async.
1820+
"""
1821+
if self.guardrail is not None:
1822+
output = await self._process_kickoff_guardrail_async(
1823+
output=output,
1824+
executor=executor,
1825+
inputs=inputs,
1826+
response_format=response_format,
1827+
usage_baseline=usage_baseline,
1828+
)
1829+
1830+
self._save_kickoff_to_memory(messages, output.raw)
1831+
1832+
crewai_event_bus.emit(
1833+
self,
1834+
event=LiteAgentExecutionCompletedEvent(
1835+
agent_info=agent_info,
1836+
output=output.raw,
1837+
),
1838+
)
1839+
1840+
return output
1841+
18061842
def _emit_kickoff_error(self, agent_info: dict[str, Any], e: Exception) -> NoReturn:
18071843
"""Emit a kickoff error event and re-raise."""
18081844
crewai_event_bus.emit(
@@ -1972,6 +2008,31 @@ async def _execute_and_build_output_async(
19722008
result, executor, response_format, usage_baseline, kickoff_failures
19732009
)
19742010

2011+
def _resolve_guardrail_callable(self) -> GuardrailCallable | None:
2012+
"""Return the configured guardrail as a callable, or None if unset."""
2013+
if isinstance(self.guardrail, str):
2014+
from crewai.tasks.llm_guardrail import LLMGuardrail
2015+
2016+
return cast(
2017+
GuardrailCallable,
2018+
LLMGuardrail(description=self.guardrail, llm=cast(BaseLLM, self.llm)),
2019+
)
2020+
if callable(self.guardrail):
2021+
return self.guardrail
2022+
return None
2023+
2024+
@staticmethod
2025+
def _apply_guardrail_result(
2026+
output: LiteAgentOutput, guardrail_result: Any
2027+
) -> LiteAgentOutput:
2028+
"""Fold an accepted guardrail result into the output (shared sync/async)."""
2029+
if guardrail_result.result is not None:
2030+
if isinstance(guardrail_result.result, str):
2031+
output.raw = guardrail_result.result
2032+
elif isinstance(guardrail_result.result, BaseModel):
2033+
output.pydantic = guardrail_result.result
2034+
return output
2035+
19752036
def _process_kickoff_guardrail(
19762037
self,
19772038
output: LiteAgentOutput,
@@ -1996,17 +2057,8 @@ def _process_kickoff_guardrail(
19962057
Returns:
19972058
Validated/updated output.
19982059
"""
1999-
guardrail_callable: GuardrailCallable
2000-
if isinstance(self.guardrail, str):
2001-
from crewai.tasks.llm_guardrail import LLMGuardrail
2002-
2003-
guardrail_callable = cast(
2004-
GuardrailCallable,
2005-
LLMGuardrail(description=self.guardrail, llm=cast(BaseLLM, self.llm)),
2006-
)
2007-
elif callable(self.guardrail):
2008-
guardrail_callable = self.guardrail
2009-
else:
2060+
guardrail_callable = self._resolve_guardrail_callable()
2061+
if guardrail_callable is None:
20102062
return output
20112063

20122064
guardrail_result = process_guardrail(
@@ -2048,13 +2100,68 @@ def _process_kickoff_guardrail(
20482100
usage_baseline=usage_baseline,
20492101
)
20502102

2051-
if guardrail_result.result is not None:
2052-
if isinstance(guardrail_result.result, str):
2053-
output.raw = guardrail_result.result
2054-
elif isinstance(guardrail_result.result, BaseModel):
2055-
output.pydantic = guardrail_result.result
2103+
return self._apply_guardrail_result(output, guardrail_result)
20562104

2057-
return output
2105+
async def _process_kickoff_guardrail_async(
2106+
self,
2107+
output: LiteAgentOutput,
2108+
executor: AgentExecutor,
2109+
inputs: dict[str, str],
2110+
response_format: type[Any] | None = None,
2111+
retry_count: int = 0,
2112+
usage_baseline: UsageMetrics | None = None,
2113+
) -> LiteAgentOutput:
2114+
"""Async variant of _process_kickoff_guardrail for kickoff_async.
2115+
2116+
Identical except the retry re-executes through
2117+
_execute_and_build_output_async: the sync executor.invoke() detects a
2118+
running event loop and hands back an unawaited coroutine instead of a
2119+
result dict, which then crashes in _build_output_from_result.
2120+
"""
2121+
guardrail_callable = self._resolve_guardrail_callable()
2122+
if guardrail_callable is None:
2123+
return output
2124+
2125+
guardrail_result = process_guardrail(
2126+
output=output,
2127+
guardrail=guardrail_callable,
2128+
retry_count=retry_count,
2129+
event_source=self,
2130+
from_agent=self,
2131+
)
2132+
2133+
if not guardrail_result.success:
2134+
if retry_count >= self.guardrail_max_retries:
2135+
raise ValueError(
2136+
f"Agent's guardrail failed validation after {self.guardrail_max_retries} retries. "
2137+
f"Last error: {guardrail_result.error}"
2138+
)
2139+
2140+
executor._append_message_to_state(
2141+
guardrail_result.error or "Guardrail validation failed",
2142+
role="user",
2143+
)
2144+
2145+
retried = await self._execute_and_build_output_async(
2146+
executor, inputs, response_format, usage_baseline
2147+
)
2148+
# The retry opens its own collector, so carry the blocked attempt's
2149+
# failures forward or they vanish from the final output.
2150+
retried.tool_failures = merge_tool_failures(
2151+
output.tool_failures, retried.tool_failures
2152+
)
2153+
output = retried
2154+
2155+
return await self._process_kickoff_guardrail_async(
2156+
output=output,
2157+
executor=executor,
2158+
inputs=inputs,
2159+
response_format=response_format,
2160+
retry_count=retry_count + 1,
2161+
usage_baseline=usage_baseline,
2162+
)
2163+
2164+
return self._apply_guardrail_result(output, guardrail_result)
20582165

20592166
async def kickoff_async(
20602167
self,
@@ -2118,7 +2225,7 @@ async def kickoff_async(
21182225
output = await self._execute_and_build_output_async(
21192226
executor, inputs, response_format, usage_baseline
21202227
)
2121-
return self._finalize_kickoff(
2228+
return await self._finalize_kickoff_async(
21222229
output,
21232230
executor,
21242231
inputs,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Guardrail retries inside kickoff_async must stay on the async path (#7252)."""
2+
3+
from unittest.mock import AsyncMock, MagicMock, patch
4+
5+
import pytest
6+
7+
from crewai import Agent
8+
from crewai.lite_agent_output import LiteAgentOutput
9+
10+
11+
def _agent_with_fail_once_guardrail():
12+
calls = {"n": 0}
13+
14+
def fail_once(output):
15+
calls["n"] += 1
16+
if calls["n"] == 1:
17+
return (False, "not good enough, retry")
18+
return (True, output)
19+
20+
agent = Agent(
21+
role="Test Agent",
22+
goal="Answer",
23+
backstory="Test backstory.",
24+
guardrail=fail_once,
25+
guardrail_max_retries=2,
26+
)
27+
return agent, calls
28+
29+
30+
def _canned_output(raw="final answer"):
31+
return LiteAgentOutput(raw=raw, agent_role="Test Agent")
32+
33+
34+
@pytest.mark.asyncio
35+
async def test_kickoff_async_guardrail_retry_uses_async_execution():
36+
"""A guardrail failure on kickoff_async must retry via invoke_async.
37+
38+
Before the fix the retry went through the sync executor.invoke(), which
39+
under a running loop hands back an unawaited coroutine instead of a result
40+
dict and crashes on .get("output"). The sync execute path must not run at
41+
all here.
42+
"""
43+
agent, calls = _agent_with_fail_once_guardrail()
44+
45+
with (
46+
patch.object(
47+
Agent, "_prepare_kickoff", return_value=(MagicMock(), {}, {}, [])
48+
),
49+
patch.object(Agent, "_current_usage_summary", return_value=MagicMock()),
50+
patch.object(
51+
Agent,
52+
"_execute_and_build_output_async",
53+
new=AsyncMock(side_effect=[_canned_output("first"), _canned_output("final answer")]),
54+
),
55+
patch.object(
56+
Agent,
57+
"_execute_and_build_output",
58+
side_effect=AssertionError("sync execution path taken in async context"),
59+
),
60+
):
61+
result = await agent.kickoff_async("answer me")
62+
63+
assert calls["n"] == 2
64+
assert result.raw == "final answer"
65+
66+
67+
@pytest.mark.asyncio
68+
async def test_kickoff_async_guardrail_retry_exhaustion_still_raises():
69+
"""Retry limits are honored on the async path too."""
70+
agent, _ = _agent_with_fail_once_guardrail()
71+
agent.guardrail_max_retries = 0
72+
73+
with (
74+
patch.object(
75+
Agent, "_prepare_kickoff", return_value=(MagicMock(), {}, {}, [])
76+
),
77+
patch.object(Agent, "_current_usage_summary", return_value=MagicMock()),
78+
patch.object(
79+
Agent,
80+
"_execute_and_build_output_async",
81+
new=AsyncMock(return_value=_canned_output("first")),
82+
),
83+
):
84+
with pytest.raises(ValueError, match="guardrail failed validation"):
85+
await agent.kickoff_async("answer me")

0 commit comments

Comments
 (0)