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.
@@ -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
+ ]