needlepath-langchain 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- needlepath_langchain/__init__.py +23 -0
- needlepath_langchain/_messages.py +377 -0
- needlepath_langchain/langgraph.py +334 -0
- needlepath_langchain/middleware.py +556 -0
- needlepath_langchain/py.typed +0 -0
- needlepath_langchain/version.py +3 -0
- needlepath_langchain-0.1.0.dist-info/METADATA +229 -0
- needlepath_langchain-0.1.0.dist-info/RECORD +10 -0
- needlepath_langchain-0.1.0.dist-info/WHEEL +4 -0
- needlepath_langchain-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Needlepath context selection for LangChain agents and LangGraph.
|
|
2
|
+
|
|
3
|
+
from langchain.agents import create_agent
|
|
4
|
+
from needlepath_langchain import NeedlepathMiddleware
|
|
5
|
+
|
|
6
|
+
agent = create_agent(
|
|
7
|
+
model, tools,
|
|
8
|
+
middleware=[NeedlepathMiddleware(operating_point="np-2026-07-r2")],
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
One line. Tool results are selected against the current step, the history is
|
|
12
|
+
selected before each model call, and every error path passes the original
|
|
13
|
+
context through unmodified with the reason recorded in message metadata.
|
|
14
|
+
|
|
15
|
+
LangGraph users who cannot move to ``create_agent`` get
|
|
16
|
+
:mod:`needlepath_langchain.langgraph` — the ``pre_model_hook`` recipe, in this
|
|
17
|
+
same package rather than a second distribution.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .middleware import USER_AGENT_SUFFIX, NeedlepathMiddleware
|
|
21
|
+
from .version import __version__
|
|
22
|
+
|
|
23
|
+
__all__ = ["NeedlepathMiddleware", "USER_AGENT_SUFFIX", "__version__"]
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Message ↔ record mapping, and the safety rules that govern rewriting.
|
|
2
|
+
|
|
3
|
+
Everything structural about this integration lives here, because the sharp edges
|
|
4
|
+
are structural. Two of them are load-bearing enough to state as invariants that
|
|
5
|
+
the rest of the package is built to satisfy:
|
|
6
|
+
|
|
7
|
+
**Invariant 1 — a message is never removed, only rewritten.**
|
|
8
|
+
LangGraph's ``_validate_chat_history`` raises when an ``AIMessage`` carrying
|
|
9
|
+
``tool_calls`` loses its matching ``ToolMessage``, and every major provider
|
|
10
|
+
rejects the same shape at the API boundary::
|
|
11
|
+
|
|
12
|
+
ValueError: Found AIMessages with tool_calls that do not have a
|
|
13
|
+
corresponding ToolMessage.
|
|
14
|
+
|
|
15
|
+
A selector that drops messages has to reason about tool-call pairing on every
|
|
16
|
+
path and get it right every time. A selector that only ever *rewrites content
|
|
17
|
+
in place* cannot break the pairing at all — the message objects, their ids,
|
|
18
|
+
their ``tool_call_id``\\ s and their order are exactly what they were. We take
|
|
19
|
+
the second option, and give up the token savings of deleting a message envelope
|
|
20
|
+
to get it.
|
|
21
|
+
|
|
22
|
+
**Invariant 2 — a rewrite never grows the context and never empties a message.**
|
|
23
|
+
An excerpt longer than what it replaces, or an empty excerpt applied to a
|
|
24
|
+
non-empty message, is a regression however the service arrived at it. Both are
|
|
25
|
+
refused here rather than trusted upstream.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Sequence
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from needlepath import ContextRecord, RecordKind
|
|
34
|
+
|
|
35
|
+
try: # pragma: no cover - exercised by the installed-langchain test matrix
|
|
36
|
+
from langchain_core.messages import AIMessage, BaseMessage, SystemMessage, ToolMessage
|
|
37
|
+
except ImportError as exc: # pragma: no cover
|
|
38
|
+
raise ImportError(
|
|
39
|
+
"needlepath-langchain requires langchain (>=1.2.9,<2). "
|
|
40
|
+
"Install it with: pip install 'needlepath-langchain'"
|
|
41
|
+
) from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
#: Marker written into ``response_metadata`` on every message this package
|
|
45
|
+
#: rewrites, mirroring what LangChain's own ``ContextEditingMiddleware`` does.
|
|
46
|
+
#: It makes a rewrite visible in a trace instead of looking like model output.
|
|
47
|
+
METADATA_KEY = "needlepath"
|
|
48
|
+
|
|
49
|
+
#: What replaces a message the service did not select. Deliberately short,
|
|
50
|
+
#: deliberately explicit that content was withheld rather than absent — a model
|
|
51
|
+
#: that sees "[…]" cannot tell "there was nothing" from "there was something".
|
|
52
|
+
DEFAULT_PLACEHOLDER = "[needlepath: this tool result was not selected for the current step]"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def message_text(message: BaseMessage) -> str:
|
|
56
|
+
"""Text of a message, whatever shape its content takes.
|
|
57
|
+
|
|
58
|
+
``content`` is ``str | list[str | dict]``. ``BaseMessage.text`` handles both
|
|
59
|
+
and returns a ``TextAccessor``, not a ``str`` — hence the cast, which is
|
|
60
|
+
load-bearing the moment the value is serialized into a JSON request body.
|
|
61
|
+
"""
|
|
62
|
+
return str(message.text)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def approx_tokens(text: str) -> int:
|
|
66
|
+
"""A cheap, dependency-free token estimate for the engage/stand-down trigger.
|
|
67
|
+
|
|
68
|
+
Deliberately the same ``len // 4`` shape the published ``estimate_tokens``
|
|
69
|
+
falls back to, so a trigger set from a documented figure means roughly what
|
|
70
|
+
the reader expects. It is **never** compared against a billed quantity: the
|
|
71
|
+
meter is server-side and authoritative, and this is only ever used to decide
|
|
72
|
+
whether calling is worth it at all.
|
|
73
|
+
"""
|
|
74
|
+
return len(text) // 4 + 1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def messages_tokens(messages: Sequence[BaseMessage]) -> int:
|
|
78
|
+
return sum(approx_tokens(message_text(m)) for m in messages)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def has_tool_calls(message: BaseMessage) -> bool:
|
|
82
|
+
return bool(getattr(message, "tool_calls", None))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def kind_for(message: BaseMessage) -> str:
|
|
86
|
+
"""Map a LangChain message onto the published record-role enum."""
|
|
87
|
+
if isinstance(message, ToolMessage):
|
|
88
|
+
return (
|
|
89
|
+
RecordKind.ERROR
|
|
90
|
+
if getattr(message, "status", None) == "error"
|
|
91
|
+
else (RecordKind.TOOL_RESULT)
|
|
92
|
+
)
|
|
93
|
+
if isinstance(message, AIMessage):
|
|
94
|
+
return RecordKind.TOOL_CALL if has_tool_calls(message) else RecordKind.LLM_RESPONSE
|
|
95
|
+
if isinstance(message, SystemMessage):
|
|
96
|
+
# Unreachable in practice — system messages are excluded before this is
|
|
97
|
+
# called — but mapped rather than left to fall through to a default that
|
|
98
|
+
# would silently mislabel instructions as retrieved data.
|
|
99
|
+
return RecordKind.USER_INPUT
|
|
100
|
+
return RecordKind.USER_INPUT
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_prunable(
|
|
104
|
+
message: BaseMessage,
|
|
105
|
+
*,
|
|
106
|
+
include_ai_messages: bool = False,
|
|
107
|
+
include_human_messages: bool = False,
|
|
108
|
+
) -> bool:
|
|
109
|
+
"""Whether this message's content may be rewritten.
|
|
110
|
+
|
|
111
|
+
The default is **tool results only**, and that is a product decision as much
|
|
112
|
+
as a safety one. Tool results are where the tokens are in an agent thread,
|
|
113
|
+
they are the seam §8.1 calls our sweet spot, and they are the one message
|
|
114
|
+
kind whose content is machine-produced — so replacing it with an excerpt of
|
|
115
|
+
itself cannot silently rewrite something a human wrote or the model
|
|
116
|
+
committed to.
|
|
117
|
+
|
|
118
|
+
Opening this up to AI and Human messages is supported and off by default.
|
|
119
|
+
|
|
120
|
+
A ``SystemMessage`` is never prunable, on any setting: it carries the
|
|
121
|
+
instructions the whole run depends on, it is small, and on LangChain's
|
|
122
|
+
``wrap_model_call`` seam it is not even in ``request.messages``.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(message, SystemMessage):
|
|
125
|
+
return False
|
|
126
|
+
if isinstance(message, ToolMessage):
|
|
127
|
+
return True
|
|
128
|
+
if isinstance(message, AIMessage):
|
|
129
|
+
# An AIMessage carrying tool_calls is structure, not prose: its content
|
|
130
|
+
# is usually empty and the part that matters is the call list. Nothing
|
|
131
|
+
# to save, and every reason not to touch it.
|
|
132
|
+
return include_ai_messages and not has_tool_calls(message)
|
|
133
|
+
return include_human_messages
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def record_id_for(index: int) -> str:
|
|
137
|
+
"""A short, stable per-call record id.
|
|
138
|
+
|
|
139
|
+
Positional rather than derived from ``message.id``: it is guaranteed present
|
|
140
|
+
and unique inside one request, it is inside the published identifier
|
|
141
|
+
charset, and it is two or three bytes rather than a UUID — which matters
|
|
142
|
+
because every byte counts against the 6 MB guard on a long thread.
|
|
143
|
+
"""
|
|
144
|
+
return f"m{index}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_records(
|
|
148
|
+
messages: Sequence[BaseMessage],
|
|
149
|
+
*,
|
|
150
|
+
protected_indices: set | None = None,
|
|
151
|
+
include_ai_messages: bool = False,
|
|
152
|
+
include_human_messages: bool = False,
|
|
153
|
+
) -> tuple[list[ContextRecord], dict[str, int]]:
|
|
154
|
+
"""Records for every prunable, unprotected, non-empty message.
|
|
155
|
+
|
|
156
|
+
Returns the records and a ``record_id -> message index`` map. Anything not
|
|
157
|
+
in that map is passed through untouched, by construction.
|
|
158
|
+
"""
|
|
159
|
+
protected = protected_indices or set()
|
|
160
|
+
records: list[ContextRecord] = []
|
|
161
|
+
index_by_id: dict[str, int] = {}
|
|
162
|
+
total = len(messages)
|
|
163
|
+
for index, message in enumerate(messages):
|
|
164
|
+
if index in protected:
|
|
165
|
+
continue
|
|
166
|
+
if not is_prunable(
|
|
167
|
+
message,
|
|
168
|
+
include_ai_messages=include_ai_messages,
|
|
169
|
+
include_human_messages=include_human_messages,
|
|
170
|
+
):
|
|
171
|
+
continue
|
|
172
|
+
text = message_text(message)
|
|
173
|
+
if not text.strip():
|
|
174
|
+
continue
|
|
175
|
+
record_id = record_id_for(index)
|
|
176
|
+
index_by_id[record_id] = index
|
|
177
|
+
records.append(
|
|
178
|
+
ContextRecord(
|
|
179
|
+
text=text,
|
|
180
|
+
kind=kind_for(message),
|
|
181
|
+
id=record_id,
|
|
182
|
+
title=_title_for(message),
|
|
183
|
+
# A recency prior the engine can use, without this package
|
|
184
|
+
# deciding recency policy on its own. Newest → 1.0, oldest → ~0.
|
|
185
|
+
importance=(index + 1) / total if total else 0.0,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return records, index_by_id
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _title_for(message: BaseMessage) -> str | None:
|
|
192
|
+
"""A short human-readable label. For a tool result, the tool's name."""
|
|
193
|
+
name = getattr(message, "name", None)
|
|
194
|
+
if isinstance(name, str) and name:
|
|
195
|
+
return name[:120]
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def latest_user_prompt(messages: Sequence[BaseMessage]) -> str:
|
|
200
|
+
"""The most recent human turn — what the selected context has to serve.
|
|
201
|
+
|
|
202
|
+
Falls back to the last non-empty message of any kind, because a thread whose
|
|
203
|
+
latest turn is a tool result still has a task; using an empty prompt would
|
|
204
|
+
hand the service no query at all and make every selection meaningless.
|
|
205
|
+
"""
|
|
206
|
+
for message in reversed(messages):
|
|
207
|
+
if message.type == "human":
|
|
208
|
+
text = message_text(message)
|
|
209
|
+
if text.strip():
|
|
210
|
+
return text
|
|
211
|
+
for message in reversed(messages):
|
|
212
|
+
text = message_text(message)
|
|
213
|
+
if text.strip():
|
|
214
|
+
return text
|
|
215
|
+
return ""
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def prior_user_prompts(messages: Sequence[BaseMessage], limit: int = 4) -> list[str]:
|
|
219
|
+
"""Earlier human turns, newest first — the contract's drift signal."""
|
|
220
|
+
found: list[str] = []
|
|
221
|
+
seen_latest = False
|
|
222
|
+
for message in reversed(messages):
|
|
223
|
+
if message.type != "human":
|
|
224
|
+
continue
|
|
225
|
+
text = message_text(message)
|
|
226
|
+
if not text.strip():
|
|
227
|
+
continue
|
|
228
|
+
if not seen_latest:
|
|
229
|
+
seen_latest = True
|
|
230
|
+
continue
|
|
231
|
+
found.append(text)
|
|
232
|
+
if len(found) >= limit:
|
|
233
|
+
break
|
|
234
|
+
return found
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def protected_tail(messages: Sequence[BaseMessage], preserve_recent: int) -> set:
|
|
238
|
+
"""Indices of the trailing messages that are never rewritten.
|
|
239
|
+
|
|
240
|
+
Belt and braces alongside ``task.required_record_ids``: the contract lets us
|
|
241
|
+
*ask* the service to keep them, and this guarantees it regardless of what
|
|
242
|
+
comes back. A guard that depends on the far side honouring a request is not
|
|
243
|
+
a guard.
|
|
244
|
+
"""
|
|
245
|
+
if preserve_recent <= 0:
|
|
246
|
+
return set()
|
|
247
|
+
return set(range(max(0, len(messages) - preserve_recent), len(messages)))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def rewrite_message(
|
|
251
|
+
message: BaseMessage,
|
|
252
|
+
new_text: str,
|
|
253
|
+
*,
|
|
254
|
+
reason: str,
|
|
255
|
+
request_id: str,
|
|
256
|
+
) -> BaseMessage:
|
|
257
|
+
"""A copy of ``message`` with new content and a traceable marker.
|
|
258
|
+
|
|
259
|
+
``model_copy`` rather than reconstruction: reconstructing a ``ToolMessage``
|
|
260
|
+
silently drops ``artifact``, ``id``, ``response_metadata`` and any provider
|
|
261
|
+
metadata, and the whole safety argument here rests on the envelope being
|
|
262
|
+
untouched.
|
|
263
|
+
"""
|
|
264
|
+
metadata = dict(getattr(message, "response_metadata", {}) or {})
|
|
265
|
+
existing = metadata.get(METADATA_KEY)
|
|
266
|
+
merged = dict(existing) if isinstance(existing, dict) else {}
|
|
267
|
+
merged.update(
|
|
268
|
+
{
|
|
269
|
+
"rewritten": True,
|
|
270
|
+
# `rewrite_reason`, not `reason`: the selection result's own
|
|
271
|
+
# metadata also carries a `reason`, the two are merged into this
|
|
272
|
+
# same dict, and a collision would silently overwrite one of them.
|
|
273
|
+
"rewrite_reason": reason,
|
|
274
|
+
"request_id": request_id,
|
|
275
|
+
"original_tokens": approx_tokens(message_text(message)),
|
|
276
|
+
"new_tokens": approx_tokens(new_text),
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
metadata[METADATA_KEY] = merged
|
|
280
|
+
return message.model_copy(update={"content": new_text, "response_metadata": metadata})
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def apply_selection(
|
|
284
|
+
messages: Sequence[BaseMessage],
|
|
285
|
+
*,
|
|
286
|
+
excerpts: dict[str, str],
|
|
287
|
+
index_by_id: dict[str, int],
|
|
288
|
+
placeholder: str,
|
|
289
|
+
request_id: str,
|
|
290
|
+
) -> tuple[list[BaseMessage], int, int]:
|
|
291
|
+
"""Rewrite the selected and unselected messages. Returns (messages, before, after).
|
|
292
|
+
|
|
293
|
+
Enforces invariant 2 message by message: an excerpt that is empty, or that
|
|
294
|
+
would make a message *longer*, is refused and the original kept. Both are
|
|
295
|
+
regressions however the service arrived at them, and refusing one message's
|
|
296
|
+
rewrite is cheaper than failing the whole selection open.
|
|
297
|
+
"""
|
|
298
|
+
out: list[BaseMessage] = list(messages)
|
|
299
|
+
before = 0
|
|
300
|
+
after = 0
|
|
301
|
+
for record_id, index in index_by_id.items():
|
|
302
|
+
original = out[index]
|
|
303
|
+
original_text = message_text(original)
|
|
304
|
+
before += approx_tokens(original_text)
|
|
305
|
+
excerpt = excerpts.get(record_id)
|
|
306
|
+
if excerpt is None and not placeholder.strip():
|
|
307
|
+
# Invariant 2, at the mutation boundary. A blank custom placeholder
|
|
308
|
+
# would rewrite a non-empty message to nothing — the pairing
|
|
309
|
+
# survives, the content does not. Refused rather than trusted.
|
|
310
|
+
after += approx_tokens(original_text)
|
|
311
|
+
continue
|
|
312
|
+
if excerpt is None:
|
|
313
|
+
# Not selected: collapse to the placeholder. This is the only place
|
|
314
|
+
# content is discarded, and it is the difference between selection
|
|
315
|
+
# and a no-op.
|
|
316
|
+
new_text = placeholder
|
|
317
|
+
reason = "not_selected"
|
|
318
|
+
elif not excerpt.strip():
|
|
319
|
+
# Selected with nothing in it. Applying this would delete content
|
|
320
|
+
# the service said to keep — refuse it and keep the original.
|
|
321
|
+
after += approx_tokens(original_text)
|
|
322
|
+
continue
|
|
323
|
+
elif len(excerpt) >= len(original_text):
|
|
324
|
+
# An "excerpt" that is not shorter buys nothing and may cost.
|
|
325
|
+
after += approx_tokens(original_text)
|
|
326
|
+
continue
|
|
327
|
+
else:
|
|
328
|
+
new_text = excerpt
|
|
329
|
+
reason = "excerpt"
|
|
330
|
+
if len(new_text) >= len(original_text) and reason == "not_selected":
|
|
331
|
+
# A placeholder longer than the message it replaces. Rare, but a
|
|
332
|
+
# short tool result plus a long custom placeholder gets here.
|
|
333
|
+
after += approx_tokens(original_text)
|
|
334
|
+
continue
|
|
335
|
+
out[index] = rewrite_message(original, new_text, reason=reason, request_id=request_id)
|
|
336
|
+
after += approx_tokens(new_text)
|
|
337
|
+
return out, before, after
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def tool_call_keywords(args: Any, limit: int = 6, max_len: int = 120) -> list[str]:
|
|
341
|
+
"""Short string values from a tool call's arguments, as selection keywords.
|
|
342
|
+
|
|
343
|
+
A tool call's arguments are the closest thing to a query the service will
|
|
344
|
+
ever see for a tool result, so they are worth sending. Bounded on both count
|
|
345
|
+
and length because they are model-generated and therefore unbounded.
|
|
346
|
+
"""
|
|
347
|
+
if not isinstance(args, dict):
|
|
348
|
+
return []
|
|
349
|
+
out: list[str] = []
|
|
350
|
+
for value in args.values():
|
|
351
|
+
if isinstance(value, str) and value.strip():
|
|
352
|
+
out.append(value.strip()[:max_len])
|
|
353
|
+
elif isinstance(value, (int, float, bool)):
|
|
354
|
+
out.append(str(value))
|
|
355
|
+
if len(out) >= limit:
|
|
356
|
+
break
|
|
357
|
+
return out
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
__all__ = [
|
|
361
|
+
"METADATA_KEY",
|
|
362
|
+
"DEFAULT_PLACEHOLDER",
|
|
363
|
+
"message_text",
|
|
364
|
+
"approx_tokens",
|
|
365
|
+
"messages_tokens",
|
|
366
|
+
"kind_for",
|
|
367
|
+
"is_prunable",
|
|
368
|
+
"has_tool_calls",
|
|
369
|
+
"record_id_for",
|
|
370
|
+
"build_records",
|
|
371
|
+
"latest_user_prompt",
|
|
372
|
+
"prior_user_prompts",
|
|
373
|
+
"protected_tail",
|
|
374
|
+
"rewrite_message",
|
|
375
|
+
"apply_selection",
|
|
376
|
+
"tool_call_keywords",
|
|
377
|
+
]
|