needlepath-strands
Needlepath context selection for Strands Agents
(AWS's agent framework), as a ConversationManager.
pip install needlepath-strands
One line
from strands import Agent
from needlepath_strands import NeedlepathConversationManager
agent = Agent(
model=model,
tools=tools,
conversation_manager=NeedlepathConversationManager(
operating_point="np-2026-08-r4",
),
)
The key comes from NEEDLEPATH_API_KEY, the operating point is pinned
explicitly, and if the service is slow, down, or stands down, agent.messages
is left exactly as it was and the reason is recorded on .stats and on each
rewritten message's metadata.custom.needlepath.
This measures and reports; it does not rewrite anything yet. shadow
defaults to True — pass shadow=False once .engine_stats shows what it
would have saved (.stats, the actual-applied ledger, is always 0 under
shadow — see Two stats objects, two truths below). See Shadow-first.
Not the same shape as the LangChain / LlamaIndex adapters
Strands' ConversationManager interface is not AgentMiddleware. There is
no per-tool-call hook: the interface gives an implementation exactly two
places to run —
apply_management(agent)— called once after every invocation completes. This is the seam this package uses: the accumulated tool results inagent.messagesare selected against the current task and rewritten in place. Engages only when the prunable history exceedsmax_context_tokens(ortrigger_tokens, if set separately).reduce_context(agent, e=...)— called reactively on a context-window overflow, and proactively (ifproactive_compression=is configured) before a model call projected to exceed a threshold. Tries a Needlepath selection first, without the trigger gate. See Reactive overflow below for what happens when that selection does not resolve the overflow.
Both work by mutating agent.messages — a Bedrock-shaped
list[{"role": ..., "content": [ContentBlock, ...]}] — in place, which is
the contract every shipped ConversationManager (SlidingWindowConversationManager,
SummarizingConversationManager) also follows.
Two invariants, ported from the LangChain adapter
A message or content block is never removed, only rewritten.
SlidingWindowConversationManager goes to real lengths
(find_valid_trim_point, _find_tool_pair_trim_point) to avoid ever leaving a
toolUse block without its paired toolResult — every model provider rejects
that shape. This package never deletes a message or a content block, so it
cannot produce that shape at all: a toolResult keeps its toolUseId and its
position; only its content may shrink.
A rewrite never grows the context and never empties a message. An excerpt longer than what it replaces, or an empty excerpt applied to non-empty content, is refused here rather than trusted upstream.
By default only toolResult content blocks are rewritten. A toolUse block
is never rewritten, on any setting: it is the call, not prose.
No preserve_recent — a deliberate difference from SlidingWindowConversationManager
Strands' own conversation managers let you pin a trailing window of messages
(SlidingWindowConversationManager(pin_first=...), or simply never trimming
recent turns). This package does not offer an equivalent, and an earlier
version that did was removed (NEXPE-517 review).
The reason is not that tail protection is a bad idea — it is that a
client-side version of it cannot be both honest and free of a selection
decision made locally. The only way to protect a trailing window without
telling the engine is to silently narrow which records a rewrite is allowed to
touch, while .stats goes on reporting the engine's un-narrowed
tokens_after/tokens_saved — which makes the receipt lie about what the
model actually received whenever the engine's own selection happens to favor
the protected window. What this package applies is the engine's result,
exactly as returned, over every record it sent. No exceptions, no local
overrides.
If you need recent-turn protection, it is either a request to make of the
engine (a position/recency signal it can act on like any other input to its
own decision), or a genuinely separate, engine-blind concern you compose
yourself — fallback=SlidingWindowConversationManager() covers the one place
in this package's own lifecycle where that composition already has a hook
(reactive overflow; see below).
Configuration
| Parameter | Default | What it does |
|---|---|---|
operating_point |
— | Required. Immutable label. Also NEEDLEPATH_OPERATING_POINT. |
shadow |
True |
Measure and report; never apply. Shadow-first — see below. Pass False for live selection. |
enabled |
True |
Kill switch. Needs no credentials when False. |
max_context_tokens |
8000 |
Trigger and budget for the prunable history. |
trigger_tokens |
0 |
Separate trigger from the budget; 0 means "use max_context_tokens". |
include_ai_messages / include_human_messages |
False |
Widen beyond tool results. |
placeholder |
see source | Replaces an unselected tool result. |
per_turn |
False |
Also run before every (or every Nth) model call within a turn. See below. |
fallback |
None |
A real ConversationManager to delegate to on an unresolved reactive overflow. See below. |
proactive_compression |
None |
Forwarded unchanged to the base class. |
Any other keyword is forwarded to the core client (base_url, api_key,
timeout, max_retries, …).
Reactive overflow: what reduce_context does when selection is not enough
Needlepath's selection is itself fail-open: it may decline (shadow,
escalation, empty selection, a transport failure, an unrecognised outcome) and
produce no reduction. reduce_context's reactive contract (e set) requires
the implementation to still reduce the history, or re-raise.
This package does not add a trimming heuristic of its own to satisfy that. "Just drop the oldest messages" is a choice your application should make explicitly, with the manager it already trusts.
Instead: pass fallback= a real, host-native ConversationManager —
SlidingWindowConversationManager() is the natural choice — and this class
delegates to it, in full, exactly as if it had been composed by the caller.
Without one, a reactive overflow that Needlepath's own selection did not
resolve re-raises the original exception — the same thing
NullConversationManager does, and the honest answer when there is nothing
safe left to try:
from strands.agent import SlidingWindowConversationManager
from needlepath_strands import NeedlepathConversationManager
conversation_manager = NeedlepathConversationManager(
operating_point="np-2026-08-r4",
fallback=SlidingWindowConversationManager(),
)
Sync only — a real difference from the other two adapters
Strands' ConversationManager.apply_management / reduce_context are
synchronous methods with no async counterpart; the event loop calls them
directly even under agent.invoke_async(). This adapter therefore wraps
needlepath.NeedlepathClient only — there is no async seam to put an
AsyncNeedlepathClient into, unlike needlepath_langchain and
llama-index-postprocessor-needlepath, whose host frameworks do expose one. A
slow selection call blocks whatever called apply_management, the same as any
other synchronous hook would; size timeout accordingly.
Fail open
Every error path leaves agent.messages untouched, with the reason recorded
on conversation_manager.stats and, for any message that was rewritten, on
message["metadata"]["custom"]["needlepath"]:
{
"rewritten": True,
"request_id": "np-…",
"blocks": [
{
"block_index": 0,
"record_id": "m2b0",
"rewrite_reason": "excerpt",
"original_tokens": 3001,
"new_tokens": 12,
},
],
}
conversation_manager.stats.as_dict() aggregates the same counters (calls,
applied, passthrough, failures, tokens_saved, reasons), in the same
shape the other adapters expose — but reporting what was actually applied,
not the engine's raw claim. For a shadow report (what a live run would have
saved), read conversation_manager.engine_stats.as_dict() instead; see Two
stats objects, two truths below.
Two stats objects, two truths
conversation_manager.stats reports what this package actually did to
agent.messages — real before/after token counts, measured the same way the
rewrite itself is measured. conversation_manager.engine_stats reports what
the engine's raw response claimed, unmodified.
These are not the same number, on purpose. The engine cannot see the
placeholder text (DEFAULT_PLACEHOLDER, or your own) this package substitutes
for a record it declined to select, so the engine's own tokens_after
describes an idealized outcome — "if you kept only what I selected" — not the
message this package actually installs, which is a little larger because every
unselected record still costs a placeholder's worth of tokens instead of
costing nothing. In shadow mode the gap is total: stats.tokens_saved is
always 0 (nothing is ever applied), while engine_stats.tokens_saved carries
the engine's prediction of what a live run would have saved.
Use .stats for anything downstream that has to be true of agent.messages.
Use .engine_stats to reconcile against what the service itself measured or
billed.
Shadow-first
shadow defaults to True. Wiring in NeedlepathConversationManager with
nothing but an operating_point measures, don't apply:
NeedlepathConversationManager(operating_point="np-2026-08-r4") # shadow=True, implicitly
A shadow run makes the same call, at the same rate, as a live run would — and
never touches agent.messages. Check conversation_manager.engine_stats for
what the engine predicts a live run would save (conversation_manager.stats
correctly shows 0, since nothing was applied). Turn on live selection
explicitly, once you trust the numbers:
NeedlepathConversationManager(operating_point="np-2026-08-r4", shadow=False)
This is the one constructor default that intentionally does not match
needlepath_langchain or llama-index-postprocessor-needlepath, both of
which default to live selection (shadow=False). Those two adapters shipped
and were verified together; this one is newer and has not run against real
Strands traffic yet, so the safer posture is the default rather than
something a caller has to remember to opt into.
Operating point
Pinned explicitly, always — np-2026-08-r4 today (see API_VERSIONING.md
for why an operating point is never left to a service default).
Tested against
strands-agents==1.52.0 — installed and introspected directly
(strands.agent.ConversationManager, strands.types.content,
strands.types.tools, strands.types.exceptions.ContextWindowOverflowException),
the newest published release at the time this package was written (NEXPE-517).
Floored at >=1.52.0 for that reason — the interface was verified against
exactly this version, not assumed from documentation — and capped at
<2.0.0, same as every other adapter in this tree.
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_strands-0.1.2.tar.gz.
File metadata
- Download URL: needlepath_strands-0.1.2.tar.gz
- Upload date:
- Size: 39.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7760249dadc4827bf1c9bfd834b12db3410c96603d37bf3c33296385a67eed07
|
|
| MD5 |
e4aaf30c2438799616be26926e2b2eec
|
|
| BLAKE2b-256 |
b2187d821592bd3679cdd3d4eee0b7a7f511e7574a4df4e8953c908c191bebde
|
File details
Details for the file needlepath_strands-0.1.2-py3-none-any.whl.
File metadata
- Download URL: needlepath_strands-0.1.2-py3-none-any.whl
- Upload date:
- Size: 27.9 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 |
37f6a95fc84f64b8bf269940d043698030e0824eecc965a7e4d81bb838cd8df6
|
|
| MD5 |
ce0ef0895f3cdb8c5dc1fd5b4ebacc9f
|
|
| BLAKE2b-256 |
e7af13ca27ffc6f85f70a33058abdf13d090ac2c90891cc35daca9eacf71034e
|