langchain-replylayer 0.1.0__tar.gz → 0.2.0__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 (24) hide show
  1. langchain_replylayer-0.2.0/LICENSE +21 -0
  2. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/PKG-INFO +59 -5
  3. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/README.md +57 -4
  4. langchain_replylayer-0.2.0/examples/langchain_retrieval_quickstart.py +194 -0
  5. langchain_replylayer-0.2.0/langchain_replylayer/__init__.py +29 -0
  6. langchain_replylayer-0.2.0/langchain_replylayer/_clients.py +69 -0
  7. langchain_replylayer-0.2.0/langchain_replylayer/_documents.py +213 -0
  8. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/langchain_replylayer/_governance.py +2 -2
  9. langchain_replylayer-0.2.0/langchain_replylayer/loader.py +215 -0
  10. langchain_replylayer-0.2.0/langchain_replylayer/retriever.py +242 -0
  11. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/langchain_replylayer/toolkit.py +26 -32
  12. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/pyproject.toml +1 -1
  13. langchain_replylayer-0.2.0/tests/test_documents.py +324 -0
  14. langchain_replylayer-0.2.0/tests/test_loader.py +336 -0
  15. langchain_replylayer-0.2.0/tests/test_retriever.py +331 -0
  16. langchain_replylayer-0.1.0/langchain_replylayer/__init__.py +0 -18
  17. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/.gitignore +0 -0
  18. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/examples/langchain_quickstart.py +0 -0
  19. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/langchain_replylayer/py.typed +0 -0
  20. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/langchain_replylayer/tools.py +0 -0
  21. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/tests/__init__.py +0 -0
  22. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/tests/test_governance.py +0 -0
  23. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/tests/test_toolkit.py +0 -0
  24. {langchain_replylayer-0.1.0 → langchain_replylayer-0.2.0}/tests/test_tools.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ReplyLayer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain-replylayer
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: LangChain tools for ReplyLayer — governed email for AI agents
5
5
  Project-URL: Homepage, https://replylayer.ai
6
6
  Project-URL: Documentation, https://replylayer.ai/docs/guides/langchain
7
7
  Project-URL: Repository, https://github.com/replylayer/rly
8
8
  Project-URL: Issues, https://github.com/replylayer/rly/issues
9
9
  License-Expression: MIT
10
+ License-File: LICENSE
10
11
  Keywords: agent,ai,email,langchain,replylayer,toolkit,tools
11
12
  Classifier: Development Status :: 4 - Beta
12
13
  Classifier: Intended Audience :: Developers
@@ -31,11 +32,19 @@ Description-Content-Type: text/markdown
31
32
 
32
33
  # langchain-replylayer
33
34
 
