Skip to content

fix(drivers-prompt-anthropic): warn when generation stops at max_tokens - #2336

Open
breken-ai wants to merge 1 commit into
griptape-ai:mainfrom
breken-ai:fix/anthropic-max-tokens-truncation-warning
Open

breken-ai wants to merge 1 commit into
griptape-ai:mainfrom
breken-ai:fix/anthropic-max-tokens-truncation-warning

Conversation

@breken-ai

Copy link
Copy Markdown
Contributor

Two caveats on that box, stated plainly rather than glossed over:

  1. CONTRIBUTING.md says "Pull requests should be associated with a previously accepted issue." The linked issue was filed shortly before this PR and, at the time of writing, has not been triaged by a maintainer.
  2. This change adds an observability signal rather than correcting wrong output, so you may reasonably classify it as an enhancement — which CONTRIBUTING routes to a Discussion, not an issue. The linked issue says so itself and lists five alternative designs.

If you would rather settle the design in a Discussion first, or accept the issue before reviewing this, please say so and I will follow it. I am not asking you to review a design in a PR.

Describe your changes

The symptom

When Anthropic ends a generation because the configured max_tokens cap was reached, AnthropicPromptDriver returns the partial text as an ordinary Message, and the streaming path yields ordinary deltas. There is no signal — not in the return value, not in the logs at default level — that the answer was cut off.

The returned Message carries no stop-reason field, so a caller cannot recover it either:

text          : The three main causes of the 1929 crash were
public attrs  : ['ASSISTANT_ROLE', 'SYSTEM_ROLE', 'USER_ROLE', 'content', 'module_name', 'role', 'type', 'usage', 'value']

A truncated answer is indistinguishable from a complete one. For structured output, long summarization, and anything that parses the model's text, that is a bad way to fail.

The root cause

Both paths in griptape/drivers/prompt/anthropic_prompt_driver.py at e825448bee212745aa58aa09f628caa5f8a0f9ef ignore the API's stop_reason.

try_run dumps the whole response at DEBUG and then discards it:

