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.
Files changed (34) hide show
  1. langchain/__init__.py +1 -1
  2. langchain/agents/__init__.py +1 -7
  3. langchain/agents/factory.py +153 -79
  4. langchain/agents/middleware/__init__.py +18 -23
  5. langchain/agents/middleware/_execution.py +29 -32
  6. langchain/agents/middleware/_redaction.py +108 -22
  7. langchain/agents/middleware/_retry.py +123 -0
  8. langchain/agents/middleware/context_editing.py +47 -25
  9. langchain/agents/middleware/file_search.py +19 -14
  10. langchain/agents/middleware/human_in_the_loop.py +87 -57
  11. langchain/agents/middleware/model_call_limit.py +64 -18
  12. langchain/agents/middleware/model_fallback.py +7 -9
  13. langchain/agents/middleware/model_retry.py +307 -0
  14. langchain/agents/middleware/pii.py +82 -29
  15. langchain/agents/middleware/shell_tool.py +254 -107
  16. langchain/agents/middleware/summarization.py +469 -95
  17. langchain/agents/middleware/todo.py +129 -31
  18. langchain/agents/middleware/tool_call_limit.py +105 -71
  19. langchain/agents/middleware/tool_emulator.py +47 -38
  20. langchain/agents/middleware/tool_retry.py +183 -164
  21. langchain/agents/middleware/tool_selection.py +81 -37
  22. langchain/agents/middleware/types.py +856 -427
  23. langchain/agents/structured_output.py +65 -42
  24. langchain/chat_models/__init__.py +1 -7
  25. langchain/chat_models/base.py +253 -196
  26. langchain/embeddings/__init__.py +0 -5
  27. langchain/embeddings/base.py +79 -65
  28. langchain/messages/__init__.py +0 -5
  29. langchain/tools/__init__.py +1 -7
  30. {langchain-1.0.5.dist-info → langchain-1.2.4.dist-info}/METADATA +5 -7
  31. langchain-1.2.4.dist-info/RECORD +36 -0
  32. {langchain-1.0.5.dist-info → langchain-1.2.4.dist-info}/WHEEL +1 -1
  33. langchain-1.0.5.dist-info/RECORD +0 -34
  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
- resource = None # type: ignore[assignment]
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 :class:`HostExecutionPolicy` for trusted, same-host
61
- execution; :class:`CodexSandboxExecutionPolicy` when the Codex CLI sandbox is
62
- available and you want additional syscall restrictions; and
63
- :class:`DockerExecutionPolicy` for container-level isolation using Docker.
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. It enforces optional CPU
95
- and memory limits to prevent runaway commands but offers **no** filesystem or network
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 ``resource.prlimit`` after the
99
- shell starts. On macOS, where ``prlimit`` is unavailable, limits are set in a
100
- ``preexec_fn`` before ``exec``. In both cases the shell runs in its own process group
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 resource is None:
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 resource is None: # pragma: no cover - defensive
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
- :class:`RuntimeError`.
199
+ `RuntimeError`.
203
200
 
204
- Configure sandbox behaviour via ``config_overrides`` to align with your Codex CLI
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 when
275
- it refers to an existing non-temporary directory; ephemeral sessions run without a
276
- mount to minimise host exposure. The container's network namespace is disabled by
277
- default (``--network none``) and you can enable further hardening via
278
- ``read_only_rootfs`` and ``user``.
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 review
282
- any additional volumes or capabilities passed through ``extra_run_args``. The default
283
- image is ``python:3.12-alpine3.19``; supply a custom image if you need preinstalled
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 ("http", "https") and result.netloc:
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 13 <= len(digits) <= 19:
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=lambda item: item["start"], reverse=True):
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=lambda item: item["start"], reverse=True):
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) >= 2
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 = f"****{separator}****{separator}****{separator}{digits_only[-4:]}"
271
+ masked = (
272
+ f"****{separator}****{separator}****{separator}"
273
+ f"{digits_only[-_UNMASKED_CHAR_NUMBER:]}"
274
+ )
229
275
  else:
230
- masked = f"************{digits_only[-4:]}"
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) == 4 else "****"
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 = f"****{value[-4:]}" if len(value) > 4 else "****"
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=lambda item: item["start"], reverse=True):
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
- This middleware mirrors Anthropic's context editing capabilities by clearing
4
- older tool results once the conversation grows beyond a configurable token
5
- threshold. The implementation is intentionally model-agnostic so it can be used
6
- with any LangChain chat model.
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 prunes tool results to manage context size.
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
- exceeds configured thresholds. Currently the `ClearToolUsesEdit` strategy is
189
- supported, aligning with Anthropic's `clear_tool_uses_20250919` behaviour.
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
- """Initializes a context editing middleware instance.
204
+ """Initialize an instance of context editing middleware.
202
205
 
203
206
  Args:
204
- edits: Sequence of edit strategies to apply. Defaults to a single
205
- `ClearToolUsesEdit` mirroring Anthropic defaults.
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(request.messages, count_tokens=count_tokens)
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 (async version)."""
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(request.messages, count_tokens=count_tokens)
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__ = [