[BUG] Truncated responses are detected in the Bedrock provider only; other providers accept them silently
@Vidit-Ostwal is already working on this.
Since Aug 25, 2026.
- Dominant language
- Python
- Stars
- 58.8k
- Forks
- 8.5k
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 109
Description
AI-assisted contribution. This issue was researched and drafted with Claude Code,
and reviewed by me before filing. PerCONTRIBUTING.mdthis needs thellm-generated
label; I do not have triage permission here, so could a maintainer please apply it.
Everything below was reproduced against crewai 1.15.16 today, and the scripts are
included so you can re-run any of it.
Description
crewai/llms/providers/bedrock/completion.py checks whether the provider stopped because
it hit the token cap, and warns:
if stop_reason == "max_tokens":
logging.warning("Response truncated due to max_tokens limit")
No other provider does. In crewai 1.15.16 that check exists at four sites, all in
bedrock/completion.py (683, 1121, 1285, 1723). Grepping the published wheel for any
equivalent branch elsewhere returns nothing:
$ grep -rnE 'stop_reason *== *"max_tokens"|finish_reason *== *"length"|== *"MAX_TOKENS"' crewai/
crewai/llms/providers/bedrock/completion.py:683
crewai/llms/providers/bedrock/completion.py:1121
crewai/llms/providers/bedrock/completion.py:1285
crewai/llms/providers/bedrock/completion.py:1723
So on Anthropic, OpenAI, Azure, Gemini, openai_compatible and Snowflake, a response cut
off mid-sentence is handed back to the agent as if it were a complete answer. The agent
then reasons over a partial result, or parses it and fails, with nothing distinguishing
"the model was interrupted" from "the model answered badly".
Observed end to end
A one-agent crew with max_tokens=16, capturing both every WARNING-or-above log record
and the finish_reason crewAI puts on its own LLMCallCompletedEvent:
[openai] openai/gpt-4o-mini
finish_reason crewAI observed : ['length']
task result : 'The TCP three-way handshake is a fundamental process used to establish a reliable connec'
warnings logged : 0
truncation warnings : 0
[anthropic] anthropic/claude-haiku-4-5-20251001
finish_reason crewAI observed : ['max_tokens']
task result : '# TCP Three-Way Handshake: A Detailed Explanation'
warnings logged : 0
truncation warnings : 0
Both runs return the cut-off text as the successful task result. The Anthropic run
returns a markdown heading with no body under it. Nothing in either run indicates the
answer is incomplete.
The two lines worth putting side by side are finish_reason crewAI observed: ['length']
and warnings logged: 0. crewAI had the signal, on its own event bus, and did not act on
it. That is the entire report.
The value looks like it is already within reach. Every provider extracts it at the call site:
anthropic/completion.py:1010:finish_reason, response_id = self._extract_finish_reason_and_id(...)azure/completion.py:864gemini/completion.py:847openai/completion.py:978
and forwards it to LLMCallCompletedEvent(finish_reason=...), so it is in scope at the
point a check would go. Outside Bedrock nothing reads it.
Why the silence is costly
Two provider behaviours make this cost more than it might appear.
Some models return zero visible characters while billing a full budget. Measured
2026-08-16 with a 16-token cap on one prompt, one call per provider:
| provider | model | finish reason | visible chars | output tokens |
|---|---|---|---|---|
| anthropic | claude-haiku-4-5-20251001 | max_tokens |
49 | 16 |
| openai | gpt-4o-mini | length |
90 | 16 |
| gemini | gemini-3.7-flash | MAX_TOKENS |
43 | 12 |
| deepseek | deepseek-v4-flash | length |
0 | 16 |
| moonshot | kimi-k2.6 | length |
0 | 16 |
And with reasoning models given a 200-token cap, where thinking consumes the budget
before any answer is emitted:
| provider | model | finish reason | visible chars | output tokens |
|---|---|---|---|---|
| anthropic | claude-sonnet-5 | max_tokens |
623 | 200 |
| gemini | gemini-3.7-flash | MAX_TOKENS |
29 | 196 |
| deepseek | deepseek-v4-pro | length |
0 | 200 |
| moonshot | kimi-k3 | length |
0 | 200 |
An empty string that cost 200 output tokens is indistinguishable, downstream of the
provider layer, from a model that declined to answer. The finish reason is the only thing
that separates them, and it is the thing being dropped.
Retrying does not help. A retry at the same max_tokens truncates at the same place,
so the failure is not transient. It is a configuration problem that presents as a model
problem. Measured on a separate router that escalated on this signal, the truncation was
read as a quality failure and escalated to a stronger model, which truncated identically
at the same ceiling. The remedy is a larger cap rather than a larger model, and at the moment there is no
signal available to tell the caller which of the two they need.
Steps to Reproduce
The provider-layer gap is a property of the published artifact, so it can be checked
without a key:
curl -sL -o crewai.whl "$(curl -s https://pypi.org/pypi/crewai/1.15.16/json | python3 -c "import json,sys;print(next(f['url'] for f in json.load(sys.stdin)['urls'] if f['filename'].endswith('.whl')))")"unzip -q crewai.whl -d x && cd xgrep -rnE 'stop_reason *== *"max_tokens"|finish_reason *== *"length"|== *"MAX_TOKENS"' crewai/- Every hit is in
crewai/llms/providers/bedrock/completion.py. grep -rn -i truncat crewai/ | grep -i warn, and the only two warnings are Bedrock's.
For the end-to-end behaviour, run a one-agent crew against any non-Bedrock provider with a
small cap, and watch the logs and the event bus at the same time:
llm = LLM(model="openai/gpt-4o-mini", max_tokens=16)
@crewai_event_bus.on(LLMCallCompletedEvent)
def record(source, event):
print("finish_reason:", event.finish_reason) # -> 'length'
agent = Agent(role="Explainer", goal="Explain networking clearly",
backstory="You explain protocols.", llm=llm, max_iter=2)
task = Task(description="Explain the TCP three-way handshake in detail.",
expected_output="A detailed explanation.", agent=agent)
print(Crew(agents=[agent], tasks=[task]).kickoff())
The event prints finish_reason: length, the crew returns the truncated text as its
result, and no warning is logged at any level.
Expected behavior
A response terminated by the token cap is distinguishable from a complete one on every
provider, not just Bedrock. At minimum the same logging.warning Bedrock already emits,
naming the current max_tokens so the reader knows which knob to turn.
Screenshots/Code snippets
Existing Bedrock precedent, crewai/llms/providers/bedrock/completion.py:683:
if stop_reason == "max_tokens":
logging.warning("Response truncated due to max_tokens limit")
Equivalent point in the Anthropic provider, anthropic/completion.py:1010, where
finish_reason is already bound and no check happens:
finish_reason, response_id = self._extract_finish_reason_and_id(response)
# ... forwarded to LLMCallCompletedEvent(finish_reason=finish_reason) and no further use
Operating System
macOS 26.5 (arm64)
Python Version
3.13.15 (uv-managed CPython)
crewAI Version
1.15.16 (published wheel from PyPI)
crewAI Tools Version
N/A, the finding is in crewai core
Virtual Environment
Venv (uv)
Evidence
Three independent pieces, all re-runnable:
- Source, against the published wheel, not a git checkout, so it reflects exactly what
users install. Output above, reproducible in three commands with no API key. - Provider behaviour, live: 9 calls across 5 providers on 2026-08-16, raw HTTP, no
framework in the path. Tables above. - crewAI end to end: one-agent crew on crewai 1.15.16 / Python 3.13.15, capturing logs
and the event bus together. Output above.
One honest gap: the Gemini leg of the end-to-end run returned transient 503 UNAVAILABLE
("experiencing high demand") rather than completing, so Gemini is covered by the source
inspection and the raw-HTTP table but not by an end-to-end crew run. OpenAI and Anthropic
both completed and both show the behaviour.
The empty-content-with-billed-tokens behaviour was first measured across five providers
here, with method and raw numbers:
https://github.com/JoaquinDG/governor/blob/main/experiments/FINDINGS.md#f5-reasoning-models-bill-output-tokens-and-emit-no-text
Possible Solution
Mirror the Bedrock check into the other providers at the point where finish_reason is
already in scope. Roughly, per provider:
if finish_reason in ("length", "max_tokens", "MAX_TOKENS"):
logging.warning(
"Response truncated: finish_reason=%r. Consider increasing max_tokens (current: %s).",
finish_reason, self.max_tokens,
)
The finish-reason vocabulary already differs per provider (length, max_tokens,
MAX_TOKENS), so a shared predicate in crewai/llms/_finish_reason_utils.py, which
already centralises extraction, would keep the comparison in one place rather than
repeating the tuple six times.
A warning is the smallest change consistent with existing behaviour. Whether truncation
should also be surfaced structurally (so callers can react without scraping logs) is a
larger design question I have deliberately not assumed an answer to.
I have not opened a PR. Happy to, if you would like it in this shape.
Additional context
#5148 raised closely related behaviour in the Anthropic provider. It was auto-closed by
the no-issue-activity bot, so I do not think it ever got a real decision either way.
Some of what it asked for has since shipped: finish_reason now reaches
LLMCallCompletedEvent, which was the broader fix that issue suggested. The part still
outstanding is any use of that value, which is what this report is about.
I opened this separately because the code has moved on since March and the framing is
different now, but I am equally happy for it to be folded into #5148 if you would rather
keep the history in one place.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.