From 021ede2f728d5e012060239f24d3240a36fb8b7b Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Fri, 31 Mar 2023 18:49:31 -0300 Subject: [PATCH 1/9] feat: add toolkits to settings --- src/backend/langflow/config.yaml | 4 ++++ src/backend/langflow/interface/toolkits/base.py | 3 ++- src/backend/langflow/settings.py | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/backend/langflow/config.yaml b/src/backend/langflow/config.yaml index b8c2be6da..114db79d4 100644 --- a/src/backend/langflow/config.yaml +++ b/src/backend/langflow/config.yaml @@ -28,4 +28,8 @@ tools: wrappers: - RequestsWrapper +toolkits: + - OpenAPIToolkit + - JsonToolkit + dev: false diff --git a/src/backend/langflow/interface/toolkits/base.py b/src/backend/langflow/interface/toolkits/base.py index 0af46e504..c78a9c051 100644 --- a/src/backend/langflow/interface/toolkits/base.py +++ b/src/backend/langflow/interface/toolkits/base.py @@ -4,6 +4,7 @@ from langchain.agents import agent_toolkits from langflow.interface.base import LangChainTypeCreator from langflow.interface.importing.utils import import_class, import_module +from langflow.settings import settings from langflow.utils.util import build_template_from_class @@ -33,7 +34,7 @@ class ToolkitCreator(LangChainTypeCreator): ) # if toolkit_name is not lower case it is a class for toolkit_name in agent_toolkits.__all__ - if not toolkit_name.islower() + if not toolkit_name.islower() and toolkit_name in settings.toolkits } return self.type_dict diff --git a/src/backend/langflow/settings.py b/src/backend/langflow/settings.py index 092bea040..a8c2edc4a 100644 --- a/src/backend/langflow/settings.py +++ b/src/backend/langflow/settings.py @@ -13,6 +13,7 @@ class Settings(BaseSettings): tools: List[str] = [] memories: List[str] = [] wrappers: List[str] = [] + toolkits: List[str] = [] dev: bool = False class Config: @@ -35,6 +36,7 @@ class Settings(BaseSettings): self.tools = new_settings.tools or [] self.memories = new_settings.memories or [] self.wrappers = new_settings.wrappers or [] + self.toolkits = new_settings.toolkits or [] self.dev = new_settings.dev or False From be71829493f5bcee58081cf6ac3c334820873dad Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Fri, 31 Mar 2023 22:05:08 -0300 Subject: [PATCH 2/9] fix: tools now show the params properly --- src/backend/langflow/interface/tools/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/langflow/interface/tools/base.py b/src/backend/langflow/interface/tools/base.py index d7bf655e2..8d14e3612 100644 --- a/src/backend/langflow/interface/tools/base.py +++ b/src/backend/langflow/interface/tools/base.py @@ -112,7 +112,7 @@ class ToolCreator(LangChainTypeCreator): # Copy the field and add the name fields = [] for param in params: - field = TOOL_INPUTS.get(param, TOOL_INPUTS["str"]) + field = TOOL_INPUTS.get(param, TOOL_INPUTS["str"]).copy() field.name = param if param == "aiosession": field.show = False From e28c3dc18eec13326716b5a0e1d592bdcb06068a Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Fri, 31 Mar 2023 23:15:22 -0300 Subject: [PATCH 3/9] feat: new tests --- tests/test_creators.py | 49 +++++++ tests/test_frontend_nodes.py | 60 +++++++++ tests/test_template.py | 242 +++++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 tests/test_creators.py create mode 100644 tests/test_frontend_nodes.py create mode 100644 tests/test_template.py diff --git a/tests/test_creators.py b/tests/test_creators.py new file mode 100644 index 000000000..08b2fc9d1 --- /dev/null +++ b/tests/test_creators.py @@ -0,0 +1,49 @@ +from typing import Dict, List +from langflow.interface.base import LangChainTypeCreator +from langflow.interface.agents.base import AgentCreator +import pytest + + +@pytest.fixture +def sample_lang_chain_type_creator() -> LangChainTypeCreator: + class SampleLangChainTypeCreator(LangChainTypeCreator): + type_name: str = "test_type" + + def type_to_loader_dict(self) -> Dict: + return {"test_type": "TestClass"} + + def to_list(self) -> List[str]: + return ["node1", "node2"] + + def get_signature(self, name: str) -> Dict: + return { + "template": {"test_field": {"type": "str"}}, + "description": "test description", + "base_classes": ["base_class1", "base_class2"], + } + + return SampleLangChainTypeCreator() + + +@pytest.fixture +def sample_agent_creator() -> AgentCreator: + return AgentCreator() + + +def test_lang_chain_type_creator_to_dict( + sample_lang_chain_type_creator: LangChainTypeCreator, +): + type_dict = sample_lang_chain_type_creator.to_dict() + assert len(type_dict) == 1 + assert "test_type" in type_dict + assert "node1" in type_dict["test_type"] + assert "node2" in type_dict["test_type"] + assert "template" in type_dict["test_type"]["node1"] + assert "description" in type_dict["test_type"]["node1"] + assert "base_classes" in type_dict["test_type"]["node1"] + + +def test_agent_creator_type_to_loader_dict(sample_agent_creator: AgentCreator): + type_to_loader_dict = sample_agent_creator.type_to_loader_dict + assert len(type_to_loader_dict) > 0 + assert "JsonAgent" diff --git a/tests/test_frontend_nodes.py b/tests/test_frontend_nodes.py new file mode 100644 index 000000000..75b0b741f --- /dev/null +++ b/tests/test_frontend_nodes.py @@ -0,0 +1,60 @@ +import pytest +from typing import Dict, List +from langflow.template.base import TemplateField, FrontendNode, Template +from langflow.interface.base import LangChainTypeCreator +from langflow.interface.agents.base import AgentCreator + + +@pytest.fixture +def sample_template_field() -> TemplateField: + return TemplateField(name="test_field", field_type="str") + + +@pytest.fixture +def sample_template(sample_template_field: TemplateField) -> Template: + return Template(type_name="test_template", fields=[sample_template_field]) + + +@pytest.fixture +def sample_frontend_node(sample_template: Template) -> FrontendNode: + return FrontendNode( + template=sample_template, + description="test description", + base_classes=["base_class1", "base_class2"], + name="test_frontend_node", + ) + + +def test_template_field_defaults(sample_template_field: TemplateField): + assert sample_template_field.field_type == "str" + assert sample_template_field.required == False + assert sample_template_field.placeholder == "" + assert sample_template_field.is_list == False + assert sample_template_field.show == True + assert sample_template_field.multiline == False + assert sample_template_field.value == None + assert sample_template_field.suffixes == [] + assert sample_template_field.file_types == [] + assert sample_template_field.content == None + assert sample_template_field.password == False + assert sample_template_field.name == "test_field" + + +def test_template_to_dict( + sample_template: Template, sample_template_field: TemplateField +): + template_dict = sample_template.to_dict() + assert template_dict["_type"] == "test_template" + assert len(template_dict) == 2 # _type and test_field + assert "test_field" in template_dict + assert "type" in template_dict["test_field"] + assert "required" in template_dict["test_field"] + + +def test_frontend_node_to_dict(sample_frontend_node: FrontendNode): + node_dict = sample_frontend_node.to_dict() + assert len(node_dict) == 1 + assert "test_frontend_node" in node_dict + assert "description" in node_dict["test_frontend_node"] + assert "template" in node_dict["test_frontend_node"] + assert "base_classes" in node_dict["test_frontend_node"] diff --git a/tests/test_template.py b/tests/test_template.py new file mode 100644 index 000000000..102df3b56 --- /dev/null +++ b/tests/test_template.py @@ -0,0 +1,242 @@ +from langflow.utils.constants import CHAT_OPENAI_MODELS, OPENAI_MODELS +from pydantic import BaseModel +import pytest +import re +import importlib +from typing import Dict +from langflow.utils.util import ( + build_template_from_class, + format_dict, + get_base_classes, + get_default_factory, + get_class_doc, +) + + +# Dummy classes for testing purposes +class Parent(BaseModel): + """Parent Class""" + + parent_field: str + + +class Child(Parent): + """Child Class""" + + child_field: int + + +# Test build_template_from_class +def test_build_template_from_class(): + type_to_cls_dict: Dict[str, type] = {"parent": Parent, "child": Child} + + # Test valid input + result = build_template_from_class("Child", type_to_cls_dict) + assert "template" in result + assert "description" in result + assert "base_classes" in result + assert "Child" in result["base_classes"] + assert "Parent" in result["base_classes"] + assert result["description"] == "Child Class" + + # Test invalid input + with pytest.raises(ValueError, match="InvalidClass not found."): + build_template_from_class("InvalidClass", type_to_cls_dict) + + +# Test format_dict +def test_format_dict(): + # Test 1: Optional type removal + input_dict = { + "field1": {"type": "Optional[str]", "required": False}, + } + expected_output = { + "field1": { + "type": "str", + "required": False, + "list": False, + "show": False, + "password": False, + "multiline": False, + }, + } + assert format_dict(input_dict) == expected_output + + # Test 2: List type processing + input_dict = { + "field1": {"type": "List[str]", "required": False}, + } + expected_output = { + "field1": { + "type": "str", + "required": False, + "list": True, + "show": False, + "password": False, + "multiline": False, + }, + } + assert format_dict(input_dict) == expected_output + + # Test 3: Mapping type replacement + input_dict = { + "field1": {"type": "Mapping[str, int]", "required": False}, + } + expected_output = { + "field1": { + "type": "code", # Mapping type is replaced with dict which is replaced with code + "required": False, + "list": False, + "show": False, + "password": False, + "multiline": False, + }, + } + assert format_dict(input_dict) == expected_output + + # Test 4: Replace default value with actual value + input_dict = { + "field1": {"type": "str", "required": False, "default": "test"}, + } + expected_output = { + "field1": { + "type": "str", + "required": False, + "list": False, + "show": False, + "password": False, + "multiline": False, + "value": "test", + }, + } + assert format_dict(input_dict) == expected_output + + # Test 5: Add password field + input_dict = { + "field1": {"type": "str", "required": False}, + "api_key": {"type": "str", "required": False}, + } + expected_output = { + "field1": { + "type": "str", + "required": False, + "list": False, + "show": False, + "password": False, + "multiline": False, + }, + "api_key": { + "type": "str", + "required": False, + "list": False, + "show": True, + "password": True, + "multiline": False, + }, + } + assert format_dict(input_dict) == expected_output + + # Test 6: Add multiline + input_dict = { + "field1": {"type": "str", "required": False}, + "prefix": {"type": "str", "required": False}, + } + expected_output = { + "field1": { + "type": "str", + "required": False, + "list": False, + "show": False, + "password": False, + "multiline": False, + }, + "prefix": { + "type": "str", + "required": False, + "list": False, + "show": True, + "password": False, + "multiline": True, + }, + } + assert format_dict(input_dict) == expected_output + + # Test 7: Check class name-specific cases (OpenAI, OpenAIChat) + input_dict = { + "model_name": {"type": "str", "required": False}, + } + expected_output_openai = { + "model_name": { + "type": "str", + "required": False, + "list": True, + "show": True, + "password": False, + "multiline": False, + "options": OPENAI_MODELS, + }, + } + expected_output_openai_chat = { + "model_name": { + "type": "str", + "required": False, + "list": True, + "show": True, + "password": False, + "multiline": False, + "options": CHAT_OPENAI_MODELS, + }, + } + assert format_dict(input_dict, "OpenAI") == expected_output_openai + assert format_dict(input_dict, "OpenAIChat") == expected_output_openai_chat + + # Test 8: Replace dict type with str + input_dict = { + "field1": {"type": "Dict[str, int]", "required": False}, + } + expected_output = { + "field1": { + "type": "code", + "required": False, + "list": False, + "show": False, + "password": False, + "multiline": False, + }, + } + assert format_dict(input_dict) == expected_output + + +# Test get_base_classes +def test_get_base_classes(): + base_classes_parent = get_base_classes(Parent) + base_classes_child = get_base_classes(Child) + + assert "Parent" in base_classes_parent + assert "Child" in base_classes_child + assert "Parent" in base_classes_child + + +# Test get_default_factory +def test_get_default_factory(): + module_name = "langflow.utils.util" + function_repr = "" + + def dummy_function(): + return "default_value" + + # Add dummy_function to your_module + setattr(importlib.import_module(module_name), "dummy_function", dummy_function) + + default_value = get_default_factory(module_name, function_repr) + + assert default_value == "default_value" + + +# Test get_class_doc +def test_get_class_doc(): + class_doc_parent = get_class_doc(Parent) + class_doc_child = get_class_doc(Child) + + assert class_doc_parent["Description"] == "Parent Class" + assert class_doc_child["Description"] == "Child Class" From b0615e9a4e09707bb67eb1aae581b3956c453d16 Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Fri, 31 Mar 2023 23:15:46 -0300 Subject: [PATCH 4/9] fix: remove format_instructions from input_v extrac --- src/backend/langflow/graph/nodes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/backend/langflow/graph/nodes.py b/src/backend/langflow/graph/nodes.py index c6834e842..7f29e3aa7 100644 --- a/src/backend/langflow/graph/nodes.py +++ b/src/backend/langflow/graph/nodes.py @@ -68,7 +68,9 @@ class PromptNode(Node): ) self.params["tools"] = tools prompt_params = [ - key for key, value in self.params.items() if value["type"] == "str" + key + for key, value in self.params.items() + if isinstance(value, str) and key != "format_instructions" ] else: prompt_params = ["template"] From 48d2ab27daeed601ab5f6532f3e055f9a6436ba1 Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Fri, 31 Mar 2023 23:16:16 -0300 Subject: [PATCH 5/9] fix: added options and cls to base_classes --- src/backend/langflow/template/base.py | 92 ++++++++++++++++++++++++++- src/backend/langflow/utils/util.py | 4 +- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/backend/langflow/template/base.py b/src/backend/langflow/template/base.py index bcd6ed162..e050be55e 100644 --- a/src/backend/langflow/template/base.py +++ b/src/backend/langflow/template/base.py @@ -1,5 +1,6 @@ from abc import ABC -from typing import Any, Union +from typing import Any, Optional, Union, Dict +from langflow.utils import constants from pydantic import BaseModel @@ -16,6 +17,7 @@ class TemplateFieldCreator(BaseModel, ABC): file_types: list[str] = [] content: Union[str, None] = None password: bool = False + options: list[str] = [] # _name will be used to store the name of the field # in the template name: str = "" @@ -36,6 +38,88 @@ class TemplateFieldCreator(BaseModel, ABC): result["content"] = self.content return result + def process_field( + self, key: str, value: Dict[str, Any], name: Optional[str] = None + ) -> None: + _type = value["type"] + + # Remove 'Optional' wrapper + if "Optional" in _type: + _type = _type.replace("Optional[", "")[:-1] + + # Check for list type + if "List" in _type: + _type = _type.replace("List[", "")[:-1] + self.is_list = True + else: + self.is_list = False + + # Replace 'Mapping' with 'dict' + if "Mapping" in _type: + _type = _type.replace("Mapping", "dict") + + # Change type from str to Tool + self.field_type = "Tool" if key in ["allowed_tools"] else _type + + self.field_type = "int" if key in ["max_value_length"] else self.field_type + + # Show or not field + self.show = bool( + (self.required and key not in ["input_variables"]) + or key + in [ + "allowed_tools", + "memory", + "prefix", + "examples", + "temperature", + "model_name", + "headers", + "max_value_length", + ] + or "api_key" in key + ) + + # Add password field + self.password = any( + text in key.lower() for text in ["password", "token", "api", "key"] + ) + + # Add multline + self.multiline = key in [ + "suffix", + "prefix", + "template", + "examples", + "code", + "headers", + ] + + # Replace dict type with str + if "dict" in self.field_type.lower(): + self.field_type = "code" + + if key == "dict_": + self.field_type = "file" + self.suffixes = [".json", ".yaml", ".yml"] + self.file_types = ["json", "yaml", "yml"] + + # Replace default value with actual value + if "default" in value: + self.value = value["default"] + + if key == "headers": + self.value = """{'Authorization': + 'Bearer '}""" + + # Add options to openai + if name == "OpenAI" and key == "model_name": + self.options = constants.OPENAI_MODELS + self.is_list = True + elif name == "OpenAIChat" and key == "model_name": + self.options = constants.CHAT_OPENAI_MODELS + self.is_list = True + class TemplateField(TemplateFieldCreator): pass @@ -45,7 +129,13 @@ class Template(BaseModel): type_name: str fields: list[TemplateField] + def process_fields(self, name: Optional[str] = None) -> None: + for field in self.fields: + signature = field.to_dict() + field.process_field(field.name, signature, name) + def to_dict(self): + self.process_fields(self.type_name) result = {field.name: field.to_dict() for field in self.fields} result["_type"] = self.type_name # type: ignore return result diff --git a/src/backend/langflow/utils/util.py b/src/backend/langflow/utils/util.py index 961058950..2fee19475 100644 --- a/src/backend/langflow/utils/util.py +++ b/src/backend/langflow/utils/util.py @@ -173,7 +173,7 @@ def get_base_classes(cls): result = [cls.__name__] if not result: result = [cls.__name__] - return list(set(result)) + return list(set(result + [cls.__name__])) def get_default_factory(module: str, function: str): @@ -333,8 +333,10 @@ def format_dict(d, name: Optional[str] = None): # Add options to openai if name == "OpenAI" and key == "model_name": value["options"] = constants.OPENAI_MODELS + value["list"] = True elif name == "OpenAIChat" and key == "model_name": value["options"] = constants.CHAT_OPENAI_MODELS + value["list"] = True return d From 0fc454f9b7fb48d613cec8ba8d2ece408386fd74 Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Sat, 1 Apr 2023 00:01:29 -0300 Subject: [PATCH 6/9] feat: added more tests for nodes --- tests/conftest.py | 3 + tests/data/Openapi.json | 445 ++++++++++++++++++++++++++++++++++++++++ tests/test_graph.py | 285 ++++++++++++++++++------- 3 files changed, 662 insertions(+), 71 deletions(-) create mode 100644 tests/data/Openapi.json diff --git a/tests/conftest.py b/tests/conftest.py index 3c9837957..e6eb3562f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,9 @@ def pytest_configure(): pytest.COMPLEX_EXAMPLE_PATH = ( Path(__file__).parent.absolute() / "data" / "complex_example.json" ) + pytest.OPENAPI_EXAMPLE_PATH = ( + Path(__file__).parent.absolute() / "data" / "Openapi.json" + ) pytest.CODE_WITH_SYNTAX_ERROR = """ def get_text(): diff --git a/tests/data/Openapi.json b/tests/data/Openapi.json new file mode 100644 index 000000000..d51404bbb --- /dev/null +++ b/tests/data/Openapi.json @@ -0,0 +1,445 @@ +{ + "description": "", + "name": "openapi", + "id": "1", + "data": { + "nodes": [ + { + "width": 384, + "height": 311, + "id": "dndnode_19", + "type": "genericNode", + "position": { + "x": -207.85635949789724, + "y": -105.73915116823618 + }, + "data": { + "type": "JsonToolkit", + "node": { + "template": { + "spec": { + "required": true, + "placeholder": "", + "show": true, + "multiline": false, + "password": false, + "name": "spec", + "type": "JsonSpec", + "list": false + }, + "_type": "JsonToolkit" + }, + "description": "Toolkit for interacting with a JSON spec.", + "base_classes": [ + "BaseToolkit" + ] + }, + "id": "dndnode_19", + "value": null + }, + "selected": false, + "positionAbsolute": { + "x": -207.85635949789724, + "y": -105.73915116823618 + }, + "dragging": false + }, + { + "width": 384, + "height": 351, + "id": "dndnode_32", + "type": "genericNode", + "position": { + "x": 745.308873444751, + "y": -37.007911201107675 + }, + "data": { + "type": "OpenAPIToolkit", + "node": { + "template": { + "json_agent": { + "required": true, + "placeholder": "", + "show": true, + "multiline": false, + "password": false, + "name": "json_agent", + "type": "AgentExecutor", + "list": false + }, + "requests_wrapper": { + "required": true, + "placeholder": "", + "show": true, + "multiline": false, + "password": false, + "name": "requests_wrapper", + "type": "RequestsWrapper", + "list": false + }, + "_type": "OpenAPIToolkit" + }, + "description": "Toolkit for interacting with a OpenAPI api.", + "base_classes": [ + "BaseToolkit" + ] + }, + "id": "dndnode_32", + "value": null + }, + "selected": false, + "positionAbsolute": { + "x": 745.308873444751, + "y": -37.007911201107675 + }, + "dragging": false + }, + { + "width": 384, + "height": 351, + "id": "dndnode_33", + "type": "genericNode", + "position": { + "x": 281.30887344475104, + "y": 2.9920887988923255 + }, + "data": { + "type": "JsonAgent", + "node": { + "template": { + "toolkit": { + "required": true, + "placeholder": "", + "show": true, + "multiline": false, + "password": false, + "name": "toolkit", + "type": "BaseToolkit", + "list": false + }, + "llm": { + "required": true, + "placeholder": "", + "show": true, + "multiline": false, + "password": false, + "name": "llm", + "type": "BaseLanguageModel", + "list": false + }, + "_type": "JsonAgent" + }, + "description": "Construct a json agent from an LLM and tools.", + "base_classes": [ + "AgentExecutor" + ] + }, + "id": "dndnode_33", + "value": null + }, + "selected": false, + "dragging": false, + "positionAbsolute": { + "x": 281.30887344475104, + "y": 2.9920887988923255 + } + }, + { + "width": 384, + "height": 349, + "id": "dndnode_34", + "type": "genericNode", + "position": { + "x": 301.30887344475104, + "y": 532.9920887988924 + }, + "data": { + "type": "RequestsWrapper", + "node": { + "template": { + "headers": { + "required": false, + "placeholder": "", + "show": true, + "multiline": true, + "value": "{'Authorization':\n 'Bearer '}", + "password": false, + "name": "headers", + "type": "code", + "list": false + }, + "aiosession": { + "required": false, + "placeholder": "", + "show": false, + "multiline": false, + "password": false, + "name": "aiosession", + "type": "ClientSession", + "list": false + }, + "_type": "RequestsWrapper" + }, + "description": "Lightweight wrapper around requests library.", + "base_classes": [ + "RequestsWrapper" + ] + }, + "id": "dndnode_34", + "value": null + }, + "positionAbsolute": { + "x": 301.30887344475104, + "y": 532.9920887988924 + } + }, + { + "width": 384, + "height": 407, + "id": "dndnode_35", + "type": "genericNode", + "position": { + "x": -754.691126555249, + "y": -37.00791120110762 + }, + "data": { + "type": "JsonSpec", + "node": { + "template": { + "dict_": { + "required": true, + "placeholder": "", + "show": true, + "multiline": false, + "value": "api-with-examples.yaml", + "suffixes": [ + ".json", + ".yaml", + ".yml" + ], + "password": false, + "name": "dict_", + "type": "file", + "list": false, + "fileTypes": [ + "json", + "yaml", + "yml" + ], + "content": "data:application/x-yaml;base64,b3BlbmFwaTogIjMuMC4wIgppbmZvOgogIHRpdGxlOiBTaW1wbGUgQVBJIG92ZXJ2aWV3CiAgdmVyc2lvbjogMi4wLjAKcGF0aHM6CiAgLzoKICAgIGdldDoKICAgICAgb3BlcmF0aW9uSWQ6IGxpc3RWZXJzaW9uc3YyCiAgICAgIHN1bW1hcnk6IExpc3QgQVBJIHZlcnNpb25zCiAgICAgIHJlc3BvbnNlczoKICAgICAgICAnMjAwJzoKICAgICAgICAgIGRlc2NyaXB0aW9uOiB8LQogICAgICAgICAgICAyMDAgcmVzcG9uc2UKICAgICAgICAgIGNvbnRlbnQ6CiAgICAgICAgICAgIGFwcGxpY2F0aW9uL2pzb246CiAgICAgICAgICAgICAgZXhhbXBsZXM6IAogICAgICAgICAgICAgICAgZm9vOgogICAgICAgICAgICAgICAgICB2YWx1ZToKICAgICAgICAgICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAgICAgICAidmVyc2lvbnMiOiBbCiAgICAgICAgICAgICAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICJzdGF0dXMiOiAiQ1VSUkVOVCIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAidXBkYXRlZCI6ICIyMDExLTAxLTIxVDExOjMzOjIxWiIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAiaWQiOiAidjIuMCIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAibGlua3MiOiBbCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAiaHJlZiI6ICJodHRwOi8vMTI3LjAuMC4xOjg3NzQvdjIvIiwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgInJlbCI6ICJzZWxmIgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICAgICAgICAgIF0KICAgICAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgInN0YXR1cyI6ICJFWFBFUklNRU5UQUwiLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgInVwZGF0ZWQiOiAiMjAxMy0wNy0yM1QxMTozMzoyMVoiLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgImlkIjogInYzLjAiLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgImxpbmtzIjogWwogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgImhyZWYiOiAiaHR0cDovLzEyNy4wLjAuMTo4Nzc0L3YzLyIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJyZWwiOiAic2VsZiIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBdCiAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICAgIF0KICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgJzMwMCc6CiAgICAgICAgICBkZXNjcmlwdGlvbjogfC0KICAgICAgICAgICAgMzAwIHJlc3BvbnNlCiAgICAgICAgICBjb250ZW50OgogICAgICAgICAgICBhcHBsaWNhdGlvbi9qc29uOiAKICAgICAgICAgICAgICBleGFtcGxlczogCiAgICAgICAgICAgICAgICBmb286CiAgICAgICAgICAgICAgICAgIHZhbHVlOiB8CiAgICAgICAgICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAgICAgInZlcnNpb25zIjogWwogICAgICAgICAgICAgICAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICJzdGF0dXMiOiAiQ1VSUkVOVCIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAidXBkYXRlZCI6ICIyMDExLTAxLTIxVDExOjMzOjIxWiIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAiaWQiOiAidjIuMCIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAibGlua3MiOiBbCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAiaHJlZiI6ICJodHRwOi8vMTI3LjAuMC4xOjg3NzQvdjIvIiwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgInJlbCI6ICJzZWxmIgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICAgICAgICAgIF0KICAgICAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgInN0YXR1cyI6ICJFWFBFUklNRU5UQUwiLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgInVwZGF0ZWQiOiAiMjAxMy0wNy0yM1QxMTozMzoyMVoiLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgImlkIjogInYzLjAiLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgImxpbmtzIjogWwogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgImhyZWYiOiAiaHR0cDovLzEyNy4wLjAuMTo4Nzc0L3YzLyIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJyZWwiOiAic2VsZiIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgICAgICAgICBdCiAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICBdCiAgICAgICAgICAgICAgICAgICB9CiAgL3YyOgogICAgZ2V0OgogICAgICBvcGVyYXRpb25JZDogZ2V0VmVyc2lvbkRldGFpbHN2MgogICAgICBzdW1tYXJ5OiBTaG93IEFQSSB2ZXJzaW9uIGRldGFpbHMKICAgICAgcmVzcG9uc2VzOgogICAgICAgICcyMDAnOgogICAgICAgICAgZGVzY3JpcHRpb246IHwtCiAgICAgICAgICAgIDIwMCByZXNwb25zZQogICAgICAgICAgY29udGVudDoKICAgICAgICAgICAgYXBwbGljYXRpb24vanNvbjogCiAgICAgICAgICAgICAgZXhhbXBsZXM6CiAgICAgICAgICAgICAgICBmb286CiAgICAgICAgICAgICAgICAgIHZhbHVlOgogICAgICAgICAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICAgICAgICJ2ZXJzaW9uIjogewogICAgICAgICAgICAgICAgICAgICAgICAic3RhdHVzIjogIkNVUlJFTlQiLAogICAgICAgICAgICAgICAgICAgICAgICAidXBkYXRlZCI6ICIyMDExLTAxLTIxVDExOjMzOjIxWiIsCiAgICAgICAgICAgICAgICAgICAgICAgICJtZWRpYS10eXBlcyI6IFsKICAgICAgICAgICAgICAgICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJiYXNlIjogImFwcGxpY2F0aW9uL3htbCIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJ0eXBlIjogImFwcGxpY2F0aW9uL3ZuZC5vcGVuc3RhY2suY29tcHV0ZSt4bWw7dmVyc2lvbj0yIgogICAgICAgICAgICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAiYmFzZSI6ICJhcHBsaWNhdGlvbi9qc29uIiwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgInR5cGUiOiAiYXBwbGljYXRpb24vdm5kLm9wZW5zdGFjay5jb21wdXRlK2pzb247dmVyc2lvbj0yIgogICAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICAgICAgXSwKICAgICAgICAgICAgICAgICAgICAgICAgImlkIjogInYyLjAiLAogICAgICAgICAgICAgICAgICAgICAgICAibGlua3MiOiBbCiAgICAgICAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAiaHJlZiI6ICJodHRwOi8vMTI3LjAuMC4xOjg3NzQvdjIvIiwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgInJlbCI6ICJzZWxmIgogICAgICAgICAgICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAiaHJlZiI6ICJodHRwOi8vZG9jcy5vcGVuc3RhY2sub3JnL2FwaS9vcGVuc3RhY2stY29tcHV0ZS8yL29zLWNvbXB1dGUtZGV2Z3VpZGUtMi5wZGYiLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAidHlwZSI6ICJhcHBsaWNhdGlvbi9wZGYiLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAicmVsIjogImRlc2NyaWJlZGJ5IgogICAgICAgICAgICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAiaHJlZiI6ICJodHRwOi8vZG9jcy5vcGVuc3RhY2sub3JnL2FwaS9vcGVuc3RhY2stY29tcHV0ZS8yL3dhZGwvb3MtY29tcHV0ZS0yLndhZGwiLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAidHlwZSI6ICJhcHBsaWNhdGlvbi92bmQuc3VuLndhZGwreG1sIiwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgInJlbCI6ICJkZXNjcmliZWRieSIKICAgICAgICAgICAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICJocmVmIjogImh0dHA6Ly9kb2NzLm9wZW5zdGFjay5vcmcvYXBpL29wZW5zdGFjay1jb21wdXRlLzIvd2FkbC9vcy1jb21wdXRlLTIud2FkbCIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAidHlwZSI6ICJhcHBsaWNhdGlvbi92bmQuc3VuLndhZGwreG1sIiwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICJyZWwiOiAiZGVzY3JpYmVkYnkiCiAgICAgICAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgICAgICAgICBdCiAgICAgICAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICcyMDMnOgogICAgICAgICAgZGVzY3JpcHRpb246IHwtCiAgICAgICAgICAgIDIwMyByZXNwb25zZQogICAgICAgICAgY29udGVudDoKICAgICAgICAgICAgYXBwbGljYXRpb24vanNvbjogCiAgICAgICAgICAgICAgZXhhbXBsZXM6CiAgICAgICAgICAgICAgICBmb286CiAgICAgICAgICAgICAgICAgIHZhbHVlOgogICAgICAgICAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICAgICAgICJ2ZXJzaW9uIjogewogICAgICAgICAgICAgICAgICAgICAgICAic3RhdHVzIjogIkNVUlJFTlQiLAogICAgICAgICAgICAgICAgICAgICAgICAidXBkYXRlZCI6ICIyMDExLTAxLTIxVDExOjMzOjIxWiIsCiAgICAgICAgICAgICAgICAgICAgICAgICJtZWRpYS10eXBlcyI6IFsKICAgICAgICAgICAgICAgICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJiYXNlIjogImFwcGxpY2F0aW9uL3htbCIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJ0eXBlIjogImFwcGxpY2F0aW9uL3ZuZC5vcGVuc3RhY2suY29tcHV0ZSt4bWw7dmVyc2lvbj0yIgogICAgICAgICAgICAgICAgICAgICAgICAgIH0sCiAgICAgICAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAiYmFzZSI6ICJhcHBsaWNhdGlvbi9qc29uIiwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgInR5cGUiOiAiYXBwbGljYXRpb24vdm5kLm9wZW5zdGFjay5jb21wdXRlK2pzb247dmVyc2lvbj0yIgogICAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICAgICAgXSwKICAgICAgICAgICAgICAgICAgICAgICAgImlkIjogInYyLjAiLAogICAgICAgICAgICAgICAgICAgICAgICAibGlua3MiOiBbCiAgICAgICAgICAgICAgICAgICAgICAgICAgewogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAiaHJlZiI6ICJodHRwOi8vMjMuMjUzLjIyOC4yMTE6ODc3NC92Mi8iLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAicmVsIjogInNlbGYiCiAgICAgICAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJocmVmIjogImh0dHA6Ly9kb2NzLm9wZW5zdGFjay5vcmcvYXBpL29wZW5zdGFjay1jb21wdXRlLzIvb3MtY29tcHV0ZS1kZXZndWlkZS0yLnBkZiIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJ0eXBlIjogImFwcGxpY2F0aW9uL3BkZiIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJyZWwiOiAiZGVzY3JpYmVkYnkiCiAgICAgICAgICAgICAgICAgICAgICAgICAgfSwKICAgICAgICAgICAgICAgICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJocmVmIjogImh0dHA6Ly9kb2NzLm9wZW5zdGFjay5vcmcvYXBpL29wZW5zdGFjay1jb21wdXRlLzIvd2FkbC9vcy1jb21wdXRlLTIud2FkbCIsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICJ0eXBlIjogImFwcGxpY2F0aW9uL3ZuZC5zdW4ud2FkbCt4bWwiLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAicmVsIjogImRlc2NyaWJlZGJ5IgogICAgICAgICAgICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgICAgICAgICAgXQogICAgICAgICAgICAgICAgICAgICAgfQogICAgICAgICAgICAgICAgICAgIH0K" + }, + "max_value_length": { + "required": false, + "placeholder": "", + "show": true, + "multiline": false, + "value": "4000", + "password": false, + "name": "max_value_length", + "type": "int", + "list": false + }, + "_type": "JsonSpec" + }, + "description": "", + "base_classes": [ + "Tool", + "JsonSpec" + ] + }, + "id": "dndnode_35", + "value": null + }, + "selected": false, + "dragging": false, + "positionAbsolute": { + "x": -754.691126555249, + "y": -37.00791120110762 + } + }, + { + "width": 384, + "height": 563, + "id": "dndnode_36", + "type": "genericNode", + "position": { + "x": -310.69112655524896, + "y": 514.9920887988924 + }, + "data": { + "type": "OpenAIChat", + "node": { + "template": { + "cache": { + "required": false, + "placeholder": "", + "show": false, + "multiline": false, + "password": false, + "name": "cache", + "type": "bool", + "list": false + }, + "verbose": { + "required": false, + "placeholder": "", + "show": false, + "multiline": false, + "value": false, + "password": false, + "name": "verbose", + "type": "bool", + "list": false + }, + "client": { + "required": false, + "placeholder": "", + "show": false, + "multiline": false, + "password": false, + "name": "client", + "type": "Any", + "list": false + }, + "model_name": { + "required": false, + "placeholder": "", + "show": true, + "multiline": false, + "value": "gpt-3.5-turbo", + "password": false, + "name": "model_name", + "type": "str", + "list": false + }, + "model_kwargs": { + "required": false, + "placeholder": "", + "show": false, + "multiline": false, + "password": false, + "name": "model_kwargs", + "type": "code", + "list": false + }, + "openai_api_key": { + "required": false, + "placeholder": "", + "show": true, + "multiline": false, + "password": false, + "name": "openai_api_key", + "type": "str", + "list": false, + "value": "sk-" + }, + "max_retries": { + "required": false, + "placeholder": "", + "show": false, + "multiline": false, + "value": 6, + "password": false, + "name": "max_retries", + "type": "int", + "list": false + }, + "prefix_messages": { + "required": false, + "placeholder": "", + "show": false, + "multiline": false, + "password": false, + "name": "prefix_messages", + "type": "Any", + "list": true + }, + "streaming": { + "required": false, + "placeholder": "", + "show": false, + "multiline": false, + "value": false, + "password": false, + "name": "streaming", + "type": "bool", + "list": false + }, + "_type": "OpenAIChat" + }, + "description": "Wrapper around OpenAI Chat large language models.To use, you should have the ``openai`` python package installed, and theenvironment variable ``OPENAI_API_KEY`` set with your API key.Any parameters that are valid to be passed to the openai.create call can be passedin, even if not explicitly saved on this class.", + "base_classes": [ + "BaseLanguageModel", + "BaseLLM" + ] + }, + "id": "dndnode_36", + "value": null + }, + "selected": false, + "dragging": false, + "positionAbsolute": { + "x": -310.69112655524896, + "y": 514.9920887988924 + } + } + ], + "edges": [ + { + "source": "dndnode_19", + "sourceHandle": "JsonToolkit|dndnode_19|BaseToolkit", + "target": "dndnode_33", + "targetHandle": "BaseToolkit|toolkit|dndnode_33", + "className": "animate-pulse", + "id": "reactflow__edge-dndnode_19JsonToolkit|dndnode_19|BaseToolkit-dndnode_33BaseToolkit|toolkit|dndnode_33", + "selected": false + }, + { + "source": "dndnode_33", + "sourceHandle": "JsonAgent|dndnode_33|AgentExecutor", + "target": "dndnode_32", + "targetHandle": "AgentExecutor|json_agent|dndnode_32", + "className": "animate-pulse", + "id": "reactflow__edge-dndnode_33JsonAgent|dndnode_33|AgentExecutor-dndnode_32AgentExecutor|json_agent|dndnode_32", + "selected": false + }, + { + "source": "dndnode_34", + "sourceHandle": "RequestsWrapper|dndnode_34|RequestsWrapper", + "target": "dndnode_32", + "targetHandle": "RequestsWrapper|requests_wrapper|dndnode_32", + "className": "animate-pulse", + "id": "reactflow__edge-dndnode_34RequestsWrapper|dndnode_34|RequestsWrapper-dndnode_32RequestsWrapper|requests_wrapper|dndnode_32", + "selected": false + }, + { + "source": "dndnode_35", + "sourceHandle": "JsonSpec|dndnode_35|Tool|JsonSpec", + "target": "dndnode_19", + "targetHandle": "JsonSpec|spec|dndnode_19", + "className": "animate-pulse", + "id": "reactflow__edge-dndnode_35JsonSpec|dndnode_35|Tool|JsonSpec-dndnode_19JsonSpec|spec|dndnode_19", + "selected": false + }, + { + "source": "dndnode_36", + "sourceHandle": "OpenAIChat|dndnode_36|BaseLanguageModel|BaseLLM", + "target": "dndnode_33", + "targetHandle": "BaseLanguageModel|llm|dndnode_33", + "className": "animate-pulse", + "id": "reactflow__edge-dndnode_36OpenAIChat|dndnode_36|BaseLanguageModel|BaseLLM-dndnode_33BaseLanguageModel|llm|dndnode_33" + } + ], + "viewport": { + "x": 0, + "y": 0, + "zoom": 1 + } + }, + "chat": [ + { + "message": "test", + "isSend": true + } + ] +} \ No newline at end of file diff --git a/tests/test_graph.py b/tests/test_graph.py index 6dbe8a7e3..9bc7b4220 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,16 +1,36 @@ import json +from langflow.graph.nodes import ( + WrapperNode, + AgentNode, + ToolNode, + ChainNode, + PromptNode, + LLMNode, + ToolkitNode, + FileToolNode, +) import pytest from langchain.agents import AgentExecutor from langflow.graph import Edge, Graph, Node from langflow.utils.payload import build_json, get_root_node + # Test cases for the graph module +# now we have three types of graph: +# BASIC_EXAMPLE_PATH, COMPLEX_EXAMPLE_PATH, OPENAPI_EXAMPLE_PATH -def get_graph(basic=True): + +def get_graph(_type="basic"): """Get a graph from a json file""" - path = pytest.BASIC_EXAMPLE_PATH if basic else pytest.COMPLEX_EXAMPLE_PATH + if _type == "basic": + path = pytest.BASIC_EXAMPLE_PATH + elif _type == "complex": + path = pytest.COMPLEX_EXAMPLE_PATH + elif _type == "openapi": + path = pytest.OPENAPI_EXAMPLE_PATH + with open(path, "r") as f: flow_graph = json.load(f) data_graph = flow_graph["data"] @@ -19,26 +39,94 @@ def get_graph(basic=True): return Graph(nodes, edges) -def test_get_nodes_with_target(): +@pytest.fixture +def basic_graph(): + return get_graph() + + +@pytest.fixture +def complex_graph(): + return get_graph("complex") + + +@pytest.fixture +def openapi_graph(): + return get_graph("openapi") + + +def get_node_by_type(graph, node_type): + """Get a node by type""" + return next((node for node in graph.nodes if isinstance(node, node_type)), None) + + +def test_graph_structure(basic_graph): + assert isinstance(basic_graph, Graph) + assert len(basic_graph.nodes) > 0 + assert len(basic_graph.edges) > 0 + for node in basic_graph.nodes: + assert isinstance(node, Node) + for edge in basic_graph.edges: + assert isinstance(edge, Edge) + assert edge.source in basic_graph.nodes + assert edge.target in basic_graph.nodes + + +def test_circular_dependencies(basic_graph): + assert isinstance(basic_graph, Graph) + + def check_circular(node, visited): + visited.add(node) + neighbors = basic_graph.get_nodes_with_target(node) + for neighbor in neighbors: + if neighbor in visited: + return True + if check_circular(neighbor, visited.copy()): + return True + return False + + for node in basic_graph.nodes: + assert not check_circular(node, set()) + + +def test_invalid_node_types(): + graph_data = { + "nodes": [ + { + "id": "1", + "data": { + "node": { + "base_classes": ["BaseClass"], + "template": { + "_type": "InvalidNodeType", + }, + }, + }, + }, + ], + "edges": [], + } + with pytest.raises(Exception): + Graph(graph_data["nodes"], graph_data["edges"]) + + +def test_get_nodes_with_target(basic_graph): """Test getting connected nodes""" - graph = get_graph() - assert isinstance(graph, Graph) + assert isinstance(basic_graph, Graph) # Get root node - root = get_root_node(graph) + root = get_root_node(basic_graph) assert root is not None - connected_nodes = graph.get_nodes_with_target(root) + connected_nodes = basic_graph.get_nodes_with_target(root) assert connected_nodes is not None -def test_get_node_neighbors_basic(): +def test_get_node_neighbors_basic(basic_graph): """Test getting node neighbors""" - graph = get_graph(basic=True) - assert isinstance(graph, Graph) + assert isinstance(basic_graph, Graph) # Get root node - root = get_root_node(graph) + root = get_root_node(basic_graph) assert root is not None - neighbors = graph.get_node_neighbors(root) + neighbors = basic_graph.get_node_neighbors(root) assert neighbors is not None assert isinstance(neighbors, dict) # Root Node is an Agent, it requires an LLMChain and tools @@ -57,7 +145,7 @@ def test_get_node_neighbors_basic(): for neighbor, val in neighbors.items() if "Chain" in neighbor.data["type"] and val ) - chain_neighbors = graph.get_node_neighbors(chain) + chain_neighbors = basic_graph.get_node_neighbors(chain) assert chain_neighbors is not None assert isinstance(chain_neighbors, dict) # Check if there is a LLM in the chain's neighbors @@ -74,15 +162,13 @@ def test_get_node_neighbors_basic(): ) -def test_get_node_neighbors_complex(): +def test_get_node_neighbors_complex(complex_graph): """Test getting node neighbors""" - - graph = get_graph(basic=False) - assert isinstance(graph, Graph) + assert isinstance(complex_graph, Graph) # Get root node - root = get_root_node(graph) + root = get_root_node(complex_graph) assert root is not None - neighbors = graph.get_nodes_with_target(root) + neighbors = complex_graph.get_nodes_with_target(root) assert neighbors is not None # Neighbors should be a list of nodes assert isinstance(neighbors, list) @@ -93,7 +179,7 @@ def test_get_node_neighbors_complex(): assert any("Tool" in neighbor.data["type"] for neighbor in neighbors) # Now on to the Chain's neighbors chain = next(neighbor for neighbor in neighbors if "Chain" in neighbor.data["type"]) - chain_neighbors = graph.get_nodes_with_target(chain) + chain_neighbors = complex_graph.get_nodes_with_target(chain) assert chain_neighbors is not None # Check if there is a LLM in the chain's neighbors assert any("OpenAI" in neighbor.data["type"] for neighbor in chain_neighbors) @@ -101,7 +187,7 @@ def test_get_node_neighbors_complex(): assert any("Prompt" in neighbor.data["type"] for neighbor in chain_neighbors) # Now on to the Tool's neighbors tool = next(neighbor for neighbor in neighbors if "Tool" in neighbor.data["type"]) - tool_neighbors = graph.get_nodes_with_target(tool) + tool_neighbors = complex_graph.get_nodes_with_target(tool) assert tool_neighbors is not None # Check if there is an Agent in the tool's neighbors assert any("Agent" in neighbor.data["type"] for neighbor in tool_neighbors) @@ -109,7 +195,7 @@ def test_get_node_neighbors_complex(): agent = next( neighbor for neighbor in tool_neighbors if "Agent" in neighbor.data["type"] ) - agent_neighbors = graph.get_nodes_with_target(agent) + agent_neighbors = complex_graph.get_nodes_with_target(agent) assert agent_neighbors is not None # Check if there is a Tool in the agent's neighbors assert any("Tool" in neighbor.data["type"] for neighbor in agent_neighbors) @@ -117,62 +203,57 @@ def test_get_node_neighbors_complex(): tool = next( neighbor for neighbor in agent_neighbors if "Tool" in neighbor.data["type"] ) - tool_neighbors = graph.get_nodes_with_target(tool) + tool_neighbors = complex_graph.get_nodes_with_target(tool) assert tool_neighbors is not None # Check if there is a PythonFunction in the tool's neighbors assert any("PythonFunction" in neighbor.data["type"] for neighbor in tool_neighbors) -def test_get_node(): +def test_get_node(basic_graph): """Test getting a single node""" - graph = get_graph() - node_id = graph.nodes[0].id - node = graph.get_node(node_id) + node_id = basic_graph.nodes[0].id + node = basic_graph.get_node(node_id) assert isinstance(node, Node) assert node.id == node_id -def test_build_nodes(): +def test_build_nodes(basic_graph): """Test building nodes""" - graph = get_graph() - assert len(graph.nodes) == len(graph._nodes) - for node in graph.nodes: + + assert len(basic_graph.nodes) == len(basic_graph._nodes) + for node in basic_graph.nodes: assert isinstance(node, Node) -def test_build_edges(): +def test_build_edges(basic_graph): """Test building edges""" - graph = get_graph() - assert len(graph.edges) == len(graph._edges) - for edge in graph.edges: + assert len(basic_graph.edges) == len(basic_graph._edges) + for edge in basic_graph.edges: assert isinstance(edge, Edge) assert isinstance(edge.source, Node) assert isinstance(edge.target, Node) -def test_get_root_node(): +def test_get_root_node(basic_graph, complex_graph): """Test getting root node""" - graph = get_graph(basic=True) - assert isinstance(graph, Graph) - root = get_root_node(graph) + assert isinstance(basic_graph, Graph) + root = get_root_node(basic_graph) assert root is not None assert isinstance(root, Node) assert root.data["type"] == "ZeroShotAgent" # For complex example, the root node is a ZeroShotAgent too - graph = get_graph(basic=False) - assert isinstance(graph, Graph) - root = get_root_node(graph) + assert isinstance(complex_graph, Graph) + root = get_root_node(complex_graph) assert root is not None assert isinstance(root, Node) assert root.data["type"] == "ZeroShotAgent" -def test_build_json(): +def test_build_json(basic_graph): """Test building JSON from graph""" - graph = get_graph() - assert isinstance(graph, Graph) - root = get_root_node(graph) - json_data = build_json(root, graph) + assert isinstance(basic_graph, Graph) + root = get_root_node(basic_graph) + json_data = build_json(root, basic_graph) assert isinstance(json_data, dict) assert json_data["_type"] == "zero-shot-react-description" assert isinstance(json_data["llm_chain"], dict) @@ -188,38 +269,37 @@ def test_build_json(): assert all(isinstance(val, str) for val in json_data["return_values"]) -def test_validate_edges(): +def test_validate_edges(basic_graph): """Test validating edges""" - graph = get_graph() - assert isinstance(graph, Graph) + + assert isinstance(basic_graph, Graph) # all edges should be valid - assert all(edge.valid for edge in graph.edges) + assert all(edge.valid for edge in basic_graph.edges) -def test_matched_type(): +def test_matched_type(basic_graph): """Test matched type attribute in Edge""" - graph = get_graph() - assert isinstance(graph, Graph) + assert isinstance(basic_graph, Graph) # all edges should be valid - assert all(edge.valid for edge in graph.edges) + assert all(edge.valid for edge in basic_graph.edges) # all edges should have a matched_type attribute - assert all(hasattr(edge, "matched_type") for edge in graph.edges) + assert all(hasattr(edge, "matched_type") for edge in basic_graph.edges) # The matched_type attribute should be in the source_types attr - assert all(edge.matched_type in edge.source_types for edge in graph.edges) + assert all(edge.matched_type in edge.source_types for edge in basic_graph.edges) -def test_build_params(): +def test_build_params(basic_graph): """Test building params""" - graph = get_graph() - assert isinstance(graph, Graph) + + assert isinstance(basic_graph, Graph) # all edges should be valid - assert all(edge.valid for edge in graph.edges) + assert all(edge.valid for edge in basic_graph.edges) # all edges should have a matched_type attribute - assert all(hasattr(edge, "matched_type") for edge in graph.edges) + assert all(hasattr(edge, "matched_type") for edge in basic_graph.edges) # The matched_type attribute should be in the source_types attr - assert all(edge.matched_type in edge.source_types for edge in graph.edges) + assert all(edge.matched_type in edge.source_types for edge in basic_graph.edges) # Get the root node - root = get_root_node(graph) + root = get_root_node(basic_graph) # Root node is a ZeroShotAgent # which requires an llm_chain, allowed_tools and return_values assert isinstance(root.params, dict) @@ -261,7 +341,7 @@ def test_build_params(): assert isinstance(llm_node.params["model_name"], str) -def test_build(): +def test_build(basic_graph, complex_graph): """Test Node's build method""" # def build(self): # # The params dict is used to build the module @@ -284,18 +364,81 @@ def test_build(): # # and instantiate it with the params # # and return the instance # return LANGCHAIN_TYPES_DICT[self.node_type](**self.params) - graph = get_graph() - assert isinstance(graph, Graph) + + assert isinstance(basic_graph, Graph) # Now we test the build method # Build the Agent - agent = graph.build() + agent = basic_graph.build() # The agent should be a AgentExecutor assert isinstance(agent, AgentExecutor) # Now we test the complex example - graph = get_graph(basic=False) - assert isinstance(graph, Graph) + assert isinstance(complex_graph, Graph) # Now we test the build method - agent = graph.build() + agent = complex_graph.build() # The agent should be a AgentExecutor assert isinstance(agent, AgentExecutor) + + +def test_agent_node_build(basic_graph): + agent_node = get_node_by_type(basic_graph, AgentNode) + assert agent_node is not None + built_object = agent_node.build() + assert built_object is not None + # Add any further assertions specific to the AgentNode's build() method + + +def test_tool_node_build(basic_graph): + tool_node = get_node_by_type(basic_graph, ToolNode) + assert tool_node is not None + built_object = tool_node.build() + assert built_object is not None + # Add any further assertions specific to the ToolNode's build() method + + +def test_chain_node_build(complex_graph): + chain_node = get_node_by_type(complex_graph, ChainNode) + assert chain_node is not None + built_object = chain_node.build() + assert built_object is not None + # Add any further assertions specific to the ChainNode's build() method + + +def test_prompt_node_build(complex_graph): + prompt_node = get_node_by_type(complex_graph, PromptNode) + assert prompt_node is not None + built_object = prompt_node.build() + assert built_object is not None + # Add any further assertions specific to the PromptNode's build() method + + +def test_llm_node_build(basic_graph): + llm_node = get_node_by_type(basic_graph, LLMNode) + assert llm_node is not None + built_object = llm_node.build() + assert built_object is not None + # Add any further assertions specific to the LLMNode's build() method + + +def test_toolkit_node_build(openapi_graph): + toolkit_node = get_node_by_type(openapi_graph, ToolkitNode) + assert toolkit_node is not None + built_object = toolkit_node.build() + assert built_object is not None + # Add any further assertions specific to the ToolkitNode's build() method + + +def test_file_tool_node_build(openapi_graph): + file_tool_node = get_node_by_type(openapi_graph, FileToolNode) + assert file_tool_node is not None + built_object = file_tool_node.build() + assert built_object is not None + # Add any further assertions specific to the FileToolNode's build() method + + +def test_wrapper_node_build(openapi_graph): + wrapper_node = get_node_by_type(openapi_graph, WrapperNode) + assert wrapper_node is not None + built_object = wrapper_node.build() + assert built_object is not None + # Add any further assertions specific to the WrapperNode's build() method From f30d2d333061601c59caa7a9548510f8778186b4 Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Sat, 1 Apr 2023 00:11:36 -0300 Subject: [PATCH 7/9] feat: a few more tests --- tests/test_template.py | 51 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/tests/test_template.py b/tests/test_template.py index 102df3b56..ed22bae3d 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -3,9 +3,10 @@ from pydantic import BaseModel import pytest import re import importlib -from typing import Dict +from typing import Dict, List, Optional from langflow.utils.util import ( build_template_from_class, + build_template_from_function, format_dict, get_base_classes, get_default_factory, @@ -26,6 +27,54 @@ class Child(Parent): child_field: int +class ExampleClass1(BaseModel): + """Example class 1.""" + + def __init__(self, data: Optional[List[int]] = None): + self.data = data or [1, 2, 3] + + +class ExampleClass2(BaseModel): + """Example class 2.""" + + def __init__(self, data: Optional[Dict[str, int]] = None): + self.data = data or {"a": 1, "b": 2, "c": 3} + + +def example_loader_1() -> ExampleClass1: + """Example loader function 1.""" + return ExampleClass1() + + +def example_loader_2() -> ExampleClass2: + """Example loader function 2.""" + return ExampleClass2() + + +def test_build_template_from_function(): + type_to_loader_dict = { + "example1": example_loader_1, + "example2": example_loader_2, + } + + # Test with valid name + result = build_template_from_function("ExampleClass1", type_to_loader_dict) + + assert "template" in result + assert "description" in result + assert "base_classes" in result + + # Test with add_function=True + result_with_function = build_template_from_function( + "ExampleClass1", type_to_loader_dict, add_function=True + ) + assert "function" in result_with_function["base_classes"] + + # Test with invalid name + with pytest.raises(ValueError, match=r".* not found"): + build_template_from_function("NonExistent", type_to_loader_dict) + + # Test build_template_from_class def test_build_template_from_class(): type_to_cls_dict: Dict[str, type] = {"parent": Parent, "child": Child} From 5125fde38af62a66373971fe9e2f134e0435392c Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Sat, 1 Apr 2023 00:11:55 -0300 Subject: [PATCH 8/9] fix: base_classes were being passed incorrectly --- src/backend/langflow/utils/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/langflow/utils/util.py b/src/backend/langflow/utils/util.py index 2fee19475..2f6b66a43 100644 --- a/src/backend/langflow/utils/util.py +++ b/src/backend/langflow/utils/util.py @@ -98,7 +98,7 @@ def build_template_from_function( return { "template": format_dict(variables, name), "description": docs["Description"], - "base_classes": get_base_classes(_class), + "base_classes": base_classes, } From 2235767d22913b52eafd12a55de18141697fa120 Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Sat, 1 Apr 2023 10:58:46 -0300 Subject: [PATCH 9/9] feat: implemented caching of agent --- src/backend/langflow/cache/__init__.py | 0 src/backend/langflow/cache/utils.py | 49 ++++++++++++++++++++ src/backend/langflow/interface/run.py | 38 +++++++++++---- tests/test_cache.py | 64 ++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 src/backend/langflow/cache/__init__.py create mode 100644 src/backend/langflow/cache/utils.py create mode 100644 tests/test_cache.py diff --git a/src/backend/langflow/cache/__init__.py b/src/backend/langflow/cache/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/langflow/cache/utils.py b/src/backend/langflow/cache/utils.py new file mode 100644 index 000000000..2c4acd074 --- /dev/null +++ b/src/backend/langflow/cache/utils.py @@ -0,0 +1,49 @@ +import contextlib +import hashlib +import json +import os +from pathlib import Path +import tempfile +import dill + +PREFIX = "langflow_cache" + + +def clear_old_cache_files(max_cache_size: int = 10): + cache_dir = Path(tempfile.gettempdir()) + cache_files = list(cache_dir.glob(f"{PREFIX}_*.dill")) + + if len(cache_files) > max_cache_size: + cache_files_sorted_by_mtime = sorted( + cache_files, key=lambda x: x.stat().st_mtime, reverse=True + ) + + for cache_file in cache_files_sorted_by_mtime[max_cache_size:]: + with contextlib.suppress(OSError): + os.remove(cache_file) + + +def remove_position_info(node): + node.pop("position", None) + + +def compute_hash(graph_data): + for node in graph_data["nodes"]: + remove_position_info(node) + + cleaned_graph_json = json.dumps(graph_data, sort_keys=True) + return hashlib.sha256(cleaned_graph_json.encode("utf-8")).hexdigest() + + +def save_cache(hash_val, chat_data): + cache_path = Path(tempfile.gettempdir()) / f"{PREFIX}_{hash_val}.dill" + with cache_path.open("wb") as cache_file: + dill.dump(chat_data, cache_file) + + +def load_cache(hash_val): + cache_path = Path(tempfile.gettempdir()) / f"{PREFIX}_{hash_val}.dill" + if cache_path.exists(): + with cache_path.open("rb") as cache_file: + return dill.load(cache_file) + return None diff --git a/src/backend/langflow/interface/run.py b/src/backend/langflow/interface/run.py index 360bad364..998ee297d 100644 --- a/src/backend/langflow/interface/run.py +++ b/src/backend/langflow/interface/run.py @@ -2,30 +2,48 @@ import contextlib import io import re from typing import Any, Dict +from langflow.cache.utils import compute_hash, load_cache, save_cache from langflow.graph.graph import Graph from langflow.interface import loading from langflow.utils import payload +def load_langchain_object(data_graph): + computed_hash = compute_hash(data_graph) + + # Load langchain_object from cache if it exists + langchain_object = load_cache(computed_hash) + if langchain_object is None: + nodes = data_graph["nodes"] + # Add input variables + nodes = payload.extract_input_variables(nodes) + # Nodes, edges and root node + edges = data_graph["edges"] + graph = Graph(nodes, edges) + + langchain_object = graph.build() + + return computed_hash, langchain_object + + def process_graph(data_graph: Dict[str, Any]): """ Process graph by extracting input variables and replacing ZeroShotPrompt with PromptTemplate,then run the graph and return the result and thought. """ - nodes = data_graph["nodes"] - # Add input variables - # ? Is this necessary? - nodes = payload.extract_input_variables(nodes) - # Nodes, edges and root node - edges = data_graph["edges"] - graph = Graph(nodes, edges) - - langchain_object = graph.build() + # Load langchain object + computed_hash, langchain_object = load_langchain_object(data_graph) message = data_graph["message"] - # Process json + + # Generate result and thought result, thought = get_result_and_thought_using_graph(langchain_object, message) + # Save langchain_object to cache + # We have to save it here because if the + # memory is updated we need to keep the new values + save_cache(computed_hash, langchain_object) + return { "result": result, "thought": re.sub( diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 000000000..0d9102b49 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,64 @@ +import json +import hashlib +from pathlib import Path +import dill +import tempfile +from langflow.cache.utils import compute_hash, load_cache, save_cache, PREFIX +from langflow.interface.run import load_langchain_object, process_graph +import pytest + + +def get_graph(_type="basic"): + """Get a graph from a json file""" + if _type == "basic": + path = pytest.BASIC_EXAMPLE_PATH + elif _type == "complex": + path = pytest.COMPLEX_EXAMPLE_PATH + elif _type == "openapi": + path = pytest.OPENAPI_EXAMPLE_PATH + + with open(path, "r") as f: + flow_graph = json.load(f) + return flow_graph["data"] + + +@pytest.fixture +def basic_data_graph(): + return get_graph() + + +@pytest.fixture +def complex_data_graph(): + return get_graph("complex") + + +@pytest.fixture +def openapi_data_graph(): + return get_graph("openapi") + + +def langchain_objects_are_equal(obj1, obj2): + return str(obj1) == str(obj2) + + +def test_cache_creation(basic_data_graph): + # Compute hash for the input data_graph + computed_hash = compute_hash(basic_data_graph) + + # Call process_graph function to build and cache the langchain_object + _ = load_langchain_object(basic_data_graph) + + # Check if the cache file exists + cache_file = Path(tempfile.gettempdir()) / f"{PREFIX}_{computed_hash}.dill" + assert cache_file.exists() + + +def test_cache_reuse(basic_data_graph): + # Call process_graph function to build and cache the langchain_object + result1 = load_langchain_object(basic_data_graph) + + # Call process_graph function again to use the cached langchain_object + result2 = load_langchain_object(basic_data_graph) + + # Compare the results to ensure the same langchain_object was used + assert langchain_objects_are_equal(result1, result2)