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.
Files changed (18) hide show
  1. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/CHANGELOG.md +40 -18
  2. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/PKG-INFO +2 -2
  3. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/pyproject.toml +2 -2
  4. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/_messages.py +34 -1
  5. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/langgraph.py +25 -6
  6. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/middleware.py +57 -23
  7. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/version.py +1 -1
  8. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/tests/test_langgraph_recipe.py +85 -0
  9. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/tests/test_middleware.py +335 -2
  10. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/.gitignore +0 -0
  11. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/LICENSE +0 -0
  12. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/README.md +0 -0
  13. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/__init__.py +0 -0
  14. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/py.typed +0 -0
  15. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/src/needlepath_langchain/system_one.py +0 -0
  16. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/tests/conftest.py +0 -0
  17. {needlepath_langchain-0.2.2 → needlepath_langchain-0.2.3}/tests/test_end_to_end.py +0 -0
  18. {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.2
5
+ ## 0.2.3 — 2026-11-04
6
6
 
7
7
  ### Fixed
8
8
 
9
- - **Tool-result seam applies a `sanitized_fallback`.** The service answers a
10
- screened tool result with one model-facing packet (`rendered_context`, the
11
- record with the offending span removed and marked in place) and no per-record
12
- excerpt. The seam declined that packet as `NO_SAVING` and delivered the
13
- unscreened original to the model. It now applies the packet
14
- (`rewrite_reason: "sanitized"`), and the length guard is skipped for screened
15
- packets, which are applied for what they removed, not for what they saved.
16
- - **Receipt on the message** (`response_metadata["needlepath"]`) now also carries
17
- `outcome`, `security_action` and `usage`, so an agent trace holds the same
18
- receipt the console shows.
19
-
20
- ## Unreleased
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` with `needlepath.integrations.system_one`**, which the
41
- unreleased core adds. The dependency floor here still reads
42
- `needlepath>=0.1.0,<1.0.0` and **must be raised to the core release that
43
- carries that module before this package is published**, or a resolver is free
44
- to pair this release with a core that cannot import it. Core releases first.
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.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.1.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.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.1.0,<1.0.0",
32
+ "needlepath>=0.2.3,<1.0.0",
33
33
  "langchain>=1.2.9,<2.0.0",
34
34
  ]
35
35
 
@@ -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",
@@ -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": [...]}`` on every pass without exception,
125
- and the list is never empty — see edges 2 and 3 in the module docstring.
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
- if not selection.applied or self.shadow:
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
- placeholder=self.placeholder,
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.
@@ -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
- Both fail open. Both stand down below a trigger. Neither ever removes a message.
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 for a LangChain agent, failing open on every error path.
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 already guarantees that ``select()`` never raises, so
312
- the service being slow, down, or wrong is handled a layer below this.
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
- placeholder=self.placeholder,
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
 
@@ -1,3 +1,3 @@
1
- __version__ = "0.2.2"
1
+ __version__ = "0.2.3"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -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 = [