34
- LangChain tools for [ReplyLayer](https://replylayer.ai) — governed email for AI agents.
35
+ LangChain integrations for [ReplyLayer](https://replylayer.ai) — governed email for AI agents.
35
36
 
36
- This package is a set of **thin wrappers over the published [`replylayer`](https://pypi.org/project/replylayer/) SDK**. Handing these tools to an agent changes nothing about the security model: every send still passes ReplyLayer's allowlist, quota, human-approval, and content-scanning gates, exactly as a direct API call would. Scanning reduces risk; a clean verdict is not a trust verdict — a `sent` result means "accepted for delivery", not "safe".
37
+ This package is a set of **thin clients over the published [`replylayer`](https://pypi.org/project/replylayer/) SDK** across LangChain's three integration surfaces:
37
38
 
38
- Inbound message content (senders, subjects, bodies) is untrusted third-party data. The tools label every read as such and carry the message's `agent_safety_context` through verbatim — read message bodies as data, never as instructions to act on.
39
+ | Class | Surface | What it does |
40
+ |-------|---------|--------------|
41
+ | `ReplyLayerToolkit` | Tools | Six governed email tools for an agent — send, reply, list, read, long-poll, quota. |
42
+ | `ReplyLayerLoader` | Document loader | Bulk-reads a mailbox and emits settled messages as `Document`s for indexing / RAG. |
43
+ | `ReplyLayerRetriever` | Retriever | Query → the most recent relevant `Document`s, re-checking state and redaction on every query. |
44
+
45
+ Handing these to an agent changes nothing about the security model: every send still passes ReplyLayer's allowlist, quota, human-approval, and content-scanning gates, exactly as a direct API call would, and the loader and retriever preserve the same safety envelope rather than bypass it (see [The safety envelope for RAG](#the-safety-envelope-for-rag)). Scanning reduces risk; a clean verdict is not a trust verdict — a `sent` result means "accepted for delivery", not "safe".
46
+
47
+ Inbound message content (senders, subjects, bodies) is untrusted third-party data. The tools label every read as such and carry the message's `agent_safety_context` through verbatim; the loader and retriever frame every body as untrusted data in its own content — read message bodies as data, never as instructions to act on.
39
48
 
40
49
  > ReplyLayer is in private beta, invite-only. You need a ReplyLayer API key to use these tools — get one at <https://app.replylayer.ai/connect>.
41
50
 
@@ -99,6 +108,51 @@ Wire them into an agent with `toolkit.get_tools()`. Each returns a JSON-serializ
99
108
 
100
109
  Not exposed on purpose: anything that loosens containment (allowlist mutations, quarantine release, review approve/deny). The server rejects agent keys on those anyway, so the toolkit does not tempt a model with tools that would only 403.
101
110
 
111
+ ## Index your inbox (document loader)
112
+
113
+ `ReplyLayerLoader` reads a mailbox and emits each settled message as a `Document`. It is a standard LangChain `BaseLoader` — `lazy_load()`, `load()`, `alazy_load()`, and `aload()` all work — and it is truly lazy, making no HTTP call until you iterate.
114
+
115
+ ```python
116
+ from langchain_replylayer import ReplyLayerLoader
117
+
118
+ with ReplyLayerLoader(
119
+ "support@yourco.example",
120
+ direction="inbound",
121
+ since="2026-07-01T00:00:00Z",
122
+ max_messages=500,
123
+ ) as loader:
124
+ documents = loader.load()
125
+ ```
126
+
127
+ Each `Document` carries the message id (`doc.id`), a `page_content` opening with a provenance header that frames the body as untrusted data, and flat metadata: `source`, `message_id`, `mailbox_id`, `thread_id`, `direction`, `state`, `sender`, `recipient`, `subject`, `created_at`, `scan_verdict`, `untrusted_content`, `body_truncated`, `char_count`, `returned_char_count`, and `has_attachments`. Emission is grouped by state, newest-first within each group; each unique message costs one audited read and no `Document.id` repeats within a run.
128
+
129
+ Constructor: `ReplyLayerLoader(mailbox_id, *, api_key=None, base_url=..., direction=None, since=None, until=None, unread=None, max_messages=None, include_provenance_header=True, on_truncated="include")`. `max_messages=0` yields nothing; a negative value raises. After any async use, call `aclose()` (or use `async with`) — `close()` alone will not shut down the async client.
130
+
131
+ ## Search your inbox (retriever)
132
+
133
+ `ReplyLayerRetriever` is a live, search-backed retriever. Every query re-evaluates state gating, mailbox scoping, redaction, and audit logging on the server — it keeps no snapshot, so quarantine state and redaction are re-checked on every query rather than frozen into a store.
134
+
135
+ ```python
136
+ from langchain_replylayer import ReplyLayerRetriever
137
+
138
+ with ReplyLayerRetriever(mailbox_id="support@yourco.example", k=5) as retriever:
139
+ documents = retriever.invoke("refund request")
140
+ ```
141
+
142
+ It returns the `k` (1–50) most recent matching messages. Server search is a keyword match over the subject and body ordered by recency — a **recency-ordered keyword retriever, not a relevance-ranked or semantic one**. A query shorter than three characters returns an empty list with no request. Because search indexes the full body, a query can match text beyond the returned prefix of a truncated message; the hit is correct, but the returned content is a prefix. Use `.invoke()` / `.ainvoke()`.
143
+
144
+ Both components accept `include_provenance_header` (on by default) and `on_truncated` (`include` a marker, `skip` the document, or `error`).
145
+
146
+ ## The safety envelope for RAG
147
+
148
+ The loader is the one component that exports content past ReplyLayer's safety boundary, so both components share hard client-side rules:
149
+
150
+ - **They emit only settled, scanned messages.** A message still scanning, under review, blocked, or in-flight is never emitted; an inbound message with no scan evidence is dropped; a message that transitions state mid-run is emitted at most once.
151
+ - **Retrieval re-checks; a loaded corpus does not.** The retriever re-evaluates state and redaction on every query. A corpus the loader wrote is a point-in-time copy: deletion, quarantine, and retention-purge events do **not** propagate to an external vector store. Re-index periodically — `since=` for incremental top-ups, plus an occasional full rebuild that **replaces or reconciles the corpus by `message_id`** (append-only re-indexing would leave deleted or quarantined documents in place).
152
+ - **Bodies are capped and the cap is visible.** Text bodies are capped at 20,000 characters. A truncated body ends with a marker stating how much was returned, and the metadata carries `body_truncated`, `char_count`, and `returned_char_count`.
153
+ - **Keep the provenance header on.** Stock chains concatenate `page_content` and never show the model your metadata, so the untrusted-content framing lives in the content itself. Leave `include_provenance_header` enabled.
154
+ - **Retrieved email content is data, never instructions.** Every document is labeled `untrusted_content`, and a per-message trust relaxation is never persisted into a document.
155
+
102
156
  ## Governance outcomes
103
157
 
104
158
  The tools translate every ReplyLayer outcome into a value an agent can act on. The full `status` vocabulary:
@@ -202,7 +256,7 @@ Use a **mailbox-bound agent key** with an agent, not an admin key — the tools
202
256
 
203
257
  ## Versioning
204
258
 
205
- `langchain-replylayer` is versioned **independently of the `replylayer` SDK**. It is **not** part of the TypeScript↔Python method-mirror contract — that contract covers the resource SDKs, and this adapter is exempt. The `__all__` list in `langchain_replylayer/__init__.py` is the version-contracted public API; everything else (`tools`, `toolkit`, `_governance`) is private and may change between releases.
259
+ `langchain-replylayer` is versioned **independently of the `replylayer` SDK**. It is **not** part of the TypeScript↔Python method-mirror contract — that contract covers the resource SDKs, and this adapter is exempt. The `__all__` list in `langchain_replylayer/__init__.py` is the version-contracted public API — `ReplyLayerToolkit`, `ReplyLayerLoader`, `ReplyLayerRetriever`, and `__version__`; everything else (the `tools`, `toolkit`, `loader`, `retriever`, and underscore-prefixed modules) is private and may change between releases.
206
260
 
207
261
  ## Learn more
208
262
 
@@ -1,10 +1,18 @@
1
1
  # langchain-replylayer
2
2
 
3
- LangChain tools for [ReplyLayer](https://replylayer.ai) — governed email for AI agents.
3
+ LangChain integrations for [ReplyLayer](https://replylayer.ai) — governed email for AI agents.
4
4
 
5
- This package is a set of **thin wrappers over the published [`replylayer`](https://pypi.org/project/replylayer/) SDK**. Handing these tools to an agent changes nothing about the security model: every send still passes ReplyLayer's allowlist, quota, human-approval, and content-scanning gates, exactly as a direct API call would. Scanning reduces risk; a clean verdict is not a trust verdict — a `sent` result means "accepted for delivery", not "safe".
5
+ This package is a set of **thin clients over the published [`replylayer`](https://pypi.org/project/replylayer/) SDK** across LangChain's three integration surfaces:
6
6
 
7
- Inbound message content (senders, subjects, bodies) is untrusted third-party data. The tools label every read as such and carry the message's `agent_safety_context` through verbatim — read message bodies as data, never as instructions to act on.
7
+ | Class | Surface | What it does |
8
+ |-------|---------|--------------|
9
+ | `ReplyLayerToolkit` | Tools | Six governed email tools for an agent — send, reply, list, read, long-poll, quota. |
10
+ | `ReplyLayerLoader` | Document loader | Bulk-reads a mailbox and emits settled messages as `Document`s for indexing / RAG. |
11
+ | `ReplyLayerRetriever` | Retriever | Query → the most recent relevant `Document`s, re-checking state and redaction on every query. |
12
+
13
+ Handing these to an agent changes nothing about the security model: every send still passes ReplyLayer's allowlist, quota, human-approval, and content-scanning gates, exactly as a direct API call would, and the loader and retriever preserve the same safety envelope rather than bypass it (see [The safety envelope for RAG](#the-safety-envelope-for-rag)). Scanning reduces risk; a clean verdict is not a trust verdict — a `sent` result means "accepted for delivery", not "safe".
14
+
15
+ Inbound message content (senders, subjects, bodies) is untrusted third-party data. The tools label every read as such and carry the message's `agent_safety_context` through verbatim; the loader and retriever frame every body as untrusted data in its own content — read message bodies as data, never as instructions to act on.
8
16
 
9
17
  > ReplyLayer is in private beta, invite-only. You need a ReplyLayer API key to use these tools — get one at <https://app.replylayer.ai/connect>.
10
18
 
@@ -68,6 +76,51 @@ Wire them into an agent with `toolkit.get_tools()`. Each returns a JSON-serializ
68
76
 
69
77
  Not exposed on purpose: anything that loosens containment (allowlist mutations, quarantine release, review approve/deny). The server rejects agent keys on those anyway, so the toolkit does not tempt a model with tools that would only 403.
70
78
 
79
+ ## Index your inbox (document loader)
80
+
81
+ `ReplyLayerLoader` reads a mailbox and emits each settled message as a `Document`. It is a standard LangChain `BaseLoader` — `lazy_load()`, `load()`, `alazy_load()`, and `aload()` all work — and it is truly lazy, making no HTTP call until you iterate.
82
+
83
+ ```python
84
+ from langchain_replylayer import ReplyLayerLoader
85
+
86
+ with ReplyLayerLoader(
87
+ "support@yourco.example",
88
+ direction="inbound",
89
+ since="2026-07-01T00:00:00Z",
90
+ max_messages=500,
91
+ ) as loader:
92
+ documents = loader.load()
93
+ ```
94
+
95
+ Each `Document` carries the message id (`doc.id`), a `page_content` opening with a provenance header that frames the body as untrusted data, and flat metadata: `source`, `message_id`, `mailbox_id`, `thread_id`, `direction`, `state`, `sender`, `recipient`, `subject`, `created_at`, `scan_verdict`, `untrusted_content`, `body_truncated`, `char_count`, `returned_char_count`, and `has_attachments`. Emission is grouped by state, newest-first within each group; each unique message costs one audited read and no `Document.id` repeats within a run.
96
+
97
+ Constructor: `ReplyLayerLoader(mailbox_id, *, api_key=None, base_url=..., direction=None, since=None, until=None, unread=None, max_messages=None, include_provenance_header=True, on_truncated="include")`. `max_messages=0` yields nothing; a negative value raises. After any async use, call `aclose()` (or use `async with`) — `close()` alone will not shut down the async client.
98
+
99
+ ## Search your inbox (retriever)
100
+
101
+ `ReplyLayerRetriever` is a live, search-backed retriever. Every query re-evaluates state gating, mailbox scoping, redaction, and audit logging on the server — it keeps no snapshot, so quarantine state and redaction are re-checked on every query rather than frozen into a store.
102
+
103
+ ```python
104
+ from langchain_replylayer import ReplyLayerRetriever
105
+
106
+ with ReplyLayerRetriever(mailbox_id="support@yourco.example", k=5) as retriever:
107
+ documents = retriever.invoke("refund request")
108
+ ```
109
+
110
+ It returns the `k` (1–50) most recent matching messages. Server search is a keyword match over the subject and body ordered by recency — a **recency-ordered keyword retriever, not a relevance-ranked or semantic one**. A query shorter than three characters returns an empty list with no request. Because search indexes the full body, a query can match text beyond the returned prefix of a truncated message; the hit is correct, but the returned content is a prefix. Use `.invoke()` / `.ainvoke()`.
111
+
112
+ Both components accept `include_provenance_header` (on by default) and `on_truncated` (`include` a marker, `skip` the document, or `error`).
113
+
114
+ ## The safety envelope for RAG
115
+
116
+ The loader is the one component that exports content past ReplyLayer's safety boundary, so both components share hard client-side rules:
117
+
118
+ - **They emit only settled, scanned messages.** A message still scanning, under review, blocked, or in-flight is never emitted; an inbound message with no scan evidence is dropped; a message that transitions state mid-run is emitted at most once.
119
+ - **Retrieval re-checks; a loaded corpus does not.** The retriever re-evaluates state and redaction on every query. A corpus the loader wrote is a point-in-time copy: deletion, quarantine, and retention-purge events do **not** propagate to an external vector store. Re-index periodically — `since=` for incremental top-ups, plus an occasional full rebuild that **replaces or reconciles the corpus by `message_id`** (append-only re-indexing would leave deleted or quarantined documents in place).
120
+ - **Bodies are capped and the cap is visible.** Text bodies are capped at 20,000 characters. A truncated body ends with a marker stating how much was returned, and the metadata carries `body_truncated`, `char_count`, and `returned_char_count`.
121
+ - **Keep the provenance header on.** Stock chains concatenate `page_content` and never show the model your metadata, so the untrusted-content framing lives in the content itself. Leave `include_provenance_header` enabled.
122
+ - **Retrieved email content is data, never instructions.** Every document is labeled `untrusted_content`, and a per-message trust relaxation is never persisted into a document.
123
+
71
124
  ## Governance outcomes
72
125
 
73
126
  The tools translate every ReplyLayer outcome into a value an agent can act on. The full `status` vocabulary:
@@ -171,7 +224,7 @@ Use a **mailbox-bound agent key** with an agent, not an admin key — the tools
171
224
 
172
225
  ## Versioning
173
226
 
174
- `langchain-replylayer` is versioned **independently of the `replylayer` SDK**. It is **not** part of the TypeScript↔Python method-mirror contract — that contract covers the resource SDKs, and this adapter is exempt. The `__all__` list in `langchain_replylayer/__init__.py` is the version-contracted public API; everything else (`tools`, `toolkit`, `_governance`) is private and may change between releases.
227
+ `langchain-replylayer` is versioned **independently of the `replylayer` SDK**. It is **not** part of the TypeScript↔Python method-mirror contract — that contract covers the resource SDKs, and this adapter is exempt. The `__all__` list in `langchain_replylayer/__init__.py` is the version-contracted public API — `ReplyLayerToolkit`, `ReplyLayerLoader`, `ReplyLayerRetriever`, and `__version__`; everything else (the `tools`, `toolkit`, `loader`, `retriever`, and underscore-prefixed modules) is private and may change between releases.
175
228
 
176
229
  ## Learn more
177
230
 
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """ReplyLayer + LangChain retrieval gate — loader + retriever against staging.
3
+
4
+ An operator-run acceptance check for ``ReplyLayerLoader`` and
5
+ ``ReplyLayerRetriever``. No language model and no second credential: it seeds two
6
+ known messages through the simulator, waits for both to reach their terminal
7
+ states, then asserts the loader and retriever emit the safe one and never the
8
+ quarantined one.
9
+
10
+ It seeds via a raw HTTP call to ``POST /v1/simulator/inbound`` (the same
11
+ mechanism the tools quickstart's gate uses), passing a unique per-run ``label``
12
+ that lands in each seeded message's subject so this run can find exactly its own
13
+ messages — even though the simulator may answer ``pending`` without an id.
14
+
15
+ Run against a STAGING sandbox mailbox with a mailbox-bound *agent* key, never a
16
+ production or admin key. Because it WRITES (seeds two simulator messages),
17
+ ``REPLYLAYER_BASE_URL`` is REQUIRED and has no default — the script refuses to
18
+ run without an explicit staging base, and refuses a base whose host is the
19
+ production API (``api.replylayer.ai``) unless an explicit override is set.
20
+
21
+ Environment
22
+ -----------
23
+ REPLYLAYER_API_KEY (required) a staging sandbox, mailbox-bound agent key.
24
+ REPLYLAYER_MAILBOX (required) the target mailbox id or name.
25
+ REPLYLAYER_BASE_URL (required) the staging API base (e.g.
26
+ ``https://<staging-host>.up.railway.app``). No
27
+ default — this script seeds messages, so it
28
+ will not fall back to production.
29
+ REPLYLAYER_QUICKSTART_ALLOW_PRODUCTION
30
+ (guard) must equal the literal
31
+ ``I_UNDERSTAND_THIS_SEEDS_PRODUCTION`` to permit
32
+ a base whose host is ``api.replylayer.ai``;
33
+ otherwise the script refuses to seed production.
34
+
35
+ pip install "langchain-replylayer"
36
+ REPLYLAYER_BASE_URL=https://<staging-host> \
37
+ python examples/langchain_retrieval_quickstart.py
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import os
42
+ import secrets
43
+ import time
44
+ import urllib.parse
45
+
46
+ import httpx
47
+
48
+ from replylayer import ReplyLayer
49
+
50
+ from langchain_replylayer import ReplyLayerLoader, ReplyLayerRetriever
51
+
52
+ BASE_URL = os.environ.get("REPLYLAYER_BASE_URL", "")
53
+ MAILBOX = os.environ.get("REPLYLAYER_MAILBOX", "")
54
+
55
+ # This script seeds production-shaped writes, so it must never default to
56
+ # production. The host it refuses (unless the operator opts in) and the exact
57
+ # opt-in token:
58
+ _PRODUCTION_HOST = "api.replylayer.ai"
59
+ _ALLOW_PRODUCTION_TOKEN = "I_UNDERSTAND_THIS_SEEDS_PRODUCTION"
60
+
61
+ # The header a projected inbound document opens with; a loaded clean message must
62
+ # carry it, proving the untrusted-content framing rode along in page_content.
63
+ _INBOUND_HEADER_MARK = "inbound message from an external sender"
64
+
65
+ # Poll bound: both seeded messages must reach a TERMINAL state (clean ->
66
+ # available, quarantined -> quarantined) before the assertions run — otherwise
67
+ # "quarantined absent" could pass merely because the row is still scanning.
68
+ _POLL_DEADLINE_SECONDS = 60
69
+ _POLL_INTERVAL_SECONDS = 3
70
+
71
+
72
+ def _seed(api_key: str, scenario: str, label: str) -> None:
73
+ """Raw HTTP seed of one simulator message carrying the run label."""
74
+ response = httpx.post(
75
+ f"{BASE_URL}/v1/simulator/inbound",
76
+ headers={"Authorization": f"Bearer {api_key}"},
77
+ json={"mailbox_id": MAILBOX, "scenario": scenario, "label": label},
78
+ timeout=30.0,
79
+ )
80
+ response.raise_for_status()
81
+ print(f" seeded {scenario} -> {response.json().get('status')}")
82
+
83
+
84
+ def _await_both_terminal(client: ReplyLayer, label: str) -> bool:
85
+ """Poll until BOTH the clean (available) and quarantined rows carrying the
86
+ label are present, or the deadline passes. Returns True on success."""
87
+ deadline = time.monotonic() + _POLL_DEADLINE_SECONDS
88
+ while time.monotonic() < deadline:
89
+ available = client.messages.list(MAILBOX, status="available", search=label).data
90
+ quarantined = client.messages.list(
91
+ MAILBOX, status="quarantined", search=label
92
+ ).data
93
+ if available and quarantined:
94
+ return True
95
+ time.sleep(_POLL_INTERVAL_SECONDS)
96
+ return False
97
+
98
+
99
+ def _has_instruction_trust(metadata: dict) -> bool:
100
+ """True if any key named ``instruction_trust`` exists at any depth."""
101
+ stack = [metadata]
102
+ while stack:
103
+ current = stack.pop()
104
+ if isinstance(current, dict):
105
+ if "instruction_trust" in current:
106
+ return True
107
+ stack.extend(current.values())
108
+ elif isinstance(current, (list, tuple)):
109
+ stack.extend(current)
110
+ return False
111
+
112
+
113
+ def main() -> int:
114
+ if not BASE_URL:
115
+ print(
116
+ "Set REPLYLAYER_BASE_URL to your STAGING API base first — this script "
117
+ "WRITES (seeds two simulator messages) and has no production default."
118
+ )
119
+ return 1
120
+ if (
121
+ urllib.parse.urlparse(BASE_URL).hostname == _PRODUCTION_HOST
122
+ and os.environ.get("REPLYLAYER_QUICKSTART_ALLOW_PRODUCTION")
123
+ != _ALLOW_PRODUCTION_TOKEN
124
+ ):
125
+ print(
126
+ f"Refusing to run against production ({BASE_URL}): this script seeds "
127
+ "two simulator messages into the target mailbox. To override, set "
128
+ f"REPLYLAYER_QUICKSTART_ALLOW_PRODUCTION={_ALLOW_PRODUCTION_TOKEN}."
129
+ )
130
+ return 1
131
+
132
+ api_key = os.environ.get("REPLYLAYER_API_KEY")
133
+ if not api_key:
134
+ print("Set REPLYLAYER_API_KEY (a staging sandbox, mailbox-bound agent key) first.")
135
+ return 1
136
+ if not MAILBOX:
137
+ print("Set REPLYLAYER_MAILBOX to the target mailbox id or name first.")
138
+ return 1
139
+
140
+ label = secrets.token_hex(4) # 8 hex chars — unique per run, >= 3 chars.
141
+ print(f"ReplyLayer LangChain retrieval gate against {BASE_URL}")
142
+ print(f" mailbox: {MAILBOX} | run label: {label}")
143
+
144
+ print("\n1) seed a clean and a quarantined message:")
145
+ _seed(api_key, "clean", label)
146
+ _seed(api_key, "prompt_injection_quarantined", label)
147
+
148
+ print("\n2) wait for both to reach terminal states:")
149
+ with ReplyLayer(api_key=api_key, base_url=BASE_URL) as client:
150
+ if not _await_both_terminal(client, label):
151
+ print(" FAIL: both terminal states not reached within the deadline.")
152
+ return 1
153
+ print(" both terminal — proceeding to assertions.")
154
+
155
+ print("\n3) loader: the quarantined message is absent, the clean one present:")
156
+ with ReplyLayerLoader(
157
+ MAILBOX, api_key=api_key, base_url=BASE_URL, direction="inbound"
158
+ ) as loader:
159
+ docs = [d for d in loader.load() if label in (d.metadata.get("subject") or "")]
160
+
161
+ subjects = [d.metadata.get("subject") for d in docs]
162
+ assert docs, f"loader emitted no labelled document (label={label})"
163
+ assert all(
164
+ "quarantine" not in (s or "").lower() for s in subjects
165
+ ), f"loader emitted a quarantined message: {subjects}"
166
+ clean = next((d for d in docs if "clean" in (d.metadata.get("subject") or "").lower()), None)
167
+ assert clean is not None, f"loader did not emit the clean message: {subjects}"
168
+ assert _INBOUND_HEADER_MARK in clean.page_content, "clean doc missing the inbound header"
169
+ assert not any(_has_instruction_trust(d.metadata) for d in docs), (
170
+ "an emitted document carried an instruction_trust metadata key"
171
+ )
172
+ print(f" loader emitted {len(docs)} labelled doc(s); quarantined absent; header present.")
173
+
174
+ print("\n4) retriever: the clean message is a hit for the run label:")
175
+ with ReplyLayerRetriever(
176
+ api_key=api_key, base_url=BASE_URL, mailbox_id=MAILBOX, k=5
177
+ ) as retriever:
178
+ hits = retriever.invoke(label)
179
+
180
+ hit_subjects = [h.metadata.get("subject") for h in hits]
181
+ assert any(
182
+ "clean" in (s or "").lower() for s in hit_subjects
183
+ ), f"retriever did not return the clean hit: {hit_subjects}"
184
+ assert not any(_has_instruction_trust(h.metadata) for h in hits), (
185
+ "a retrieved document carried an instruction_trust metadata key"
186
+ )
187
+ print(f" retriever returned {len(hits)} hit(s); clean message present.")
188
+
189
+ print("\nGate passed.")
190
+ return 0
191
+
192
+
193
+ if __name__ == "__main__":
194
+ raise SystemExit(main())
@@ -0,0 +1,29 @@
1
+ """LangChain integrations for ReplyLayer — governed email for AI agents.
2
+
3
+ Thin clients over the published ``replylayer`` SDK across LangChain's three
4
+ integration surfaces: the ``ReplyLayerToolkit`` (agent action tools), the
5
+ ``ReplyLayerLoader`` (bulk-read an inbox as ``Document``s for indexing), and the
6
+ ``ReplyLayerRetriever`` (query -> relevant ``Document``s, re-checking state and
7
+ redaction on every query). The loader and retriever preserve ReplyLayer's safety
8
+ envelope rather than bypass it: they emit only settled-state messages with scan
9
+ evidence for inbound mail, never persist a per-message trust relaxation, and
10
+ frame every body as untrusted data. This package is versioned independently of
11
+ the ``replylayer`` SDK and is not part of the TypeScript<->Python method-mirror
12
+ contract.
13
+
14
+ ``__all__`` below is the version-contracted public API; everything else
15
+ (``tools``, ``toolkit``, ``loader``, ``retriever``, and the underscore-prefixed
16
+ modules) is private.
17
+ """
18
+ from .loader import ReplyLayerLoader
19
+ from .retriever import ReplyLayerRetriever
20
+ from .toolkit import ReplyLayerToolkit
21
+
22
+ __version__ = "0.2.0"
23
+
24
+ __all__ = [
25
+ "ReplyLayerToolkit",
26
+ "ReplyLayerLoader",
27
+ "ReplyLayerRetriever",
28
+ "__version__",
29
+ ]
@@ -0,0 +1,69 @@
1
+ """Lazy sync+async ReplyLayer client pair with a shared lifecycle.
2
+
3
+ Not a pydantic model — the toolkit, retriever, and loader each hold one as a
4
+ private attribute. Both clients are built with ``strict_outcome=True`` and
5
+ created lazily, so a sync-only caller never constructs the async client. The
6
+ governed-outcome mapping the send tools rely on only applies when the server
7
+ sees ``Prefer: outcome=strict``, which ``strict_outcome=True`` sends.
8
+
9
+ ``close()`` releases the sync client; ``aclose()`` releases both. After either,
10
+ the pair is closed and any further client access raises ``RuntimeError`` — a
11
+ reused-after-close object fails loudly instead of handing back a live client.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ from replylayer import AsyncReplyLayer, ReplyLayer
18
+
19
+
20
+ class _ClientPair:
21
+ def __init__(self, *, api_key: str, base_url: str, owner: str) -> None:
22
+ self._api_key = api_key
23
+ self._base_url = base_url
24
+ # Names the owner in the closed-guard error so the message reads naturally
25
+ # (e.g. "ReplyLayerRetriever is closed.").
26
+ self._owner = owner
27
+ self._sync_client: Optional[ReplyLayer] = None
28
+ self._async_client: Optional[AsyncReplyLayer] = None
29
+ self._closed = False
30
+
31
+ def get_sync(self) -> ReplyLayer:
32
+ if self._closed:
33
+ raise RuntimeError(f"{self._owner} is closed.")
34
+ if self._sync_client is None:
35
+ self._sync_client = ReplyLayer(
36
+ api_key=self._api_key,
37
+ base_url=self._base_url,
38
+ strict_outcome=True,
39
+ )
40
+ return self._sync_client
41
+
42
+ def get_async(self) -> AsyncReplyLayer:
43
+ if self._closed:
44
+ raise RuntimeError(f"{self._owner} is closed.")
45
+ if self._async_client is None:
46
+ self._async_client = AsyncReplyLayer(
47
+ api_key=self._api_key,
48
+ base_url=self._base_url,
49
+ strict_outcome=True,
50
+ )
51
+ return self._async_client
52
+
53
+ def close(self) -> None:
54
+ """Close the sync client. Idempotent. If any async work ran, call
55
+ ``aclose()`` as well to close the async client."""
56
+ self._closed = True
57
+ if self._sync_client is not None:
58
+ self._sync_client.close()
59
+ self._sync_client = None
60
+
61
+ async def aclose(self) -> None:
62
+ """Close both clients. Idempotent."""
63
+ self._closed = True
64
+ if self._async_client is not None:
65
+ await self._async_client.aclose()
66
+ self._async_client = None
67
+ if self._sync_client is not None:
68
+ self._sync_client.close()
69
+ self._sync_client = None