105:        response = self.client.messages.create(**params)
106:
107:        logger.debug(response.model_dump())
108:
109:        return Message(

try_stream consumes message_delta events purely for usage accounting, dropping event.delta.stop_reason:

127:            elif event.type == "message_delta":
128:                yield DeltaMessage(usage=DeltaMessage.Usage(output_tokens=event.usage.output_tokens))

Both fields exist on the installed SDK. Checked against anthropic 1.4.0 rather than assumed:

Message.stop_reason type: typing.Optional[typing.Literal['end_turn', 'max_tokens', 'stop_sequence', 'tool_use', 'pause_turn', 'refusal', 'model_context_window_exceeded']]
delta type: <class 'anthropic.types.raw_message_delta_event.Delta'>
delta.stop_reason in fields: True

What this change does

Inspects stop_reason in both paths and emits a single logger.warning only when it equals max_tokens. Every other termination — end_turn, tool_use, stop_sequence, absent — stays silent.

No exception is raised, no content is altered, no return type changes, and caller-specified max_tokens is still honoured exactly. This is a diagnostic signal, not a behaviour change.

Full diff: 2 files changed, 40 insertions(+), zero deletions — 4 added lines in the driver, 36 in its test file.

The trade-off a reviewer should decide on

The module-level logger in this file is shared, not module-specific:

# griptape/drivers/prompt/anthropic_prompt_driver.py:49
logger = logging.getLogger(Defaults.logging_config.logger_name)

I resolved it at runtime rather than reasoning about it:

$ .venv/bin/python -c "from griptape.drivers.prompt.anthropic_prompt_driver import logger; print(repr(logger.name))"
'griptape'

So this record lands on the single griptape logger. Consequence, stated plainly: a caller who deliberately sets a low max_tokens will see this warning on every single call, and can only silence it by silencing griptape warnings as a whole. There is no per-driver filter.

I chose the shared logger to stay consistent with the rest of the file rather than change the repo's logging convention inside a bug fix. If you would prefer any of these, say the word and I will rework the branch:

  • logging.getLogger(__name__) for this record so it can be filtered per driver;
  • a once-per-driver-instance guard so it fires only on the first truncation;
  • logger.debug instead of logger.warning;
  • surfacing stop_reason on Message rather than logging at all (a public API change, so probably its own discussion).

Issue ticket number and link

Closes #2333 (#2333)

Testing

Python 3.12.12, ruff 0.16.6, anthropic 1.4.0, run from a clean checkout of this branch on top of e825448.

Targeted unit tests — 78 passed on the base commit, 94 on this branch:

$ .venv/bin/python -m pytest -q tests/unit/drivers/prompt/test_anthropic_prompt_driver.py
........................................................................ [ 76%]
......................                                                   [100%]
94 passed in 2.52s

Revert proof. Restoring only griptape/drivers/prompt/anthropic_prompt_driver.py to its state at e825448 while keeping the new tests:

$ git checkout e825448 -- griptape/drivers/prompt/anthropic_prompt_driver.py
$ .venv/bin/python -m pytest -q tests/unit/drivers/prompt/test_anthropic_prompt_driver.py
=========================== short test summary info ============================
FAILED tests/unit/drivers/prompt/test_anthropic_prompt_driver.py::TestAnthropicPromptDriver::test_try_run_warns_when_max_tokens_stops_response[True]
FAILED tests/unit/drivers/prompt/test_anthropic_prompt_driver.py::TestAnthropicPromptDriver::test_try_run_warns_when_max_tokens_stops_response[False]
FAILED tests/unit/drivers/prompt/test_anthropic_prompt_driver.py::TestAnthropicPromptDriver::test_try_stream_warns_only_when_max_tokens_stops_stream[True-max_tokens]
FAILED tests/unit/drivers/prompt/test_anthropic_prompt_driver.py::TestAnthropicPromptDriver::test_try_stream_warns_only_when_max_tokens_stops_stream[False-max_tokens]
4 failed, 90 passed in 1.00s

Restoring the fix returns it to 94 passed in 0.93s.

Being precise about what the new tests are. This branch adds 16 test nodes (94 − 78). Only 4 of them are regression tests — the two max_tokens cases in the sync path and the two in the stream path, each doubled by the file's class-level parametrization; those are the 4 listed above. The other 12 pass against the unpatched source too: the test_try_run_does_not_warn_for_normal_stop_reasons family (6 nodes) and the three non-max_tokens stream parameters (6 nodes). They are guards proving the warning does not fire on normal terminations, not proof that the fix works. I would rather say that than let a "16 new tests" number imply more than it does.

End-to-end symptom check, same script as in the linked issue (client patched, no network), run on both refs:

### base e825448
text          : The three main causes of the 1929 crash were
public attrs  : ['ASSISTANT_ROLE', 'SYSTEM_ROLE', 'USER_ROLE', 'content', 'module_name', 'role', 'type', 'usage', 'value']

### this branch
[09/10/26 13:26:07] WARNING  Anthropic response stopped because max_tokens was
                             reached.
text          : The three main causes of the 1929 crash were
public attrs  : ['ASSISTANT_ROLE', 'SYSTEM_ROLE', 'USER_ROLE', 'content', 'module_name', 'role', 'type', 'usage', 'value']

Note the returned Message is unchanged in both — only the log record is new.

Formatting:

$ .venv/bin/ruff format --check griptape/drivers/prompt/anthropic_prompt_driver.py tests/unit/drivers/prompt/test_anthropic_prompt_driver.py
2 files already formatted

Lint — 2 findings, both CPY001, and the base commit reports the same 2 for the same 2 files. No new findings:

$ .venv/bin/ruff check griptape/drivers/prompt/anthropic_prompt_driver.py tests/unit/drivers/prompt/test_anthropic_prompt_driver.py
CPY001 Missing copyright notice at top of file
--> griptape/drivers/prompt/anthropic_prompt_driver.py:1:1

CPY001 Missing copyright notice at top of file
--> tests/unit/drivers/prompt/test_anthropic_prompt_driver.py:1:1

Found 2 errors.

Whitespace: git diff --check e825448 is clean (exit 0).

Full unit suite, to show nothing else moved. --continue-on-collection-errors is needed in my environment because 29 test modules fail to import for lack of optional extras (boto3, pypdf, and similar), which would otherwise abort the run before a single test executed:

$ .venv/bin/python -m pytest -q --continue-on-collection-errors tests/unit
this branch : 82 failed, 3245 passed, 1 skipped, 32 warnings, 248 errors
base e825448: 82 failed, 3229 passed, 1 skipped, 32 warnings, 248 errors

(Wall-clock times are omitted from those two lines because they swing with machine load and prove nothing.)

Those 82 failures and 248 errors are pre-existing on e825448 in my environment (missing optional extras), not caused by this branch. I sorted the FAILED/ERROR node-id lines from both runs — 330 lines each — and diffed them: identical. The only delta is +16 passing nodes, which are the new test cases.

What was not verified

Stated plainly, because understating is safer than overstating:

  • No live Anthropic API call. The client is mocked in every test and in the repro. The shapes this fix depends on (Message.stop_reason, RawMessageDeltaEvent.delta.stop_reason) were confirmed against the installed anthropic 1.4.0 type definitions, as pasted above — but not against the wire. If the API ever reports truncation differently from what the SDK types declare, this would not catch it.

  • Only 4 of the 7 documented stop reasons are exercised. The SDK's Literal includes stop_sequence, pause_turn, refusal, and model_context_window_exceeded; my tests cover max_tokens, end_turn, tool_use, and None. The untested ones take the same silent path as end_turn, but I did not add nodes for them. Notably model_context_window_exceeded is arguably also a truncation a caller would want to know about — I deliberately scoped this PR to max_tokens and did not touch it.

  • make check was run, but with drifting tool versions. All four legs — check/format, check/lint, check/types (pyright), check/spell (typos) — were run repo-wide on e825448 and on this branch. Each of the four outputs is byte-identical between the two refs:

                          base e825448                                   this branch
    pyright               189 errors, 5 warnings, 0 informations         identical
    typos                 exit 0                                         identical
    ruff format --check   55 files would be reformatted, 1271 already    identical
    ruff check            Found 1236 errors.                             identical
    

    Those absolute counts are large because my local tools are newer than the versions uv.lock pins (ruff 0.16.6 vs 0.15.15, pyright 1.1.413 vs 1.1.410, typos 1.50.1 vs 1.47.0), so they are not what CI will report — the pre-existing findings are an artifact of that drift. The load-bearing part is the base-to-branch delta, which is zero, and neither file this PR touches is in the 55-file "would reformat" set. make check/format also runs mdformat --check .github/ docs/; mdformat is not installed here, but this branch touches neither directory.

  • No wording review. I have not tried to match the warning text to any house style; if you want different wording or a different level, it is a one-line change.

  • One interpreter, one OS. Python 3.12.12 on macOS only. The >=3.10, <4 matrix and Windows were not exercised.

  • The full unit suite is not green to begin with in this environment, so "no regressions" here means "the failure set is byte-identical to base", not "everything passes".

  • No integration tests. make test/integration executes documentation code blocks and needs live provider credentials. Not run.

Problem
-------
When Anthropic ends a generation because the configured `max_tokens`
cap was reached, `AnthropicPromptDriver` returned the partial text as an
ordinary `Message` (and the stream path yielded ordinary deltas) with no
indication that the output was cut off. Callers saw a truncated answer
that was indistinguishable from a complete one — a silent failure mode
for downstream parsing, structured output, and long summarization tasks.

Root cause
----------
Both `try_run` and `try_stream` ignored the API's `stop_reason`. The
sync path logged the full response at DEBUG only, and the stream path
consumed `message_delta` events purely for usage accounting, discarding
`event.delta.stop_reason`.

Approach
--------
Inspect `stop_reason` in both paths and emit a single `logger.warning`
only when it equals `max_tokens`. Normal terminations (`end_turn`,
`tool_use`, `stop_sequence`, absent) stay silent. No exception is
raised, no content is altered, and caller-specified `max_tokens` is
still honoured exactly — this is a diagnostic signal, not a behaviour
change.

Verification
------------
Parameterized tests cover `max_tokens`, `end_turn`, `tool_use`, and a
`None` stop reason across both the sync and stream paths, asserting the
warning fires exactly once for `max_tokens` and never otherwise.
`pytest tests/unit/drivers/prompt/test_anthropic_prompt_driver.py`
reports 94 passed. Restoring only
`griptape/drivers/prompt/anthropic_prompt_driver.py` to its pre-fix
state while keeping the new tests fails 4 nodes (the two `max_tokens`
cases across sync and stream, each doubled by the file's class-level
parametrization), so the tests genuinely cover this change.
`ruff format --check` reports both touched files already formatted;
`ruff check` reports only the pre-existing `CPY001` (missing copyright
notice) that the base commit already reports for the same two files.
The Anthropic client is mocked; no live API call was made.

Impact
------
Operators get a log line pointing at the real cause of a truncated
completion. The record is emitted on the shared griptape logger — the
module's `logger` is `logging.getLogger(Defaults.logging_config.logger_name)`,
whose name resolves to `griptape`, not to a module-specific name — so it
follows whatever handler and level are configured for `griptape`. A
caller who intentionally sets a low `max_tokens` will therefore see this
warning on every call and can only silence it by silencing griptape
warnings as a whole. No API surface, return type, or token accounting
changes.
@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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.

AnthropicPromptDriver silently drops stop_reason, so a max_tokens truncation is indistinguishable from a complete response

1 participant