Skip to content

fix: guard output_pydantic injection when LLM lacks function calling#4862

Open
gambletan wants to merge 1 commit intocrewAIInc:mainfrom
gambletan:fix/output-pydantic-no-function-calling
Open

fix: guard output_pydantic injection when LLM lacks function calling#4862
gambletan wants to merge 1 commit intocrewAIInc:mainfrom
gambletan:fix/output-pydantic-no-function-calling

Conversation

@gambletan
Copy link
Copy Markdown

@gambletan gambletan commented Mar 14, 2026

Summary

Fixes #4695

When a Task has output_pydantic set and the LLM does not support function calling (e.g., Ollama models via LiteLLM), the pydantic schema is incorrectly injected as a native tool via response_model. This forces the LLM to produce structured output immediately via tool_choice without first gathering data through the agent's tools, resulting in empty/placeholder values.

Changes

  • Added _llm_supports_function_calling() helper method on Agent to check LLM function calling support without requiring a tools list
  • Modified create_agent_executor() to only pass output_pydantic/output_json as response_model when the LLM supports function calling
  • Modified _update_executor_parameters() with the same guard
  • task.response_model (explicit user override) is always respected regardless of function calling support

How it works

When supports_function_calling() returns False:

  • response_model is set to None (unless task.response_model is explicitly set)
  • The agent uses the ReAct loop (Action/Action Input/Observation) to gather data with tools
  • After execution, the existing text-based conversion path in converter.py handles converting the result to the pydantic model

When supports_function_calling() returns True:

  • Behavior is unchanged -- output_pydantic/output_json is passed as response_model

Test plan

  • Run with an Ollama model (no function calling) + output_pydantic task + agent tools -- verify the agent uses ReAct loop and tools before producing structured output
  • Run with an OpenAI model (function calling supported) + output_pydantic -- verify existing behavior is unchanged
  • Verify task.response_model is always passed through regardless of function calling support
  • Run existing test suite to ensure no regressions

Note

Medium Risk
Changes how response_model is set for task execution, which can alter structured-output and tool-calling behavior across LLM providers. Risk is moderate because it affects agent executor configuration but is narrowly scoped to guarding schema injection based on function-calling support.

Overview
Prevents Task.output_pydantic/output_json from being passed as response_model when the underlying LLM does not support native function calling, avoiding forced structured output that can bypass tool usage.

Adds an Agent._llm_supports_function_calling() helper and updates both create_agent_executor() and _update_executor_parameters() to only set response_model from output_pydantic/output_json when function calling is supported (while always honoring an explicit task.response_model).

Written by Cursor Bugbot for commit 0ca3977. This will update automatically on new commits. Configure here.

…on calling

When a Task has output_pydantic set and the LLM does not support function
calling (e.g., Ollama models), the pydantic schema was being injected as a
native tool via response_model, forcing the LLM to produce structured output
immediately via tool_choice without gathering data first.

This fix adds a guard to only set response_model to output_pydantic/output_json
when the LLM supports function calling. When function calling is not supported,
the existing text-based conversion path in converter.py handles structured
output correctly.

Fixes crewAIInc#4695
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

hasattr(self.llm, "supports_function_calling")
and callable(getattr(self.llm, "supports_function_calling", None))
and self.llm.supports_function_calling()
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New method duplicates existing method's core logic

Low Severity

_llm_supports_function_calling() duplicates the first three conditions of _supports_native_tool_calling(). The existing method could be refactored to delegate: return self._llm_supports_function_calling() and len(tools) > 0. Additionally, the condition use_native_tool_calling or self._llm_supports_function_calling() in create_agent_executor is logically equivalent to just self._llm_supports_function_calling() (by absorption: (A∧B)∨A = A), making the use_native_tool_calling or part misleadingly redundant.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown

@alvinttang alvinttang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

This PR addresses a real problem: when output_pydantic is set on a task and the LLM lacks function calling support (e.g., some Ollama models), the pydantic schema gets injected as a tool, forcing the LLM to produce structured output immediately without first using the agent's actual tools.

Issues and suggestions:

  1. _llm_supports_function_calling() is fragile: The method checks hasattr + callable + calls the method. But supports_function_calling() on LLM is determined by looking up the model in LiteLLM's model info, and it can raise exceptions (e.g., if the model isn't in LiteLLM's registry). This helper doesn't handle exceptions — a litellm.exceptions.BadRequestError or KeyError from the model registry lookup would propagate up and crash. Consider wrapping in a try/except that defaults to True (safe fallback: assume function calling is supported, preserving existing behavior).

  2. Inconsistency between create_agent_executor and _update_executor_parameters: In create_agent_executor, the guard is use_native_tool_calling or self._llm_supports_function_calling(). In _update_executor_parameters, it's only self._llm_supports_function_calling() — the use_native_tool_calling check is missing. If use_native_tool_calling was True in the first method, shouldn't it also be respected in the update path? This could cause the response model to be dropped on executor parameter updates even when native tool calling was initially enabled.

  3. Relationship with PR #4856: This PR and #4856 both fix issues around output_pydantic + tools interaction but from different angles. PR #4856 fixes the LLM-level short-circuit in InternalInstructor. This PR guards at the agent level. They seem complementary but could interact: if both are merged, and the LLM does support function calling, the response_model will be passed through (this PR allows it) but tools will also be passed (PR #4856 ensures they aren't dropped). Worth verifying they compose correctly.

  4. No tests: This is a behavioral change to core agent executor creation with no test coverage. At minimum, a unit test should verify that when supports_function_calling() returns False, response_model is None (and when it returns True, it's set). Mocking the LLM's supports_function_calling method would make this straightforward.

  5. task.response_model always passes through: This is correct — an explicit response_model set by the user should always be respected. Good design choice.

The fix direction is right, but the implementation needs hardening around error handling in _llm_supports_function_calling() and consistency between the two code paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] output_pydantic model injected as native tool even when supports_function_calling() is False

2 participants