needlepath-langchain 0.2.2__tar.gz → 0.2.4__tar.gz
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-0.2.2 → needlepath_langchain-0.2.4}/CHANGELOG.md +69 -17
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/PKG-INFO +6 -3
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/README.md +4 -1
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/pyproject.toml +2 -2
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/_messages.py +86 -31
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/langgraph.py +39 -8
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/middleware.py +96 -35
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/version.py +1 -1
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/tests/test_langgraph_recipe.py +185 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/tests/test_middleware.py +557 -2
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/.gitignore +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/LICENSE +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/__init__.py +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/py.typed +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/system_one.py +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/tests/conftest.py +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/tests/test_end_to_end.py +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/tests/test_system_one_chain.py +0 -0
|
@@ -2,22 +2,60 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `needlepath-langchain` Python package are documented here.
|
|
4
4
|
|
|
5
|
-
## 0.2.
|
|
5
|
+
## 0.2.4 — 2026-11-05
|
|
6
6
|
|
|
7
7
|
### Fixed
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
- **A protected record the length guard would keep is withheld, never kept.**
|
|
10
|
+
Under workspace protection (the engine's `governance` forbids raw-context
|
|
11
|
+
fallback) and a verdict that is not blocked, `apply_selection` kept an
|
|
12
|
+
individual original whenever its excerpt or placeholder was empty or not
|
|
13
|
+
shorter, and the history seam and the LangGraph hook only declined when the
|
|
14
|
+
aggregate rewrite showed no saving, so a larger saving elsewhere masked the
|
|
15
|
+
per-record refusal and the protected original reached the model. Such a
|
|
16
|
+
record now collapses to the withheld notice (`rewrite_reason: "protected"`);
|
|
17
|
+
the aggregate no-saving decline does not apply under protection, since the
|
|
18
|
+
rewrite holds no original. On the tool-result seam the same case used to
|
|
19
|
+
decline, which under protection raised `NeedlepathGuardrailError` and
|
|
20
|
+
aborted the run; it now installs the notice and the run continues. The
|
|
21
|
+
engine's own stop conditions (an empty or unusable protected response)
|
|
22
|
+
still raise.
|
|
23
|
+
- **Shadow never restores a protected original.** The adapter's own shadow
|
|
24
|
+
guard exempted only a blocked verdict, so a protected engaged or sanitized
|
|
25
|
+
delivery (the core keeps it applied in a shadow run) was discarded and the
|
|
26
|
+
original forwarded. The guard now exempts every protected result
|
|
27
|
+
(`SelectionResult.protected`). Shadow still forwards the original for every
|
|
28
|
+
unprotected delivery.
|
|
29
|
+
- **A defect while installing a protected result stops the call.** Selection
|
|
30
|
+
and installation shared one fail-open handler, so a failure after a
|
|
31
|
+
protected result had been returned restored the original. The install step
|
|
32
|
+
now runs under `SelectionResult.applying()`: under protection it raises
|
|
33
|
+
`NeedlepathGuardrailError`; otherwise it fails open as before.
|
|
34
|
+
|
|
35
|
+
## 0.2.3 — 2026-11-04
|
|
19
36
|
|
|
20
|
-
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **LangGraph `pre_model_hook` installs a blocked verdict.** The hook applied a
|
|
40
|
+
blocked selection through the ordinary placeholder path, where the length
|
|
41
|
+
guard could keep the withheld record. It now collapses every considered tool
|
|
42
|
+
result to the withheld notice, the same as the middleware's history seam.
|
|
43
|
+
- **A blocked verdict is enforced in shadow mode too.** The middleware's and
|
|
44
|
+
the hook's own shadow flags no longer restore a withheld record: the notice
|
|
45
|
+
is installed in a shadow run and the receipt says it was one
|
|
46
|
+
(`shadow: true`, `reason: blocked`). Shadow still installs nothing for
|
|
47
|
+
every other reason.
|
|
48
|
+
- **A `blocked` tool result is withheld from the model.** When workspace
|
|
49
|
+
policy withholds the record (`outcome: "blocked"`), the tool-result seam
|
|
50
|
+
returned the original message, so the withheld record reached the model.
|
|
51
|
+
The engine's verdict is now installed as the tool result: a short notice
|
|
52
|
+
naming the engine's categories (`rewrite_reason: "blocked"`), no length
|
|
53
|
+
guard, and the agent continues without the record. On the history seam
|
|
54
|
+
every record that was sent collapses to the same notice.
|
|
55
|
+
- **Receipt carries `governance` verbatim.** `response_metadata["needlepath"]`
|
|
56
|
+
now includes the engine's `governance` block (records before/after/blocked,
|
|
57
|
+
category counts, policy revision, policies applied) when the response has
|
|
58
|
+
one. It is the audit evidence a governed workspace reads.
|
|
21
59
|
|
|
22
60
|
### Added
|
|
23
61
|
|
|
@@ -37,11 +75,25 @@ All notable changes to the `needlepath-langchain` Python package are documented
|
|
|
37
75
|
|
|
38
76
|
### Requires
|
|
39
77
|
|
|
40
|
-
- **`needlepath
|
|
41
|
-
|
|
42
|
-
`needlepath
|
|
43
|
-
|
|
44
|
-
|
|
78
|
+
- **`needlepath>=0.2.3`.** The dependency floor is raised from `0.1.0`: this
|
|
79
|
+
release reads `Reason.BLOCKED`, which core 0.2.3 adds, and imports
|
|
80
|
+
`needlepath.integrations.system_one`, which core 0.2.2 already ships. A
|
|
81
|
+
resolver can no longer pair this package with a core that lacks either.
|
|
82
|
+
|
|
83
|
+
## 0.2.2 — 2026-11-03
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- **Tool-result seam applies a `sanitized_fallback`.** The service answers a
|
|
88
|
+
screened tool result with one model-facing packet (`rendered_context`, the
|
|
89
|
+
record with the offending span removed and marked in place) and no per-record
|
|
90
|
+
excerpt. The seam declined that packet as `NO_SAVING` and delivered the
|
|
91
|
+
unscreened original to the model. It now applies the packet
|
|
92
|
+
(`rewrite_reason: "sanitized"`), and the length guard is skipped for screened
|
|
93
|
+
packets, which are applied for what they removed, not for what they saved.
|
|
94
|
+
- **Receipt on the message** (`response_metadata["needlepath"]`) now also carries
|
|
95
|
+
`outcome`, `security_action` and `usage`, so an agent trace holds the same
|
|
96
|
+
receipt the console shows.
|
|
45
97
|
|
|
46
98
|
## 0.2.1 — 2026-09-03
|
|
47
99
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: needlepath-langchain
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Needlepath context selection for LangChain agents (AgentMiddleware) and LangGraph.
|
|
5
5
|
Project-URL: Homepage, https://nextmoca.com
|
|
6
6
|
Author: Next Moca Global, Inc.
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
14
|
Classifier: Typing :: Typed
|
|
15
15
|
Requires-Python: >=3.10
|
|
16
16
|
Requires-Dist: langchain<2.0.0,>=1.2.9
|
|
17
|
-
Requires-Dist: needlepath<1.0.0,>=0.
|
|
17
|
+
Requires-Dist: needlepath<1.0.0,>=0.2.3
|
|
18
18
|
Provides-Extra: dev
|
|
19
19
|
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
20
20
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
@@ -110,7 +110,10 @@ the savings from deleting message envelopes.
|
|
|
110
110
|
|
|
111
111
|
**A rewrite never grows a message and never empties one.** An excerpt longer than
|
|
112
112
|
what it replaces, or an empty excerpt applied to a non-empty message, is refused
|
|
113
|
-
here rather than trusted upstream.
|
|
113
|
+
here rather than trusted upstream. Refusing keeps the original, except under
|
|
114
|
+
workspace protection (the engine's `governance` forbids raw-context fallback):
|
|
115
|
+
there the record is withheld with the notice instead, because the original is
|
|
116
|
+
the one thing that may not be installed.
|
|
114
117
|
|
|
115
118
|
By default only `ToolMessage`s are rewritten. `SystemMessage` never is, on any
|
|
116
119
|
setting. An `AIMessage` carrying `tool_calls` never is: its content is usually
|
|
@@ -86,7 +86,10 @@ the savings from deleting message envelopes.
|
|
|
86
86
|
|
|
87
87
|
**A rewrite never grows a message and never empties one.** An excerpt longer than
|
|
88
88
|
what it replaces, or an empty excerpt applied to a non-empty message, is refused
|
|
89
|
-
here rather than trusted upstream.
|
|
89
|
+
here rather than trusted upstream. Refusing keeps the original, except under
|
|
90
|
+
workspace protection (the engine's `governance` forbids raw-context fallback):
|
|
91
|
+
there the record is withheld with the notice instead, because the original is
|
|
92
|
+
the one thing that may not be installed.
|
|
90
93
|
|
|
91
94
|
By default only `ToolMessage`s are rewritten. `SystemMessage` never is, on any
|
|
92
95
|
setting. An `AIMessage` carrying `tool_calls` never is: its content is usually
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "needlepath-langchain"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "Needlepath context selection for LangChain agents (AgentMiddleware) and LangGraph."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10" # langchain 1.x floor
|
|
@@ -29,7 +29,7 @@ classifiers = [
|
|
|
29
29
|
# `langchain.agents.middleware` package and ModelRequest.system_message is
|
|
30
30
|
# stable. Capped at the major, per §8.2.
|
|
31
31
|
dependencies = [
|
|
32
|
-
"needlepath>=0.
|
|
32
|
+
"needlepath>=0.2.3,<1.0.0",
|
|
33
33
|
"langchain>=1.2.9,<2.0.0",
|
|
34
34
|
]
|
|
35
35
|
|
{needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/_messages.py
RENAMED
|
@@ -22,7 +22,12 @@ to get it.
|
|
|
22
22
|
**Invariant 2 — a rewrite never grows the context and never empties a message.**
|
|
23
23
|
An excerpt longer than what it replaces, or an empty excerpt applied to a
|
|
24
24
|
non-empty message, is a regression however the service arrived at it. Both are
|
|
25
|
-
refused here rather than trusted upstream.
|
|
25
|
+
refused here rather than trusted upstream. Refusing a rewrite keeps the
|
|
26
|
+
original, which is the right degradation for an ordinary selection and the
|
|
27
|
+
wrong one under workspace protection: when the engine's ``governance`` forbids
|
|
28
|
+
raw-context fallback, a refused rewrite collapses to the withheld notice
|
|
29
|
+
instead (``protected_notice``), because the original is the one thing that
|
|
30
|
+
may not be installed.
|
|
26
31
|
|
|
27
32
|
**No local "preserve the last N messages" policy (NEXPE-517 review).** An
|
|
28
33
|
earlier version excluded a trailing window of messages from ``index_by_id``
|
|
@@ -45,7 +50,7 @@ from __future__ import annotations
|
|
|
45
50
|
from collections.abc import Sequence
|
|
46
51
|
from typing import Any
|
|
47
52
|
|
|
48
|
-
from needlepath import ContextRecord, RecordKind
|
|
53
|
+
from needlepath import ContextRecord, RecordKind, SelectionResult
|
|
49
54
|
|
|
50
55
|
try: # pragma: no cover - exercised by the installed-langchain test matrix
|
|
51
56
|
from langchain_core.messages import AIMessage, BaseMessage, SystemMessage, ToolMessage
|
|
@@ -298,6 +303,8 @@ def apply_selection(
|
|
|
298
303
|
index_by_id: dict[str, int],
|
|
299
304
|
placeholder: str,
|
|
300
305
|
request_id: str,
|
|
306
|
+
withhold: bool = False,
|
|
307
|
+
protected_notice: str | None = None,
|
|
301
308
|
) -> tuple[list[BaseMessage], int, int]:
|
|
302
309
|
"""Rewrite the selected and unselected messages. Returns (messages, before, after).
|
|
303
310
|
|
|
@@ -305,6 +312,18 @@ def apply_selection(
|
|
|
305
312
|
would make a message *longer*, is refused and the original kept. Both are
|
|
306
313
|
regressions however the service arrived at them, and refusing one message's
|
|
307
314
|
rewrite is cheaper than failing the whole selection open.
|
|
315
|
+
|
|
316
|
+
Two exceptions, both for a response under workspace protection, where the
|
|
317
|
+
original is the thing that must not be kept:
|
|
318
|
+
|
|
319
|
+
* ``withhold``, for a ``blocked`` verdict: every record collapses to
|
|
320
|
+
``placeholder`` (the withheld notice) whatever its length. ``excerpts``
|
|
321
|
+
is ignored; a blocked response carries none.
|
|
322
|
+
* ``protected_notice``, for any other protected delivery: a record whose
|
|
323
|
+
rewrite invariant 2 refuses collapses to this notice instead of keeping
|
|
324
|
+
the original. The refusal is per record, and a caller that only checks
|
|
325
|
+
the aggregate saving cannot see it — a large saving elsewhere would mask
|
|
326
|
+
one kept original and send it to the model.
|
|
308
327
|
"""
|
|
309
328
|
out: list[BaseMessage] = list(messages)
|
|
310
329
|
before = 0
|
|
@@ -313,41 +332,56 @@ def apply_selection(
|
|
|
313
332
|
original = out[index]
|
|
314
333
|
original_text = message_text(original)
|
|
315
334
|
before += approx_tokens(original_text)
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
# Invariant 2, at the mutation boundary. A blank custom placeholder
|
|
319
|
-
# would rewrite a non-empty message to nothing — the pairing
|
|
320
|
-
# survives, the content does not. Refused rather than trusted.
|
|
321
|
-
after += approx_tokens(original_text)
|
|
322
|
-
continue
|
|
323
|
-
if excerpt is None:
|
|
324
|
-
# Not selected: collapse to the placeholder. This is the only place
|
|
325
|
-
# content is discarded, and it is the difference between selection
|
|
326
|
-
# and a no-op.
|
|
327
|
-
new_text = placeholder
|
|
328
|
-
reason = "not_selected"
|
|
329
|
-
elif not excerpt.strip():
|
|
330
|
-
# Selected with nothing in it. Applying this would delete content
|
|
331
|
-
# the service said to keep — refuse it and keep the original.
|
|
332
|
-
after += approx_tokens(original_text)
|
|
333
|
-
continue
|
|
334
|
-
elif len(excerpt) >= len(original_text):
|
|
335
|
-
# An "excerpt" that is not shorter buys nothing and may cost.
|
|
336
|
-
after += approx_tokens(original_text)
|
|
337
|
-
continue
|
|
335
|
+
if withhold:
|
|
336
|
+
new_text, reason = placeholder, "blocked"
|
|
338
337
|
else:
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
338
|
+
rewrite = _rewrite_for(excerpts.get(record_id), original_text, placeholder)
|
|
339
|
+
if rewrite is None and protected_notice is None:
|
|
340
|
+
# Invariant 2 refused the rewrite: the original stays.
|
|
341
|
+
after += approx_tokens(original_text)
|
|
342
|
+
continue
|
|
343
|
+
if rewrite is None:
|
|
344
|
+
# Under workspace protection the original is the one thing
|
|
345
|
+
# that may not be installed. Withheld, with the notice.
|
|
346
|
+
new_text, reason = protected_notice, "protected"
|
|
347
|
+
else:
|
|
348
|
+
new_text, reason = rewrite
|
|
346
349
|
out[index] = rewrite_message(original, new_text, reason=reason, request_id=request_id)
|
|
347
350
|
after += approx_tokens(new_text)
|
|
348
351
|
return out, before, after
|
|
349
352
|
|
|
350
353
|
|
|
354
|
+
def _rewrite_for(
|
|
355
|
+
excerpt: str | None, original_text: str, placeholder: str
|
|
356
|
+
) -> tuple[str, str] | None:
|
|
357
|
+
"""What replaces a message, as ``(text, reason)``, or ``None`` to refuse.
|
|
358
|
+
|
|
359
|
+
Invariant 2, at the mutation boundary. Every ``None`` here is a rewrite
|
|
360
|
+
that would grow the context or empty a message.
|
|
361
|
+
"""
|
|
362
|
+
if excerpt is None:
|
|
363
|
+
if not placeholder.strip():
|
|
364
|
+
# A blank custom placeholder would rewrite a non-empty message to
|
|
365
|
+
# nothing — the pairing survives, the content does not.
|
|
366
|
+
return None
|
|
367
|
+
if len(placeholder) >= len(original_text):
|
|
368
|
+
# A placeholder longer than the message it replaces. Rare, but a
|
|
369
|
+
# short tool result plus a long custom placeholder gets here.
|
|
370
|
+
return None
|
|
371
|
+
# Not selected: collapse to the placeholder. This is the only place
|
|
372
|
+
# content is discarded, and it is the difference between selection
|
|
373
|
+
# and a no-op.
|
|
374
|
+
return placeholder, "not_selected"
|
|
375
|
+
if not excerpt.strip():
|
|
376
|
+
# Selected with nothing in it. Applying this would delete content the
|
|
377
|
+
# service said to keep.
|
|
378
|
+
return None
|
|
379
|
+
if len(excerpt) >= len(original_text):
|
|
380
|
+
# An "excerpt" that is not shorter buys nothing and may cost.
|
|
381
|
+
return None
|
|
382
|
+
return excerpt, "excerpt"
|
|
383
|
+
|
|
384
|
+
|
|
351
385
|
def tool_call_keywords(args: Any, limit: int = 6, max_len: int = 120) -> list[str]:
|
|
352
386
|
"""Short string values from a tool call's arguments, as selection keywords.
|
|
353
387
|
|
|
@@ -399,6 +433,26 @@ def reject_removed_options(kwargs: dict[str, Any]) -> None:
|
|
|
399
433
|
raise TypeError(message)
|
|
400
434
|
|
|
401
435
|
|
|
436
|
+
def withheld_notice(selection: SelectionResult, *, tool_name: str | None = None) -> str:
|
|
437
|
+
"""The model-facing text installed for a record workspace policy withheld.
|
|
438
|
+
|
|
439
|
+
Built from the receipt only — the engine's category labels and the request
|
|
440
|
+
id — never from the record. The categories are the engine's own names for
|
|
441
|
+
what the policy matched (``scope:source:hr``, ``classifier:pii.us_ssn``);
|
|
442
|
+
they are wire fields, read here as labels, not evaluated.
|
|
443
|
+
"""
|
|
444
|
+
response = selection.response
|
|
445
|
+
governance = response.extra.get("governance") if response is not None else None
|
|
446
|
+
counts = governance.get("category_counts") if isinstance(governance, dict) else None
|
|
447
|
+
categories = ", ".join(sorted(str(k) for k in counts)) if isinstance(counts, dict) else ""
|
|
448
|
+
what = f"the {tool_name} result" if tool_name else "this tool result"
|
|
449
|
+
why = f" ({categories})" if categories else ""
|
|
450
|
+
return (
|
|
451
|
+
f"[WITHHELD by workspace policy: {what} was not delivered to the model{why}. "
|
|
452
|
+
f"Request {selection.request_id}.]"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
402
456
|
__all__ = [
|
|
403
457
|
"METADATA_KEY",
|
|
404
458
|
"DEFAULT_PLACEHOLDER",
|
|
@@ -414,6 +468,7 @@ __all__ = [
|
|
|
414
468
|
"prior_user_prompts",
|
|
415
469
|
"rewrite_message",
|
|
416
470
|
"apply_selection",
|
|
471
|
+
"withheld_notice",
|
|
417
472
|
"tool_call_keywords",
|
|
418
473
|
"REMOVED_OPTIONS",
|
|
419
474
|
"reject_removed_options",
|
{needlepath_langchain-0.2.2 → needlepath_langchain-0.2.4}/src/needlepath_langchain/langgraph.py
RENAMED
|
@@ -88,6 +88,7 @@ from typing import Any
|
|
|
88
88
|
from needlepath import (
|
|
89
89
|
AsyncNeedlepathClient,
|
|
90
90
|
NeedlepathClient,
|
|
91
|
+
NeedlepathGuardrailError,
|
|
91
92
|
Reason,
|
|
92
93
|
SelectionResult,
|
|
93
94
|
SelectionStats,
|
|
@@ -102,6 +103,7 @@ from ._messages import (
|
|
|
102
103
|
latest_user_prompt,
|
|
103
104
|
prior_user_prompts,
|
|
104
105
|
reject_removed_options,
|
|
106
|
+
withheld_notice,
|
|
105
107
|
)
|
|
106
108
|
from .version import __version__
|
|
107
109
|
|
|
@@ -121,8 +123,8 @@ LLM_INPUT_MESSAGES = "llm_input_messages"
|
|
|
121
123
|
class NeedlepathPreModelHook:
|
|
122
124
|
"""A callable ``pre_model_hook`` that selects context non-destructively.
|
|
123
125
|
|
|
124
|
-
Returns ``{"llm_input_messages": [...]}``
|
|
125
|
-
|
|
126
|
+
Returns ``{"llm_input_messages": [...]}`` unless mandatory guardrail
|
|
127
|
+
enforcement fails. Returned lists are never empty; see edges 2 and 3.
|
|
126
128
|
|
|
127
129
|
Instances are callable both synchronously and (via :meth:`ainvoke`)
|
|
128
130
|
asynchronously; ``create_react_agent`` accepts any ``RunnableLike``, and a
|
|
@@ -194,7 +196,11 @@ class NeedlepathPreModelHook:
|
|
|
194
196
|
selection_strategy=self.selection_strategy,
|
|
195
197
|
support_contract=self.support_contract,
|
|
196
198
|
)
|
|
197
|
-
|
|
199
|
+
with selection.applying():
|
|
200
|
+
return self._apply(messages, plan, selection)
|
|
201
|
+
except NeedlepathGuardrailError:
|
|
202
|
+
# Mandatory protection failures must stop the framework call.
|
|
203
|
+
raise
|
|
198
204
|
except Exception: # noqa: BLE001 - a defect here must not break the graph
|
|
199
205
|
self._note(Reason.UNEXPECTED_ERROR)
|
|
200
206
|
return self._always_write(messages)
|
|
@@ -204,6 +210,9 @@ class NeedlepathPreModelHook:
|
|
|
204
210
|
messages = _messages_of(state)
|
|
205
211
|
try:
|
|
206
212
|
return await self._ainvoke(messages)
|
|
213
|
+
except NeedlepathGuardrailError:
|
|
214
|
+
# Mandatory protection failures must stop the framework call.
|
|
215
|
+
raise
|
|
207
216
|
except Exception: # noqa: BLE001 - a defect here must not break the graph
|
|
208
217
|
self._note(Reason.UNEXPECTED_ERROR)
|
|
209
218
|
return self._always_write(messages)
|
|
@@ -237,7 +246,8 @@ class NeedlepathPreModelHook:
|
|
|
237
246
|
support_contract=self.support_contract,
|
|
238
247
|
),
|
|
239
248
|
)
|
|
240
|
-
|
|
249
|
+
with selection.applying():
|
|
250
|
+
return self._apply(messages, plan, selection)
|
|
241
251
|
|
|
242
252
|
# ------------------------------------------------------------------ #
|
|
243
253
|
|
|
@@ -299,8 +309,12 @@ class NeedlepathPreModelHook:
|
|
|
299
309
|
self.engine_stats.record(selection)
|
|
300
310
|
# `self.shadow` is belt and braces — the core client already declines a
|
|
301
311
|
# shadow result — but "measuring never changes behaviour" is enforced at
|
|
302
|
-
# the point of mutation rather than inherited.
|
|
303
|
-
|
|
312
|
+
# the point of mutation rather than inherited. What shadow does not
|
|
313
|
+
# suppress is a protected delivery (a blocked verdict, or a selection
|
|
314
|
+
# under workspace protection): shadow never restores a withheld record
|
|
315
|
+
# or a protected original, so it is installed below, shadow or not.
|
|
316
|
+
blocked = selection.reason == Reason.BLOCKED
|
|
317
|
+
if not selection.applied or (self.shadow and not selection.protected):
|
|
304
318
|
# Nothing is installed on this path, so the ACTUAL before/after is
|
|
305
319
|
# the size of what was considered, unchanged.
|
|
306
320
|
candidate_tokens = sum(approx_tokens(r.text) for r in plan["records"])
|
|
@@ -308,14 +322,31 @@ class NeedlepathPreModelHook:
|
|
|
308
322
|
_with_actual_tokens(selection, before=candidate_tokens, after=candidate_tokens)
|
|
309
323
|
)
|
|
310
324
|
return self._always_write(messages)
|
|
325
|
+
# Workspace protection: the engine's `governance` forbids raw-context
|
|
326
|
+
# fallback (a blocked verdict is one case of it). Nothing of the
|
|
327
|
+
# caller's original may be written, per record or in aggregate.
|
|
328
|
+
protected = not selection.raw_context_fallback_allowed
|
|
329
|
+
notice = withheld_notice(selection) if protected else None
|
|
311
330
|
rewritten, before, after = apply_selection(
|
|
312
331
|
messages,
|
|
313
332
|
excerpts=selection.excerpts,
|
|
314
333
|
index_by_id=plan["index_by_id"],
|
|
315
|
-
|
|
334
|
+
# A blocked verdict covers every record that was sent: each one
|
|
335
|
+
# collapses to the withheld notice, which names the policy's
|
|
336
|
+
# categories, rather than to the selection placeholder, which
|
|
337
|
+
# would claim a record was merely not selected.
|
|
338
|
+
placeholder=withheld_notice(selection) if blocked else self.placeholder,
|
|
316
339
|
request_id=selection.request_id,
|
|
340
|
+
withhold=blocked,
|
|
341
|
+
# Any other protected delivery: a record whose rewrite the length
|
|
342
|
+
# guards refuse is withheld with the notice, never kept.
|
|
343
|
+
protected_notice=notice,
|
|
317
344
|
)
|
|
318
|
-
if after >= before:
|
|
345
|
+
if after >= before and not protected:
|
|
346
|
+
# The guard refuses a rewrite that saves nothing. Under protection
|
|
347
|
+
# it does not apply: the rewrite holds no original, a withheld
|
|
348
|
+
# record is not a saving, and declining would raise and stop a run
|
|
349
|
+
# the engine did not stop.
|
|
319
350
|
declined = selection.declined(Reason.NO_SAVING)
|
|
320
351
|
# Nothing is installed here either: `rewritten` is computed but
|
|
321
352
|
# never written, so actual after == actual before.
|