Skip to content

Commit 7e6171d

Browse files
fix: ensure lite agents course-correct on validation errors
* fix: ensure lite agents course-correct on validation errors * chore: update cassettes and test expectations * fix: ensure multiple guardrails propogate
1 parent 61ad1fb commit 7e6171d

28 files changed

+6778
-8948
lines changed

lib/crewai/src/crewai/lite_agent.py

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
from collections.abc import Callable
33
import inspect
4+
import json
45
from typing import (
56
Any,
67
Literal,
@@ -58,7 +59,11 @@
5859
process_llm_response,
5960
render_text_description_and_args,
6061
)
61-
from crewai.utilities.converter import generate_model_description
62+
from crewai.utilities.converter import (
63+
Converter,
64+
ConverterError,
65+
generate_model_description,
66+
)
6267
from crewai.utilities.guardrail import process_guardrail
6368
from crewai.utilities.guardrail_types import GuardrailCallable, GuardrailType
6469
from crewai.utilities.i18n import I18N, get_i18n
@@ -241,14 +246,20 @@ def _original_role(self) -> str:
241246
"""Return the original role for compatibility with tool interfaces."""
242247
return self.role
243248

244-
def kickoff(self, messages: str | list[LLMMessage]) -> LiteAgentOutput:
249+
def kickoff(
250+
self,
251+
messages: str | list[LLMMessage],
252+
response_format: type[BaseModel] | None = None,
253+
) -> LiteAgentOutput:
245254
"""
246255
Execute the agent with the given messages.
247256
248257
Args:
249258
messages: Either a string query or a list of message dictionaries.
250259
If a string is provided, it will be converted to a user message.
251260
If a list is provided, each dict should have 'role' and 'content' keys.
261+
response_format: Optional Pydantic model for structured output. If provided,
262+
overrides self.response_format for this execution.
252263
253264
Returns:
254265
LiteAgentOutput: The result of the agent execution.
@@ -269,9 +280,13 @@ def kickoff(self, messages: str | list[LLMMessage]) -> LiteAgentOutput:
269280
self.tools_results = []
270281

271282
# Format messages for the LLM
272-
self._messages = self._format_messages(messages)
283+
self._messages = self._format_messages(
284+
messages, response_format=response_format
285+
)
273286

274-
return self._execute_core(agent_info=agent_info)
287+
return self._execute_core(
288+
agent_info=agent_info, response_format=response_format
289+
)
275290

276291
except Exception as e:
277292
self._printer.print(
@@ -289,7 +304,9 @@ def kickoff(self, messages: str | list[LLMMessage]) -> LiteAgentOutput:
289304
)
290305
raise e
291306

292-
def _execute_core(self, agent_info: dict[str, Any]) -> LiteAgentOutput:
307+
def _execute_core(
308+
self, agent_info: dict[str, Any], response_format: type[BaseModel] | None = None
309+
) -> LiteAgentOutput:
293310
# Emit event for agent execution start
294311
crewai_event_bus.emit(
295312
self,
@@ -303,15 +320,29 @@ def _execute_core(self, agent_info: dict[str, Any]) -> LiteAgentOutput:
303320
# Execute the agent using invoke loop
304321
agent_finish = self._invoke_loop()
305322
formatted_result: BaseModel | None = None
306-
if self.response_format:
323+
324+
active_response_format = response_format or self.response_format
325+
if active_response_format:
307326
try:
308-
# Cast to BaseModel to ensure type safety
309-
result = self.response_format.model_validate_json(agent_finish.output)
327+
model_schema = generate_model_description(active_response_format)
328+
schema = json.dumps(model_schema, indent=2)
329+
instructions = self.i18n.slice("formatted_task_instructions").format(
330+
output_format=schema
331+
)
332+
333+
converter = Converter(
334+
llm=self.llm,
335+
text=agent_finish.output,
336+
model=active_response_format,
337+
instructions=instructions,
338+
)
339+
340+
result = converter.to_pydantic()
310341
if isinstance(result, BaseModel):
311342
formatted_result = result
312-
except Exception as e:
343+
except ConverterError as e:
313344
self._printer.print(
314-
content=f"Failed to parse output into response format: {e!s}",
345+
content=f"Failed to parse output into response format after retries: {e.message}",
315346
color="yellow",
316347
)
317348

@@ -400,8 +431,14 @@ async def kickoff_async(self, messages: str | list[LLMMessage]) -> LiteAgentOutp
400431
"""
401432
return await asyncio.to_thread(self.kickoff, messages)
402433

403-
def _get_default_system_prompt(self) -> str:
404-
"""Get the default system prompt for the agent."""
434+
def _get_default_system_prompt(
435+
self, response_format: type[BaseModel] | None = None
436+
) -> str:
437+
"""Get the default system prompt for the agent.
438+
439+
Args:
440+
response_format: Optional response format to use instead of self.response_format
441+
"""
405442
base_prompt = ""
406443
if self._parsed_tools:
407444
# Use the prompt template for agents with tools
@@ -422,21 +459,31 @@ def _get_default_system_prompt(self) -> str:
422459
goal=self.goal,
423460
)
424461

