Skip to content

[BUG] Fabricated-Observation recovery in process_llm_response is dead code since #2483 — real tool calls silently discarded for models without stop-word support #6449

Description

@ritsth

Description

process_llm_response() in lib/crewai/src/crewai/utilities/agent_utils.py contains a recovery block that is meant to handle models that don't support the stop parameter (use_stop_words=False: the gpt-5 family, the o1 family, and any custom BaseLLM that ignores stop sequences). Without a "\nObservation:" stop sequence, the LLM generates straight past the real tool call and fabricates the rest of the ReAct loop in a single completion:

Thought: ...
Action: web_search
Action Input: {"search_query": "..."}
Observation: <fabricated — the tool never ran>
Final Answer: <fabricated>

The recovery block is supposed to discard the fabricated continuation so the real Action executes:

if not use_stop_words:
    try:
        format_answer(answer)
    except OutputParserError as e:
        if FINAL_ANSWER_AND_PARSABLE_ACTION_ERROR_MESSAGE in e.error:
            answer = answer.split("Observation:")[0].strip()

This block has been dead code since April 2025. It catches an error that can no longer be raised:

Other leftovers of the original intent are still in the tree:

  • FINAL_ANSWER_AND_PARSABLE_ACTION_ERROR_MESSAGE still sits in crewai/agents/constants.py, imported only by the dead catch.
  • The experimental executor has an unreachable warning block (experimental/agent_executor.py, "LLM returned 'Final Answer:' but parsed as AgentAction") that only makes sense with the recovery alive.

User-visible impact: this is, as far as I can tell, the root cause of the many reports collected in #3154 ("Agent does not actually invoke tools, only simulates tool usage with fabricated output"). That thread independently observed the exact correlation this explains: gpt-4.1 works (stop words supported → generation stops before a fabricated Observation:), while gpt-5 / o-series fabricate (supports_stop_words() returns False for both, so nothing stops the completion and the dead recovery never rescues the real tool call).

Steps to Reproduce

from crewai.utilities.agent_utils import process_llm_response

# What a model without stop-word support typically returns (shape from #3154):
answer = """Thought: I should use the Web Search Tool to investigate trends in AI.
Action: Web Search Tool
Action Input: {"search_query": "trends in AI 2025"}
Observation: The search results show that the main trends include multimodal models.
Thought: I now have enough information.
Final Answer: The main AI trends are multimodal models."""

result = process_llm_response(answer, use_stop_words=False)
print(type(result).__name__)  # AgentFinish — the fabricated answer wins

Expected behavior

AgentAction(tool="Web Search Tool", ...) — the fabricated Observation/Final Answer continuation is discarded and the real tool call executes, as it did before #2483.

Screenshots/Code snippets

See above; the dead block is at lib/crewai/src/crewai/utilities/agent_utils.py (in process_llm_response), current main (2b90117e).

Operating System

macOS (reproduces on any OS)

Python Version

3.13

crewAI Version

current main (1.15.2a2, commit 2b90117e)

crewAI Tools Version

n/a

Virtual Environment

uv

Evidence

Regression timeline verified with git log -S FINAL_ANSWER_AND_PARSABLE_ACTION_ERROR_MESSAGE: raise introduced in e77442cf (#1322), removed in efe27bd5 (#2483) together with its test, catch block moved (already dead) into process_llm_response in the same commit.

Possible Solution

Replace the dead except block with an explicit position check — no exception plumbing, so it is immune to format_answer()'s exception swallowing and independent of #3771:

if not use_stop_words and FINAL_ANSWER_ACTION in answer:
    action_match = ACTION_INPUT_REGEX.search(answer)
    final_answer_idx = answer.find(FINAL_ANSWER_ACTION)
    if action_match and action_match.start() < final_answer_idx:
        observation_idx = answer.find(
            "Observation:", action_match.start(), final_answer_idx
        )
        if observation_idx != -1:
            answer = answer[:observation_idx].strip()

return format_answer(answer)

Only the exact fabrication shape (ActionObservationFinal Answer, with stop words unsupported) is affected; every other response shape, and all behavior for stop-word-supporting models, is unchanged. This also fixes all four call sites at once (experimental AgentExecutor, CrewAgentExecutor, LiteAgent, step_executor) and makes the experimental executor's existing warning reachable.

I have this implemented with regression tests (fails-before/passes-after) and will open a PR referencing this issue.

Additional context

Found by code inspection while tracing why the recovery path never fires. AI-assisted analysis (Claude); every claim above was verified by hand against git history and a local reproduction.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions