vellum-ai 1.2.1__py3-none-any.whl → 1.2.3__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.
- vellum/__init__.py +40 -0
- vellum/client/core/client_wrapper.py +2 -2
- vellum/client/core/pydantic_utilities.py +3 -2
- vellum/client/reference.md +16 -0
- vellum/client/resources/workflow_executions/client.py +28 -4
- vellum/client/resources/workflow_executions/raw_client.py +32 -2
- vellum/client/types/__init__.py +40 -0
- vellum/client/types/audio_input_request.py +30 -0
- vellum/client/types/delimiter_chunker_config.py +20 -0
- vellum/client/types/delimiter_chunker_config_request.py +20 -0
- vellum/client/types/delimiter_chunking.py +21 -0
- vellum/client/types/delimiter_chunking_request.py +21 -0
- vellum/client/types/document_index_chunking.py +4 -1
- vellum/client/types/document_index_chunking_request.py +2 -1
- vellum/client/types/document_input_request.py +30 -0
- vellum/client/types/execution_audio_vellum_value.py +31 -0
- vellum/client/types/execution_document_vellum_value.py +31 -0
- vellum/client/types/execution_image_vellum_value.py +31 -0
- vellum/client/types/execution_vellum_value.py +8 -0
- vellum/client/types/execution_video_vellum_value.py +31 -0
- vellum/client/types/image_input_request.py +30 -0
- vellum/client/types/logical_operator.py +1 -0
- vellum/client/types/node_input_compiled_audio_value.py +23 -0
- vellum/client/types/node_input_compiled_document_value.py +23 -0
- vellum/client/types/node_input_compiled_image_value.py +23 -0
- vellum/client/types/node_input_compiled_video_value.py +23 -0
- vellum/client/types/node_input_variable_compiled_value.py +8 -0
- vellum/client/types/prompt_deployment_input_request.py +13 -1
- vellum/client/types/prompt_request_audio_input.py +26 -0
- vellum/client/types/prompt_request_document_input.py +26 -0
- vellum/client/types/prompt_request_image_input.py +26 -0
- vellum/client/types/prompt_request_input.py +13 -1
- vellum/client/types/prompt_request_video_input.py +26 -0
- vellum/client/types/video_input_request.py +30 -0
- vellum/prompts/blocks/compilation.py +13 -11
- vellum/types/audio_input_request.py +3 -0
- vellum/types/delimiter_chunker_config.py +3 -0
- vellum/types/delimiter_chunker_config_request.py +3 -0
- vellum/types/delimiter_chunking.py +3 -0
- vellum/types/delimiter_chunking_request.py +3 -0
- vellum/types/document_input_request.py +3 -0
- vellum/types/execution_audio_vellum_value.py +3 -0
- vellum/types/execution_document_vellum_value.py +3 -0
- vellum/types/execution_image_vellum_value.py +3 -0
- vellum/types/execution_video_vellum_value.py +3 -0
- vellum/types/image_input_request.py +3 -0
- vellum/types/node_input_compiled_audio_value.py +3 -0
- vellum/types/node_input_compiled_document_value.py +3 -0
- vellum/types/node_input_compiled_image_value.py +3 -0
- vellum/types/node_input_compiled_video_value.py +3 -0
- vellum/types/prompt_request_audio_input.py +3 -0
- vellum/types/prompt_request_document_input.py +3 -0
- vellum/types/prompt_request_image_input.py +3 -0
- vellum/types/prompt_request_video_input.py +3 -0
- vellum/types/video_input_request.py +3 -0
- vellum/workflows/context.py +27 -9
- vellum/workflows/emitters/vellum_emitter.py +16 -69
- vellum/workflows/events/context.py +53 -78
- vellum/workflows/events/node.py +5 -5
- vellum/workflows/events/relational_threads.py +41 -0
- vellum/workflows/events/tests/test_basic_workflow.py +50 -0
- vellum/workflows/events/tests/test_event.py +1 -0
- vellum/workflows/events/workflow.py +15 -1
- vellum/workflows/expressions/contains.py +7 -0
- vellum/workflows/expressions/tests/test_contains.py +175 -0
- vellum/workflows/graph/graph.py +52 -8
- vellum/workflows/graph/tests/test_graph.py +17 -0
- vellum/workflows/integrations/mcp_service.py +35 -5
- vellum/workflows/integrations/tests/test_mcp_service.py +81 -0
- vellum/workflows/nodes/bases/base.py +0 -1
- vellum/workflows/nodes/core/error_node/node.py +4 -0
- vellum/workflows/nodes/core/inline_subworkflow_node/tests/test_node.py +35 -0
- vellum/workflows/nodes/core/map_node/node.py +7 -0
- vellum/workflows/nodes/core/map_node/tests/test_node.py +19 -0
- vellum/workflows/nodes/displayable/bases/utils.py +4 -2
- vellum/workflows/nodes/displayable/final_output_node/node.py +4 -0
- vellum/workflows/nodes/displayable/subworkflow_deployment_node/node.py +88 -2
- vellum/workflows/nodes/displayable/tool_calling_node/node.py +1 -0
- vellum/workflows/nodes/displayable/tool_calling_node/tests/test_node.py +85 -1
- vellum/workflows/nodes/displayable/tool_calling_node/tests/test_utils.py +12 -0
- vellum/workflows/nodes/displayable/tool_calling_node/utils.py +5 -2
- vellum/workflows/ports/node_ports.py +3 -0
- vellum/workflows/ports/port.py +8 -11
- vellum/workflows/state/context.py +47 -2
- vellum/workflows/types/definition.py +4 -4
- vellum/workflows/utils/uuids.py +15 -0
- vellum/workflows/utils/vellum_variables.py +5 -3
- vellum/workflows/workflows/base.py +1 -0
- {vellum_ai-1.2.1.dist-info → vellum_ai-1.2.3.dist-info}/METADATA +1 -1
- {vellum_ai-1.2.1.dist-info → vellum_ai-1.2.3.dist-info}/RECORD +128 -82
- vellum_ee/workflows/display/nodes/base_node_display.py +19 -10
- vellum_ee/workflows/display/nodes/vellum/api_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/code_execution_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/conditional_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/error_node.py +1 -3
- vellum_ee/workflows/display/nodes/vellum/final_output_node.py +1 -3
- vellum_ee/workflows/display/nodes/vellum/guardrail_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/inline_prompt_node.py +1 -8
- vellum_ee/workflows/display/nodes/vellum/inline_subworkflow_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/map_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/merge_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/note_node.py +2 -4
- vellum_ee/workflows/display/nodes/vellum/prompt_deployment_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/search_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/subworkflow_deployment_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/templating_node.py +1 -4
- vellum_ee/workflows/display/nodes/vellum/tests/test_code_execution_node.py +1 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_api_node_serialization.py +4 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_code_execution_node_serialization.py +12 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_conditional_node_serialization.py +16 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_error_node_serialization.py +5 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_guardrail_node_serialization.py +4 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_inline_subworkflow_serialization.py +4 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_map_node_serialization.py +4 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_merge_node_serialization.py +4 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_prompt_deployment_serialization.py +12 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_search_node_serialization.py +4 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_subworkflow_deployment_serialization.py +4 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_templating_node_serialization.py +4 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_terminal_node_serialization.py +5 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_basic_tool_calling_node_composio_serialization.py +1 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_complex_terminal_node_serialization.py +5 -0
- vellum_ee/workflows/display/utils/events.py +24 -0
- vellum_ee/workflows/display/utils/tests/test_events.py +69 -0
- vellum_ee/workflows/tests/test_server.py +95 -0
- {vellum_ai-1.2.1.dist-info → vellum_ai-1.2.3.dist-info}/LICENSE +0 -0
- {vellum_ai-1.2.1.dist-info → vellum_ai-1.2.3.dist-info}/WHEEL +0 -0
- {vellum_ai-1.2.1.dist-info → vellum_ai-1.2.3.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,175 @@
|
|
1
|
+
import pytest
|
2
|
+
|
3
|
+
from vellum.workflows.constants import undefined
|
4
|
+
from vellum.workflows.descriptors.exceptions import InvalidExpressionException
|
5
|
+
from vellum.workflows.expressions.contains import ContainsExpression
|
6
|
+
from vellum.workflows.references.constant import ConstantValueReference
|
7
|
+
from vellum.workflows.state.base import BaseState
|
8
|
+
|
9
|
+
|
10
|
+
class TestState(BaseState):
|
11
|
+
dict_value: dict = {"key": "value"}
|
12
|
+
list_value: list = [1, 2, 3]
|
13
|
+
string_value: str = "hello world"
|
14
|
+
|
15
|
+
|
16
|
+
def test_dict_contains_dict_raises_error():
|
17
|
+
"""
|
18
|
+
Tests that ContainsExpression raises clear error for dict-contains-dict scenarios.
|
19
|
+
"""
|
20
|
+
state = TestState()
|
21
|
+
lhs_dict = {"foo": "bar"}
|
22
|
+
rhs_dict = {"foo": "bar"}
|
23
|
+
|
24
|
+
expression = ContainsExpression(lhs=lhs_dict, rhs=rhs_dict)
|
25
|
+
|
26
|
+
with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
|
27
|
+
expression.resolve(state)
|
28
|
+
|
29
|
+
|
30
|
+
def test_dict_contains_different_dict_raises_error():
|
31
|
+
"""
|
32
|
+
Tests that ContainsExpression raises clear error for different dict-contains-dict scenarios.
|
33
|
+
"""
|
34
|
+
state = TestState()
|
35
|
+
lhs_dict = {"foo": "bar"}
|
36
|
+
rhs_dict = {"hello": "world"}
|
37
|
+
|
38
|
+
expression = ContainsExpression(lhs=lhs_dict, rhs=rhs_dict)
|
39
|
+
|
40
|
+
with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
|
41
|
+
expression.resolve(state)
|
42
|
+
|
43
|
+
|
44
|
+
def test_string_contains_dict_raises_error():
|
45
|
+
"""
|
46
|
+
Tests that ContainsExpression raises clear error for string-contains-dict scenarios.
|
47
|
+
"""
|
48
|
+
state = TestState()
|
49
|
+
lhs_string = 'Response: {"status": "success"} was returned'
|
50
|
+
rhs_dict = {"status": "success"}
|
51
|
+
|
52
|
+
expression = ContainsExpression(lhs=lhs_string, rhs=rhs_dict)
|
53
|
+
|
54
|
+
with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
|
55
|
+
expression.resolve(state)
|
56
|
+
|
57
|
+
|
58
|
+
def test_nested_dict_contains_dict_raises_error():
|
59
|
+
"""
|
60
|
+
Tests that ContainsExpression raises clear error for nested dict scenarios.
|
61
|
+
"""
|
62
|
+
state = TestState()
|
63
|
+
lhs_dict = {"user": {"name": "john", "age": 30}}
|
64
|
+
rhs_dict = {"age": 30, "name": "john"}
|
65
|
+
|
66
|
+
expression = ContainsExpression(lhs=lhs_dict, rhs=rhs_dict)
|
67
|
+
|
68
|
+
with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
|
69
|
+
expression.resolve(state)
|
70
|
+
|
71
|
+
|
72
|
+
def test_list_contains_string():
|
73
|
+
"""
|
74
|
+
Tests that ContainsExpression preserves original list functionality.
|
75
|
+
"""
|
76
|
+
state = TestState()
|
77
|
+
|
78
|
+
expression = TestState.list_value.contains(2)
|
79
|
+
result = expression.resolve(state)
|
80
|
+
|
81
|
+
assert result is True
|
82
|
+
|
83
|
+
|
84
|
+
def test_string_contains_substring():
|
85
|
+
"""
|
86
|
+
Tests that ContainsExpression preserves original string functionality.
|
87
|
+
"""
|
88
|
+
state = TestState()
|
89
|
+
|
90
|
+
expression = TestState.string_value.contains("world")
|
91
|
+
result = expression.resolve(state)
|
92
|
+
|
93
|
+
assert result is True
|
94
|
+
|
95
|
+
|
96
|
+
def test_set_contains_item():
|
97
|
+
"""
|
98
|
+
Tests that ContainsExpression works with sets.
|
99
|
+
"""
|
100
|
+
state = TestState()
|
101
|
+
lhs_set = {1, 2, 3}
|
102
|
+
rhs_item = 2
|
103
|
+
|
104
|
+
expression = ContainsExpression(lhs=lhs_set, rhs=rhs_item)
|
105
|
+
result = expression.resolve(state)
|
106
|
+
|
107
|
+
assert result is True
|
108
|
+
|
109
|
+
|
110
|
+
def test_tuple_contains_item():
|
111
|
+
"""
|
112
|
+
Tests that ContainsExpression works with tuples.
|
113
|
+
"""
|
114
|
+
state = TestState()
|
115
|
+
lhs_tuple = (1, 2, 3)
|
116
|
+
rhs_item = 2
|
117
|
+
|
118
|
+
expression = ContainsExpression(lhs=lhs_tuple, rhs=rhs_item)
|
119
|
+
result = expression.resolve(state)
|
120
|
+
|
121
|
+
assert result is True
|
122
|
+
|
123
|
+
|
124
|
+
def test_invalid_lhs_type():
|
125
|
+
"""
|
126
|
+
Tests that ContainsExpression raises exception for invalid LHS types.
|
127
|
+
"""
|
128
|
+
|
129
|
+
class NoContainsSupport:
|
130
|
+
pass
|
131
|
+
|
132
|
+
state = TestState()
|
133
|
+
no_contains_obj = NoContainsSupport()
|
134
|
+
expression = ContainsExpression(lhs=no_contains_obj, rhs="test")
|
135
|
+
|
136
|
+
with pytest.raises(
|
137
|
+
InvalidExpressionException, match="Expected a LHS that supported `contains`, got `NoContainsSupport`"
|
138
|
+
):
|
139
|
+
expression.resolve(state)
|
140
|
+
|
141
|
+
|
142
|
+
def test_undefined_lhs_returns_false():
|
143
|
+
"""
|
144
|
+
Tests that ContainsExpression returns False for undefined LHS.
|
145
|
+
"""
|
146
|
+
state = TestState()
|
147
|
+
expression = ContainsExpression(lhs=undefined, rhs="test")
|
148
|
+
|
149
|
+
result = expression.resolve(state)
|
150
|
+
|
151
|
+
assert result is False
|
152
|
+
|
153
|
+
|
154
|
+
def test_contains_with_constant_value_reference():
|
155
|
+
"""
|
156
|
+
Tests ContainsExpression with ConstantValueReference for valid operations.
|
157
|
+
"""
|
158
|
+
state = TestState()
|
159
|
+
lhs_ref = ConstantValueReference([1, 2, 3])
|
160
|
+
rhs_ref = ConstantValueReference(2)
|
161
|
+
|
162
|
+
expression: ContainsExpression = ContainsExpression(lhs=lhs_ref, rhs=rhs_ref)
|
163
|
+
result = expression.resolve(state)
|
164
|
+
|
165
|
+
assert result is True
|
166
|
+
|
167
|
+
|
168
|
+
def test_expression_metadata():
|
169
|
+
"""
|
170
|
+
Tests that ContainsExpression has correct name and types properties.
|
171
|
+
"""
|
172
|
+
expression = ContainsExpression(lhs=[1, 2, 3], rhs=2)
|
173
|
+
|
174
|
+
assert expression.name == "[1, 2, 3] contains 2"
|
175
|
+
assert expression.types == (bool,)
|
vellum/workflows/graph/graph.py
CHANGED
@@ -9,40 +9,67 @@ if TYPE_CHECKING:
|
|
9
9
|
from vellum.workflows.nodes.bases.base import BaseNode
|
10
10
|
from vellum.workflows.ports.port import Port
|
11
11
|
|
12
|
+
|
13
|
+
class NoPortsNode:
|
14
|
+
"""Wrapper for nodes that have no ports defined."""
|
15
|
+
|
16
|
+
def __init__(self, node_class: Type["BaseNode"]):
|
17
|
+
self.node_class = node_class
|
18
|
+
|
19
|
+
def __repr__(self) -> str:
|
20
|
+
return self.node_class.__name__
|
21
|
+
|
22
|
+
def __rshift__(self, other: "GraphTarget") -> "Graph":
|
23
|
+
raise ValueError(
|
24
|
+
f"Cannot create edges from {self.node_class.__name__} because it has no ports defined. "
|
25
|
+
f"Nodes with empty Ports classes cannot be connected to other nodes."
|
26
|
+
)
|
27
|
+
|
28
|
+
|
12
29
|
GraphTargetOfSets = Union[
|
13
30
|
Set[NodeType],
|
14
31
|
Set["Graph"],
|
15
32
|
Set["Port"],
|
16
|
-
Set[Union[Type["BaseNode"], "Graph", "Port"]],
|
33
|
+
Set[Union[Type["BaseNode"], "Graph", "Port", "NoPortsNode"]],
|
17
34
|
]
|
18
35
|
|
19
36
|
GraphTarget = Union[
|
20
37
|
Type["BaseNode"],
|
21
38
|
"Port",
|
22
39
|
"Graph",
|
40
|
+
"NoPortsNode",
|
23
41
|
GraphTargetOfSets,
|
24
42
|
]
|
25
43
|
|
26
44
|
|
27
45
|
class Graph:
|
28
|
-
_entrypoints: Set["Port"]
|
46
|
+
_entrypoints: Set[Union["Port", "NoPortsNode"]]
|
29
47
|
_edges: List[Edge]
|
30
|
-
_terminals: Set["Port"]
|
31
|
-
|
32
|
-
def __init__(
|
48
|
+
_terminals: Set[Union["Port", "NoPortsNode"]]
|
49
|
+
|
50
|
+
def __init__(
|
51
|
+
self,
|
52
|
+
entrypoints: Set[Union["Port", "NoPortsNode"]],
|
53
|
+
edges: List[Edge],
|
54
|
+
terminals: Set[Union["Port", "NoPortsNode"]],
|
55
|
+
):
|
33
56
|
self._edges = edges
|
34
57
|
self._entrypoints = entrypoints
|
35
58
|
self._terminals = terminals
|
36
59
|
|
37
60
|
@staticmethod
|
38
61
|
def from_port(port: "Port") -> "Graph":
|
39
|
-
ports = {port}
|
62
|
+
ports: Set[Union["Port", "NoPortsNode"]] = {port}
|
40
63
|
return Graph(entrypoints=ports, edges=[], terminals=ports)
|
41
64
|
|
42
65
|
@staticmethod
|
43
66
|
def from_node(node: Type["BaseNode"]) -> "Graph":
|
44
67
|
ports = {port for port in node.Ports}
|
45
|
-
|
68
|
+
if not ports:
|
69
|
+
no_ports_node = NoPortsNode(node)
|
70
|
+
return Graph(entrypoints={no_ports_node}, edges=[], terminals={no_ports_node})
|
71
|
+
ports_set: Set[Union["Port", "NoPortsNode"]] = set(ports)
|
72
|
+
return Graph(entrypoints=ports_set, edges=[], terminals=ports_set)
|
46
73
|
|
47
74
|
@staticmethod
|
48
75
|
def from_set(targets: GraphTargetOfSets) -> "Graph":
|
@@ -73,10 +100,19 @@ class Graph:
|
|
73
100
|
if not self._edges and not self._entrypoints:
|
74
101
|
raise ValueError("Graph instance can only create new edges from nodes within existing edges")
|
75
102
|
|
103
|
+
if self._terminals and all(isinstance(terminal, NoPortsNode) for terminal in self._terminals):
|
104
|
+
terminal_names = [terminal.node_class.__name__ for terminal in self._terminals]
|
105
|
+
raise ValueError(
|
106
|
+
f"Cannot create edges from graph because all terminal nodes have no ports defined: "
|
107
|
+
f"{', '.join(terminal_names)}. Nodes with empty Ports classes cannot be connected to other nodes."
|
108
|
+
)
|
109
|
+
|
76
110
|
if isinstance(other, set):
|
77
111
|
new_terminals = set()
|
78
112
|
for elem in other:
|
79
113
|
for final_output_node in self._terminals:
|
114
|
+
if isinstance(final_output_node, NoPortsNode):
|
115
|
+
continue
|
80
116
|
if isinstance(elem, Graph):
|
81
117
|
midgraph = final_output_node >> set(elem.entrypoints)
|
82
118
|
self._extend_edges(midgraph.edges)
|
@@ -98,6 +134,8 @@ class Graph:
|
|
98
134
|
|
99
135
|
if isinstance(other, Graph):
|
100
136
|
for final_output_node in self._terminals:
|
137
|
+
if isinstance(final_output_node, NoPortsNode):
|
138
|
+
continue
|
101
139
|
midgraph = final_output_node >> set(other.entrypoints)
|
102
140
|
self._extend_edges(midgraph.edges)
|
103
141
|
self._extend_edges(other.edges)
|
@@ -106,6 +144,8 @@ class Graph:
|
|
106
144
|
|
107
145
|
if hasattr(other, "Ports"):
|
108
146
|
for final_output_node in self._terminals:
|
147
|
+
if isinstance(final_output_node, NoPortsNode):
|
148
|
+
continue
|
109
149
|
subgraph = final_output_node >> other
|
110
150
|
self._extend_edges(subgraph.edges)
|
111
151
|
self._terminals = {port for port in other.Ports}
|
@@ -113,6 +153,8 @@ class Graph:
|
|
113
153
|
|
114
154
|
# other is a Port
|
115
155
|
for final_output_node in self._terminals:
|
156
|
+
if isinstance(final_output_node, NoPortsNode):
|
157
|
+
continue
|
116
158
|
subgraph = final_output_node >> other
|
117
159
|
self._extend_edges(subgraph.edges)
|
118
160
|
self._terminals = {other}
|
@@ -238,8 +280,10 @@ class Graph:
|
|
238
280
|
|
239
281
|
return "\n".join(lines)
|
240
282
|
|
241
|
-
def _get_port_name(self, port: "Port") -> str:
|
283
|
+
def _get_port_name(self, port: Union["Port", "NoPortsNode"]) -> str:
|
242
284
|
"""Get a readable name for a port."""
|
285
|
+
if isinstance(port, NoPortsNode):
|
286
|
+
return f"{port.node_class.__name__} (no ports)"
|
243
287
|
try:
|
244
288
|
if hasattr(port, "node_class") and hasattr(port.node_class, "__name__"):
|
245
289
|
node_name = port.node_class.__name__
|
@@ -583,3 +583,20 @@ def test_graph__str_single_node():
|
|
583
583
|
# THEN it shows the single node
|
584
584
|
assert "SingleNode.default" in result
|
585
585
|
assert "Graph:" in result
|
586
|
+
|
587
|
+
|
588
|
+
def test_graph__from_node_with_empty_ports():
|
589
|
+
"""
|
590
|
+
Tests that building a graph from a single node with empty Ports class generates 1 node.
|
591
|
+
"""
|
592
|
+
|
593
|
+
# GIVEN a node with an empty Ports class
|
594
|
+
class NodeWithEmptyPorts(BaseNode):
|
595
|
+
class Ports(BaseNode.Ports):
|
596
|
+
pass
|
597
|
+
|
598
|
+
# WHEN we create a graph from the node
|
599
|
+
graph = Graph.from_node(NodeWithEmptyPorts)
|
600
|
+
|
601
|
+
# THEN the graph should have exactly 1 node
|
602
|
+
assert len(list(graph.nodes)) == 1
|
@@ -73,7 +73,7 @@ class MCPHttpClient:
|
|
73
73
|
# Prepare headers
|
74
74
|
headers = {
|
75
75
|
"Content-Type": "application/json",
|
76
|
-
"Accept": "application/json",
|
76
|
+
"Accept": "application/json, text/event-stream",
|
77
77
|
}
|
78
78
|
|
79
79
|
# Include session ID if we have one
|
@@ -88,11 +88,41 @@ class MCPHttpClient:
|
|
88
88
|
# Check for session ID in response headers
|
89
89
|
if "Mcp-Session-Id" in response.headers:
|
90
90
|
self.session_id = response.headers["Mcp-Session-Id"]
|
91
|
-
logger.debug(f"Received session ID: {self.session_id}")
|
92
91
|
|
93
|
-
# Handle
|
94
|
-
|
95
|
-
|
92
|
+
# Handle response based on content type
|
93
|
+
content_type = response.headers.get("content-type", "").lower()
|
94
|
+
|
95
|
+
if "text/event-stream" in content_type:
|
96
|
+
# Handle SSE response
|
97
|
+
response_text = response.text
|
98
|
+
|
99
|
+
# Parse SSE format to extract JSON data
|
100
|
+
lines = response_text.strip().split("\n")
|
101
|
+
json_data = None
|
102
|
+
|
103
|
+
for line in lines:
|
104
|
+
if line.startswith("data: "):
|
105
|
+
data_content = line[6:] # Remove 'data: ' prefix
|
106
|
+
if data_content.strip() and data_content != "[DONE]":
|
107
|
+
try:
|
108
|
+
json_data = json.loads(data_content)
|
109
|
+
break
|
110
|
+
except json.JSONDecodeError:
|
111
|
+
continue
|
112
|
+
|
113
|
+
if json_data is None:
|
114
|
+
raise Exception("No valid JSON data found in SSE response")
|
115
|
+
|
116
|
+
response_data = json_data
|
117
|
+
else:
|
118
|
+
# Handle regular JSON response
|
119
|
+
if not response.text.strip():
|
120
|
+
raise Exception("Empty response received from server")
|
121
|
+
|
122
|
+
try:
|
123
|
+
response_data = response.json()
|
124
|
+
except json.JSONDecodeError as e:
|
125
|
+
raise Exception(f"Invalid JSON response: {str(e)}")
|
96
126
|
|
97
127
|
if "error" in response_data:
|
98
128
|
raise Exception(f"MCP Error: {response_data['error']}")
|
@@ -0,0 +1,81 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
from unittest import mock
|
4
|
+
|
5
|
+
from vellum.workflows.integrations.mcp_service import MCPHttpClient
|
6
|
+
|
7
|
+
|
8
|
+
def test_mcp_http_client_sse_response():
|
9
|
+
"""Test that SSE responses are correctly parsed to JSON"""
|
10
|
+
# GIVEN an SSE response from the server
|
11
|
+
sample_sse_response = (
|
12
|
+
"event: message\n"
|
13
|
+
'data: {"result":{"protocolVersion":"2025-06-18",'
|
14
|
+
'"capabilities":{"tools":{"listChanged":true}},'
|
15
|
+
'"serverInfo":{"name":"TestServer","version":"1.0.0"},'
|
16
|
+
'"instructions":"Test server for unit tests."},'
|
17
|
+
'"jsonrpc":"2.0","id":1}\n\n'
|
18
|
+
)
|
19
|
+
expected_json = {
|
20
|
+
"result": {
|
21
|
+
"protocolVersion": "2025-06-18",
|
22
|
+
"capabilities": {"tools": {"listChanged": True}},
|
23
|
+
"serverInfo": {"name": "TestServer", "version": "1.0.0"},
|
24
|
+
"instructions": "Test server for unit tests.",
|
25
|
+
},
|
26
|
+
"jsonrpc": "2.0",
|
27
|
+
"id": 1,
|
28
|
+
}
|
29
|
+
|
30
|
+
with mock.patch("vellum.workflows.integrations.mcp_service.httpx.AsyncClient") as mock_client_class:
|
31
|
+
mock_client = mock.AsyncMock()
|
32
|
+
mock_client_class.return_value = mock_client
|
33
|
+
|
34
|
+
mock_response = mock.Mock()
|
35
|
+
mock_response.headers = {"content-type": "text/event-stream"}
|
36
|
+
mock_response.text = sample_sse_response
|
37
|
+
mock_client.post.return_value = mock_response
|
38
|
+
|
39
|
+
# WHEN we send a request through the MCP client
|
40
|
+
async def test_request():
|
41
|
+
async with MCPHttpClient("https://test.server.com", {}) as client:
|
42
|
+
result = await client._send_request("initialize", {"test": "params"})
|
43
|
+
return result
|
44
|
+
|
45
|
+
result = asyncio.run(test_request())
|
46
|
+
|
47
|
+
# THEN the SSE response should be parsed correctly to JSON
|
48
|
+
assert result == expected_json
|
49
|
+
|
50
|
+
# AND the request should have been made with correct headers
|
51
|
+
mock_client.post.assert_called_once()
|
52
|
+
call_args = mock_client.post.call_args
|
53
|
+
assert call_args[1]["headers"]["Accept"] == "application/json, text/event-stream"
|
54
|
+
assert call_args[1]["headers"]["Content-Type"] == "application/json"
|
55
|
+
|
56
|
+
|
57
|
+
def test_mcp_http_client_json_response():
|
58
|
+
"""Test that regular JSON responses still work"""
|
59
|
+
# GIVEN a regular JSON response from the server
|
60
|
+
sample_json_response = {"result": {"test": "data"}, "jsonrpc": "2.0", "id": 1}
|
61
|
+
|
62
|
+
with mock.patch("vellum.workflows.integrations.mcp_service.httpx.AsyncClient") as mock_client_class:
|
63
|
+
mock_client = mock.AsyncMock()
|
64
|
+
mock_client_class.return_value = mock_client
|
65
|
+
|
66
|
+
mock_response = mock.Mock()
|
67
|
+
mock_response.headers = {"content-type": "application/json"}
|
68
|
+
mock_response.text = json.dumps(sample_json_response)
|
69
|
+
mock_response.json.return_value = sample_json_response
|
70
|
+
mock_client.post.return_value = mock_response
|
71
|
+
|
72
|
+
# WHEN we send a request through the MCP client
|
73
|
+
async def test_request():
|
74
|
+
async with MCPHttpClient("https://test.server.com", {}) as client:
|
75
|
+
result = await client._send_request("initialize", {"test": "params"})
|
76
|
+
return result
|
77
|
+
|
78
|
+
result = asyncio.run(test_request())
|
79
|
+
|
80
|
+
# THEN the JSON response should be returned as expected
|
81
|
+
assert result == sample_json_response
|
@@ -125,7 +125,6 @@ class BaseNodeMeta(ABCMeta):
|
|
125
125
|
# Add cls to relevant nested classes, since python should've been doing this by default
|
126
126
|
for port in node_class.Ports:
|
127
127
|
port.node_class = node_class
|
128
|
-
port.validate()
|
129
128
|
|
130
129
|
node_class.Execution.node_class = node_class
|
131
130
|
node_class.Trigger.node_class = node_class
|
@@ -4,6 +4,7 @@ from vellum.client.types.vellum_error import VellumError
|
|
4
4
|
from vellum.workflows.errors.types import WorkflowError, WorkflowErrorCode, vellum_error_to_workflow_error
|
5
5
|
from vellum.workflows.exceptions import NodeException
|
6
6
|
from vellum.workflows.nodes.bases.base import BaseNode
|
7
|
+
from vellum.workflows.ports import NodePorts
|
7
8
|
|
8
9
|
|
9
10
|
class ErrorNode(BaseNode):
|
@@ -15,6 +16,9 @@ class ErrorNode(BaseNode):
|
|
15
16
|
|
16
17
|
error: ClassVar[Union[str, WorkflowError, VellumError]]
|
17
18
|
|
19
|
+
class Ports(NodePorts):
|
20
|
+
pass
|
21
|
+
|
18
22
|
def run(self) -> BaseNode.Outputs:
|
19
23
|
if isinstance(self.error, str):
|
20
24
|
raise NodeException(message=self.error, code=WorkflowErrorCode.USER_DEFINED_ERROR)
|
@@ -9,6 +9,7 @@ from vellum.workflows.nodes.core.try_node.node import TryNode
|
|
9
9
|
from vellum.workflows.outputs.base import BaseOutput
|
10
10
|
from vellum.workflows.state.base import BaseState
|
11
11
|
from vellum.workflows.workflows.base import BaseWorkflow
|
12
|
+
from vellum.workflows.workflows.event_filters import all_workflow_event_filter
|
12
13
|
|
13
14
|
|
14
15
|
class Inputs(BaseInputs):
|
@@ -143,3 +144,37 @@ def test_inline_subworkflow_node__with_adornment():
|
|
143
144
|
outputs = list(node.run())
|
144
145
|
|
145
146
|
assert outputs[-1].name == "final_output" and outputs[-1].value == "hello"
|
147
|
+
|
148
|
+
|
149
|
+
@pytest.mark.skip(reason="Enable after we set is_dynamic on the subworkflow class")
|
150
|
+
def test_inline_subworkflow_node__is_dynamic_subworkflow():
|
151
|
+
"""Test that InlineSubworkflowNode sets is_dynamic=True on the subworkflow class"""
|
152
|
+
|
153
|
+
# GIVEN a subworkflow class
|
154
|
+
class TestSubworkflow(BaseWorkflow[BaseInputs, BaseState]):
|
155
|
+
graph = MyInnerNode
|
156
|
+
|
157
|
+
class Outputs(BaseWorkflow.Outputs):
|
158
|
+
out = MyInnerNode.Outputs.out
|
159
|
+
|
160
|
+
# AND a node that uses this subworkflow
|
161
|
+
class TestNode(InlineSubworkflowNode):
|
162
|
+
subworkflow = TestSubworkflow
|
163
|
+
|
164
|
+
# AND a workflow that uses this node
|
165
|
+
class TestWorkflow(BaseWorkflow[BaseInputs, BaseState]):
|
166
|
+
graph = TestNode
|
167
|
+
|
168
|
+
class Outputs(BaseWorkflow.Outputs):
|
169
|
+
out = TestNode.Outputs.out
|
170
|
+
|
171
|
+
# WHEN the workflow is executed
|
172
|
+
workflow = TestWorkflow()
|
173
|
+
events = list(workflow.stream(event_filter=all_workflow_event_filter))
|
174
|
+
|
175
|
+
# AND we should find workflow execution initiated events
|
176
|
+
initiated_events = [event for event in events if event.name == "workflow.execution.initiated"]
|
177
|
+
assert len(initiated_events) == 2 # Main workflow + inline workflow
|
178
|
+
|
179
|
+
assert initiated_events[0].body.workflow_definition.is_dynamic is False # Main workflow
|
180
|
+
assert initiated_events[1].body.workflow_definition.is_dynamic is True # Inline workflow
|
@@ -30,6 +30,7 @@ from vellum.workflows.outputs.base import BaseOutput
|
|
30
30
|
from vellum.workflows.references.output import OutputReference
|
31
31
|
from vellum.workflows.state.context import WorkflowContext
|
32
32
|
from vellum.workflows.types.generics import StateType
|
33
|
+
from vellum.workflows.utils.uuids import uuid4_from_hash
|
33
34
|
from vellum.workflows.workflows.event_filters import all_workflow_event_filter
|
34
35
|
|
35
36
|
if TYPE_CHECKING:
|
@@ -211,4 +212,10 @@ class MapNode(BaseAdornmentNode[StateType], Generic[StateType, MapNodeItemType])
|
|
211
212
|
annotation = List[parameter_type] # type: ignore[valid-type]
|
212
213
|
|
213
214
|
previous_annotations = {prev: annotation for prev in outputs_class.__annotations__ if not prev.startswith("_")}
|
215
|
+
# Map node output is a list of the same type so we use annotation=List[parameter_type] and not reference
|
216
|
+
# class Outputs(BaseOutputs):
|
217
|
+
# value: List[str]
|
214
218
|
outputs_class.__annotations__ = {**previous_annotations, reference.name: annotation}
|
219
|
+
|
220
|
+
output_id = uuid4_from_hash(f"{cls.__id__}|{reference.name}")
|
221
|
+
cls.__output_ids__[reference.name] = output_id
|
@@ -119,6 +119,7 @@ def test_map_node__inner_try():
|
|
119
119
|
# THEN the workflow should succeed
|
120
120
|
assert outputs[-1].name == "final_output"
|
121
121
|
assert len(outputs[-1].value) == 2
|
122
|
+
assert len(SimpleMapNode.__output_ids__) == 1
|
122
123
|
|
123
124
|
|
124
125
|
def test_map_node__nested_map_node():
|
@@ -275,3 +276,21 @@ def test_map_node__shared_state_race_condition():
|
|
275
276
|
# AND all results should be in correct order
|
276
277
|
expected_result = ["a!", "b!", "c!", "d!", "e!", "f!"]
|
277
278
|
assert final_result == expected_result, f"Failed on run {index}"
|
279
|
+
|
280
|
+
|
281
|
+
def test_map_node__output_ids():
|
282
|
+
class TestNode(BaseNode):
|
283
|
+
class Outputs(BaseOutputs):
|
284
|
+
value: str
|
285
|
+
|
286
|
+
class SimpleMapNodeWorkflow(BaseWorkflow[MapNode.SubworkflowInputs, BaseState]):
|
287
|
+
graph = TestNode
|
288
|
+
|
289
|
+
class Outputs(BaseWorkflow.Outputs):
|
290
|
+
final_output = TestNode.Outputs.value
|
291
|
+
|
292
|
+
class TestMapNode(MapNode):
|
293
|
+
items = [1, 2, 3]
|
294
|
+
subworkflow = SimpleMapNodeWorkflow
|
295
|
+
|
296
|
+
assert len(TestMapNode.__output_ids__) == 1
|
@@ -28,6 +28,8 @@ from vellum.client.types.string_vellum_value_request import StringVellumValueReq
|
|
28
28
|
from vellum.client.types.vellum_error import VellumError
|
29
29
|
from vellum.client.types.vellum_value import VellumValue
|
30
30
|
from vellum.client.types.vellum_value_request import VellumValueRequest
|
31
|
+
from vellum.client.types.video_vellum_value import VideoVellumValue
|
32
|
+
from vellum.client.types.video_vellum_value_request import VideoVellumValueRequest
|
31
33
|
from vellum.workflows.errors.types import WorkflowError, workflow_error_to_vellum_error
|
32
34
|
from vellum.workflows.state.encoder import DefaultStateEncoder
|
33
35
|
|
@@ -36,7 +38,7 @@ VELLUM_VALUE_REQUEST_TUPLE = (
|
|
36
38
|
NumberVellumValueRequest,
|
37
39
|
JsonVellumValueRequest,
|
38
40
|
AudioVellumValueRequest,
|
39
|
-
|
41
|
+
VideoVellumValueRequest,
|
40
42
|
ImageVellumValueRequest,
|
41
43
|
FunctionCallVellumValueRequest,
|
42
44
|
ErrorVellumValueRequest,
|
@@ -80,7 +82,7 @@ def primitive_to_vellum_value(value: Any) -> VellumValue:
|
|
80
82
|
NumberVellumValue,
|
81
83
|
JsonVellumValue,
|
82
84
|
AudioVellumValue,
|
83
|
-
|
85
|
+
VideoVellumValue,
|
84
86
|
ImageVellumValue,
|
85
87
|
FunctionCallVellumValue,
|
86
88
|
ErrorVellumValue,
|
@@ -4,6 +4,7 @@ from vellum.workflows.constants import undefined
|
|
4
4
|
from vellum.workflows.nodes.bases import BaseNode
|
5
5
|
from vellum.workflows.nodes.bases.base import BaseNodeMeta
|
6
6
|
from vellum.workflows.nodes.utils import cast_to_output_type
|
7
|
+
from vellum.workflows.ports import NodePorts
|
7
8
|
from vellum.workflows.types import MergeBehavior
|
8
9
|
from vellum.workflows.types.generics import StateType
|
9
10
|
from vellum.workflows.types.utils import get_original_base
|
@@ -47,6 +48,9 @@ class FinalOutputNode(BaseNode[StateType], Generic[StateType, _OutputType], meta
|
|
47
48
|
class Trigger(BaseNode.Trigger):
|
48
49
|
merge_behavior = MergeBehavior.AWAIT_ANY
|
49
50
|
|
51
|
+
class Ports(NodePorts):
|
52
|
+
pass
|
53
|
+
|
50
54
|
class Outputs(BaseNode.Outputs):
|
51
55
|
# We use our mypy plugin to override the _OutputType with the actual output type
|
52
56
|
# for downstream references to this output.
|