Metadata-Version: 2.5
Name: needlepath-strands
Version: 0.1.0
Summary: Needlepath context selection for Strands Agents (ConversationManager).
Project-URL: Homepage, https://nextmoca.com
Author: Next Moca Global, Inc.
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agents,context,conversation-manager,needlepath,strands,strands-agents
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: needlepath<1.0.0,>=0.1.0
Requires-Dist: strands-agents<2.0.0,>=1.52.0
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# `needlepath-strands`

Needlepath context selection for [Strands Agents](https://github.com/strands-agents/sdk-python)
(AWS's agent framework), as a `ConversationManager`.

```bash
pip install needlepath-strands
```

## One line

```python
from strands import Agent
from needlepath_strands import NeedlepathConversationManager

agent = Agent(
    model=model,
    tools=tools,
    conversation_manager=NeedlepathConversationManager(
        operating_point="np-2026-08-r3",
    ),
)
```

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
  in `agent.messages` are selected against the current task and rewritten in
  place. Engages only when the prunable history exceeds `max_context_tokens`
  (or `trigger_tokens`, if set separately).
- **`reduce_context(agent, e=...)`** — called reactively on a context-window
  overflow, and proactively (if `proactive_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** invent a trimming heuristic of its own to satisfy
that. Deciding which messages survive a plain truncation is exactly the sort
of decision the thin-client rule keeps out of a client package — a selection
decision belongs server-side, and "just drop the oldest messages" is not
Needlepath's call to make unilaterally from inside a package that also carries
the selection contract.

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:

```python
from strands.agent import SlidingWindowConversationManager
from needlepath_strands import NeedlepathConversationManager

conversation_manager = NeedlepathConversationManager(
    operating_point="np-2026-08-r3",
    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"]`:

```python
{
    "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:

```python
NeedlepathConversationManager(operating_point="np-2026-08-r3")  # 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:

```python
NeedlepathConversationManager(operating_point="np-2026-08-r3", 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-r3` today (do not pin `r4`; see
`docs/needlepath/R3-FLIP-HANDOFF.md` for the current default and
`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.
