needlepath-langchain 0.2.2__tar.gz → 0.2.3__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.3}/CHANGELOG.md +40 -18
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/PKG-INFO +2 -2
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/pyproject.toml +2 -2
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/_messages.py +34 -1
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/langgraph.py +25 -6
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/middleware.py +57 -23
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/version.py +1 -1
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/tests/test_langgraph_recipe.py +85 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/tests/test_middleware.py +335 -2
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/.gitignore +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/LICENSE +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/README.md +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/__init__.py +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/py.typed +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/system_one.py +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/tests/conftest.py +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/tests/test_end_to_end.py +0 -0
- {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/tests/test_system_one_chain.py +0 -0
|
@@ -2,22 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `needlepath-langchain` Python package are documented here.
|
|
4
4
|
|
|
5
|
-
## 0.2.
|
|
5
|
+
## 0.2.3 — 2026-11-04
|
|
6
6
|
|
|
7
7
|
### Fixed
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
- **LangGraph `pre_model_hook` installs a blocked verdict.** The hook applied a
|
|
10
|
+
blocked selection through the ordinary placeholder path, where the length
|
|
11
|
+
guard could keep the withheld record. It now collapses every considered tool
|
|
12
|
+
result to the withheld notice, the same as the middleware's history seam.
|
|
13
|
+
- **A blocked verdict is enforced in shadow mode too.** The middleware's and
|
|
14
|
+
the hook's own shadow flags no longer restore a withheld record: the notice
|
|
15
|
+
is installed in a shadow run and the receipt says it was one
|
|
16
|
+
(`shadow: true`, `reason: blocked`). Shadow still installs nothing for
|
|
17
|
+
every other reason.
|
|
18
|
+
- **A `blocked` tool result is withheld from the model.** When workspace
|
|
19
|
+
policy withholds the record (`outcome: "blocked"`), the tool-result seam
|
|
20
|
+
returned the original message, so the withheld record reached the model.
|
|
21
|
+
The engine's verdict is now installed as the tool result: a short notice
|
|
22
|
+
naming the engine's categories (`rewrite_reason: "blocked"`), no length
|
|
23
|
+
guard, and the agent continues without the record. On the history seam
|
|
24
|
+
every record that was sent collapses to the same notice.
|
|
25
|
+
- **Receipt carries `governance` verbatim.** `response_metadata["needlepath"]`
|
|
26
|
+
now includes the engine's `governance` block (records before/after/blocked,
|
|
27
|
+
category counts, policy revision, policies applied) when the response has
|
|
28
|
+
one. It is the audit evidence a governed workspace reads.
|
|
21
29
|
|
|
22
30
|
### Added
|
|
23
31
|
|
|
@@ -37,11 +45,25 @@ All notable changes to the `needlepath-langchain` Python package are documented
|
|
|
37
45
|
|
|
38
46
|
### Requires
|
|
39
47
|
|
|
40
|
-
- **`needlepath
|
|
41
|
-
|
|
42
|
-
`needlepath
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
- **`needlepath>=0.2.3`.** The dependency floor is raised from `0.1.0`: this
|
|
49
|
+
release reads `Reason.BLOCKED`, which core 0.2.3 adds, and imports
|
|
50
|
+
`needlepath.integrations.system_one`, which core 0.2.2 already ships. A
|
|
51
|
+
resolver can no longer pair this package with a core that lacks either.
|
|
52
|
+
|
|
53
|
+
## 0.2.2 — 2026-11-03
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **Tool-result seam applies a `sanitized_fallback`.** The service answers a
|
|
58
|
+
screened tool result with one model-facing packet (`rendered_context`, the
|
|
59
|
+
record with the offending span removed and marked in place) and no per-record
|
|
60
|
+
excerpt. The seam declined that packet as `NO_SAVING` and delivered the
|
|
61
|
+
unscreened original to the model. It now applies the packet
|
|
62
|
+
(`rewrite_reason: "sanitized"`), and the length guard is skipped for screened
|
|
63
|
+
packets, which are applied for what they removed, not for what they saved.
|
|
64
|
+
- **Receipt on the message** (`response_metadata["needlepath"]`) now also carries
|
|
65
|
+
`outcome`, `security_action` and `usage`, so an agent trace holds the same
|
|
66
|
+
receipt the console shows.
|
|
45
67
|
|
|
46
68
|
## 0.2.1 — 2026-09-03
|
|
47
69
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: needlepath-langchain
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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'
|
|
@@ -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.3"
|
|
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.3}/src/needlepath_langchain/_messages.py
RENAMED
|
@@ -45,7 +45,7 @@ from __future__ import annotations
|
|
|
45
45
|
from collections.abc import Sequence
|
|
46
46
|
from typing import Any
|
|
47
47
|
|
|
48
|
-
from needlepath import ContextRecord, RecordKind
|
|
48
|
+
from needlepath import ContextRecord, RecordKind, SelectionResult
|
|
49
49
|
|
|
50
50
|
try: # pragma: no cover - exercised by the installed-langchain test matrix
|
|
51
51
|
from langchain_core.messages import AIMessage, BaseMessage, SystemMessage, ToolMessage
|
|
@@ -298,6 +298,7 @@ def apply_selection(
|
|
|
298
298
|
index_by_id: dict[str, int],
|
|
299
299
|
placeholder: str,
|
|
300
300
|
request_id: str,
|
|
301
|
+
withhold: bool = False,
|
|
301
302
|
) -> tuple[list[BaseMessage], int, int]:
|
|
302
303
|
"""Rewrite the selected and unselected messages. Returns (messages, before, after).
|
|
303
304
|
|
|
@@ -305,6 +306,11 @@ def apply_selection(
|
|
|
305
306
|
would make a message *longer*, is refused and the original kept. Both are
|
|
306
307
|
regressions however the service arrived at them, and refusing one message's
|
|
307
308
|
rewrite is cheaper than failing the whole selection open.
|
|
309
|
+
|
|
310
|
+
``withhold`` is the one exception, for a ``blocked`` verdict: every record
|
|
311
|
+
collapses to ``placeholder`` (the withheld notice) whatever its length,
|
|
312
|
+
because the original is the thing that must not be kept. ``excerpts`` is
|
|
313
|
+
ignored; a blocked response carries none.
|
|
308
314
|
"""
|
|
309
315
|
out: list[BaseMessage] = list(messages)
|
|
310
316
|
before = 0
|
|
@@ -313,6 +319,12 @@ def apply_selection(
|
|
|
313
319
|
original = out[index]
|
|
314
320
|
original_text = message_text(original)
|
|
315
321
|
before += approx_tokens(original_text)
|
|
322
|
+
if withhold:
|
|
323
|
+
out[index] = rewrite_message(
|
|
324
|
+
original, placeholder, reason="blocked", request_id=request_id
|
|
325
|
+
)
|
|
326
|
+
after += approx_tokens(placeholder)
|
|
327
|
+
continue
|
|
316
328
|
excerpt = excerpts.get(record_id)
|
|
317
329
|
if excerpt is None and not placeholder.strip():
|
|
318
330
|
# Invariant 2, at the mutation boundary. A blank custom placeholder
|
|
@@ -399,6 +411,26 @@ def reject_removed_options(kwargs: dict[str, Any]) -> None:
|
|
|
399
411
|
raise TypeError(message)
|
|
400
412
|
|
|
401
413
|
|
|
414
|
+
def withheld_notice(selection: SelectionResult, *, tool_name: str | None = None) -> str:
|
|
415
|
+
"""The model-facing text installed for a record workspace policy withheld.
|
|
416
|
+
|
|
417
|
+
Built from the receipt only — the engine's category labels and the request
|
|
418
|
+
id — never from the record. The categories are the engine's own names for
|
|
419
|
+
what the policy matched (``scope:source:hr``, ``classifier:pii.us_ssn``);
|
|
420
|
+
they are wire fields, read here as labels, not evaluated.
|
|
421
|
+
"""
|
|
422
|
+
response = selection.response
|
|
423
|
+
governance = response.extra.get("governance") if response is not None else None
|
|
424
|
+
counts = governance.get("category_counts") if isinstance(governance, dict) else None
|
|
425
|
+
categories = ", ".join(sorted(str(k) for k in counts)) if isinstance(counts, dict) else ""
|
|
426
|
+
what = f"the {tool_name} result" if tool_name else "this tool result"
|
|
427
|
+
why = f" ({categories})" if categories else ""
|
|
428
|
+
return (
|
|
429
|
+
f"[WITHHELD by workspace policy: {what} was not delivered to the model{why}. "
|
|
430
|
+
f"Request {selection.request_id}.]"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
402
434
|
__all__ = [
|
|
403
435
|
"METADATA_KEY",
|
|
404
436
|
"DEFAULT_PLACEHOLDER",
|
|
@@ -414,6 +446,7 @@ __all__ = [
|
|
|
414
446
|
"prior_user_prompts",
|
|
415
447
|
"rewrite_message",
|
|
416
448
|
"apply_selection",
|
|
449
|
+
"withheld_notice",
|
|
417
450
|
"tool_call_keywords",
|
|
418
451
|
"REMOVED_OPTIONS",
|
|
419
452
|
"reject_removed_options",
|
{needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/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
|
|
@@ -195,6 +197,9 @@ class NeedlepathPreModelHook:
|
|
|
195
197
|
support_contract=self.support_contract,
|
|
196
198
|
)
|
|
197
199
|
return self._apply(messages, plan, selection)
|
|
200
|
+
except NeedlepathGuardrailError:
|
|
201
|
+
# Mandatory protection failures must stop the framework call.
|
|
202
|
+
raise
|
|
198
203
|
except Exception: # noqa: BLE001 - a defect here must not break the graph
|
|
199
204
|
self._note(Reason.UNEXPECTED_ERROR)
|
|
200
205
|
return self._always_write(messages)
|
|
@@ -204,6 +209,9 @@ class NeedlepathPreModelHook:
|
|
|
204
209
|
messages = _messages_of(state)
|
|
205
210
|
try:
|
|
206
211
|
return await self._ainvoke(messages)
|
|
212
|
+
except NeedlepathGuardrailError:
|
|
213
|
+
# Mandatory protection failures must stop the framework call.
|
|
214
|
+
raise
|
|
207
215
|
except Exception: # noqa: BLE001 - a defect here must not break the graph
|
|
208
216
|
self._note(Reason.UNEXPECTED_ERROR)
|
|
209
217
|
return self._always_write(messages)
|
|
@@ -299,8 +307,11 @@ class NeedlepathPreModelHook:
|
|
|
299
307
|
self.engine_stats.record(selection)
|
|
300
308
|
# `self.shadow` is belt and braces — the core client already declines a
|
|
301
309
|
# shadow result — but "measuring never changes behaviour" is enforced at
|
|
302
|
-
# the point of mutation rather than inherited.
|
|
303
|
-
|
|
310
|
+
# the point of mutation rather than inherited. A blocked verdict is the
|
|
311
|
+
# one delivery shadow does not suppress: shadow never restores a
|
|
312
|
+
# withheld record, so it is installed below, shadow or not.
|
|
313
|
+
blocked = selection.reason == Reason.BLOCKED
|
|
314
|
+
if not selection.applied or (self.shadow and not blocked):
|
|
304
315
|
# Nothing is installed on this path, so the ACTUAL before/after is
|
|
305
316
|
# the size of what was considered, unchanged.
|
|
306
317
|
candidate_tokens = sum(approx_tokens(r.text) for r in plan["records"])
|
|
@@ -312,10 +323,18 @@ class NeedlepathPreModelHook:
|
|
|
312
323
|
messages,
|
|
313
324
|
excerpts=selection.excerpts,
|
|
314
325
|
index_by_id=plan["index_by_id"],
|
|
315
|
-
|
|
326
|
+
# A blocked verdict covers every record that was sent: each one
|
|
327
|
+
# collapses to the withheld notice, which names the policy's
|
|
328
|
+
# categories, rather than to the selection placeholder, which
|
|
329
|
+
# would claim a record was merely not selected.
|
|
330
|
+
placeholder=withheld_notice(selection) if blocked else self.placeholder,
|
|
316
331
|
request_id=selection.request_id,
|
|
332
|
+
withhold=blocked,
|
|
317
333
|
)
|
|
318
|
-
if after >= before:
|
|
334
|
+
if after >= before and not blocked:
|
|
335
|
+
# The guard refuses a rewrite that saves nothing. A withheld
|
|
336
|
+
# record is not a saving, and refusing the notice on length would
|
|
337
|
+
# write the original — exactly what a blocked verdict forbids.
|
|
319
338
|
declined = selection.declined(Reason.NO_SAVING)
|
|
320
339
|
# Nothing is installed here either: `rewritten` is computed but
|
|
321
340
|
# never written, so actual after == actual before.
|
{needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/middleware.py
RENAMED
|
@@ -10,7 +10,8 @@ tool results in the history are selected against the *current* task. A result
|
|
|
10
10
|
that mattered three steps ago and does not matter now collapses; the one that
|
|
11
11
|
matters is excerpted verbatim.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Ordinary selection failures fail open; mandatory guardrail errors stop the call.
|
|
14
|
+
Both stand down below a trigger. Neither ever removes a message.
|
|
14
15
|
|
|
15
16
|
Positioning, because a developer evaluating this already has the free option
|
|
16
17
|
installed (§8.3): LangChain ships ``ContextEditingMiddleware`` with a
|
|
@@ -47,6 +48,7 @@ from needlepath import (
|
|
|
47
48
|
AsyncNeedlepathClient,
|
|
48
49
|
NeedlepathClient,
|
|
49
50
|
NeedlepathConfigError,
|
|
51
|
+
NeedlepathGuardrailError,
|
|
50
52
|
Reason,
|
|
51
53
|
SelectionResult,
|
|
52
54
|
SelectionStats,
|
|
@@ -65,6 +67,7 @@ from ._messages import (
|
|
|
65
67
|
reject_removed_options,
|
|
66
68
|
rewrite_message,
|
|
67
69
|
tool_call_keywords,
|
|
70
|
+
withheld_notice,
|
|
68
71
|
)
|
|
69
72
|
from .version import __version__
|
|
70
73
|
|
|
@@ -89,7 +92,7 @@ USER_AGENT_SUFFIX = f"needlepath-langchain/{__version__}"
|
|
|
89
92
|
|
|
90
93
|
|
|
91
94
|
class NeedlepathMiddleware(AgentMiddleware):
|
|
92
|
-
"""Select context
|
|
95
|
+
"""Select context; propagate mandatory guardrail enforcement failures.
|
|
93
96
|
|
|
94
97
|
::
|
|
95
98
|
|
|
@@ -249,6 +252,9 @@ class NeedlepathMiddleware(AgentMiddleware):
|
|
|
249
252
|
return result
|
|
250
253
|
selection = self._select_sync(plan)
|
|
251
254
|
return self._finish_tool_call(result, plan, selection)
|
|
255
|
+
except NeedlepathGuardrailError:
|
|
256
|
+
# Mandatory protection failures must stop the framework call.
|
|
257
|
+
raise
|
|
252
258
|
except Exception: # noqa: BLE001 - see _fail_open
|
|
253
259
|
return self._fail_open(result)
|
|
254
260
|
|
|
@@ -264,6 +270,9 @@ class NeedlepathMiddleware(AgentMiddleware):
|
|
|
264
270
|
return result
|
|
265
271
|
selection = await self._select_async(plan)
|
|
266
272
|
return self._finish_tool_call(result, plan, selection)
|
|
273
|
+
except NeedlepathGuardrailError:
|
|
274
|
+
# Mandatory protection failures must stop the framework call.
|
|
275
|
+
raise
|
|
267
276
|
except Exception: # noqa: BLE001 - see _fail_open
|
|
268
277
|
return self._fail_open(result)
|
|
269
278
|
|
|
@@ -283,6 +292,9 @@ class NeedlepathMiddleware(AgentMiddleware):
|
|
|
283
292
|
if plan is None
|
|
284
293
|
else self._finish_history(request, plan, self._select_sync(plan))
|
|
285
294
|
)
|
|
295
|
+
except NeedlepathGuardrailError:
|
|
296
|
+
# Mandatory protection failures must stop the framework call.
|
|
297
|
+
raise
|
|
286
298
|
except Exception: # noqa: BLE001 - see _fail_open
|
|
287
299
|
outgoing = self._fail_open(request)
|
|
288
300
|
return handler(outgoing)
|
|
@@ -299,6 +311,9 @@ class NeedlepathMiddleware(AgentMiddleware):
|
|
|
299
311
|
if plan is None
|
|
300
312
|
else self._finish_history(request, plan, await self._select_async(plan))
|
|
301
313
|
)
|
|
314
|
+
except NeedlepathGuardrailError:
|
|
315
|
+
# Mandatory protection failures must stop the framework call.
|
|
316
|
+
raise
|
|
302
317
|
except Exception: # noqa: BLE001 - see _fail_open
|
|
303
318
|
outgoing = self._fail_open(request)
|
|
304
319
|
return await handler(outgoing)
|
|
@@ -308,8 +323,8 @@ class NeedlepathMiddleware(AgentMiddleware):
|
|
|
308
323
|
def _fail_open(self, original: Any) -> Any:
|
|
309
324
|
"""The last line of §8.2's fail-open rule: **our own** defects.
|
|
310
325
|
|
|
311
|
-
The core client
|
|
312
|
-
|
|
326
|
+
The core client handles ordinary selection failures. Mandatory
|
|
327
|
+
guardrail errors are propagated before this fallback is reached.
|
|
313
328
|
What is *not* handled a layer below is a bug in this package — an index
|
|
314
329
|
slipping in ``apply_selection``, a message shape nobody anticipated, a
|
|
315
330
|
framework minor that changes a field's type under us. Without this, such
|
|
@@ -420,13 +435,27 @@ class NeedlepathMiddleware(AgentMiddleware):
|
|
|
420
435
|
self.engine_stats.record(selection)
|
|
421
436
|
original = message_text(result)
|
|
422
437
|
original_tokens = approx_tokens(original)
|
|
423
|
-
if not selection.applied or self._shadow_blocks():
|
|
438
|
+
if not selection.applied or self._shadow_blocks(selection):
|
|
424
439
|
# Nothing is installed on this path: actual after == actual before.
|
|
425
440
|
unchanged = _with_actual_tokens(
|
|
426
441
|
selection, before=original_tokens, after=original_tokens
|
|
427
442
|
)
|
|
428
443
|
self.stats.record(unchanged)
|
|
429
444
|
return _annotate(result, selection)
|
|
445
|
+
if selection.reason == Reason.BLOCKED:
|
|
446
|
+
# Workspace policy withheld the record. The engine's verdict is
|
|
447
|
+
# installed as the tool result — a short notice in place of the
|
|
448
|
+
# content — so the agent continues without the record and the
|
|
449
|
+
# receipt says why. No length guard: a withheld record is not a
|
|
450
|
+
# saving, and refusing the notice would hand the record back.
|
|
451
|
+
notice = withheld_notice(selection, tool_name=getattr(result, "name", None))
|
|
452
|
+
self.stats.record(
|
|
453
|
+
_with_actual_tokens(selection, before=original_tokens, after=approx_tokens(notice))
|
|
454
|
+
)
|
|
455
|
+
rewritten = rewrite_message(
|
|
456
|
+
result, notice, reason="blocked", request_id=selection.request_id
|
|
457
|
+
)
|
|
458
|
+
return _annotate(rewritten, selection)
|
|
430
459
|
excerpt = selection.excerpts.get(plan.records[0].id or "")
|
|
431
460
|
rewrite_reason = "excerpt"
|
|
432
461
|
if excerpt is None and selection.reason == Reason.SANITIZED_FALLBACK:
|
|
@@ -463,7 +492,7 @@ class NeedlepathMiddleware(AgentMiddleware):
|
|
|
463
492
|
self, request: ModelRequest, plan: _Plan, selection: SelectionResult
|
|
464
493
|
) -> ModelRequest:
|
|
465
494
|
self.engine_stats.record(selection)
|
|
466
|
-
if not selection.applied or self._shadow_blocks():
|
|
495
|
+
if not selection.applied or self._shadow_blocks(selection):
|
|
467
496
|
# Nothing is installed on this path, so the ACTUAL before/after is
|
|
468
497
|
# the size of what was considered, unchanged.
|
|
469
498
|
candidate_tokens = sum(approx_tokens(r.text) for r in plan.records)
|
|
@@ -471,14 +500,23 @@ class NeedlepathMiddleware(AgentMiddleware):
|
|
|
471
500
|
_with_actual_tokens(selection, before=candidate_tokens, after=candidate_tokens)
|
|
472
501
|
)
|
|
473
502
|
return request
|
|
503
|
+
blocked = selection.reason == Reason.BLOCKED
|
|
474
504
|
messages, before, after = apply_selection(
|
|
475
505
|
plan.messages,
|
|
476
506
|
excerpts=selection.excerpts,
|
|
477
507
|
index_by_id=plan.index_by_id,
|
|
478
|
-
|
|
508
|
+
# A blocked verdict covers every record that was sent: each one
|
|
509
|
+
# collapses to the withheld notice, which names the policy's
|
|
510
|
+
# categories, rather than to the selection placeholder, which
|
|
511
|
+
# would claim a record was merely not selected.
|
|
512
|
+
placeholder=withheld_notice(selection) if blocked else self.placeholder,
|
|
479
513
|
request_id=selection.request_id,
|
|
514
|
+
withhold=blocked,
|
|
480
515
|
)
|
|
481
|
-
if after >= before:
|
|
516
|
+
if after >= before and not blocked:
|
|
517
|
+
# The guard refuses a rewrite that saves nothing. A withheld
|
|
518
|
+
# record is not a saving, and refusing the notice on length would
|
|
519
|
+
# install the original — exactly what a blocked verdict forbids.
|
|
482
520
|
declined = selection.declined(Reason.NO_SAVING)
|
|
483
521
|
# Nothing is installed here either: `messages` is computed but
|
|
484
522
|
# never returned, so actual after == actual before.
|
|
@@ -498,7 +536,7 @@ class NeedlepathMiddleware(AgentMiddleware):
|
|
|
498
536
|
# Calling
|
|
499
537
|
# ------------------------------------------------------------------ #
|
|
500
538
|
|
|
501
|
-
def _shadow_blocks(self) -> bool:
|
|
539
|
+
def _shadow_blocks(self, selection: SelectionResult) -> bool:
|
|
502
540
|
"""Belt and braces: shadow must not apply, whatever the result says.
|
|
503
541
|
|
|
504
542
|
``_finish`` in the core client already marks a shadow run's reason as
|
|
@@ -507,8 +545,12 @@ class NeedlepathMiddleware(AgentMiddleware):
|
|
|
507
545
|
made to a customer running this against production traffic, and a
|
|
508
546
|
promise like that should be enforced at the point of mutation rather
|
|
509
547
|
than inherited from a value computed three call frames away.
|
|
548
|
+
|
|
549
|
+
The one delivery shadow does not suppress is a blocked verdict: shadow
|
|
550
|
+
never restores a withheld record, so the notice is installed in a
|
|
551
|
+
shadow run too, and the receipt says it was one.
|
|
510
552
|
"""
|
|
511
|
-
return self.shadow
|
|
553
|
+
return self.shadow and selection.reason != Reason.BLOCKED
|
|
512
554
|
|
|
513
555
|
def _shadow_override(self) -> bool | None:
|
|
514
556
|
"""Per-call shadow, so a caller-supplied client cannot silently ignore it.
|
|
@@ -643,20 +685,12 @@ def _annotate(message: ToolMessage, selection: SelectionResult) -> ToolMessage:
|
|
|
643
685
|
metadata = dict(getattr(message, "response_metadata", {}) or {})
|
|
644
686
|
metadata.setdefault("needlepath", {})
|
|
645
687
|
merged = dict(metadata["needlepath"]) if isinstance(metadata["needlepath"], dict) else {}
|
|
688
|
+
# The whole receipt comes from the core client: outcome, screening action,
|
|
689
|
+
# metered line and the governance evidence (records before and after, what
|
|
690
|
+
# was withheld and under which policy revision) included, verbatim. This
|
|
691
|
+
# package reads one label out of `governance` for the notice and decides
|
|
692
|
+
# nothing from it.
|
|
646
693
|
merged.update(selection.metadata(prefix=""))
|
|
647
|
-
response = selection.response
|
|
648
|
-
if response is not None:
|
|
649
|
-
# The rest of the receipt, metadata-only like everything above: how the
|
|
650
|
-
# service resolved the request, what the screening did, and the
|
|
651
|
-
# metered line it billed. Nothing here is derived from record text.
|
|
652
|
-
if response.outcome is not None:
|
|
653
|
-
merged["outcome"] = response.outcome
|
|
654
|
-
action = response.format_metrics.get("security_action")
|
|
655
|
-
if action:
|
|
656
|
-
merged["security_action"] = str(action)
|
|
657
|
-
usage = response.extra.get("usage")
|
|
658
|
-
if isinstance(usage, dict):
|
|
659
|
-
merged["usage"] = dict(usage)
|
|
660
694
|
metadata["needlepath"] = merged
|
|
661
695
|
return message.model_copy(update={"response_metadata": metadata})
|
|
662
696
|
|
|
@@ -14,6 +14,7 @@ import pytest
|
|
|
14
14
|
from conftest import OP, FakeToolCallingModel, big, clients, echoing_service, partial_service
|
|
15
15
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
16
16
|
from langchain_core.tools import tool
|
|
17
|
+
from needlepath import NeedlepathGuardrailError, Reason
|
|
17
18
|
from needlepath.testing import error
|
|
18
19
|
|
|
19
20
|
from needlepath_langchain._messages import approx_tokens, message_text
|
|
@@ -63,6 +64,90 @@ def thread():
|
|
|
63
64
|
]
|
|
64
65
|
|
|
65
66
|
|
|
67
|
+
@pytest.mark.parametrize("asynchronous", [False, True])
|
|
68
|
+
@pytest.mark.parametrize(
|
|
69
|
+
"response",
|
|
70
|
+
[
|
|
71
|
+
pytest.param(error(503, "guardrails_unavailable"), id="unavailable"),
|
|
72
|
+
pytest.param(
|
|
73
|
+
echoing_service(
|
|
74
|
+
governance={"enabled": True, "policies_applied": 1},
|
|
75
|
+
),
|
|
76
|
+
id="malformed-evidence",
|
|
77
|
+
),
|
|
78
|
+
],
|
|
79
|
+
)
|
|
80
|
+
@pytest.mark.parametrize("shadow", [False, True])
|
|
81
|
+
def test_guardrail_failure_never_writes_unfiltered_model_input(asynchronous, response, shadow):
|
|
82
|
+
h, _, _ = hook([response], shadow=shadow, history_max_tokens=10)
|
|
83
|
+
with pytest.raises(NeedlepathGuardrailError):
|
|
84
|
+
if asynchronous:
|
|
85
|
+
asyncio.run(h.ainvoke({"messages": thread()}))
|
|
86
|
+
else:
|
|
87
|
+
h({"messages": thread()})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def blocked_service():
|
|
91
|
+
return echoing_service(
|
|
92
|
+
selected=[],
|
|
93
|
+
records_selected=0,
|
|
94
|
+
rendered_context="",
|
|
95
|
+
outcome="blocked",
|
|
96
|
+
governance={
|
|
97
|
+
"enabled": True,
|
|
98
|
+
"policies_applied": 1,
|
|
99
|
+
"records_blocked": 1,
|
|
100
|
+
"raw_context_fallback_allowed": False,
|
|
101
|
+
"category_counts": {"scope:source:hr": 1},
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.parametrize("asynchronous", [False, True])
|
|
107
|
+
@pytest.mark.parametrize("shadow", [False, True])
|
|
108
|
+
def test_a_blocked_verdict_withholds_the_records_from_the_model_input_never_raises(
|
|
109
|
+
asynchronous, shadow
|
|
110
|
+
):
|
|
111
|
+
"""The counterpart of the test above for ``outcome: blocked``: not an
|
|
112
|
+
enforcement failure but an applied verdict. The key is still written, the
|
|
113
|
+
list is still the full thread, and every considered tool result is the
|
|
114
|
+
withheld notice rather than the record. Shadow installs nothing."""
|
|
115
|
+
h, _, _ = hook([blocked_service()], shadow=shadow, history_max_tokens=10)
|
|
116
|
+
messages = thread()
|
|
117
|
+
out = (
|
|
118
|
+
asyncio.run(h.ainvoke({"messages": messages}))
|
|
119
|
+
if asynchronous
|
|
120
|
+
else h({"messages": messages})
|
|
121
|
+
)
|
|
122
|
+
written = out[LLM_INPUT_MESSAGES]
|
|
123
|
+
assert len(written) == len(messages)
|
|
124
|
+
tool_texts = [message_text(m) for m in written if isinstance(m, ToolMessage)]
|
|
125
|
+
assert tool_texts
|
|
126
|
+
# Shadow or live: shadow never restores a withheld record.
|
|
127
|
+
for text in tool_texts:
|
|
128
|
+
assert "withheld" in text.lower()
|
|
129
|
+
assert "scope:source:hr" in text
|
|
130
|
+
assert BIG_PAYLOAD not in text
|
|
131
|
+
assert h.stats.reasons == {Reason.BLOCKED: 1}
|
|
132
|
+
assert h.stats.applied == 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_shadow_still_withholds_a_blocked_record_from_the_model_input():
|
|
136
|
+
"""The hook's own shadow flag is belt and braces for every delivery the
|
|
137
|
+
model could have had anyway. A withheld record is not one of those: the
|
|
138
|
+
notice is written to ``llm_input_messages`` in a shadow run too."""
|
|
139
|
+
h, transport, _ = hook([blocked_service()], shadow=True, history_max_tokens=10)
|
|
140
|
+
out = h({"messages": thread()})
|
|
141
|
+
assert len(transport.calls) == 1
|
|
142
|
+
written = out[LLM_INPUT_MESSAGES]
|
|
143
|
+
tool_texts = [message_text(m) for m in written if isinstance(m, ToolMessage)]
|
|
144
|
+
assert len(tool_texts) == 1
|
|
145
|
+
assert BIG_PAYLOAD not in tool_texts[0]
|
|
146
|
+
assert "withheld" in tool_texts[0].lower()
|
|
147
|
+
assert h.stats.reasons == {Reason.BLOCKED: 1}
|
|
148
|
+
assert h.stats.applied == 1
|
|
149
|
+
|
|
150
|
+
|
|
66
151
|
# --------------------------------------------------------------------------- #
|
|
67
152
|
# Edge 2: the key is written unconditionally
|
|
68
153
|
# --------------------------------------------------------------------------- #
|
|
@@ -6,9 +6,9 @@ import asyncio
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
from conftest import OP, big, clients, echoing_service, partial_service
|
|
9
|
-
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
|
9
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
10
10
|
from langgraph.types import Command
|
|
11
|
-
from needlepath import NeedlepathTimeoutError, Reason, SelectionResult
|
|
11
|
+
from needlepath import NeedlepathGuardrailError, NeedlepathTimeoutError, Reason, SelectionResult
|
|
12
12
|
from needlepath.testing import error
|
|
13
13
|
|
|
14
14
|
from needlepath_langchain import NeedlepathMiddleware
|
|
@@ -43,6 +43,100 @@ class FakeModelRequest:
|
|
|
43
43
|
return clone
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
@pytest.mark.parametrize("asynchronous", [False, True])
|
|
47
|
+
@pytest.mark.parametrize("seam", ["tool", "model"])
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
"response",
|
|
50
|
+
[
|
|
51
|
+
pytest.param(error(503, "guardrails_unavailable"), id="unavailable"),
|
|
52
|
+
pytest.param(
|
|
53
|
+
echoing_service(
|
|
54
|
+
governance={"enabled": True, "policies_applied": 1},
|
|
55
|
+
),
|
|
56
|
+
id="malformed-evidence",
|
|
57
|
+
),
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
@pytest.mark.parametrize("shadow", [False, True])
|
|
61
|
+
def test_guardrail_failure_stops_instead_of_forwarding_raw_context(
|
|
62
|
+
long_tool_message,
|
|
63
|
+
asynchronous,
|
|
64
|
+
seam,
|
|
65
|
+
response,
|
|
66
|
+
shadow,
|
|
67
|
+
):
|
|
68
|
+
mw, _, _ = middleware([response], shadow=shadow, history_max_tokens=10)
|
|
69
|
+
request = (
|
|
70
|
+
FakeToolCallRequest()
|
|
71
|
+
if seam == "tool"
|
|
72
|
+
else FakeModelRequest([HumanMessage(content="question"), long_tool_message])
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def handler(request):
|
|
76
|
+
if seam == "model":
|
|
77
|
+
pytest.fail("Model received context after failed enforcement")
|
|
78
|
+
return long_tool_message
|
|
79
|
+
|
|
80
|
+
async def ahandler(request):
|
|
81
|
+
return handler(request)
|
|
82
|
+
|
|
83
|
+
with pytest.raises(NeedlepathGuardrailError):
|
|
84
|
+
if asynchronous:
|
|
85
|
+
asyncio.run(getattr(mw, f"awrap_{seam}_call")(request, ahandler))
|
|
86
|
+
else:
|
|
87
|
+
getattr(mw, f"wrap_{seam}_call")(request, handler)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize("asynchronous", [False, True])
|
|
91
|
+
@pytest.mark.parametrize("seam", ["tool", "model"])
|
|
92
|
+
@pytest.mark.parametrize("shadow", [False, True])
|
|
93
|
+
def test_a_blocked_verdict_is_installed_never_raised_on_either_seam(
|
|
94
|
+
long_tool_message, asynchronous, seam, shadow
|
|
95
|
+
):
|
|
96
|
+
"""The counterpart of the test above for ``outcome: blocked``. A blocked
|
|
97
|
+
verdict is not an enforcement failure: it is applied as "deliver nothing
|
|
98
|
+
from these records" and the agent run continues. Live, the model sees the
|
|
99
|
+
withheld notice and never the record. In shadow nothing is installed (the
|
|
100
|
+
original is forwarded) and the receipt still carries the verdict."""
|
|
101
|
+
mw, _, _ = middleware([blocked_service()], shadow=shadow, history_max_tokens=10)
|
|
102
|
+
request = (
|
|
103
|
+
FakeToolCallRequest()
|
|
104
|
+
if seam == "tool"
|
|
105
|
+
else FakeModelRequest([HumanMessage(content="question"), long_tool_message])
|
|
106
|
+
)
|
|
107
|
+
seen: dict[str, list] = {}
|
|
108
|
+
|
|
109
|
+
def handler(request):
|
|
110
|
+
if seam == "model":
|
|
111
|
+
seen["messages"] = list(request.messages)
|
|
112
|
+
return None
|
|
113
|
+
return long_tool_message
|
|
114
|
+
|
|
115
|
+
async def ahandler(request):
|
|
116
|
+
return handler(request)
|
|
117
|
+
|
|
118
|
+
if asynchronous:
|
|
119
|
+
out = asyncio.run(getattr(mw, f"awrap_{seam}_call")(request, ahandler))
|
|
120
|
+
else:
|
|
121
|
+
out = getattr(mw, f"wrap_{seam}_call")(request, handler)
|
|
122
|
+
delivered = out if seam == "tool" else seen["messages"][1]
|
|
123
|
+
# Shadow or live, the verdict is installed: shadow never restores a
|
|
124
|
+
# withheld record. The receipt says which kind of run it was.
|
|
125
|
+
assert "withheld" in delivered.text.lower()
|
|
126
|
+
assert long_tool_message.text not in delivered.text
|
|
127
|
+
assert "scope:source:hr" in delivered.text
|
|
128
|
+
meta = delivered.response_metadata[METADATA_KEY]
|
|
129
|
+
assert meta["rewrite_reason"] == "blocked"
|
|
130
|
+
if seam == "tool":
|
|
131
|
+
# The tool seam annotates the delivered message with the whole
|
|
132
|
+
# receipt; the history seam records it on `.stats`.
|
|
133
|
+
assert meta["reason"] == Reason.BLOCKED
|
|
134
|
+
assert meta["outcome"] == "blocked"
|
|
135
|
+
assert meta["shadow"] is shadow
|
|
136
|
+
assert meta["governance"] == BLOCKED_GOVERNANCE
|
|
137
|
+
assert mw.stats.applied == 1 and mw.stats.reasons == {Reason.BLOCKED: 1}
|
|
138
|
+
|
|
139
|
+
|
|
46
140
|
# --------------------------------------------------------------------------- #
|
|
47
141
|
# wrap_tool_call
|
|
48
142
|
# --------------------------------------------------------------------------- #
|
|
@@ -205,6 +299,245 @@ def test_a_sanitized_packet_longer_than_the_original_still_lands(long_tool_messa
|
|
|
205
299
|
assert mw.stats.applied == 1
|
|
206
300
|
|
|
207
301
|
|
|
302
|
+
#: A real governance block, as the local engine returned it for an HR record
|
|
303
|
+
#: under a refunds-agent data-scope policy (workspace org_local_demo, 2026-11-04).
|
|
304
|
+
BLOCKED_GOVERNANCE = {
|
|
305
|
+
"enabled": True,
|
|
306
|
+
"provider_id": "nextmoca",
|
|
307
|
+
"policy_scope": "workspace",
|
|
308
|
+
"policy_revision": 20,
|
|
309
|
+
"records_before": 1,
|
|
310
|
+
"records_after": 0,
|
|
311
|
+
"records_blocked": 1,
|
|
312
|
+
"engine_records_selected": 1,
|
|
313
|
+
"engine_tokens_after": 189,
|
|
314
|
+
"engine_latency_ms": 11.409166269004345,
|
|
315
|
+
"policies_applied": 2,
|
|
316
|
+
"scope_policies_applied": 1,
|
|
317
|
+
"raw_context_fallback_allowed": False,
|
|
318
|
+
"category_counts": {"scope:source:hr": 1},
|
|
319
|
+
"output_tokens": 0,
|
|
320
|
+
"context_changed": True,
|
|
321
|
+
"governance_latency_ms": 114.95654238387942,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#: What a governed workspace returns alongside an ordinary engaged selection.
|
|
325
|
+
CLEAN_GOVERNANCE = {
|
|
326
|
+
"enabled": True,
|
|
327
|
+
"provider_id": "nextmoca",
|
|
328
|
+
"policy_scope": "workspace",
|
|
329
|
+
"policy_revision": 20,
|
|
330
|
+
"records_before": 1,
|
|
331
|
+
"records_after": 1,
|
|
332
|
+
"records_blocked": 0,
|
|
333
|
+
"policies_applied": 2,
|
|
334
|
+
"raw_context_fallback_allowed": False,
|
|
335
|
+
"category_counts": {},
|
|
336
|
+
"governance_latency_ms": 9.5,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def blocked_service(**overrides):
|
|
341
|
+
kwargs = dict(
|
|
342
|
+
selected=[],
|
|
343
|
+
records_selected=0,
|
|
344
|
+
rendered_context="",
|
|
345
|
+
outcome="blocked",
|
|
346
|
+
tokens_after=0,
|
|
347
|
+
governance=dict(BLOCKED_GOVERNANCE),
|
|
348
|
+
usage={
|
|
349
|
+
"meter_version": "npm-2026-08-r1",
|
|
350
|
+
"outcome": "pass_through",
|
|
351
|
+
"input_tokens": 232,
|
|
352
|
+
"charge_multiplier": "0",
|
|
353
|
+
"price_version": "npp-2026-08-r1",
|
|
354
|
+
"currency": "USD",
|
|
355
|
+
},
|
|
356
|
+
)
|
|
357
|
+
kwargs.update(overrides)
|
|
358
|
+
return echoing_service(**kwargs)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def test_a_blocked_tool_result_is_withheld_from_the_model():
|
|
362
|
+
"""`outcome: blocked` means workspace policy withheld every record. The
|
|
363
|
+
engine's verdict is installed as the tool result, a short notice, so the
|
|
364
|
+
agent continues without the record. The original must never be the
|
|
365
|
+
message the model sees, and the run must not abort."""
|
|
366
|
+
hr_record = ToolMessage(
|
|
367
|
+
content=big("employee Marcus Lee, SSN 123-45-6789, salary band L5"),
|
|
368
|
+
tool_call_id="call_hr",
|
|
369
|
+
name="lookup_employee",
|
|
370
|
+
)
|
|
371
|
+
mw, transport, _ = middleware([blocked_service()], operating_point=OP)
|
|
372
|
+
out = mw.wrap_tool_call(FakeToolCallRequest(name="lookup_employee"), lambda r: hr_record)
|
|
373
|
+
assert isinstance(out, ToolMessage)
|
|
374
|
+
assert out.tool_call_id == "call_hr"
|
|
375
|
+
assert "123-45-6789" not in out.text
|
|
376
|
+
assert out.text != hr_record.text
|
|
377
|
+
# The notice says what happened and names the engine's categories, nothing
|
|
378
|
+
# from the record itself.
|
|
379
|
+
assert "withheld" in out.text.lower()
|
|
380
|
+
assert "scope:source:hr" in out.text
|
|
381
|
+
assert transport.bodies[0]["request_id"] in out.text
|
|
382
|
+
meta = out.response_metadata[METADATA_KEY]
|
|
383
|
+
assert meta["rewritten"] is True
|
|
384
|
+
assert meta["rewrite_reason"] == "blocked"
|
|
385
|
+
assert meta["reason"] == Reason.BLOCKED
|
|
386
|
+
assert meta["applied"] is True
|
|
387
|
+
assert meta["outcome"] == "blocked"
|
|
388
|
+
assert meta["governance"] == BLOCKED_GOVERNANCE
|
|
389
|
+
assert meta["usage"]["outcome"] == "pass_through"
|
|
390
|
+
assert mw.stats.applied == 1 and mw.stats.reasons == {Reason.BLOCKED: 1}
|
|
391
|
+
assert mw.stats.failures == 0
|
|
392
|
+
assert mw.stats.tokens_after == approx_tokens(out.text)
|
|
393
|
+
assert mw.engine_stats.reasons == {Reason.BLOCKED: 1}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_a_blocked_tool_result_is_withheld_even_when_the_notice_is_longer():
|
|
397
|
+
"""The length guard exists to refuse a rewrite that saves nothing. A
|
|
398
|
+
withheld record is not a saving; refusing the notice on length would hand
|
|
399
|
+
the withheld record to the model."""
|
|
400
|
+
short = ToolMessage(content="SSN 123-45-6789", tool_call_id="call_hr", name="lookup_employee")
|
|
401
|
+
mw, _, _ = middleware([blocked_service()], operating_point=OP, tool_result_max_tokens=1)
|
|
402
|
+
out = mw.wrap_tool_call(FakeToolCallRequest(name="lookup_employee"), lambda r: short)
|
|
403
|
+
assert "123-45-6789" not in out.text
|
|
404
|
+
assert out.response_metadata[METADATA_KEY]["rewrite_reason"] == "blocked"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def test_a_blocked_tool_result_without_categories_still_names_the_policy():
|
|
408
|
+
governance = dict(BLOCKED_GOVERNANCE, category_counts={})
|
|
409
|
+
mw, _, _ = middleware([blocked_service(governance=governance)], operating_point=OP)
|
|
410
|
+
out = mw.wrap_tool_call(
|
|
411
|
+
FakeToolCallRequest(),
|
|
412
|
+
lambda r: ToolMessage(content=big("payload"), tool_call_id="c", name="t"),
|
|
413
|
+
)
|
|
414
|
+
assert "withheld" in out.text.lower()
|
|
415
|
+
assert "workspace policy" in out.text
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def test_a_blocked_tool_result_is_withheld_on_the_async_seam_too():
|
|
419
|
+
hr_record = ToolMessage(content=big("SSN 123-45-6789"), tool_call_id="call_hr", name="t")
|
|
420
|
+
mw, _, _ = middleware([blocked_service()], operating_point=OP)
|
|
421
|
+
|
|
422
|
+
async def handler(request):
|
|
423
|
+
return hr_record
|
|
424
|
+
|
|
425
|
+
out = asyncio.run(mw.awrap_tool_call(FakeToolCallRequest(), handler))
|
|
426
|
+
assert "123-45-6789" not in out.text
|
|
427
|
+
assert out.response_metadata[METADATA_KEY]["reason"] == Reason.BLOCKED
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_a_blocked_history_selection_withholds_every_considered_tool_result(history):
|
|
431
|
+
"""On the history seam a blocked verdict covers every record that was sent:
|
|
432
|
+
each collapses to the withheld notice, not to the selection placeholder and
|
|
433
|
+
never to its original text."""
|
|
434
|
+
mw, transport, _ = middleware([blocked_service()], operating_point=OP, history_max_tokens=100)
|
|
435
|
+
captured = {}
|
|
436
|
+
mw.wrap_model_call(FakeModelRequest(history), lambda r: captured.setdefault("r", r))
|
|
437
|
+
out = captured["r"].messages
|
|
438
|
+
assert len(out) == len(history)
|
|
439
|
+
for index in (2, 4):
|
|
440
|
+
assert out[index].text != history[index].text
|
|
441
|
+
assert "withheld" in out[index].text.lower()
|
|
442
|
+
assert "scope:source:hr" in out[index].text
|
|
443
|
+
assert out[index].tool_call_id == history[index].tool_call_id
|
|
444
|
+
assert out[0].content == history[0].content
|
|
445
|
+
assert mw.stats.applied == 1 and mw.stats.reasons == {Reason.BLOCKED: 1}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_a_blocked_history_selection_is_installed_even_when_the_notice_is_longer():
|
|
449
|
+
tiny = [
|
|
450
|
+
HumanMessage(content="q"),
|
|
451
|
+
AIMessage(
|
|
452
|
+
content="", tool_calls=[{"name": "t", "args": {}, "id": "c1", "type": "tool_call"}]
|
|
453
|
+
),
|
|
454
|
+
ToolMessage(content="SSN 123-45-6789", tool_call_id="c1", name="t"),
|
|
455
|
+
HumanMessage(content="and?"),
|
|
456
|
+
]
|
|
457
|
+
mw, _, _ = middleware([blocked_service()], operating_point=OP, history_max_tokens=1)
|
|
458
|
+
captured = {}
|
|
459
|
+
mw.wrap_model_call(FakeModelRequest(tiny), lambda r: captured.setdefault("r", r))
|
|
460
|
+
assert "123-45-6789" not in captured["r"].messages[2].text
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def test_shadow_still_withholds_a_blocked_tool_result(long_tool_message):
|
|
464
|
+
"""Shadow measures every delivery the caller could have had anyway; a
|
|
465
|
+
withheld record is not one of those. The middleware's own shadow flag,
|
|
466
|
+
which is belt and braces for every other reason, does not restore it:
|
|
467
|
+
the notice is installed and the receipt says this was a shadow run."""
|
|
468
|
+
mw, transport, _ = middleware([blocked_service()], operating_point=OP, shadow=True)
|
|
469
|
+
out = mw.wrap_tool_call(FakeToolCallRequest(), lambda r: long_tool_message)
|
|
470
|
+
assert len(transport.calls) == 1
|
|
471
|
+
assert out.text != long_tool_message.text
|
|
472
|
+
assert "withheld" in out.text.lower()
|
|
473
|
+
assert "scope:source:hr" in out.text
|
|
474
|
+
assert out.tool_call_id == long_tool_message.tool_call_id
|
|
475
|
+
meta = out.response_metadata[METADATA_KEY]
|
|
476
|
+
assert meta["applied"] is True
|
|
477
|
+
assert meta["shadow"] is True
|
|
478
|
+
assert meta["reason"] == Reason.BLOCKED
|
|
479
|
+
assert meta["rewrite_reason"] == "blocked"
|
|
480
|
+
assert meta["outcome"] == "blocked"
|
|
481
|
+
assert meta["governance"] == BLOCKED_GOVERNANCE
|
|
482
|
+
assert mw.stats.applied == 1 and mw.stats.reasons == {Reason.BLOCKED: 1}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def test_the_governance_block_lands_on_the_receipt_verbatim(long_tool_message):
|
|
486
|
+
"""The audit evidence a customer reads: carried as the engine sent it, on an
|
|
487
|
+
ordinary engaged selection as much as on a blocked one."""
|
|
488
|
+
mw, _, _ = middleware([echoing_service(governance=dict(CLEAN_GOVERNANCE))], operating_point=OP)
|
|
489
|
+
out = mw.wrap_tool_call(FakeToolCallRequest(), lambda r: long_tool_message)
|
|
490
|
+
meta = out.response_metadata[METADATA_KEY]
|
|
491
|
+
assert meta["applied"] is True
|
|
492
|
+
assert meta["governance"] == CLEAN_GOVERNANCE
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
#: What a governed workspace returns when no policy applied to the request:
|
|
496
|
+
#: governance is on, nothing matched, and the raw context is permitted.
|
|
497
|
+
UNGOVERNED_GOVERNANCE = {
|
|
498
|
+
"enabled": True,
|
|
499
|
+
"provider_id": "nextmoca",
|
|
500
|
+
"policy_scope": "workspace",
|
|
501
|
+
"policy_revision": 20,
|
|
502
|
+
"records_before": 1,
|
|
503
|
+
"records_after": 1,
|
|
504
|
+
"records_blocked": 0,
|
|
505
|
+
"policies_applied": 0,
|
|
506
|
+
"raw_context_fallback_allowed": True,
|
|
507
|
+
"category_counts": {},
|
|
508
|
+
"governance_latency_ms": 9.5,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def test_the_governance_block_lands_on_a_passthrough_receipt_too(long_tool_message):
|
|
513
|
+
mw, _, _ = middleware(
|
|
514
|
+
[echoing_service(selected=[], records_selected=0, governance=dict(UNGOVERNED_GOVERNANCE))],
|
|
515
|
+
operating_point=OP,
|
|
516
|
+
)
|
|
517
|
+
out = mw.wrap_tool_call(FakeToolCallRequest(), lambda r: long_tool_message)
|
|
518
|
+
meta = out.response_metadata[METADATA_KEY]
|
|
519
|
+
assert meta["applied"] is False
|
|
520
|
+
assert meta["governance"] == UNGOVERNED_GOVERNANCE
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def test_an_empty_selection_under_workspace_protection_stops_the_call(long_tool_message):
|
|
524
|
+
"""Policies applied, raw fallback forbidden, and nothing to apply: that is
|
|
525
|
+
not a blocked verdict, so there is no empty delivery to install, and a
|
|
526
|
+
passthrough would deliver the raw record. The call stops instead."""
|
|
527
|
+
mw, _, _ = middleware(
|
|
528
|
+
[echoing_service(selected=[], records_selected=0, governance=dict(CLEAN_GOVERNANCE))],
|
|
529
|
+
operating_point=OP,
|
|
530
|
+
)
|
|
531
|
+
with pytest.raises(NeedlepathGuardrailError):
|
|
532
|
+
mw.wrap_tool_call(FakeToolCallRequest(), lambda r: long_tool_message)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def test_no_governance_block_means_no_governance_key(long_tool_message):
|
|
536
|
+
mw, _, _ = middleware([echoing_service()], operating_point=OP)
|
|
537
|
+
out = mw.wrap_tool_call(FakeToolCallRequest(), lambda r: long_tool_message)
|
|
538
|
+
assert "governance" not in out.response_metadata[METADATA_KEY]
|
|
539
|
+
|
|
540
|
+
|
|
208
541
|
def test_an_excerpt_longer_than_the_original_is_refused(long_tool_message):
|
|
209
542
|
"""Whatever produced it, applying it is a regression."""
|
|
210
543
|
longer = [
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/system_one.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|