fix: prevent tools from being discarded when response_model is set (#4697)#4699
fix: prevent tools from being discarded when response_model is set (#4697)#4699devin-ai-integration[bot] wants to merge 3 commits intomainfrom
Conversation
…4697) When both native function calling (tools) and output_pydantic (response_model) are enabled, InternalInstructor was intercepting the LLM call and creating its own completion without passing tools. This caused agent tools to be silently discarded. The fix checks whether tools are present in params before routing to InternalInstructor. When tools exist, the normal litellm.completion path is used so the LLM can see and call the agent's tools. InternalInstructor is still used when no tools are present (backward compatible). Applied to all three response handlers: - _handle_non_streaming_response - _ahandle_non_streaming_response - _handle_streaming_response Co-Authored-By: João <joao@crewai.com>
|
Prompt hidden (unlisted session) |
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| if response_model and self.is_litellm: | ||
| # Only use InternalInstructor for structured output when there | ||
| # are no tools — otherwise tools would be silently discarded. | ||
| has_tools = bool(params.get("tools")) |
There was a problem hiding this comment.
I already try this in local and it's not working
There was a problem hiding this comment.
Thanks for flagging this @Killian-fal. The initial commit only skipped InternalInstructor but still passed response_model to litellm.completion, which litellm uses internally with instructor — effectively still overriding the tools. I've pushed a follow-up commit that also prevents response_model from being forwarded to litellm when tools are present. Could you try the latest version of this branch?
…resent Addresses review feedback: when tools are present, response_model must not be passed to litellm.completion/acompletion either, because litellm uses instructor internally which would override the tools parameter. Added 2 more tests that explicitly verify response_model is NOT in the kwargs sent to litellm when tools are present. Co-Authored-By: João <joao@crewai.com>
| # Only use InternalInstructor for structured output when there | ||
| # are no tools — otherwise tools would be silently discarded. | ||
| has_tools = bool(params.get("tools")) | ||
| if response_model and self.is_litellm and not has_tools: |
There was a problem hiding this comment.
Async streaming handler missing the has_tools guard for InternalInstructor
Medium Severity
The fix is applied to _handle_streaming_response, _handle_non_streaming_response, and _ahandle_non_streaming_response, but _ahandle_streaming_response (line ~1416) also accepts response_model as a parameter yet has no response_model/InternalInstructor handling at all — neither before nor after this PR. When async streaming is used with response_model set, structured output conversion is silently skipped regardless of whether tools are present. This is an inconsistency across the four handler methods that may cause unexpected behavior in the async streaming + response_model path.
There was a problem hiding this comment.
This is a pre-existing inconsistency — _ahandle_streaming_response has never had InternalInstructor/response_model handling, neither before nor after this PR. The lack of structured output support in async streaming is a separate gap that should be addressed in a follow-up PR to keep this fix scoped to the tools-being-discarded issue (#4697).
… None Mirrors _handle_non_streaming_response's explicit path: when tool_calls exist but available_functions is None, return the tool_calls to the caller instead of returning an empty full_response string. Co-Authored-By: João <joao@crewai.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| # Skip InternalInstructor when tools are present so the LLM can | ||
| # see and call the agent's tools before returning structured output. | ||
| has_tools = bool(params.get("tools")) | ||
| if response_model and self.is_litellm and not has_tools: |
There was a problem hiding this comment.
Structured output silently lost when tools present but unused
High Severity
The has_tools guard checks whether tools are configured in params, not whether the LLM actually returned tool_calls. When tools are present but the LLM responds with plain text (no tool_calls), both InternalInstructor and response_model passthrough to litellm are skipped, so response_model is silently ignored. The caller receives unstructured text instead of the expected Pydantic-formatted output. This affects every LLM call after tool execution completes — the final-answer call still has tools in params, so structured output conversion never happens. This likely explains the reviewer's report that it's "not working" in local testing.
Additional Locations (2)
| # so the caller (e.g., executor) can handle tool execution. | ||
| # This mirrors _handle_non_streaming_response's explicit path. | ||
| if tool_calls and not available_functions: | ||
| return tool_calls |
There was a problem hiding this comment.
Streaming handler inconsistently prioritizes tool_calls over text response
Medium Severity
The new early return at lines 973–974 returns tool_calls when tool_calls and not available_functions, regardless of whether full_response contains text. The non-streaming handler (line 1254) does the opposite — it returns text_response first when truthy, and only returns tool_calls at line 1266 if text is empty. This creates inconsistent behavior between streaming and non-streaming modes for the same inputs.


fix: prevent tools from being discarded when response_model is set (#4697)
Summary
Fixes #4697. When both native function calling (
tools) andoutput_pydantic(response_model) are enabled, tools were being silently discarded through two mechanisms:InternalInstructorearly return: Whenresponse_modelwas set,InternalInstructorintercepted the LLM call and created its own completion that ignored the tools parameter entirely.response_modelforwarded to litellm: Even after skippingInternalInstructor, the code still passedresponse_modeltolitellm.completion(**params). Litellm uses instructor internally whenresponse_modelis provided, which also overrides the tools parameter._handle_streaming_responselacked an explicit path fortool_callspresent +available_functionsabsent, causing it to return an empty string instead of the tool_calls (unlike the non-streaming handler which already had this path).Changes
has_toolsguard to the three response handlers (_handle_non_streaming_response,_ahandle_non_streaming_response,_handle_streaming_response). When tools are present,InternalInstructoris skipped.response_modelis not added toparamsbefore callinglitellm.completion/litellm.acompletion, preventing litellm's own instructor from overriding tools.if tool_calls and not available_functions: return tool_callspath in_handle_streaming_response, mirroring the equivalent path in_handle_non_streaming_response.InternalInstructoris still used when no tools are present (backward compatible).response_model-not-forwarded scenarios.Known limitation (pre-existing, not addressed here)
_ahandle_streaming_responsehas noInternalInstructor/response_modelhandling at all — neither before nor after this PR. This is a separate inconsistency that should be addressed in a follow-up.Review & Testing Checklist for Human
litellm.completionentirely, so they validate the LLM layer's branching logic but not real litellm behavior. Manually verify that an agent with both tools andoutput_pydanticcan (1) call its tools, and (2) still return structured output in the expected Pydantic format after tool execution completes.InternalInstructorandresponse_modelforwarding are now bypassed. Confirm thatCrewAgentExecutoror the converter layer still enforces structured output on the final answer in this path.if tool_calls and not available_functions: return tool_callspath (line ~973) runs before the existingif not tool_calls or not available_functions:block. Verify this doesn't break any caller that expects a string response from the streaming handler when tool_calls are present.Notes