langchain-turn-window 0.1.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.
- langchain_turn_window-0.1.0/.github/workflows/ci.yml +21 -0
- langchain_turn_window-0.1.0/.gitignore +10 -0
- langchain_turn_window-0.1.0/CHANGELOG.md +17 -0
- langchain_turn_window-0.1.0/LICENSE +21 -0
- langchain_turn_window-0.1.0/PKG-INFO +104 -0
- langchain_turn_window-0.1.0/README.md +75 -0
- langchain_turn_window-0.1.0/pyproject.toml +48 -0
- langchain_turn_window-0.1.0/src/langchain_turn_window/__init__.py +8 -0
- langchain_turn_window-0.1.0/src/langchain_turn_window/middleware.py +39 -0
- langchain_turn_window-0.1.0/src/langchain_turn_window/py.typed +0 -0
- langchain_turn_window-0.1.0/src/langchain_turn_window/windowing.py +32 -0
- langchain_turn_window-0.1.0/tests/__init__.py +0 -0
- langchain_turn_window-0.1.0/tests/test_middleware.py +65 -0
- langchain_turn_window-0.1.0/tests/test_windowing.py +62 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: python -m pip install -e ".[dev]"
|
|
21
|
+
- run: python -m pytest
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-10-11
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `TurnWindowMiddleware`: LangChain agent middleware that keeps only the last N
|
|
13
|
+
human-anchored conversational turns before each model call, via
|
|
14
|
+
`wrap_model_call` and `awrap_model_call`.
|
|
15
|
+
- `window_by_human_turns`: pure helper that returns the suffix of a message list
|
|
16
|
+
starting at the Nth-most-recent `HumanMessage`, without splitting an
|
|
17
|
+
`AIMessage` tool call from its `ToolMessage` response.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bharat Khanna
|
|
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.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-turn-window
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Model-agnostic LangChain middleware that keeps only the last N conversational turns, without splitting tool_call/tool_result pairs.
|
|
5
|
+
Project-URL: Homepage, https://github.com/bharatkhanna-dev/langchain-turn-window
|
|
6
|
+
Project-URL: Repository, https://github.com/bharatkhanna-dev/langchain-turn-window
|
|
7
|
+
Project-URL: Issues, https://github.com/bharatkhanna-dev/langchain-turn-window/issues
|
|
8
|
+
Author: Bharat Khanna
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,context-window,conversation-history,langchain,langgraph,middleware
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: langchain-core>=1.0.0
|
|
24
|
+
Requires-Dist: langchain>=1.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# langchain-turn-window
|
|
31
|
+
|
|
32
|
+
A small, model-agnostic [LangChain](https://github.com/langchain-ai/langchain) agent middleware that keeps only the last **N conversational turns** before each model call — without ever splitting an `AIMessage` tool call from its `ToolMessage` response.
|
|
33
|
+
|
|
34
|
+
## Why
|
|
35
|
+
|
|
36
|
+
Every provider (OpenAI, Anthropic, Gemini, ...) rejects a request where a `tool_calls` entry has no matching tool result message. Naive history trimming — slicing by raw message count or token count — can easily cut the window in the middle of a tool-call/tool-result pair and break the request.
|
|
37
|
+
|
|
38
|
+
`TurnWindowMiddleware` anchors the window on `HumanMessage` boundaries instead: it always starts the kept slice at the Nth-most-recent `HumanMessage`, which guarantees every `AIMessage` tool call inside the window still has its `ToolMessage` response attached.
|
|
39
|
+
|
|
40
|
+
This is a lightweight alternative to LangChain's built-in `SummarizationMiddleware` (which needs an extra LLM call) or `context_editing` (which selectively clears old tool outputs) for the common case of "just bound the context to the last few turns."
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install langchain-turn-window
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from langchain.agents import create_agent
|
|
52
|
+
|
|
53
|
+
from langchain_turn_window import TurnWindowMiddleware
|
|
54
|
+
|
|
55
|
+
agent = create_agent(
|
|
56
|
+
model="openai:gpt-4o",
|
|
57
|
+
tools=[...],
|
|
58
|
+
middleware=[
|
|
59
|
+
TurnWindowMiddleware(keep_last_turns=3),
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`keep_last_turns` is the number of most-recent `HumanMessage` turns to retain. If the conversation has fewer turns than that, the full history passes through unchanged.
|
|
65
|
+
|
|
66
|
+
## How it works
|
|
67
|
+
|
|
68
|
+
`TurnWindowMiddleware` implements `wrap_model_call` / `awrap_model_call`. On every model call it:
|
|
69
|
+
|
|
70
|
+
1. Finds the indexes of all `HumanMessage`s in `request.messages`.
|
|
71
|
+
2. If there are fewer than `keep_last_turns` human turns, passes the messages through unchanged.
|
|
72
|
+
3. Otherwise, slices the message list starting at the `keep_last_turns`-th-most-recent `HumanMessage` and forwards only that suffix to the model.
|
|
73
|
+
|
|
74
|
+
The underlying pure function is also exported directly if you want to use it outside of middleware (e.g. in a custom node):
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from langchain_turn_window import window_by_human_turns
|
|
78
|
+
|
|
79
|
+
windowed = window_by_human_turns(messages, n=3)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`window_by_human_turns` never mutates its input and returns the original list object unchanged when no trimming is needed (positive-`n` guard, empty list, or fewer human turns than `n`).
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
| Constructor arg | Default | Description |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `keep_last_turns` | `3` | Number of most-recent human turns to keep. Must be `>= 1`. |
|
|
89
|
+
|
|
90
|
+
## Limitations
|
|
91
|
+
|
|
92
|
+
- Assumes a single-user conversational shape (one `HumanMessage` per turn). For multi-agent handoff patterns where turns aren't anchored on `HumanMessage`, this middleware is not the right fit as-is.
|
|
93
|
+
- This is a hard trim, not a summary — content outside the window is dropped, not condensed. Combine with `SummarizationMiddleware` if you need older context preserved in compressed form.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install -e ".[dev]"
|
|
99
|
+
pytest
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# langchain-turn-window
|
|
2
|
+
|
|
3
|
+
A small, model-agnostic [LangChain](https://github.com/langchain-ai/langchain) agent middleware that keeps only the last **N conversational turns** before each model call — without ever splitting an `AIMessage` tool call from its `ToolMessage` response.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Every provider (OpenAI, Anthropic, Gemini, ...) rejects a request where a `tool_calls` entry has no matching tool result message. Naive history trimming — slicing by raw message count or token count — can easily cut the window in the middle of a tool-call/tool-result pair and break the request.
|
|
8
|
+
|
|
9
|
+
`TurnWindowMiddleware` anchors the window on `HumanMessage` boundaries instead: it always starts the kept slice at the Nth-most-recent `HumanMessage`, which guarantees every `AIMessage` tool call inside the window still has its `ToolMessage` response attached.
|
|
10
|
+
|
|
11
|
+
This is a lightweight alternative to LangChain's built-in `SummarizationMiddleware` (which needs an extra LLM call) or `context_editing` (which selectively clears old tool outputs) for the common case of "just bound the context to the last few turns."
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install langchain-turn-window
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from langchain.agents import create_agent
|
|
23
|
+
|
|
24
|
+
from langchain_turn_window import TurnWindowMiddleware
|
|
25
|
+
|
|
26
|
+
agent = create_agent(
|
|
27
|
+
model="openai:gpt-4o",
|
|
28
|
+
tools=[...],
|
|
29
|
+
middleware=[
|
|
30
|
+
TurnWindowMiddleware(keep_last_turns=3),
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`keep_last_turns` is the number of most-recent `HumanMessage` turns to retain. If the conversation has fewer turns than that, the full history passes through unchanged.
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
`TurnWindowMiddleware` implements `wrap_model_call` / `awrap_model_call`. On every model call it:
|
|
40
|
+
|
|
41
|
+
1. Finds the indexes of all `HumanMessage`s in `request.messages`.
|
|
42
|
+
2. If there are fewer than `keep_last_turns` human turns, passes the messages through unchanged.
|
|
43
|
+
3. Otherwise, slices the message list starting at the `keep_last_turns`-th-most-recent `HumanMessage` and forwards only that suffix to the model.
|
|
44
|
+
|
|
45
|
+
The underlying pure function is also exported directly if you want to use it outside of middleware (e.g. in a custom node):
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from langchain_turn_window import window_by_human_turns
|
|
49
|
+
|
|
50
|
+
windowed = window_by_human_turns(messages, n=3)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`window_by_human_turns` never mutates its input and returns the original list object unchanged when no trimming is needed (positive-`n` guard, empty list, or fewer human turns than `n`).
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
| Constructor arg | Default | Description |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `keep_last_turns` | `3` | Number of most-recent human turns to keep. Must be `>= 1`. |
|
|
60
|
+
|
|
61
|
+
## Limitations
|
|
62
|
+
|
|
63
|
+
- Assumes a single-user conversational shape (one `HumanMessage` per turn). For multi-agent handoff patterns where turns aren't anchored on `HumanMessage`, this middleware is not the right fit as-is.
|
|
64
|
+
- This is a hard trim, not a summary — content outside the window is dropped, not condensed. Combine with `SummarizationMiddleware` if you need older context preserved in compressed form.
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install -e ".[dev]"
|
|
70
|
+
pytest
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-turn-window"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Model-agnostic LangChain middleware that keeps only the last N conversational turns, without splitting tool_call/tool_result pairs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Bharat Khanna" }]
|
|
14
|
+
keywords = ["langchain", "langgraph", "middleware", "agents", "context-window", "conversation-history"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"langchain>=1.0.0",
|
|
29
|
+
"langchain-core>=1.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.0.0",
|
|
35
|
+
"pytest-asyncio>=0.24.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/bharatkhanna-dev/langchain-turn-window"
|
|
40
|
+
Repository = "https://github.com/bharatkhanna-dev/langchain-turn-window"
|
|
41
|
+
Issues = "https://github.com/bharatkhanna-dev/langchain-turn-window/issues"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/langchain_turn_window"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Model-agnostic conversational turn-windowing middleware for LangChain agents."""
|
|
2
|
+
|
|
3
|
+
from .middleware import TurnWindowMiddleware
|
|
4
|
+
from .windowing import window_by_human_turns
|
|
5
|
+
|
|
6
|
+
__all__ = ["TurnWindowMiddleware", "window_by_human_turns"]
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""LangChain agent middleware for retaining a bounded conversational window."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
|
|
5
|
+
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
|
|
6
|
+
|
|
7
|
+
from .windowing import window_by_human_turns
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TurnWindowMiddleware(AgentMiddleware):
|
|
11
|
+
"""Keep only the most recent human-anchored turns for each model call."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, keep_last_turns: int = 3) -> None:
|
|
14
|
+
"""Initialize the middleware.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
keep_last_turns: Number of most-recent human turns to retain.
|
|
18
|
+
Must be >= 1.
|
|
19
|
+
"""
|
|
20
|
+
if keep_last_turns < 1:
|
|
21
|
+
raise ValueError("keep_last_turns must be >= 1")
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.keep_last_turns = keep_last_turns
|
|
24
|
+
|
|
25
|
+
def wrap_model_call(
|
|
26
|
+
self,
|
|
27
|
+
request: ModelRequest,
|
|
28
|
+
handler: Callable[[ModelRequest], ModelResponse],
|
|
29
|
+
) -> ModelResponse:
|
|
30
|
+
windowed = window_by_human_turns(request.messages, self.keep_last_turns)
|
|
31
|
+
return handler(request.override(messages=windowed))
|
|
32
|
+
|
|
33
|
+
async def awrap_model_call(
|
|
34
|
+
self,
|
|
35
|
+
request: ModelRequest,
|
|
36
|
+
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
37
|
+
) -> ModelResponse:
|
|
38
|
+
windowed = window_by_human_turns(request.messages, self.keep_last_turns)
|
|
39
|
+
return await handler(request.override(messages=windowed))
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Pure message-list windowing helpers, independent of any middleware framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from langchain_core.messages import HumanMessage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def window_by_human_turns(messages: list[Any], n: int) -> list[Any]:
|
|
11
|
+
"""Return a suffix window anchored to the last ``n`` HumanMessages.
|
|
12
|
+
|
|
13
|
+
Anchoring on a HumanMessage guarantees the slice never splits an
|
|
14
|
+
AIMessage tool_call from its ToolMessage. Returns ``messages`` unchanged
|
|
15
|
+
(same object) when ``n`` is non-positive or there are fewer than ``n``
|
|
16
|
+
human turns. Never mutates the input.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
messages: Full message history, oldest first.
|
|
20
|
+
n: Number of most-recent human turns to keep.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The suffix of ``messages`` starting at the nth-most-recent
|
|
24
|
+
``HumanMessage``, or ``messages`` unchanged if trimming would have
|
|
25
|
+
no effect.
|
|
26
|
+
"""
|
|
27
|
+
if n <= 0:
|
|
28
|
+
return messages
|
|
29
|
+
human_indexes = [i for i, m in enumerate(messages) if isinstance(m, HumanMessage)]
|
|
30
|
+
if not human_indexes or len(human_indexes) < n:
|
|
31
|
+
return messages
|
|
32
|
+
return messages[human_indexes[-n] :]
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Unit tests for TurnWindowMiddleware."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from langchain.agents.middleware import ModelRequest
|
|
5
|
+
from langchain_core.messages import HumanMessage
|
|
6
|
+
|
|
7
|
+
from langchain_turn_window import TurnWindowMiddleware
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _human(i: int) -> HumanMessage:
|
|
11
|
+
return HumanMessage(content=f"human-{i}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_request(messages) -> ModelRequest:
|
|
15
|
+
"""Build a minimal ModelRequest for testing wrap_model_call in isolation."""
|
|
16
|
+
return ModelRequest(
|
|
17
|
+
model=None,
|
|
18
|
+
system_prompt=None,
|
|
19
|
+
messages=messages,
|
|
20
|
+
tool_choice=None,
|
|
21
|
+
tools=[],
|
|
22
|
+
response_format=None,
|
|
23
|
+
state=(),
|
|
24
|
+
runtime=None,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_rejects_non_positive_keep_last_turns():
|
|
29
|
+
with pytest.raises(ValueError):
|
|
30
|
+
TurnWindowMiddleware(keep_last_turns=0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_wrap_model_call_forwards_windowed_messages():
|
|
34
|
+
middleware = TurnWindowMiddleware(keep_last_turns=1)
|
|
35
|
+
messages = [_human(1), _human(2), _human(3)]
|
|
36
|
+
request = _make_request(messages)
|
|
37
|
+
|
|
38
|
+
captured = {}
|
|
39
|
+
|
|
40
|
+
def handler(req: ModelRequest):
|
|
41
|
+
captured["messages"] = req.messages
|
|
42
|
+
return "ok"
|
|
43
|
+
|
|
44
|
+
result = middleware.wrap_model_call(request, handler)
|
|
45
|
+
|
|
46
|
+
assert result == "ok"
|
|
47
|
+
assert captured["messages"] == [_human(3)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.asyncio
|
|
51
|
+
async def test_awrap_model_call_forwards_windowed_messages():
|
|
52
|
+
middleware = TurnWindowMiddleware(keep_last_turns=2)
|
|
53
|
+
messages = [_human(1), _human(2), _human(3)]
|
|
54
|
+
request = _make_request(messages)
|
|
55
|
+
|
|
56
|
+
captured = {}
|
|
57
|
+
|
|
58
|
+
async def handler(req: ModelRequest):
|
|
59
|
+
captured["messages"] = req.messages
|
|
60
|
+
return "ok"
|
|
61
|
+
|
|
62
|
+
result = await middleware.awrap_model_call(request, handler)
|
|
63
|
+
|
|
64
|
+
assert result == "ok"
|
|
65
|
+
assert captured["messages"] == [_human(2), _human(3)]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Unit tests for window_by_human_turns."""
|
|
2
|
+
|
|
3
|
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
4
|
+
|
|
5
|
+
from langchain_turn_window import window_by_human_turns
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _human(i: int) -> HumanMessage:
|
|
9
|
+
return HumanMessage(content=f"human-{i}")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_non_positive_n_returns_same_object():
|
|
13
|
+
messages = [_human(1), _human(2)]
|
|
14
|
+
assert window_by_human_turns(messages, 0) is messages
|
|
15
|
+
assert window_by_human_turns(messages, -1) is messages
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_empty_messages_returns_empty():
|
|
19
|
+
assert window_by_human_turns([], 3) == []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_fewer_human_turns_than_n_returns_same_object():
|
|
23
|
+
messages = [_human(1), _human(2)]
|
|
24
|
+
assert window_by_human_turns(messages, 5) is messages
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_keeps_exact_number_of_requested_turns():
|
|
28
|
+
messages = [_human(1), _human(2), _human(3)]
|
|
29
|
+
windowed = window_by_human_turns(messages, 2)
|
|
30
|
+
assert windowed == [_human(2), _human(3)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_keeps_only_last_turn():
|
|
34
|
+
messages = [_human(1), _human(2), _human(3)]
|
|
35
|
+
windowed = window_by_human_turns(messages, 1)
|
|
36
|
+
assert windowed == [_human(3)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_does_not_split_tool_call_from_tool_message():
|
|
40
|
+
"""The window must never start between an AIMessage tool_call and its ToolMessage."""
|
|
41
|
+
messages = [
|
|
42
|
+
_human(1),
|
|
43
|
+
AIMessage(content="", tool_calls=[{"name": "lookup", "args": {}, "id": "call_1"}]),
|
|
44
|
+
ToolMessage(content="result", tool_call_id="call_1"),
|
|
45
|
+
_human(2),
|
|
46
|
+
AIMessage(content="", tool_calls=[{"name": "lookup", "args": {}, "id": "call_2"}]),
|
|
47
|
+
ToolMessage(content="result-2", tool_call_id="call_2"),
|
|
48
|
+
]
|
|
49
|
+
windowed = window_by_human_turns(messages, 1)
|
|
50
|
+
# Window starts at the last HumanMessage, so the in-flight tool call/result
|
|
51
|
+
# pair that follows it stays intact.
|
|
52
|
+
assert windowed[0] == _human(2)
|
|
53
|
+
assert isinstance(windowed[1], AIMessage)
|
|
54
|
+
assert isinstance(windowed[2], ToolMessage)
|
|
55
|
+
assert windowed[1].tool_calls[0]["id"] == windowed[2].tool_call_id
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_does_not_mutate_input():
|
|
59
|
+
messages = [_human(1), _human(2), _human(3)]
|
|
60
|
+
original = list(messages)
|
|
61
|
+
window_by_human_turns(messages, 1)
|
|
62
|
+
assert messages == original
|