Skip to content

fix(core): robust per-message token counter detection in trim_messages#35652

Closed
Giulio Leone (giulio-leone) wants to merge 3 commits into
langchain-ai:masterfrom
giulio-leone:fix/trim-messages-per-message-counter-detection
Closed

fix(core): robust per-message token counter detection in trim_messages#35652
Giulio Leone (giulio-leone) wants to merge 3 commits into
langchain-ai:masterfrom
giulio-leone:fix/trim-messages-per-message-counter-detection

Conversation

@giulio-leone

Copy link
Copy Markdown
Contributor

Summary

Fixes #35629trim_messages misdetects per-message token_counter callables when using subclass annotations (msg: HumanMessage), string annotations (msg: "BaseMessage"), or postponed annotations (from __future__ import annotations).

Root Cause

The detection logic at utils.py#L1420-L1424 uses a strict identity check:

if param.annotation is BaseMessage:  # identity, not equality or subclass check

This fails for:

Case Annotation Why it fails
Subclass msg: HumanMessage HumanMessage is not BaseMessage
String msg: "BaseMessage" "BaseMessage" is not BaseMessage
Postponed from __future__ import annotations + msg: BaseMessage annotation stored as string

In all failing cases, the callable is misclassified as a per-list counter and called with list[BaseMessage], causing AttributeError: 'list' object has no attribute 'content'.

Fix

Replace the identity check with a new _is_per_message_token_counter() helper that:

  1. Resolves annotations via typing.get_type_hints() (handles string/postponed annotations)
  2. Falls back to inspect.signature for raw annotations when get_type_hints() fails
  3. Detects list counters by checking for collection types (list[...], Sequence[...], etc.)
  4. Detects message counters via issubclass(annotation, BaseMessage) (handles all subclasses)
  5. Preserves backward compatibility: unannotated callables (lambdas, bare def) default to per-list, matching existing behavior

What's Fixed vs What's Not

Case Before After
msg: BaseMessage ✅ worked ✅ works
msg: HumanMessage AttributeError fixed
msg: "BaseMessage" (string) AttributeError fixed
from __future__ import annotations AttributeError fixed
lambda msg: ... (no annotation) ❌ per issue ⚠️ unchanged (backward compat)
def f(msg): ... (no annotation) ❌ per issue ⚠️ unchanged (backward compat)
msgs: list[BaseMessage] ✅ worked ✅ works
msgs: Sequence[BaseMessage] ✅ worked ✅ works

Unannotated callables are intentionally kept as per-list to avoid breaking existing code that relies on the current default.

Tests

All 23 tests pass (21 existing + 2 new):

  • test_trim_messages_per_message_counter_with_subclass_annotationmsg: HumanMessage
  • test_trim_messages_per_message_counter_with_postponed_annotationsmsg: BaseMessage with resolved hints

Changes

File Change
libs/core/langchain_core/messages/utils.py New _is_per_message_token_counter() helper + replaced identity check
libs/core/tests/unit_tests/messages/test_utils.py 2 new regression tests

@github-actions github-actions Bot added core `langchain-core` package issues & PRs external fix For PRs that implement a fix labels Mar 8, 2026
@codspeed-hq

codspeed-hq Bot commented Mar 8, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 27.3%

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

❌ 13 regressed benchmarks
⏩ 23 skipped benchmarks1

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime test_import_time[CallbackManager] 284.9 ms 329 ms -13.4%
WallTime test_import_time[tool] 484.6 ms 566.4 ms -14.44%
WallTime test_import_time[Runnable] 448.4 ms 512.2 ms -12.46%
WallTime test_import_time[HumanMessage] 239.9 ms 272.1 ms -11.86%
WallTime test_import_time[InMemoryVectorStore] 550.3 ms 621.1 ms -11.39%
WallTime test_import_time[PydanticOutputParser] 475.2 ms 556.6 ms -14.64%
WallTime test_import_time[Document] 170.8 ms 189.9 ms -10.04%
WallTime test_import_time[ChatPromptTemplate] 563.1 ms 648.5 ms -13.16%
WallTime test_import_time[RunnableLambda] 439.3 ms 503.9 ms -12.83%
WallTime test_import_time[LangChainTracer] 409.5 ms 469.8 ms -12.83%
WallTime test_import_time[BaseChatModel] 481.4 ms 555.7 ms -13.37%
WallTime test_async_callbacks_in_sync 18.6 ms 25.6 ms -27.3%
WallTime test_import_time[InMemoryRateLimiter] 156.3 ms 178.7 ms -12.54%

Comparing giulio-leone:fix/trim-messages-per-message-counter-detection (6ec1fce) with master (fbfe4b8)

Open in CodSpeed

Footnotes

  1. 23 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

The `trim_messages` function uses `inspect.signature` to detect whether a
`token_counter` callable operates per-message or per-list, but the check
`annotation is BaseMessage` (identity comparison) fails for:
- Subclass annotations (e.g., `msg: HumanMessage`)
- String/postponed annotations from `from __future__ import annotations`

Replace the strict identity check with `_is_per_message_token_counter()`
which:
1. Uses `typing.get_type_hints()` to resolve string/postponed annotations
2. Falls back to `inspect.signature` for raw annotations
3. Checks collection types (list, Sequence, etc.) → per-list
4. Checks BaseMessage subclasses via `issubclass()` → per-message
5. Preserves backward compatibility: unannotated callables → per-list

Fixes #35629

Signed-off-by: Giulio Leone <6887247+giulio-leone@users.noreply.github.com>
BLE001 is not enabled in the project's ruff config.

Signed-off-by: Giulio Leone <6887247+giulio-leone@users.noreply.github.com>
@gautamvarmadatla

Copy link
Copy Markdown

Hi! Creator of the issue here. Thanks for working on this. However, I already have a PR open to fix it from when I originally opened the issue. And please stop spamming on all my git issues lol :)

The `# type: ignore[type-arg]` on `_is_per_message_token_counter` is
flagged as unused by mypy's `unused-ignore` rule, causing CI lint to
fail across all Python versions.

Signed-off-by: Giulio Leone <6887247+giulio-leone@users.noreply.github.com>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@ccurme ccurme (ccurme) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Duplicated with #35630.

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

Labels

core `langchain-core` package issues & PRs external fix For PRs that implement a fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

trim_messages breaks when token_counter is a per-message callable (lambda, subclass annotation, or postponed annotations)

3 participants