425-
# Add response format instructions if specified
426-
if self.response_format:
427-
schema = generate_model_description(self.response_format)
462+
active_response_format = response_format or self.response_format
463+
if active_response_format:
464+
model_description = generate_model_description(active_response_format)
465+
schema_json = json.dumps(model_description, indent=2)
428466
base_prompt += self.i18n.slice("lite_agent_response_format").format(
429-
response_format=schema
467+
response_format=schema_json
430468
)
431469

432470
return base_prompt
433471

434-
def _format_messages(self, messages: str | list[LLMMessage]) -> list[LLMMessage]:
435-
"""Format messages for the LLM."""
472+
def _format_messages(
473+
self,
474+
messages: str | list[LLMMessage],
475+
response_format: type[BaseModel] | None = None,
476+
) -> list[LLMMessage]:
477+
"""Format messages for the LLM.
478+
479+
Args:
480+
messages: Input messages to format
481+
response_format: Optional response format to use instead of self.response_format
482+
"""
436483
if isinstance(messages, str):
437484
messages = [{"role": "user", "content": messages}]
438485

439-
system_prompt = self._get_default_system_prompt()
486+
system_prompt = self._get_default_system_prompt(response_format=response_format)
440487

441488
# Add system message at the beginning
442489
formatted_messages: list[LLMMessage] = [
@@ -506,6 +553,10 @@ def _invoke_loop(self) -> AgentFinish:
506553

507554
self._append_message(formatted_answer.text, role="assistant")
508555
except OutputParserError as e: # noqa: PERF203
556+
self._printer.print(
557+
content="Failed to parse LLM output. Retrying...",
558+
color="yellow",
559+
)
509560
formatted_answer = handle_output_parser_exception(
510561
e=e,
511562
messages=self._messages,

lib/crewai/src/crewai/task.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,11 @@ def _execute_core(
525525
tools=tools,
526526
)
527527

528-
pydantic_output, json_output = self._export_output(result)
528+
if not self._guardrails and not self._guardrail:
529+
pydantic_output, json_output = self._export_output(result)
530+
else:
531+
pydantic_output, json_output = None, None
532+
529533
task_output = TaskOutput(
530534
name=self.name or self.description,
531535
description=self.description,

lib/crewai/src/crewai/translations/en.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@
2222
"summarize_instruction": "Summarize the following text, make sure to include all the important information: {group}",
2323
"summary": "This is a summary of our conversation so far:\n{merged_summary}",
2424
"manager_request": "Your best answer to your coworker asking you this, accounting for the context shared.",
25-
"formatted_task_instructions": "Ensure your final answer contains only the content in the following format: {output_format}\n\nEnsure the final output does not include any code block markers like ```json or ```python.",
25+
"formatted_task_instructions": "Ensure your final answer strictly adheres to the following OpenAPI schema: {output_format}\n\nDo not include the OpenAPI schema in the final output. Ensure the final output does not include any code block markers like ```json or ```python.",
2626
"conversation_history_instruction": "You are a member of a crew collaborating to achieve a common goal. Your task is a specific action that contributes to this larger objective. For additional context, please review the conversation history between you and the user that led to the initiation of this crew. Use any relevant information or feedback from the conversation to inform your task execution and ensure your response aligns with both the immediate task and the crew's overall goals.",
2727
"feedback_instructions": "User feedback: {feedback}\nInstructions: Use this feedback to enhance the next output iteration.\nNote: Do not respond or add commentary.",
2828
"lite_agent_system_prompt_with_tools": "You are {role}. {backstory}\nYour personal goal is: {goal}\n\nYou ONLY have access to the following tools, and should NEVER make up tools that are not listed here:\n\n{tools}\n\nIMPORTANT: Use the following format in your response:\n\n```\nThought: you should always think about what to do\nAction: the action to take, only one name of [{tool_names}], just the name, exactly as it's written.\nAction Input: the input to the action, just a simple JSON object, enclosed in curly braces, using \" to wrap keys and values.\nObservation: the result of the action\n```\n\nOnce all necessary information is gathered, return the following format:\n\n```\nThought: I now know the final answer\nFinal Answer: the final answer to the original input question\n```",
2929
"lite_agent_system_prompt_without_tools": "You are {role}. {backstory}\nYour personal goal is: {goal}\n\nTo give my best complete final answer to the task respond using the exact following format:\n\nThought: I now can give a great answer\nFinal Answer: Your final answer must be the great and the most complete as possible, it must be outcome described.\n\nI MUST use these formats, my job depends on it!",
30-
"lite_agent_response_format": "\nIMPORTANT: Your final answer MUST contain all the information requested in the following format: {response_format}\n\nIMPORTANT: Ensure the final output does not include any code block markers like ```json or ```python.",
30+
"lite_agent_response_format": "Ensure your final answer strictly adheres to the following OpenAPI schema: {response_format}\n\nDo not include the OpenAPI schema in the final output. Ensure the final output does not include any code block markers like ```json or ```python.",
3131
"knowledge_search_query": "The original query is: {task_prompt}.",
3232
"knowledge_search_query_system_prompt": "Your goal is to rewrite the user query so that it is optimized for retrieval from a vector database. Consider how the query will be used to find relevant documents, and aim to make it more specific and context-aware. \n\n Do not include any other text than the rewritten query, especially any preamble or postamble and only add expected output format if its relevant to the rewritten query. \n\n Focus on the key words of the intended task and to retrieve the most relevant information. \n\n There will be some extra context provided that might need to be removed such as expected_output formats structured_outputs and other instructions."
3333
},

lib/crewai/src/crewai/utilities/converter.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
from typing_extensions import Unpack
1111

1212
from crewai.agents.agent_builder.utilities.base_output_converter import OutputConverter
13+
from crewai.utilities.i18n import get_i18n
1314
from crewai.utilities.internal_instructor import InternalInstructor
1415
from crewai.utilities.printer import Printer
15-
from crewai.utilities.pydantic_schema_parser import PydanticSchemaParser
1616

1717

1818
if TYPE_CHECKING:
@@ -22,6 +22,7 @@
2222
from crewai.llms.base_llm import BaseLLM
2323

2424
_JSON_PATTERN: Final[re.Pattern[str]] = re.compile(r"({.*})", re.DOTALL)
25+
_I18N = get_i18n()
2526

2627

2728
class ConverterError(Exception):
@@ -87,8 +88,7 @@ def to_pydantic(self, current_attempt: int = 1) -> BaseModel:
8788
result = self.model.model_validate(result)
8889
elif isinstance(result, str):
8990
try:
90-
parsed = json.loads(result)
91-
result = self.model.model_validate(parsed)
91+
result = self.model.model_validate_json(result)
9292
except Exception as parse_err:
9393
raise ConverterError(
9494
f"Failed to convert partial JSON result into Pydantic: {parse_err}"
@@ -172,6 +172,16 @@ def convert_to_model(
172172
model = output_pydantic or output_json
173173
if model is None:
174174
return result
175+
176+
if converter_cls:
177+
return convert_with_instructions(
178+
result=result,
179+
model=model,
180+
is_json_output=bool(output_json),
181+
agent=agent,
182+
converter_cls=converter_cls,
183+
)
184+
175185
try:
176186
escaped_result = json.dumps(json.loads(result, strict=False))
177187
return validate_model(
@@ -251,7 +261,7 @@ def handle_partial_json(
251261
except json.JSONDecodeError:
252262
pass
253263
except ValidationError:
254-
pass
264+
raise
255265
except Exception as e:
256266
Printer().print(
257267
content=f"Unexpected error during partial JSON handling: {type(e).__name__}: {e}. Attempting alternative conversion method.",
@@ -335,25 +345,26 @@ def get_conversion_instructions(
335345
Returns:
336346
337347
"""
338-
instructions = "Please convert the following text into valid JSON."
348+
instructions = ""
339349
if (
340350
llm
341351
and not isinstance(llm, str)
342352
and hasattr(llm, "supports_function_calling")
343353
and llm.supports_function_calling()
344354
):
345-
model_schema = PydanticSchemaParser(model=model).get_schema()
346-
instructions += (
347-
f"\n\nOutput ONLY the valid JSON and nothing else.\n\n"
348-
f"Use this format exactly:\n```json\n{model_schema}\n```"
355+
schema_dict = generate_model_description(model)
356+
schema = json.dumps(schema_dict, indent=2)
357+
formatted_task_instructions = _I18N.slice("formatted_task_instructions").format(
358+
output_format=schema
349359
)
360+
instructions += formatted_task_instructions
350361
else:
351362
model_description = generate_model_description(model)
352-
schema_json = json.dumps(model_description["json_schema"]["schema"], indent=2)
353-
instructions += (
354-
f"\n\nOutput ONLY the valid JSON and nothing else.\n\n"
355-
f"Use this format exactly:\n```json\n{schema_json}\n```"
363+
schema_json = json.dumps(model_description, indent=2)
364+
formatted_task_instructions = _I18N.slice("formatted_task_instructions").format(
365+
output_format=schema_json
356366
)
367+
instructions += formatted_task_instructions
357368
return instructions
358369

359370

lib/crewai/tests/agents/test_lite_agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,8 +382,8 @@ def capture_guardrail_completed(source, event):
382382
assert not guardrail_events["completed"][0].success
383383
assert guardrail_events["completed"][1].success
384384
assert (
385-
"Here are the top 10 best soccer players in the world, focusing exclusively on Brazilian players"
386-
in result.raw
385+
"top 10 best Brazilian soccer players" in result.raw or
386+
"Brazilian players" in result.raw
387387
)
388388

389389

0 commit comments

Comments
 (0)