needlepath-langchain
Needlepath context selection for LangChain agents — and, in this same package, the LangGraph recipe.
pip install needlepath-langchain
One line
from langchain.agents import create_agent
from needlepath_langchain import NeedlepathMiddleware
agent = create_agent(
model,
tools,
middleware=[NeedlepathMiddleware(operating_point="np-2026-07-r2")],
)
The key comes from NEEDLEPATH_API_KEY, the operating point is pinned
explicitly, and if the service is slow, down, or stands down, the agent runs on
its original context and says so in message metadata.
The two seams
wrap_tool_call — the sweet spot
The only documented, first-class, mutate-the-tool-result seam in the ecosystem. A tool returns 40 KB of JSON; what enters the message list is the part of it that answers the current step.
Engages only when a single tool result exceeds tool_result_max_tokens (default
2000), which is also the budget. Below it, no call is made and nothing is
touched.
wrap_model_call — selection over the accumulated history
Before every model call, the tool results already in the history are selected
against the current task. A result that mattered three steps ago and does not
matter now collapses to a placeholder; the one that matters is excerpted
verbatim. Engages only when the prunable history exceeds history_max_tokens
(default 8000).
Non-destructive by construction: request.override(messages=…) returns a new
request and never touches request.state, so the graph keeps the full history
and only the model sees the selection.
Both seams are on by default and independently switchable
(select_tool_results=, select_history=).
Configuration
| Parameter | Default | What it does |
|---|---|---|
operating_point |
— | Required. Immutable label. Also NEEDLEPATH_OPERATING_POINT. |
shadow |
False |
Measure and report; never apply. |
enabled |
True |
Kill switch. Needs no credentials when False. |
select_tool_results / select_history |
True |
The two seams. |
tool_result_max_tokens |
2000 |
Trigger and budget for one tool result. |
history_max_tokens |
8000 |
Trigger and budget for the history. |
preserve_recent |
2 |
Trailing messages never rewritten. |
include_ai_messages / include_human_messages |
False |
Widen beyond tool results. |
placeholder |
see source | Replaces an unselected tool result. |
name |
class name | Must be unique if you register two instances. |
Any other keyword is forwarded to the core client (base_url, api_key,
timeout, max_retries, …).
Two invariants, and why they cost tokens
A message is never removed, only rewritten. LangGraph's
_validate_chat_history raises when an AIMessage carrying tool_calls loses
its matching ToolMessage, and every provider rejects the same shape:
ValueError: Found AIMessages with tool_calls that do not have a corresponding ToolMessage.
A selector that drops messages has to reason about tool-call pairing on every
path and get it right every time. One that only rewrites content in place cannot
break the pairing at all — the message objects, their ids and their
tool_call_ids are exactly what they were. We take the second option and give up
the savings from deleting message envelopes.
A rewrite never grows a message and never empties one. An excerpt longer than what it replaces, or an empty excerpt applied to a non-empty message, is refused here rather than trusted upstream.
By default only ToolMessages are rewritten. SystemMessage never is, on any
setting. An AIMessage carrying tool_calls never is: its content is usually
empty and the part that matters is the call list.
Fail open
Every error path passes the original context through, with the reason recorded in
response_metadata["needlepath"]:
{
"applied": False,
"reason": "engine_fallback",
"request_id": "np-…",
"tokens_before": 8400,
"tokens_saved": 0,
"gate_reason": "standdown:flat_gap",
}
reason is an open enum. Treat an unrecognized value as "not applied".
middleware.stats.as_dict() aggregates the same counters — the input to a
shadow report.
Sync and async
Both variants of both hooks are implemented, and that is not optional:
create_agent puts a middleware in both the sync and async hook lists if it
implements either, specifically so the base class's NotImplementedError
fires. A middleware that implements only the sync hooks raises under ainvoke().
If you pass your own client= without an async_client=, the async hooks run it
on the default executor rather than blocking the event loop.
Lifecycle
A middleware is normally wired up once and lives as long as the agent, so you
never need to close it. If you construct one per request or per test, call
middleware.close() / await middleware.aclose() to release the connection
pools. Clients you passed in with client= / async_client= are yours and are
never closed for you.
LangGraph
Same package, not a second distribution.
from langgraph.prebuilt import create_react_agent
from needlepath_langchain.langgraph import needlepath_pre_model_hook
agent = create_react_agent(
model,
tools,
pre_model_hook=needlepath_pre_model_hook(operating_point="np-2026-07-r2"),
)
Prefer create_agent + NeedlepathMiddleware if you can. pre_model_hook
exists only on create_react_agent, which is deprecated since LangGraph v1.0 and
slated for removal in v2.0.
| Mechanism | Graph state | Verdict |
|---|---|---|
RemoveMessage → add_messages |
destroys the removed messages | incompatible with selection |
pre_model_hook → llm_input_messages |
messages untouched |
compatible |
create_agent + wrap_model_call |
nothing written at all | preferred |
add_messages is the only writer of the messages channel and its removal path
physically drops entries. Once it runs, they are gone from the live thread and
every checkpoint after it. That is a history compaction primitive — what
SummarizationMiddleware uses it for — not a selection primitive. Selection is
per-call by definition: the record that does not matter at step 7 may be the one
that matters at step 9.
Four sharp edges, all handled
llm_input_messagesis a persisted channel, not a transient. It lands in the checkpoint, so every turn adds a full copy of the selection to the checkpoint blob. Real storage on long threads.- Stale-selection leak. It is a
LastValuechannel that survives betweeninvoke()calls. A hook that conditionally skips writing it silently reuses the previous turn's selection. This hook writes it unconditionally, every pass. - An empty selection silently degrades to the full history. The reader does
state.get("llm_input_messages") or state.get("messages"), and[]is falsy. This hook never emits an empty list. _validate_chat_historyruns on your selection. Covered by the never-remove-a-message invariant above.
One caveat this package cannot fix
With response_format on create_react_agent, the structured-output node reads
state["messages"] directly and does not consult llm_input_messages. That
call sees the full history, not your selection. Price your savings accordingly.
Positioning
LangChain ships ContextEditingMiddleware with a ClearToolUsesEdit strategy
in-tree and free, which clears the oldest tool results once a token trigger
trips. This is not a competitor to "can something prune tool results" — it is a
different answer to which bytes should survive. We select verbatim against the
current task rather than clearing by age, stand down when trimming would not pay,
and report what was actually saved.
If clearing by age is enough for your workload, use the free one.
Tested against
langchain==1.3.14, langchain-core==1.5.3, langgraph==1.2.10. Floor
>=1.2.9 (the first release where ToolCallRequest, ModelCallResult and
ExtendedModelResponse are all importable from the public
langchain.agents.middleware package and ModelRequest.system_message is
stable). Capped at <2.0.0; CI runs against the newest 1.x minor.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file needlepath_langchain-0.1.0.tar.gz.
File metadata
- Download URL: needlepath_langchain-0.1.0.tar.gz
- Upload date:
- Size: 31.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6cc9fa218873b2c1e689c5663d937710c3005673360f2cc745aa9353a3d8b87a
|
|
| MD5 |
68c20b8c7648ca4f69be165a3a146a88
|
|
| BLAKE2b-256 |
dc6368493961222ec22e1e5bc4571cdc5e70599ec2ff14270d54a4f4c31e4780
|
File details
Details for the file needlepath_langchain-0.1.0-py3-none-any.whl.
File metadata
- Download URL: needlepath_langchain-0.1.0-py3-none-any.whl
- Upload date:
- Size: 27.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
22b5902ff44bd4c4aba55359f6faf4766e6c1d34865f392a7e2f4d3a5004be36
|
|
| MD5 |
c0ff064172a7dd90553bea51cf2432cd
|
|
| BLAKE2b-256 |
aa44c51def0b4a8a9439c067eaa6dcf270714d3ce1e01fced168cf7a1b0e6105
|