langchain 1.0.5__py3-none-any.whl → 1.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain/__init__.py +1 -1
- langchain/agents/__init__.py +1 -7
- langchain/agents/factory.py +153 -79
- langchain/agents/middleware/__init__.py +18 -23
- langchain/agents/middleware/_execution.py +29 -32
- langchain/agents/middleware/_redaction.py +108 -22
- langchain/agents/middleware/_retry.py +123 -0
- langchain/agents/middleware/context_editing.py +47 -25
- langchain/agents/middleware/file_search.py +19 -14
- langchain/agents/middleware/human_in_the_loop.py +87 -57
- langchain/agents/middleware/model_call_limit.py +64 -18
- langchain/agents/middleware/model_fallback.py +7 -9
- langchain/agents/middleware/model_retry.py +307 -0
- langchain/agents/middleware/pii.py +82 -29
- langchain/agents/middleware/shell_tool.py +254 -107
- langchain/agents/middleware/summarization.py +469 -95
- langchain/agents/middleware/todo.py +129 -31
- langchain/agents/middleware/tool_call_limit.py +105 -71
- langchain/agents/middleware/tool_emulator.py +47 -38
- langchain/agents/middleware/tool_retry.py +183 -164
- langchain/agents/middleware/tool_selection.py +81 -37
- langchain/agents/middleware/types.py +856 -427
- langchain/agents/structured_output.py +65 -42
- langchain/chat_models/__init__.py +1 -7
- langchain/chat_models/base.py +253 -196
- langchain/embeddings/__init__.py +0 -5
- langchain/embeddings/base.py +79 -65
- langchain/messages/__init__.py +0 -5
- langchain/tools/__init__.py +1 -7
- {langchain-1.0.5.dist-info → langchain-1.2.4.dist-info}/METADATA +5 -7
- langchain-1.2.4.dist-info/RECORD +36 -0
- {langchain-1.0.5.dist-info → langchain-1.2.4.dist-info}/WHEEL +1 -1
- langchain-1.0.5.dist-info/RECORD +0 -34
- {langchain-1.0.5.dist-info → langchain-1.2.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -15,8 +15,10 @@ from pathlib import Path
|
|
|
15
15
|
|
|
16
16
|
try: # pragma: no cover - optional dependency on POSIX platforms
|
|
17
17
|
import resource
|
|
18
|
+
|
|
19
|
+
_HAS_RESOURCE = True
|
|
18
20
|
except ImportError: # pragma: no cover - non-POSIX systems
|
|
19
|
-
|
|
21
|
+
_HAS_RESOURCE = False
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
SHELL_TEMP_PREFIX = "langchain-shell-"
|
|
@@ -56,11 +58,12 @@ class BaseExecutionPolicy(abc.ABC):
|
|
|
56
58
|
"""Configuration contract for persistent shell sessions.
|
|
57
59
|
|
|
58
60
|
Concrete subclasses encapsulate how a shell process is launched and constrained.
|
|
61
|
+
|
|
59
62
|
Each policy documents its security guarantees and the operating environments in
|
|
60
|
-
which it is appropriate. Use
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
which it is appropriate. Use `HostExecutionPolicy` for trusted, same-host execution;
|
|
64
|
+
`CodexSandboxExecutionPolicy` when the Codex CLI sandbox is available and you want
|
|
65
|
+
additional syscall restrictions; and `DockerExecutionPolicy` for container-level
|
|
66
|
+
isolation using Docker.
|
|
64
67
|
"""
|
|
65
68
|
|
|
66
69
|
command_timeout: float = 30.0
|
|
@@ -91,13 +94,13 @@ class HostExecutionPolicy(BaseExecutionPolicy):
|
|
|
91
94
|
|
|
92
95
|
This policy is best suited for trusted or single-tenant environments (CI jobs,
|
|
93
96
|
developer workstations, pre-sandboxed containers) where the agent must access the
|
|
94
|
-
host filesystem and tooling without additional isolation.
|
|
95
|
-
|
|
97
|
+
host filesystem and tooling without additional isolation. Enforces optional CPU and
|
|
98
|
+
memory limits to prevent runaway commands but offers **no** filesystem or network
|
|
96
99
|
sandboxing; commands can modify anything the process user can reach.
|
|
97
100
|
|
|
98
|
-
On Linux platforms resource limits are applied with
|
|
99
|
-
shell starts. On macOS, where
|
|
100
|
-
|
|
101
|
+
On Linux platforms resource limits are applied with `resource.prlimit` after the
|
|
102
|
+
shell starts. On macOS, where `prlimit` is unavailable, limits are set in a
|
|
103
|
+
`preexec_fn` before `exec`. In both cases the shell runs in its own process group
|
|
101
104
|
so timeouts can terminate the full subtree.
|
|
102
105
|
"""
|
|
103
106
|
|
|
@@ -118,7 +121,7 @@ class HostExecutionPolicy(BaseExecutionPolicy):
|
|
|
118
121
|
self._limits_requested = any(
|
|
119
122
|
value is not None for value in (self.cpu_time_seconds, self.memory_bytes)
|
|
120
123
|
)
|
|
121
|
-
if self._limits_requested and
|
|
124
|
+
if self._limits_requested and not _HAS_RESOURCE:
|
|
122
125
|
msg = (
|
|
123
126
|
"HostExecutionPolicy cpu/memory limits require the Python 'resource' module. "
|
|
124
127
|
"Either remove the limits or run on a POSIX platform."
|
|
@@ -162,11 +165,9 @@ class HostExecutionPolicy(BaseExecutionPolicy):
|
|
|
162
165
|
def _apply_post_spawn_limits(self, process: subprocess.Popen[str]) -> None:
|
|
163
166
|
if not self._limits_requested or not self._can_use_prlimit():
|
|
164
167
|
return
|
|
165
|
-
if
|
|
168
|
+
if not _HAS_RESOURCE: # pragma: no cover - defensive
|
|
166
169
|
return
|
|
167
170
|
pid = process.pid
|
|
168
|
-
if pid is None:
|
|
169
|
-
return
|
|
170
171
|
try:
|
|
171
172
|
prlimit = typing.cast("typing.Any", resource).prlimit
|
|
172
173
|
if self.cpu_time_seconds is not None:
|
|
@@ -183,11 +184,7 @@ class HostExecutionPolicy(BaseExecutionPolicy):
|
|
|
183
184
|
|
|
184
185
|
@staticmethod
|
|
185
186
|
def _can_use_prlimit() -> bool:
|
|
186
|
-
return (
|
|
187
|
-
resource is not None
|
|
188
|
-
and hasattr(resource, "prlimit")
|
|
189
|
-
and sys.platform.startswith("linux")
|
|
190
|
-
)
|
|
187
|
+
return _HAS_RESOURCE and hasattr(resource, "prlimit") and sys.platform.startswith("linux")
|
|
191
188
|
|
|
192
189
|
|
|
193
190
|
@dataclass
|
|
@@ -199,9 +196,9 @@ class CodexSandboxExecutionPolicy(BaseExecutionPolicy):
|
|
|
199
196
|
(Linux) profiles. Commands still run on the host, but within the sandbox requested by
|
|
200
197
|
the CLI. If the Codex binary is unavailable or the runtime lacks the required
|
|
201
198
|
kernel features (e.g., Landlock inside some containers), process startup fails with a
|
|
202
|
-
|
|
199
|
+
`RuntimeError`.
|
|
203
200
|
|
|
204
|
-
Configure sandbox
|
|
201
|
+
Configure sandbox behavior via `config_overrides` to align with your Codex CLI
|
|
205
202
|
profile. This policy does not add its own resource limits; combine it with
|
|
206
203
|
host-level guards (cgroups, container resource limits) as needed.
|
|
207
204
|
"""
|
|
@@ -250,9 +247,9 @@ class CodexSandboxExecutionPolicy(BaseExecutionPolicy):
|
|
|
250
247
|
return self.platform
|
|
251
248
|
if sys.platform.startswith("linux"):
|
|
252
249
|
return "linux"
|
|
253
|
-
if sys.platform == "darwin":
|
|
250
|
+
if sys.platform == "darwin": # type: ignore[unreachable, unused-ignore]
|
|
254
251
|
return "macos"
|
|
255
|
-
msg = (
|
|
252
|
+
msg = ( # type: ignore[unreachable, unused-ignore]
|
|
256
253
|
"Codex sandbox policy could not determine a supported platform; "
|
|
257
254
|
"set 'platform' explicitly."
|
|
258
255
|
)
|
|
@@ -271,17 +268,17 @@ class DockerExecutionPolicy(BaseExecutionPolicy):
|
|
|
271
268
|
"""Run the shell inside a dedicated Docker container.
|
|
272
269
|
|
|
273
270
|
Choose this policy when commands originate from untrusted users or you require
|
|
274
|
-
strong isolation between sessions. By default the workspace is bind-mounted only
|
|
275
|
-
it refers to an existing non-temporary directory; ephemeral sessions run
|
|
276
|
-
mount to minimise host exposure. The container's network namespace is
|
|
277
|
-
default (
|
|
278
|
-
|
|
271
|
+
strong isolation between sessions. By default the workspace is bind-mounted only
|
|
272
|
+
when it refers to an existing non-temporary directory; ephemeral sessions run
|
|
273
|
+
without a mount to minimise host exposure. The container's network namespace is
|
|
274
|
+
disabled by default (`--network none`) and you can enable further hardening via
|
|
275
|
+
`read_only_rootfs` and `user`.
|
|
279
276
|
|
|
280
277
|
The security guarantees depend on your Docker daemon configuration. Run the agent on
|
|
281
|
-
a host where Docker is locked down (rootless mode, AppArmor/SELinux, etc.) and
|
|
282
|
-
any additional volumes or capabilities passed through ``extra_run_args``. The
|
|
283
|
-
image is
|
|
284
|
-
tooling.
|
|
278
|
+
a host where Docker is locked down (rootless mode, AppArmor/SELinux, etc.) and
|
|
279
|
+
review any additional volumes or capabilities passed through ``extra_run_args``. The
|
|
280
|
+
default image is `python:3.12-alpine3.19`; supply a custom image if you need
|
|
281
|
+
preinstalled tooling.
|
|
285
282
|
"""
|
|
286
283
|
|
|
287
284
|
binary: str = "docker"
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import ipaddress
|
|
7
|
+
import operator
|
|
7
8
|
import re
|
|
8
9
|
from collections.abc import Callable, Sequence
|
|
9
10
|
from dataclasses import dataclass
|
|
@@ -47,7 +48,14 @@ Detector = Callable[[str], list[PIIMatch]]
|
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
def detect_email(content: str) -> list[PIIMatch]:
|
|
50
|
-
"""Detect email addresses in content.
|
|
51
|
+
"""Detect email addresses in content.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
content: The text content to scan for email addresses.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A list of detected email matches.
|
|
58
|
+
"""
|
|
51
59
|
pattern = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
|
|
52
60
|
return [
|
|
53
61
|
PIIMatch(
|
|
@@ -61,7 +69,14 @@ def detect_email(content: str) -> list[PIIMatch]:
|
|
|
61
69
|
|
|
62
70
|
|
|
63
71
|
def detect_credit_card(content: str) -> list[PIIMatch]:
|
|
64
|
-
"""Detect credit card numbers in content using Luhn validation.
|
|
72
|
+
"""Detect credit card numbers in content using Luhn validation.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
content: The text content to scan for credit card numbers.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A list of detected credit card matches.
|
|
79
|
+
"""
|
|
65
80
|
pattern = r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b"
|
|
66
81
|
matches = []
|
|
67
82
|
|
|
@@ -81,7 +96,14 @@ def detect_credit_card(content: str) -> list[PIIMatch]:
|
|
|
81
96
|
|
|
82
97
|
|
|
83
98
|
def detect_ip(content: str) -> list[PIIMatch]:
|
|
84
|
-
"""Detect IPv4 or IPv6 addresses in content.
|
|
99
|
+
"""Detect IPv4 or IPv6 addresses in content.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
content: The text content to scan for IP addresses.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
A list of detected IP address matches.
|
|
106
|
+
"""
|
|
85
107
|
matches: list[PIIMatch] = []
|
|
86
108
|
ipv4_pattern = r"\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b"
|
|
87
109
|
|
|
@@ -104,7 +126,14 @@ def detect_ip(content: str) -> list[PIIMatch]:
|
|
|
104
126
|
|
|
105
127
|
|
|
106
128
|
def detect_mac_address(content: str) -> list[PIIMatch]:
|
|
107
|
-
"""Detect MAC addresses in content.
|
|
129
|
+
"""Detect MAC addresses in content.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
content: The text content to scan for MAC addresses.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A list of detected MAC address matches.
|
|
136
|
+
"""
|
|
108
137
|
pattern = r"\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b"
|
|
109
138
|
return [
|
|
110
139
|
PIIMatch(
|
|
@@ -118,7 +147,14 @@ def detect_mac_address(content: str) -> list[PIIMatch]:
|
|
|
118
147
|
|
|
119
148
|
|
|
120
149
|
def detect_url(content: str) -> list[PIIMatch]:
|
|
121
|
-
"""Detect URLs in content using regex and stdlib validation.
|
|
150
|
+
"""Detect URLs in content using regex and stdlib validation.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
content: The text content to scan for URLs.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A list of detected URL matches.
|
|
157
|
+
"""
|
|
122
158
|
matches: list[PIIMatch] = []
|
|
123
159
|
|
|
124
160
|
# Pattern 1: URLs with scheme (http:// or https://)
|
|
@@ -127,7 +163,7 @@ def detect_url(content: str) -> list[PIIMatch]:
|
|
|
127
163
|
for match in re.finditer(scheme_pattern, content):
|
|
128
164
|
url = match.group()
|
|
129
165
|
result = urlparse(url)
|
|
130
|
-
if result.scheme in
|
|
166
|
+
if result.scheme in {"http", "https"} and result.netloc:
|
|
131
167
|
matches.append(
|
|
132
168
|
PIIMatch(
|
|
133
169
|
type="url",
|
|
@@ -179,11 +215,14 @@ BUILTIN_DETECTORS: dict[str, Detector] = {
|
|
|
179
215
|
}
|
|
180
216
|
"""Registry of built-in detectors keyed by type name."""
|
|
181
217
|
|
|
218
|
+
_CARD_NUMBER_MIN_DIGITS = 13
|
|
219
|
+
_CARD_NUMBER_MAX_DIGITS = 19
|
|
220
|
+
|
|
182
221
|
|
|
183
222
|
def _passes_luhn(card_number: str) -> bool:
|
|
184
223
|
"""Validate credit card number using the Luhn checksum."""
|
|
185
224
|
digits = [int(d) for d in card_number if d.isdigit()]
|
|
186
|
-
if not
|
|
225
|
+
if not _CARD_NUMBER_MIN_DIGITS <= len(digits) <= _CARD_NUMBER_MAX_DIGITS:
|
|
187
226
|
return False
|
|
188
227
|
|
|
189
228
|
checksum = 0
|
|
@@ -191,7 +230,7 @@ def _passes_luhn(card_number: str) -> bool:
|
|
|
191
230
|
value = digit
|
|
192
231
|
if index % 2 == 1:
|
|
193
232
|
value *= 2
|
|
194
|
-
if value > 9:
|
|
233
|
+
if value > 9: # noqa: PLR2004
|
|
195
234
|
value -= 9
|
|
196
235
|
checksum += value
|
|
197
236
|
return checksum % 10 == 0
|
|
@@ -199,24 +238,28 @@ def _passes_luhn(card_number: str) -> bool:
|
|
|
199
238
|
|
|
200
239
|
def _apply_redact_strategy(content: str, matches: list[PIIMatch]) -> str:
|
|
201
240
|
result = content
|
|
202
|
-
for match in sorted(matches, key=
|
|
241
|
+
for match in sorted(matches, key=operator.itemgetter("start"), reverse=True):
|
|
203
242
|
replacement = f"[REDACTED_{match['type'].upper()}]"
|
|
204
243
|
result = result[: match["start"]] + replacement + result[match["end"] :]
|
|
205
244
|
return result
|
|
206
245
|
|
|
207
246
|
|
|
247
|
+
_UNMASKED_CHAR_NUMBER = 4
|
|
248
|
+
_IPV4_PARTS_NUMBER = 4
|
|
249
|
+
|
|
250
|
+
|
|
208
251
|
def _apply_mask_strategy(content: str, matches: list[PIIMatch]) -> str:
|
|
209
252
|
result = content
|
|
210
|
-
for match in sorted(matches, key=
|
|
253
|
+
for match in sorted(matches, key=operator.itemgetter("start"), reverse=True):
|
|
211
254
|
value = match["value"]
|
|
212
255
|
pii_type = match["type"]
|
|
213
256
|
if pii_type == "email":
|
|
214
257
|
parts = value.split("@")
|
|
215
|
-
if len(parts) == 2:
|
|
258
|
+
if len(parts) == 2: # noqa: PLR2004
|
|
216
259
|
domain_parts = parts[1].split(".")
|
|
217
260
|
masked = (
|
|
218
261
|
f"{parts[0]}@****.{domain_parts[-1]}"
|
|
219
|
-
if len(domain_parts)
|
|
262
|
+
if len(domain_parts) > 1
|
|
220
263
|
else f"{parts[0]}@****"
|
|
221
264
|
)
|
|
222
265
|
else:
|
|
@@ -225,12 +268,15 @@ def _apply_mask_strategy(content: str, matches: list[PIIMatch]) -> str:
|
|
|
225
268
|
digits_only = "".join(c for c in value if c.isdigit())
|
|
226
269
|
separator = "-" if "-" in value else " " if " " in value else ""
|
|
227
270
|
if separator:
|
|
228
|
-
masked =
|
|
271
|
+
masked = (
|
|
272
|
+
f"****{separator}****{separator}****{separator}"
|
|
273
|
+
f"{digits_only[-_UNMASKED_CHAR_NUMBER:]}"
|
|
274
|
+
)
|
|
229
275
|
else:
|
|
230
|
-
masked = f"************{digits_only[-
|
|
276
|
+
masked = f"************{digits_only[-_UNMASKED_CHAR_NUMBER:]}"
|
|
231
277
|
elif pii_type == "ip":
|
|
232
278
|
octets = value.split(".")
|
|
233
|
-
masked = f"*.*.*.{octets[-1]}" if len(octets) ==
|
|
279
|
+
masked = f"*.*.*.{octets[-1]}" if len(octets) == _IPV4_PARTS_NUMBER else "****"
|
|
234
280
|
elif pii_type == "mac_address":
|
|
235
281
|
separator = ":" if ":" in value else "-"
|
|
236
282
|
masked = (
|
|
@@ -239,14 +285,18 @@ def _apply_mask_strategy(content: str, matches: list[PIIMatch]) -> str:
|
|
|
239
285
|
elif pii_type == "url":
|
|
240
286
|
masked = "[MASKED_URL]"
|
|
241
287
|
else:
|
|
242
|
-
masked =
|
|
288
|
+
masked = (
|
|
289
|
+
f"****{value[-_UNMASKED_CHAR_NUMBER:]}"
|
|
290
|
+
if len(value) > _UNMASKED_CHAR_NUMBER
|
|
291
|
+
else "****"
|
|
292
|
+
)
|
|
243
293
|
result = result[: match["start"]] + masked + result[match["end"] :]
|
|
244
294
|
return result
|
|
245
295
|
|
|
246
296
|
|
|
247
297
|
def _apply_hash_strategy(content: str, matches: list[PIIMatch]) -> str:
|
|
248
298
|
result = content
|
|
249
|
-
for match in sorted(matches, key=
|
|
299
|
+
for match in sorted(matches, key=operator.itemgetter("start"), reverse=True):
|
|
250
300
|
digest = hashlib.sha256(match["value"].encode()).hexdigest()[:8]
|
|
251
301
|
replacement = f"<{match['type']}_hash:{digest}>"
|
|
252
302
|
result = result[: match["start"]] + replacement + result[match["end"] :]
|
|
@@ -258,7 +308,20 @@ def apply_strategy(
|
|
|
258
308
|
matches: list[PIIMatch],
|
|
259
309
|
strategy: RedactionStrategy,
|
|
260
310
|
) -> str:
|
|
261
|
-
"""Apply the configured strategy to matches within content.
|
|
311
|
+
"""Apply the configured strategy to matches within content.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
content: The content to apply strategy to.
|
|
315
|
+
matches: List of detected PII matches.
|
|
316
|
+
strategy: The redaction strategy to apply.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
The content with the strategy applied.
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
PIIDetectionError: If the strategy is `'block'` and matches are found.
|
|
323
|
+
ValueError: If the strategy is unknown.
|
|
324
|
+
"""
|
|
262
325
|
if not matches:
|
|
263
326
|
return content
|
|
264
327
|
if strategy == "redact":
|
|
@@ -269,12 +332,24 @@ def apply_strategy(
|
|
|
269
332
|
return _apply_hash_strategy(content, matches)
|
|
270
333
|
if strategy == "block":
|
|
271
334
|
raise PIIDetectionError(matches[0]["type"], matches)
|
|
272
|
-
msg = f"Unknown redaction strategy: {strategy}"
|
|
335
|
+
msg = f"Unknown redaction strategy: {strategy}" # type: ignore[unreachable]
|
|
273
336
|
raise ValueError(msg)
|
|
274
337
|
|
|
275
338
|
|
|
276
339
|
def resolve_detector(pii_type: str, detector: Detector | str | None) -> Detector:
|
|
277
|
-
"""Return a callable detector for the given configuration.
|
|
340
|
+
"""Return a callable detector for the given configuration.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
pii_type: The PII type name.
|
|
344
|
+
detector: Optional custom detector or regex pattern. If `None`, a built-in detector
|
|
345
|
+
for the given PII type will be used.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
The resolved detector.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
ValueError: If an unknown PII type is specified without a custom detector or regex.
|
|
352
|
+
"""
|
|
278
353
|
if detector is None:
|
|
279
354
|
if pii_type not in BUILTIN_DETECTORS:
|
|
280
355
|
msg = (
|
|
@@ -310,7 +385,11 @@ class RedactionRule:
|
|
|
310
385
|
detector: Detector | str | None = None
|
|
311
386
|
|
|
312
387
|
def resolve(self) -> ResolvedRedactionRule:
|
|
313
|
-
"""Resolve runtime detector and return an immutable rule.
|
|
388
|
+
"""Resolve runtime detector and return an immutable rule.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
The resolved redaction rule.
|
|
392
|
+
"""
|
|
314
393
|
resolved_detector = resolve_detector(self.pii_type, self.detector)
|
|
315
394
|
return ResolvedRedactionRule(
|
|
316
395
|
pii_type=self.pii_type,
|
|
@@ -328,7 +407,14 @@ class ResolvedRedactionRule:
|
|
|
328
407
|
detector: Detector
|
|
329
408
|
|
|
330
409
|
def apply(self, content: str) -> tuple[str, list[PIIMatch]]:
|
|
331
|
-
"""Apply this rule to content, returning new content and matches.
|
|
410
|
+
"""Apply this rule to content, returning new content and matches.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
content: The text content to scan and redact.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
A tuple of (updated content, list of detected matches).
|
|
417
|
+
"""
|
|
332
418
|
matches = self.detector(content)
|
|
333
419
|
if not matches:
|
|
334
420
|
return content, []
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Shared retry utilities for agent middleware.
|
|
2
|
+
|
|
3
|
+
This module contains common constants, utilities, and logic used by both
|
|
4
|
+
model and tool retry middleware implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
# Type aliases
|
|
14
|
+
RetryOn = tuple[type[Exception], ...] | Callable[[Exception], bool]
|
|
15
|
+
"""Type for specifying which exceptions to retry on.
|
|
16
|
+
|
|
17
|
+
Can be either:
|
|
18
|
+
- A tuple of exception types to retry on (based on `isinstance` checks)
|
|
19
|
+
- A callable that takes an exception and returns `True` if it should be retried
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
OnFailure = Literal["error", "continue"] | Callable[[Exception], str]
|
|
23
|
+
"""Type for specifying failure handling behavior.
|
|
24
|
+
|
|
25
|
+
Can be either:
|
|
26
|
+
- A literal action string (`'error'` or `'continue'`)
|
|
27
|
+
- `'error'`: Re-raise the exception, stopping agent execution.
|
|
28
|
+
- `'continue'`: Inject a message with the error details, allowing the agent to continue.
|
|
29
|
+
For tool retries, a `ToolMessage` with the error details will be injected.
|
|
30
|
+
For model retries, an `AIMessage` with the error details will be returned.
|
|
31
|
+
- A callable that takes an exception and returns a string for error message content
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_retry_params(
|
|
36
|
+
max_retries: int,
|
|
37
|
+
initial_delay: float,
|
|
38
|
+
max_delay: float,
|
|
39
|
+
backoff_factor: float,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Validate retry parameters.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
max_retries: Maximum number of retry attempts.
|
|
45
|
+
initial_delay: Initial delay in seconds before first retry.
|
|
46
|
+
max_delay: Maximum delay in seconds between retries.
|
|
47
|
+
backoff_factor: Multiplier for exponential backoff.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ValueError: If any parameter is invalid (negative values).
|
|
51
|
+
"""
|
|
52
|
+
if max_retries < 0:
|
|
53
|
+
msg = "max_retries must be >= 0"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
if initial_delay < 0:
|
|
56
|
+
msg = "initial_delay must be >= 0"
|
|
57
|
+
raise ValueError(msg)
|
|
58
|
+
if max_delay < 0:
|
|
59
|
+
msg = "max_delay must be >= 0"
|
|
60
|
+
raise ValueError(msg)
|
|
61
|
+
if backoff_factor < 0:
|
|
62
|
+
msg = "backoff_factor must be >= 0"
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def should_retry_exception(
|
|
67
|
+
exc: Exception,
|
|
68
|
+
retry_on: RetryOn,
|
|
69
|
+
) -> bool:
|
|
70
|
+
"""Check if an exception should trigger a retry.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
exc: The exception that occurred.
|
|
74
|
+
retry_on: Either a tuple of exception types to retry on, or a callable
|
|
75
|
+
that takes an exception and returns `True` if it should be retried.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
`True` if the exception should be retried, `False` otherwise.
|
|
79
|
+
"""
|
|
80
|
+
if callable(retry_on):
|
|
81
|
+
return retry_on(exc)
|
|
82
|
+
return isinstance(exc, retry_on)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def calculate_delay(
|
|
86
|
+
retry_number: int,
|
|
87
|
+
*,
|
|
88
|
+
backoff_factor: float,
|
|
89
|
+
initial_delay: float,
|
|
90
|
+
max_delay: float,
|
|
91
|
+
jitter: bool,
|
|
92
|
+
) -> float:
|
|
93
|
+
"""Calculate delay for a retry attempt with exponential backoff and optional jitter.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
retry_number: The retry attempt number (0-indexed).
|
|
97
|
+
backoff_factor: Multiplier for exponential backoff.
|
|
98
|
+
|
|
99
|
+
Set to `0.0` for constant delay.
|
|
100
|
+
initial_delay: Initial delay in seconds before first retry.
|
|
101
|
+
max_delay: Maximum delay in seconds between retries.
|
|
102
|
+
|
|
103
|
+
Caps exponential backoff growth.
|
|
104
|
+
jitter: Whether to add random jitter to delay to avoid thundering herd.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Delay in seconds before next retry.
|
|
108
|
+
"""
|
|
109
|
+
if backoff_factor == 0.0:
|
|
110
|
+
delay = initial_delay
|
|
111
|
+
else:
|
|
112
|
+
delay = initial_delay * (backoff_factor**retry_number)
|
|
113
|
+
|
|
114
|
+
# Cap at max_delay
|
|
115
|
+
delay = min(delay, max_delay)
|
|
116
|
+
|
|
117
|
+
if jitter and delay > 0:
|
|
118
|
+
jitter_amount = delay * 0.25 # ±25% jitter
|
|
119
|
+
delay += random.uniform(-jitter_amount, jitter_amount) # noqa: S311
|
|
120
|
+
# Ensure delay is not negative after jitter
|
|
121
|
+
delay = max(0, delay)
|
|
122
|
+
|
|
123
|
+
return delay
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"""Context editing middleware.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
with any LangChain
|
|
3
|
+
Mirrors Anthropic's context editing capabilities by clearing older tool results once the
|
|
4
|
+
conversation grows beyond a configurable token threshold.
|
|
5
|
+
|
|
6
|
+
The implementation is intentionally model-agnostic so it can be used with any LangChain
|
|
7
|
+
chat model.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
11
12
|
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
|
13
|
+
from copy import deepcopy
|
|
12
14
|
from dataclasses import dataclass
|
|
13
15
|
from typing import Literal
|
|
14
16
|
|
|
@@ -16,7 +18,6 @@ from langchain_core.messages import (
|
|
|
16
18
|
AIMessage,
|
|
17
19
|
AnyMessage,
|
|
18
20
|
BaseMessage,
|
|
19
|
-
SystemMessage,
|
|
20
21
|
ToolMessage,
|
|
21
22
|
)
|
|
22
23
|
from langchain_core.messages.utils import count_tokens_approximately
|
|
@@ -151,8 +152,8 @@ class ClearToolUsesEdit(ContextEdit):
|
|
|
151
152
|
|
|
152
153
|
return
|
|
153
154
|
|
|
155
|
+
@staticmethod
|
|
154
156
|
def _build_cleared_tool_input_message(
|
|
155
|
-
self,
|
|
156
157
|
message: AIMessage,
|
|
157
158
|
tool_call_id: str,
|
|
158
159
|
) -> AIMessage:
|
|
@@ -182,11 +183,13 @@ class ClearToolUsesEdit(ContextEdit):
|
|
|
182
183
|
|
|
183
184
|
|
|
184
185
|
class ContextEditingMiddleware(AgentMiddleware):
|
|
185
|
-
"""Automatically
|
|
186
|
+
"""Automatically prune tool results to manage context size.
|
|
186
187
|
|
|
187
|
-
The middleware applies a sequence of edits when the total input token count
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
The middleware applies a sequence of edits when the total input token count exceeds
|
|
189
|
+
configured thresholds.
|
|
190
|
+
|
|
191
|
+
Currently the `ClearToolUsesEdit` strategy is supported, aligning with Anthropic's
|
|
192
|
+
`clear_tool_uses_20250919` behavior [(read more)](https://platform.claude.com/docs/en/agents-and-tools/tool-use/memory-tool).
|
|
190
193
|
"""
|
|
191
194
|
|
|
192
195
|
edits: list[ContextEdit]
|
|
@@ -198,11 +201,12 @@ class ContextEditingMiddleware(AgentMiddleware):
|
|
|
198
201
|
edits: Iterable[ContextEdit] | None = None,
|
|
199
202
|
token_count_method: Literal["approximate", "model"] = "approximate", # noqa: S107
|
|
200
203
|
) -> None:
|
|
201
|
-
"""
|
|
204
|
+
"""Initialize an instance of context editing middleware.
|
|
202
205
|
|
|
203
206
|
Args:
|
|
204
|
-
edits: Sequence of edit strategies to apply.
|
|
205
|
-
|
|
207
|
+
edits: Sequence of edit strategies to apply.
|
|
208
|
+
|
|
209
|
+
Defaults to a single `ClearToolUsesEdit` mirroring Anthropic defaults.
|
|
206
210
|
token_count_method: Whether to use approximate token counting
|
|
207
211
|
(faster, less accurate) or exact counting implemented by the
|
|
208
212
|
chat model (potentially slower, more accurate).
|
|
@@ -216,7 +220,16 @@ class ContextEditingMiddleware(AgentMiddleware):
|
|
|
216
220
|
request: ModelRequest,
|
|
217
221
|
handler: Callable[[ModelRequest], ModelResponse],
|
|
218
222
|
) -> ModelCallResult:
|
|
219
|
-
"""Apply context edits before invoking the model via handler.
|
|
223
|
+
"""Apply context edits before invoking the model via handler.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
request: Model request to execute (includes state and runtime).
|
|
227
|
+
handler: Async callback that executes the model request and returns
|
|
228
|
+
`ModelResponse`.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
The result of invoking the handler with potentially edited messages.
|
|
232
|
+
"""
|
|
220
233
|
if not request.messages:
|
|
221
234
|
return handler(request)
|
|
222
235
|
|
|
@@ -224,27 +237,36 @@ class ContextEditingMiddleware(AgentMiddleware):
|
|
|
224
237
|
|
|
225
238
|
def count_tokens(messages: Sequence[BaseMessage]) -> int:
|
|
226
239
|
return count_tokens_approximately(messages)
|
|
240
|
+
|
|
227
241
|
else:
|
|
228
|
-
system_msg =
|
|
229
|
-
[SystemMessage(content=request.system_prompt)] if request.system_prompt else []
|
|
230
|
-
)
|
|
242
|
+
system_msg = [request.system_message] if request.system_message else []
|
|
231
243
|
|
|
232
244
|
def count_tokens(messages: Sequence[BaseMessage]) -> int:
|
|
233
245
|
return request.model.get_num_tokens_from_messages(
|
|
234
246
|
system_msg + list(messages), request.tools
|
|
235
247
|
)
|
|
236
248
|
|
|
249
|
+
edited_messages = deepcopy(list(request.messages))
|
|
237
250
|
for edit in self.edits:
|
|
238
|
-
edit.apply(
|
|
251
|
+
edit.apply(edited_messages, count_tokens=count_tokens)
|
|
239
252
|
|
|
240
|
-
return handler(request)
|
|
253
|
+
return handler(request.override(messages=edited_messages))
|
|
241
254
|
|
|
242
255
|
async def awrap_model_call(
|
|
243
256
|
self,
|
|
244
257
|
request: ModelRequest,
|
|
245
258
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
246
259
|
) -> ModelCallResult:
|
|
247
|
-
"""Apply context edits before invoking the model via handler
|
|
260
|
+
"""Apply context edits before invoking the model via handler.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
request: Model request to execute (includes state and runtime).
|
|
264
|
+
handler: Async callback that executes the model request and returns
|
|
265
|
+
`ModelResponse`.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
The result of invoking the handler with potentially edited messages.
|
|
269
|
+
"""
|
|
248
270
|
if not request.messages:
|
|
249
271
|
return await handler(request)
|
|
250
272
|
|
|
@@ -252,20 +274,20 @@ class ContextEditingMiddleware(AgentMiddleware):
|
|
|
252
274
|
|
|
253
275
|
def count_tokens(messages: Sequence[BaseMessage]) -> int:
|
|
254
276
|
return count_tokens_approximately(messages)
|
|
277
|
+
|
|
255
278
|
else:
|
|
256
|
-
system_msg =
|
|
257
|
-
[SystemMessage(content=request.system_prompt)] if request.system_prompt else []
|
|
258
|
-
)
|
|
279
|
+
system_msg = [request.system_message] if request.system_message else []
|
|
259
280
|
|
|
260
281
|
def count_tokens(messages: Sequence[BaseMessage]) -> int:
|
|
261
282
|
return request.model.get_num_tokens_from_messages(
|
|
262
283
|
system_msg + list(messages), request.tools
|
|
263
284
|
)
|
|
264
285
|
|
|
286
|
+
edited_messages = deepcopy(list(request.messages))
|
|
265
287
|
for edit in self.edits:
|
|
266
|
-
edit.apply(
|
|
288
|
+
edit.apply(edited_messages, count_tokens=count_tokens)
|
|
267
289
|
|
|
268
|
-
return await handler(request)
|
|
290
|
+
return await handler(request.override(messages=edited_messages))
|
|
269
291
|
|
|
270
292
|
|
|
271
293
|
__all__ = [
|