This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new e81638a8 [runtime][python] Manage Pemja references across actions and
resources (#1058)
e81638a8 is described below
commit e81638a831c785e0b84b78635cfa217a0b7dc4a9
Author: Yu Tang <[email protected]>
AuthorDate: Tue Sep 15 14:23:43 2026 +0800
[runtime][python] Manage Pemja references across actions and resources
(#1058)
Generated-by: Codex 0.154.0-alpha.6.2 (GPT-6 Astra)
Co-Authored-By: Codex <[email protected]>
---
.../model/python/PythonChatModelConnection.java | 36 ++++---
.../chat/model/python/PythonChatModelSetup.java | 32 ++++--
.../python/PythonEmbeddingModelConnection.java | 9 +-
.../model/python/PythonEmbeddingModelSetup.java | 12 +++
.../api/resource/python/PythonObjectScope.java | 109 +++++++++++++++++++++
.../api/vectorstores/python/PythonVectorStore.java | 58 +++++++----
.../python/PythonChatModelConnectionTest.java | 11 ++-
.../model/python/PythonChatModelSetupTest.java | 8 +-
.../api/resource/python/PythonObjectScopeTest.java | 45 +++++++++
.../PythonCollectionManageableVectorStoreTest.java | 9 +-
.../plan/actions/ContextRetrievalAction.java | 59 +++++------
.../plan/resource/python/PythonMCPPrompt.java | 35 +++++--
.../plan/resource/python/PythonMCPServer.java | 13 +++
.../agents/plan/resource/python/PythonMCPTool.java | 12 +++
.../resource/python/PythonMCPResourceTest.java | 106 ++++++++++++++++++++
.../PythonResourceProviderTest.java | 65 ++++++++++++
.../agents/runtime/PythonMCPResourceDiscovery.java | 1 +
.../agents/runtime/memory/Mem0LongTermMemory.java | 86 +++++++++-------
.../runtime/python/utils/PythonActionExecutor.java | 41 +++++---
.../python/utils/PythonResourceAdapterImpl.java | 11 ++-
.../runtime/PythonMCPResourceDiscoveryTest.java | 73 ++++++++++++++
.../runtime/memory/Mem0LongTermMemoryTest.java | 11 ++-
.../python/utils/PythonActionExecutorTest.java | 100 ++++++++++++++++++-
.../utils/PythonResourceAdapterImplTest.java | 18 ++++
24 files changed, 814 insertions(+), 146 deletions(-)
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelConnection.java
b/api/src/main/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelConnection.java
index 8ce3662e..580f4be8 100644
---
a/api/src/main/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelConnection.java
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelConnection.java
@@ -22,6 +22,7 @@ import
org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
import org.apache.flink.agents.api.resource.ResourceContext;
import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.resource.python.PythonObjectScope;
import org.apache.flink.agents.api.resource.python.PythonResourceAdapter;
import org.apache.flink.agents.api.resource.python.PythonResourceWrapper;
import org.apache.flink.agents.api.tools.Tool;
@@ -41,6 +42,7 @@ public class PythonChatModelConnection extends
BaseChatModelConnection
implements PythonResourceWrapper {
private final PyObject chatModel;
private final PythonResourceAdapter adapter;
+ private boolean closed;
/**
* Creates a new PythonChatModelConnection.
@@ -82,24 +84,32 @@ public class PythonChatModelConnection extends
BaseChatModelConnection
List<ChatMessage> messages, List<Tool> tools, Map<String, Object>
modelParams) {
Map<String, Object> kwargs = new HashMap<>(modelParams);
- List<Object> pythonMessages = new ArrayList<>();
- for (ChatMessage message : messages) {
- pythonMessages.add(adapter.toPythonChatMessage(message));
- }
- kwargs.put("messages", pythonMessages);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ List<Object> pythonMessages = new ArrayList<>();
+ for (ChatMessage message : messages) {
+
pythonMessages.add(scope.own(adapter.toPythonChatMessage(message)));
+ }
+ kwargs.put("messages", pythonMessages);
- List<Object> pythonTools = new ArrayList<>();
- for (Tool tool : tools) {
- pythonTools.add(adapter.convertToPythonTool(tool));
- }
- kwargs.put("tools", pythonTools);
+ List<Object> pythonTools = new ArrayList<>();
+ for (Tool tool : tools) {
+ pythonTools.add(scope.own(adapter.convertToPythonTool(tool)));
+ }
+ kwargs.put("tools", pythonTools);
- Object pythonMessageResponse = adapter.callMethod(chatModel, "chat",
kwargs);
- return adapter.fromPythonChatMessage(pythonMessageResponse);
+ Object pythonMessageResponse =
scope.own(adapter.callMethod(chatModel, "chat", kwargs));
+ return adapter.fromPythonChatMessage(pythonMessageResponse);
+ }
}
@Override
public void close() throws Exception {
- this.chatModel.close();
+ if (closed || chatModel == null) {
+ return;
+ }
+ closed = true;
+ try (chatModel) {
+ adapter.callMethod(chatModel, "close", Map.of());
+ }
}
}
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelSetup.java
b/api/src/main/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelSetup.java
index 846105af..afae1e35 100644
---
a/api/src/main/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelSetup.java
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelSetup.java
@@ -22,6 +22,7 @@ import
org.apache.flink.agents.api.chat.model.BaseChatModelSetup;
import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
import org.apache.flink.agents.api.resource.ResourceContext;
import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.resource.python.PythonObjectScope;
import org.apache.flink.agents.api.resource.python.PythonResourceAdapter;
import org.apache.flink.agents.api.resource.python.PythonResourceWrapper;
import pemja.core.object.PyObject;
@@ -46,6 +47,7 @@ public class PythonChatModelSetup extends BaseChatModelSetup
implements PythonRe
private final PyObject chatModelSetup;
private final PythonResourceAdapter adapter;
+ private boolean closed;
public PythonChatModelSetup(
PythonResourceAdapter adapter,
@@ -73,16 +75,19 @@ public class PythonChatModelSetup extends
BaseChatModelSetup implements PythonRe
Map<String, Object> kwargs = new HashMap<>(modelParams);
- List<Object> pythonMessages = new ArrayList<>();
- for (ChatMessage message : messages) {
- pythonMessages.add(adapter.toPythonChatMessage(message));
- }
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ List<Object> pythonMessages = new ArrayList<>();
+ for (ChatMessage message : messages) {
+
pythonMessages.add(scope.own(adapter.toPythonChatMessage(message)));
+ }
- kwargs.put("messages", pythonMessages);
- kwargs.put("prompt_args", promptArgs != null ? promptArgs :
Collections.emptyMap());
+ kwargs.put("messages", pythonMessages);
+ kwargs.put("prompt_args", promptArgs != null ? promptArgs :
Collections.emptyMap());
- Object pythonMessageResponse = adapter.callMethod(chatModelSetup,
"chat", kwargs);
- return adapter.fromPythonChatMessage(pythonMessageResponse);
+ Object pythonMessageResponse =
+ scope.own(adapter.callMethod(chatModelSetup, "chat",
kwargs));
+ return adapter.fromPythonChatMessage(pythonMessageResponse);
+ }
}
@Override
@@ -105,4 +110,15 @@ public class PythonChatModelSetup extends
BaseChatModelSetup implements PythonRe
public Map<String, Object> getParameters() {
return Map.of();
}
+
+ @Override
+ public void close() throws Exception {
+ if (closed || chatModelSetup == null) {
+ return;
+ }
+ closed = true;
+ try (chatModelSetup) {
+ adapter.callMethod(chatModelSetup, "close", Map.of());
+ }
+ }
}
diff --git
a/api/src/main/java/org/apache/flink/agents/api/embedding/model/python/PythonEmbeddingModelConnection.java
b/api/src/main/java/org/apache/flink/agents/api/embedding/model/python/PythonEmbeddingModelConnection.java
index d9bdb8d4..0e557dbc 100644
---
a/api/src/main/java/org/apache/flink/agents/api/embedding/model/python/PythonEmbeddingModelConnection.java
+++
b/api/src/main/java/org/apache/flink/agents/api/embedding/model/python/PythonEmbeddingModelConnection.java
@@ -48,6 +48,7 @@ public class PythonEmbeddingModelConnection extends
BaseEmbeddingModelConnection
private final PyObject embeddingModel;
private final PythonResourceAdapter adapter;
+ private boolean closed;
/**
* Creates a new PythonEmbeddingModelConnection.
@@ -166,6 +167,12 @@ public class PythonEmbeddingModelConnection extends
BaseEmbeddingModelConnection
@Override
public void close() throws Exception {
- this.embeddingModel.close();
+ if (closed || embeddingModel == null) {
+ return;
+ }
+ closed = true;
+ try (embeddingModel) {
+ adapter.callMethod(embeddingModel, "close", Map.of());
+ }
}
}
diff --git
a/api/src/main/java/org/apache/flink/agents/api/embedding/model/python/PythonEmbeddingModelSetup.java
b/api/src/main/java/org/apache/flink/agents/api/embedding/model/python/PythonEmbeddingModelSetup.java
index b6febbc5..2285bb16 100644
---
a/api/src/main/java/org/apache/flink/agents/api/embedding/model/python/PythonEmbeddingModelSetup.java
+++
b/api/src/main/java/org/apache/flink/agents/api/embedding/model/python/PythonEmbeddingModelSetup.java
@@ -48,6 +48,7 @@ public class PythonEmbeddingModelSetup extends
BaseEmbeddingModelSetup
private final PyObject embeddingModelSetup;
private final PythonResourceAdapter adapter;
+ private boolean closed;
/**
* Creates a new PythonEmbeddingModelSetup.
@@ -173,4 +174,15 @@ public class PythonEmbeddingModelSetup extends
BaseEmbeddingModelSetup
super.setMetricGroup(metricGroup);
setPythonResourceMetricGroup(metricGroup);
}
+
+ @Override
+ public void close() throws Exception {
+ if (closed || embeddingModelSetup == null) {
+ return;
+ }
+ closed = true;
+ try (embeddingModelSetup) {
+ adapter.callMethod(embeddingModelSetup, "close", Map.of());
+ }
+ }
}
diff --git
a/api/src/main/java/org/apache/flink/agents/api/resource/python/PythonObjectScope.java
b/api/src/main/java/org/apache/flink/agents/api/resource/python/PythonObjectScope.java
new file mode 100644
index 00000000..44192ba6
--- /dev/null
+++
b/api/src/main/java/org/apache/flink/agents/api/resource/python/PythonObjectScope.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.api.resource.python;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.util.ExceptionUtils;
+import org.apache.flink.util.LambdaUtil;
+import pemja.core.object.PyObject;
+
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Owns temporary Pemja references consumed within a Java-to-Python bridge
operation.
+ *
+ * <p>Callers may register a {@link PyObject} directly or nested in a Java
map, list, or object
+ * array. Every registered handle must be fully consumed before the scope
closes. Values returned to
+ * callers must not contain handles owned by this scope.
+ */
+@Internal
+public final class PythonObjectScope implements AutoCloseable {
+
+ private final Set<PyObject> ownedObjects = Collections.newSetFromMap(new
IdentityHashMap<>());
+ private boolean closed;
+
+ /** Adds every {@link PyObject} reachable through the supplied bridge
result to this scope. */
+ public <T> T own(T value) {
+ ensureOpen();
+ visit(value, Collections.newSetFromMap(new IdentityHashMap<>()));
+ return value;
+ }
+
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+
+ try {
+ LambdaUtil.applyToAllWhileSuppressingExceptions(ownedObjects,
PyObject::close);
+ } catch (Exception e) {
+ ExceptionUtils.rethrow(e);
+ } finally {
+ ownedObjects.clear();
+ }
+ }
+
+ private void visit(Object value, Set<Object> visitedContainers) {
+ if (value == null) {
+ return;
+ }
+ if (value instanceof PyObject) {
+ ownedObjects.add((PyObject) value);
+ return;
+ }
+ if (value instanceof Map) {
+ if (!visitedContainers.add(value)) {
+ return;
+ }
+ for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
+ visit(entry.getKey(), visitedContainers);
+ visit(entry.getValue(), visitedContainers);
+ }
+ return;
+ }
+ if (value instanceof List) {
+ if (!visitedContainers.add(value)) {
+ return;
+ }
+ for (Object element : (List<?>) value) {
+ visit(element, visitedContainers);
+ }
+ return;
+ }
+ if (value instanceof Object[]) {
+ if (!visitedContainers.add(value)) {
+ return;
+ }
+ for (Object element : (Object[]) value) {
+ visit(element, visitedContainers);
+ }
+ }
+ }
+
+ private void ensureOpen() {
+ if (closed) {
+ throw new IllegalStateException("PythonObjectScope is already
closed.");
+ }
+ }
+}
diff --git
a/api/src/main/java/org/apache/flink/agents/api/vectorstores/python/PythonVectorStore.java
b/api/src/main/java/org/apache/flink/agents/api/vectorstores/python/PythonVectorStore.java
index 21bb1893..4a393adc 100644
---
a/api/src/main/java/org/apache/flink/agents/api/vectorstores/python/PythonVectorStore.java
+++
b/api/src/main/java/org/apache/flink/agents/api/vectorstores/python/PythonVectorStore.java
@@ -21,6 +21,7 @@ package org.apache.flink.agents.api.vectorstores.python;
import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
import org.apache.flink.agents.api.resource.ResourceContext;
import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.resource.python.PythonObjectScope;
import org.apache.flink.agents.api.resource.python.PythonResourceAdapter;
import org.apache.flink.agents.api.resource.python.PythonResourceWrapper;
import org.apache.flink.agents.api.vectorstores.BaseVectorStore;
@@ -54,6 +55,7 @@ import java.util.Map;
public class PythonVectorStore extends BaseVectorStore implements
PythonResourceWrapper {
protected final PyObject vectorStore;
protected final PythonResourceAdapter adapter;
+ private boolean closed;
/**
* Creates a new PythonVectorStore.
@@ -106,9 +108,10 @@ public class PythonVectorStore extends BaseVectorStore
implements PythonResource
kwargs.put("limit", limit);
}
- Object pythonDocuments = adapter.callMethod(vectorStore, "get",
kwargs);
-
- return adapter.fromPythonDocuments((List<PyObject>) pythonDocuments);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ Object pythonDocuments = scope.own(adapter.callMethod(vectorStore,
"get", kwargs));
+ return adapter.fromPythonDocuments((List<PyObject>)
pythonDocuments);
+ }
}
@Override
@@ -158,8 +161,11 @@ public class PythonVectorStore extends BaseVectorStore
implements PythonResource
if (filters != null) {
kwargs.put("filters", filters);
}
- Object pythonDocuments = adapter.callMethod(vectorStore,
"_query_embedding", kwargs);
- return adapter.fromPythonDocuments((List<PyObject>) pythonDocuments);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ Object pythonDocuments =
+ scope.own(adapter.callMethod(vectorStore,
"_query_embedding", kwargs));
+ return adapter.fromPythonDocuments((List<PyObject>)
pythonDocuments);
+ }
}
/** Embed query text via the configured model (no numpy, so it stays on
the async pool). */
@@ -202,8 +208,11 @@ public class PythonVectorStore extends BaseVectorStore
implements PythonResource
if (filters != null) {
kwargs.put("filters", filters);
}
- Object pythonDocuments = adapter.callMethod(vectorStore,
"_query_embedding", kwargs);
- return adapter.fromPythonDocuments((List<PyObject>) pythonDocuments);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ Object pythonDocuments =
+ scope.own(adapter.callMethod(vectorStore,
"_query_embedding", kwargs));
+ return adapter.fromPythonDocuments((List<PyObject>)
pythonDocuments);
+ }
}
@Override
@@ -211,23 +220,27 @@ public class PythonVectorStore extends BaseVectorStore
implements PythonResource
public List<String> addEmbedding(
List<Document> documents, @Nullable String collection, Map<String,
Object> extraArgs)
throws IOException {
- Map<String, Object> kwargs = new HashMap<>(extraArgs);
- kwargs.put("documents", adapter.toPythonDocuments(documents));
- if (collection != null) {
- kwargs.put("collection_name", collection);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ Map<String, Object> kwargs = new HashMap<>(extraArgs);
+ kwargs.put("documents",
scope.own(adapter.toPythonDocuments(documents)));
+ if (collection != null) {
+ kwargs.put("collection_name", collection);
+ }
+ return (List<String>) adapter.callMethod(vectorStore,
"_add_embedding", kwargs);
}
- return (List<String>) adapter.callMethod(vectorStore,
"_add_embedding", kwargs);
}
@Override
public void updateEmbedding(
List<Document> documents, @Nullable String collection, Map<String,
Object> extraArgs) {
- Map<String, Object> kwargs = new HashMap<>(extraArgs);
- kwargs.put("documents", adapter.toPythonDocuments(documents));
- if (collection != null) {
- kwargs.put("collection_name", collection);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ Map<String, Object> kwargs = new HashMap<>(extraArgs);
+ kwargs.put("documents",
scope.own(adapter.toPythonDocuments(documents)));
+ if (collection != null) {
+ kwargs.put("collection_name", collection);
+ }
+ adapter.callMethod(vectorStore, "_update_embedding", kwargs);
}
- adapter.callMethod(vectorStore, "_update_embedding", kwargs);
}
@Override
@@ -245,4 +258,15 @@ public class PythonVectorStore extends BaseVectorStore
implements PythonResource
super.setMetricGroup(metricGroup);
setPythonResourceMetricGroup(metricGroup);
}
+
+ @Override
+ public void close() throws Exception {
+ if (closed || vectorStore == null) {
+ return;
+ }
+ closed = true;
+ try (vectorStore) {
+ adapter.callMethod(vectorStore, "close", Map.of());
+ }
+ }
}
diff --git
a/api/src/test/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelConnectionTest.java
b/api/src/test/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelConnectionTest.java
index b3ccf82e..fc063d23 100644
---
a/api/src/test/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelConnectionTest.java
+++
b/api/src/test/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelConnectionTest.java
@@ -83,7 +83,7 @@ public class PythonChatModelConnectionTest {
}
@Test
- void testChat() {
+ void testChat() throws Exception {
ChatMessage inputMessage = mock(ChatMessage.class);
ChatMessage outputMessage = mock(ChatMessage.class);
Tool mockTool = mock(Tool.class);
@@ -93,9 +93,9 @@ public class PythonChatModelConnectionTest {
modelParams.put("temperature", 0.7);
modelParams.put("max_tokens", 100);
- Object pythonInputMessage = new Object();
- Object pythonOutputMessage = new Object();
- Object pythonTool = new Object();
+ PyObject pythonInputMessage = mock(PyObject.class);
+ PyObject pythonOutputMessage = mock(PyObject.class);
+ PyObject pythonTool = mock(PyObject.class);
when(mockAdapter.toPythonChatMessage(inputMessage)).thenReturn(pythonInputMessage);
when(mockAdapter.convertToPythonTool(mockTool)).thenReturn(pythonTool);
@@ -133,6 +133,9 @@ public class PythonChatModelConnectionTest {
return true;
}));
verify(mockAdapter).fromPythonChatMessage(pythonOutputMessage);
+ verify(pythonInputMessage).close();
+ verify(pythonTool).close();
+ verify(pythonOutputMessage).close();
}
@Test
diff --git
a/api/src/test/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelSetupTest.java
b/api/src/test/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelSetupTest.java
index 42463b08..b08afc84 100644
---
a/api/src/test/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelSetupTest.java
+++
b/api/src/test/java/org/apache/flink/agents/api/chat/model/python/PythonChatModelSetupTest.java
@@ -90,7 +90,7 @@ public class PythonChatModelSetupTest {
}
@Test
- void testChat() {
+ void testChat() throws Exception {
ChatMessage inputMessage = mock(ChatMessage.class);
ChatMessage outputMessage = mock(ChatMessage.class);
List<ChatMessage> messages = Collections.singletonList(inputMessage);
@@ -100,8 +100,8 @@ public class PythonChatModelSetupTest {
modelParams.put("temperature", 0.7);
modelParams.put("max_tokens", 100);
- Object pythonInputMessage = new Object();
- Object pythonOutputMessage = new Object();
+ PyObject pythonInputMessage = mock(PyObject.class);
+ PyObject pythonOutputMessage = mock(PyObject.class);
when(mockAdapter.toPythonChatMessage(inputMessage)).thenReturn(pythonInputMessage);
when(mockAdapter.callMethod(eq(mockChatModelSetup), eq("chat"),
any(Map.class)))
@@ -132,6 +132,8 @@ public class PythonChatModelSetupTest {
return true;
}));
verify(mockAdapter).fromPythonChatMessage(pythonOutputMessage);
+ verify(pythonInputMessage).close();
+ verify(pythonOutputMessage).close();
}
@Test
diff --git
a/api/src/test/java/org/apache/flink/agents/api/resource/python/PythonObjectScopeTest.java
b/api/src/test/java/org/apache/flink/agents/api/resource/python/PythonObjectScopeTest.java
new file mode 100644
index 00000000..560aa4b5
--- /dev/null
+++
b/api/src/test/java/org/apache/flink/agents/api/resource/python/PythonObjectScopeTest.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.api.resource.python;
+
+import org.junit.jupiter.api.Test;
+import pemja.core.object.PyObject;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+class PythonObjectScopeTest {
+
+ @Test
+ void closesNestedReferencesOnce() throws Exception {
+ PyObject first = mock(PyObject.class);
+ PyObject second = mock(PyObject.class);
+
+ PythonObjectScope scope = new PythonObjectScope();
+ scope.own(Map.of("values", List.of(first, second, first)));
+ scope.close();
+ scope.close();
+
+ verify(first, times(1)).close();
+ verify(second, times(1)).close();
+ }
+}
diff --git
a/api/src/test/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStoreTest.java
b/api/src/test/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStoreTest.java
index 37b27f85..566db3fe 100644
---
a/api/src/test/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStoreTest.java
+++
b/api/src/test/java/org/apache/flink/agents/api/vectorstores/python/PythonCollectionManageableVectorStoreTest.java
@@ -39,6 +39,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -172,7 +173,10 @@ public class PythonCollectionManageableVectorStoreTest {
List<String> expectedIds = Arrays.asList("doc1", "doc2");
- when(mockAdapter.toPythonDocuments(documents)).thenReturn(new
Object());
+ PyObject firstPythonDocument = mock(PyObject.class);
+ PyObject secondPythonDocument = mock(PyObject.class);
+ when(mockAdapter.toPythonDocuments(documents))
+ .thenReturn(List.of(firstPythonDocument,
secondPythonDocument));
when(mockAdapter.callMethod(eq(mockVectorStore), eq("_add_embedding"),
any(Map.class)))
.thenReturn(expectedIds);
@@ -183,6 +187,8 @@ public class PythonCollectionManageableVectorStoreTest {
assertThat(result).containsExactly("doc1", "doc2");
verify(mockAdapter).toPythonDocuments(documents);
+ verify(firstPythonDocument).close();
+ verify(secondPythonDocument).close();
verify(mockAdapter)
.callMethod(
eq(mockVectorStore),
@@ -246,6 +252,7 @@ public class PythonCollectionManageableVectorStoreTest {
assertThat(result).isNotNull();
assertThat(result).hasSize(2);
+ verify(mockPythonDocument).close();
verify(mockAdapter)
.callMethod(
diff --git
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ContextRetrievalAction.java
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ContextRetrievalAction.java
index 6b91c05c..907bfc8e 100644
---
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ContextRetrievalAction.java
+++
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ContextRetrievalAction.java
@@ -25,6 +25,7 @@ import org.apache.flink.agents.api.context.RunnerContext;
import org.apache.flink.agents.api.event.ContextRetrievalRequestEvent;
import org.apache.flink.agents.api.event.ContextRetrievalResponseEvent;
import org.apache.flink.agents.api.resource.ResourceType;
+import org.apache.flink.agents.api.resource.python.PythonObjectScope;
import org.apache.flink.agents.api.vectorstores.BaseVectorStore;
import org.apache.flink.agents.api.vectorstores.Document;
import org.apache.flink.agents.api.vectorstores.VectorStoreQuery;
@@ -136,33 +137,35 @@ public class ContextRetrievalAction {
}
});
- final Object normalized = store.normalizeEmbedding(embedding);
-
- final List<Document> documents =
- ctx.durableExecuteAsync(
- new DurableCallable<List<Document>>() {
- @Override
- public String getId() {
- return "rag-query";
- }
-
- @SuppressWarnings("unchecked")
- @Override
- public Class<List<Document>> getResultClass() {
- return (Class<List<Document>>) (Class<?>)
List.class;
- }
-
- @Override
- public List<Document> call() {
- return store.queryNormalized(
- normalized,
- query.getLimit(),
- query.getCollection(),
- query.getFilters(),
- store.getStoreKwargs());
- }
- });
-
- return new VectorStoreQueryResult(documents);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ final Object normalized =
scope.own(store.normalizeEmbedding(embedding));
+
+ final List<Document> documents =
+ ctx.durableExecuteAsync(
+ new DurableCallable<List<Document>>() {
+ @Override
+ public String getId() {
+ return "rag-query";
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public Class<List<Document>> getResultClass() {
+ return (Class<List<Document>>) (Class<?>)
List.class;
+ }
+
+ @Override
+ public List<Document> call() {
+ return store.queryNormalized(
+ normalized,
+ query.getLimit(),
+ query.getCollection(),
+ query.getFilters(),
+ store.getStoreKwargs());
+ }
+ });
+
+ return new VectorStoreQueryResult(documents);
+ }
}
}
diff --git
a/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPPrompt.java
b/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPPrompt.java
index cb4a3935..901fa06c 100644
---
a/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPPrompt.java
+++
b/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPPrompt.java
@@ -21,6 +21,7 @@ import org.apache.flink.agents.api.chat.messages.ChatMessage;
import org.apache.flink.agents.api.chat.messages.MessageRole;
import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
import org.apache.flink.agents.api.prompt.Prompt;
+import org.apache.flink.agents.api.resource.python.PythonObjectScope;
import org.apache.flink.agents.api.resource.python.PythonResourceAdapter;
import org.apache.flink.agents.api.resource.python.PythonResourceWrapper;
import pemja.core.object.PyObject;
@@ -36,6 +37,7 @@ public class PythonMCPPrompt extends Prompt implements
PythonResourceWrapper {
private final PyObject prompt;
private final PythonResourceAdapter adapter;
+ private boolean closed;
private String name;
public PythonMCPPrompt(PythonResourceAdapter adapter, PyObject prompt) {
@@ -75,18 +77,31 @@ public class PythonMCPPrompt extends Prompt implements
PythonResourceWrapper {
@Override
public List<ChatMessage> formatMessages(MessageRole defaultRole,
Map<String, String> kwargs) {
Map<String, Object> parameters = new HashMap<>(kwargs);
- Object pythonRole = adapter.invoke(FROM_JAVA_MESSAGE_ROLE,
defaultRole);
- parameters.put("role", pythonRole);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ Object pythonRole = adapter.invoke(FROM_JAVA_MESSAGE_ROLE,
defaultRole);
+ parameters.put("role", pythonRole);
- Object result = adapter.callMethod(prompt, "format_messages",
parameters);
- if (result instanceof List) {
- List<Object> pythonMessages = (List<Object>) result;
- List<ChatMessage> messages = new
ArrayList<>(pythonMessages.size());
- for (Object pythonMessage : pythonMessages) {
- messages.add(adapter.fromPythonChatMessage(pythonMessage));
+ Object result = scope.own(adapter.callMethod(prompt,
"format_messages", parameters));
+ if (result instanceof List) {
+ List<Object> pythonMessages = (List<Object>) result;
+ List<ChatMessage> messages = new
ArrayList<>(pythonMessages.size());
+ for (Object pythonMessage : pythonMessages) {
+ messages.add(adapter.fromPythonChatMessage(pythonMessage));
+ }
+ return messages;
}
- return messages;
+ return Collections.emptyList();
+ }
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (closed || prompt == null) {
+ return;
+ }
+ closed = true;
+ try (prompt) {
+ adapter.callMethod(prompt, "close", Map.of());
}
- return Collections.emptyList();
}
}
diff --git
a/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPServer.java
b/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPServer.java
index 6bfd95d2..08ae93f2 100644
---
a/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPServer.java
+++
b/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPServer.java
@@ -31,10 +31,12 @@ import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
public class PythonMCPServer extends Resource implements PythonResourceWrapper
{
private final PyObject server;
private final PythonResourceAdapter adapter;
+ private boolean closed;
/**
* Creates a new PythonMCPServer.
@@ -107,4 +109,15 @@ public class PythonMCPServer extends Resource implements
PythonResourceWrapper {
public ResourceType getResourceType() {
return ResourceType.MCP_SERVER;
}
+
+ @Override
+ public void close() throws Exception {
+ if (closed || server == null) {
+ return;
+ }
+ closed = true;
+ try (server) {
+ adapter.callMethod(server, "close", Map.of());
+ }
+ }
}
diff --git
a/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPTool.java
b/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPTool.java
index ecd16286..859d67ce 100644
---
a/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPTool.java
+++
b/plan/src/main/java/org/apache/flink/agents/plan/resource/python/PythonMCPTool.java
@@ -43,6 +43,7 @@ public class PythonMCPTool extends Tool
"python_java_utils.invoke_python_tool_instance";
private final PyObject tool;
private final PythonResourceAdapter adapter;
+ private boolean closed;
@Nullable private final String mcpServerName;
/**
@@ -122,4 +123,15 @@ public class PythonMCPTool extends Tool
}
return metadata;
}
+
+ @Override
+ public void close() throws Exception {
+ if (closed || tool == null) {
+ return;
+ }
+ closed = true;
+ try (tool) {
+ adapter.callMethod(tool, "close", Map.of());
+ }
+ }
}
diff --git
a/plan/src/test/java/org/apache/flink/agents/plan/resource/python/PythonMCPResourceTest.java
b/plan/src/test/java/org/apache/flink/agents/plan/resource/python/PythonMCPResourceTest.java
new file mode 100644
index 00000000..47e11cb2
--- /dev/null
+++
b/plan/src/test/java/org/apache/flink/agents/plan/resource/python/PythonMCPResourceTest.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.plan.resource.python;
+
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.resource.python.PythonResourceAdapter;
+import org.junit.jupiter.api.Test;
+import pemja.core.object.PyObject;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class PythonMCPResourceTest {
+
+ @Test
+ void closesDiscoveredResourcesOnce() throws Exception {
+ PythonResourceAdapter adapter = mock(PythonResourceAdapter.class);
+ PyObject serverObject = mock(PyObject.class);
+ PyObject toolObject = mock(PyObject.class);
+ PyObject promptObject = mock(PyObject.class);
+ PythonMCPServer server =
+ new PythonMCPServer(
+ adapter,
+ serverObject,
+ mock(ResourceDescriptor.class),
+ mock(ResourceContext.class));
+
+ when(adapter.callMethod(serverObject, "list_tools", Map.of()))
+ .thenReturn(List.of(toolObject));
+
when(adapter.invoke("python_java_utils.get_java_tool_metadata_from_tool",
toolObject))
+ .thenReturn(
+ Map.of(
+ "name", "tool",
+ "description", "description",
+ "inputSchema", "{}"));
+ when(adapter.callMethod(serverObject, "list_prompts", Map.of()))
+ .thenReturn(List.of(promptObject));
+
+ PythonMCPTool tool = server.listTools("server").get(0);
+ PythonMCPPrompt prompt = server.listPrompts().get(0);
+
+ verify(toolObject, never()).close();
+ verify(promptObject, never()).close();
+
+ tool.close();
+ prompt.close();
+ server.close();
+ tool.close();
+ prompt.close();
+ server.close();
+
+ verify(adapter).callMethod(toolObject, "close", Map.of());
+ verify(adapter).callMethod(promptObject, "close", Map.of());
+ verify(adapter).callMethod(serverObject, "close", Map.of());
+ verify(toolObject).close();
+ verify(promptObject).close();
+ verify(serverObject).close();
+ }
+
+ @Test
+ void releasesPromptBridgeValuesAfterConversion() throws Exception {
+ PythonResourceAdapter adapter = mock(PythonResourceAdapter.class);
+ PyObject promptObject = mock(PyObject.class);
+ PyObject messageObject = mock(PyObject.class);
+ ChatMessage message = mock(ChatMessage.class);
+ PythonMCPPrompt prompt = new PythonMCPPrompt(adapter, promptObject);
+
+ when(adapter.invoke("python_java_utils.from_java_message_role",
MessageRole.USER))
+ .thenReturn("user");
+ when(adapter.callMethod(eq(promptObject), eq("format_messages"),
any(Map.class)))
+ .thenReturn(List.of(messageObject));
+ when(adapter.fromPythonChatMessage(messageObject)).thenReturn(message);
+
+ assertThat(prompt.formatMessages(MessageRole.USER,
Map.of())).containsExactly(message);
+
+ verify(messageObject).close();
+
+ prompt.close();
+ }
+}
diff --git
a/plan/src/test/java/org/apache/flink/agents/plan/resourceprovider/PythonResourceProviderTest.java
b/plan/src/test/java/org/apache/flink/agents/plan/resourceprovider/PythonResourceProviderTest.java
new file mode 100644
index 00000000..9dc54824
--- /dev/null
+++
b/plan/src/test/java/org/apache/flink/agents/plan/resourceprovider/PythonResourceProviderTest.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.plan.resourceprovider;
+
+import org.apache.flink.agents.api.resource.Resource;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.resource.ResourceType;
+import org.apache.flink.agents.api.resource.python.PythonResourceAdapter;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import pemja.core.object.PyObject;
+
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class PythonResourceProviderTest {
+
+ @ParameterizedTest
+ @EnumSource(
+ value = ResourceType.class,
+ names = {
+ "CHAT_MODEL", "CHAT_MODEL_CONNECTION", "EMBEDDING_MODEL",
+ "EMBEDDING_MODEL_CONNECTION", "VECTOR_STORE", "MCP_SERVER"
+ })
+ void providedResourceOwnsAndClosesPythonHandleOnce(ResourceType type)
throws Exception {
+ PythonResourceAdapter adapter = mock(PythonResourceAdapter.class);
+ PyObject pythonResource = mock(PyObject.class);
+ ResourceDescriptor descriptor =
+ new ResourceDescriptor("example.module", "ExampleModel",
Map.of());
+ PythonResourceProvider provider = new PythonResourceProvider("model",
type, descriptor);
+ provider.setPythonResourceAdapter(adapter);
+ when(adapter.initPythonResource(anyString(), anyString(), anyMap()))
+ .thenReturn(pythonResource);
+
+ Resource resource = provider.provide(mock(ResourceContext.class));
+
+ verify(pythonResource, never()).close();
+ resource.close();
+ resource.close();
+ verify(adapter).callMethod(pythonResource, "close", Map.of());
+ verify(pythonResource).close();
+ }
+}
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/PythonMCPResourceDiscovery.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/PythonMCPResourceDiscovery.java
index b9e0b48d..eca4019e 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/PythonMCPResourceDiscovery.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/PythonMCPResourceDiscovery.java
@@ -71,6 +71,7 @@ public class PythonMCPResourceDiscovery {
provider.setPythonResourceAdapter(adapter);
PythonMCPServer server = (PythonMCPServer)
provider.provide(cache.getResourceContext());
+ cache.put(provider.getName(), MCP_SERVER, server);
for (PythonMCPTool tool : server.listTools(provider.getName())) {
cache.put(tool.getName(), TOOL, tool);
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemory.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemory.java
index bc4d16a1..67c0cc38 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemory.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemory.java
@@ -19,6 +19,7 @@ package org.apache.flink.agents.runtime.memory;
import org.apache.flink.agents.api.memory.MemorySet;
import org.apache.flink.agents.api.memory.MemorySetItem;
+import org.apache.flink.agents.api.resource.python.PythonObjectScope;
import org.apache.flink.agents.api.resource.python.PythonResourceAdapter;
import pemja.core.object.PyObject;
@@ -92,13 +93,15 @@ public class Mem0LongTermMemory implements
InteranlBaseLongTermMemory {
MemorySet memorySet,
List<String> memoryItems,
@Nullable List<Map<String, Object>> metadatas) {
- Map<String, Object> kwargs = new HashMap<>();
- kwargs.put("memory_set", buildPyMemorySet(memorySet));
- kwargs.put("memory_items", memoryItems);
- if (metadatas != null) {
- kwargs.put("metadatas", metadatas);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ Map<String, Object> kwargs = new HashMap<>();
+ kwargs.put("memory_set", buildPyMemorySet(scope, memorySet));
+ kwargs.put("memory_items", memoryItems);
+ if (metadatas != null) {
+ kwargs.put("metadatas", metadatas);
+ }
+ return (List<String>) adapter.callMethod(pyMem0, "add", kwargs);
}
- return (List<String>) adapter.callMethod(pyMem0, "add", kwargs);
}
@Override
@@ -107,29 +110,33 @@ public class Mem0LongTermMemory implements
InteranlBaseLongTermMemory {
@Nullable List<String> ids,
@Nullable Map<String, Object> filters,
@Nullable Integer limit) {
- Map<String, Object> kwargs = new HashMap<>();
- kwargs.put("memory_set", buildPyMemorySet(memorySet));
- if (ids != null) {
- kwargs.put("ids", ids);
- }
- if (filters != null) {
- kwargs.put("filters", filters);
- }
- if (limit != null) {
- kwargs.put("limit", limit);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ Map<String, Object> kwargs = new HashMap<>();
+ kwargs.put("memory_set", buildPyMemorySet(scope, memorySet));
+ if (ids != null) {
+ kwargs.put("ids", ids);
+ }
+ if (filters != null) {
+ kwargs.put("filters", filters);
+ }
+ if (limit != null) {
+ kwargs.put("limit", limit);
+ }
+ Object pyItems = scope.own(adapter.callMethod(pyMem0, "get",
kwargs));
+ return convertItems(pyItems);
}
- Object pyItems = adapter.callMethod(pyMem0, "get", kwargs);
- return convertItems(pyItems);
}
@Override
public void delete(MemorySet memorySet, @Nullable List<String> ids) {
- Map<String, Object> kwargs = new HashMap<>();
- kwargs.put("memory_set", buildPyMemorySet(memorySet));
- if (ids != null) {
- kwargs.put("ids", ids);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ Map<String, Object> kwargs = new HashMap<>();
+ kwargs.put("memory_set", buildPyMemorySet(scope, memorySet));
+ if (ids != null) {
+ kwargs.put("ids", ids);
+ }
+ adapter.callMethod(pyMem0, "delete", kwargs);
}
- adapter.callMethod(pyMem0, "delete", kwargs);
}
@Override
@@ -139,15 +146,17 @@ public class Mem0LongTermMemory implements
InteranlBaseLongTermMemory {
int limit,
@Nullable Map<String, Object> filters,
Map<String, Object> extraArgs) {
- Map<String, Object> kwargs = new HashMap<>(extraArgs);
- kwargs.put("memory_set", buildPyMemorySet(memorySet));
- kwargs.put("query", query);
- kwargs.put("limit", limit);
- if (filters != null) {
- kwargs.put("filters", filters);
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ Map<String, Object> kwargs = new HashMap<>(extraArgs);
+ kwargs.put("memory_set", buildPyMemorySet(scope, memorySet));
+ kwargs.put("query", query);
+ kwargs.put("limit", limit);
+ if (filters != null) {
+ kwargs.put("filters", filters);
+ }
+ Object pyItems = scope.own(adapter.callMethod(pyMem0, "search",
kwargs));
+ return convertItems(pyItems);
}
- Object pyItems = adapter.callMethod(pyMem0, "search", kwargs);
- return convertItems(pyItems);
}
@Override
@@ -203,7 +212,7 @@ public class Mem0LongTermMemory implements
InteranlBaseLongTermMemory {
}
}
- private Object buildPyMemorySet(MemorySet memorySet) {
+ private Object buildPyMemorySet(PythonObjectScope scope, MemorySet
memorySet) {
// Mem0 ignores a falsy agent_id rather than matching on it, so
forwarding an
// unbound or empty-keyed set would widen the operation to every key
sharing the job
// id and set name, which for a delete means deleting another key's
items.
@@ -216,12 +225,13 @@ public class Mem0LongTermMemory implements
InteranlBaseLongTermMemory {
memorySet.getName()));
}
requireNonEmptyPartitionKey(memorySet.getPartitionKey());
- return adapter.invoke(
- TO_PYTHON_MEMORY_SET,
- memorySet.getName(),
- memorySet.getPartitionKey(),
- memorySet.getObservationId(),
- memorySet.isObservationSuppressed());
+ return scope.own(
+ adapter.invoke(
+ TO_PYTHON_MEMORY_SET,
+ memorySet.getName(),
+ memorySet.getPartitionKey(),
+ memorySet.getObservationId(),
+ memorySet.isObservationSuppressed()));
}
/** Returns the partition key in scope, refusing what Mem0 cannot scope an
operation to. */
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
index e645b071..4012bd11 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutor.java
@@ -23,6 +23,7 @@ import org.apache.flink.agents.api.Event;
import org.apache.flink.agents.api.agents.AgentExecutionOptions;
import org.apache.flink.agents.api.resource.Resource;
import org.apache.flink.agents.api.resource.ResourceType;
+import org.apache.flink.agents.api.resource.python.PythonObjectScope;
import org.apache.flink.agents.plan.AgentPlan;
import org.apache.flink.agents.plan.PythonFunction;
import org.apache.flink.agents.runtime.operator.ActionTask;
@@ -230,9 +231,9 @@ public class PythonActionExecutor implements AutoCloseable {
/**
* Execute the Python function, which may return a Python coroutine
(awaitable) that needs to be
* processed in the future. Due to an issue in Pemja regarding incorrect
object reference
- * counting, this may lead to garbage collection of the object. To prevent
this, we use the set
- * and get methods to manually increment the object's reference count,
then return the name of
- * the Python awaitable variable.
+ * counting, this may lead to garbage collection of the object. To prevent
this, we store the
+ * awaitable in the interpreter globals, then return the name of that
variable. The temporary
+ * Java wrapper can be closed after the interpreter takes ownership of its
own reference.
*
* @return The name of the Python awaitable variable. It may be null if
the Python function does
* not return a coroutine.
@@ -242,10 +243,10 @@ public class PythonActionExecutor implements
AutoCloseable {
function.setInterpreter(interpreter);
String eventJson = new ObjectMapper().writeValueAsString(event);
- Object pythonEventObject =
interpreter.invoke(CONVERT_JSON_TO_PYTHON_EVENT, eventJson);
-
- try {
- Object calledResult = function.call(pythonEventObject,
pythonRunnerContext);
+ try (PyObject pythonEventObject =
+ (PyObject)
interpreter.invoke(CONVERT_JSON_TO_PYTHON_EVENT, eventJson);
+ PyObject calledResult =
+ (PyObject) function.call(pythonEventObject,
pythonRunnerContext)) {
if (calledResult == null) {
return null;
} else {
@@ -301,15 +302,23 @@ public class PythonActionExecutor implements
AutoCloseable {
* @return true if the awaitable has completed; false otherwise
*/
public boolean callPythonAwaitable(String pythonAwaitableRef) {
- // Calling awaitable.send(None) in Python returns a tuple of
(finished, output).
- Object pythonAwaitable = interpreter.get(pythonAwaitableRef);
- checkState(
- pythonAwaitable != null,
- "Python awaitable '%s' not found in interpreter. ",
- pythonAwaitableRef);
- Object invokeResult = interpreter.invoke(CALL_PYTHON_AWAITABLE,
pythonAwaitable);
- checkState(invokeResult.getClass().isArray() && ((Object[])
invokeResult).length == 2);
- return (boolean) ((Object[]) invokeResult)[0];
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ PyObject pythonAwaitable = scope.own((PyObject)
interpreter.get(pythonAwaitableRef));
+ checkState(
+ pythonAwaitable != null,
+ "Python awaitable '%s' not found in interpreter.",
+ pythonAwaitableRef);
+ // Actions communicate through Events, so this caller consumes
only the completion flag.
+ Object invokeResult =
+ scope.own(interpreter.invoke(CALL_PYTHON_AWAITABLE,
pythonAwaitable));
+ checkState(invokeResult instanceof Object[] && ((Object[])
invokeResult).length == 2);
+ Object[] result = (Object[]) invokeResult;
+ boolean finished = (boolean) result[0];
+ if (finished) {
+ interpreter.exec("del " + pythonAwaitableRef);
+ }
+ return finished;
+ }
}
@Override
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java
index 31c89713..73f206ab 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImpl.java
@@ -24,6 +24,7 @@ import org.apache.flink.agents.api.prompt.Prompt;
import org.apache.flink.agents.api.resource.Resource;
import org.apache.flink.agents.api.resource.ResourceContext;
import org.apache.flink.agents.api.resource.ResourceType;
+import org.apache.flink.agents.api.resource.python.PythonObjectScope;
import org.apache.flink.agents.api.resource.python.PythonResourceAdapter;
import org.apache.flink.agents.api.resource.python.PythonResourceWrapper;
import org.apache.flink.agents.api.tools.Tool;
@@ -194,9 +195,13 @@ public class PythonResourceAdapterImpl implements
PythonResourceAdapter, AutoClo
@Override
public VectorStoreQueryResult fromPythonVectorStoreQueryResult(
PyObject pythonVectorStoreQueryResult) {
- List<PyObject> pythonDocuments =
- (List<PyObject>)
pythonVectorStoreQueryResult.getAttr("documents", List.class);
- return new
VectorStoreQueryResult(fromPythonDocuments(pythonDocuments));
+ try (PythonObjectScope scope = new PythonObjectScope()) {
+ List<PyObject> pythonDocuments =
+ scope.own(
+ (List<PyObject>)
+
pythonVectorStoreQueryResult.getAttr("documents", List.class));
+ return new
VectorStoreQueryResult(fromPythonDocuments(pythonDocuments));
+ }
}
@Override
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/PythonMCPResourceDiscoveryTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/PythonMCPResourceDiscoveryTest.java
new file mode 100644
index 00000000..36fb9217
--- /dev/null
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/PythonMCPResourceDiscoveryTest.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.runtime;
+
+import org.apache.flink.agents.api.resource.ResourceType;
+import org.apache.flink.agents.api.resource.python.PythonResourceAdapter;
+import org.apache.flink.agents.plan.resource.python.PythonMCPPrompt;
+import org.apache.flink.agents.plan.resource.python.PythonMCPServer;
+import org.apache.flink.agents.plan.resource.python.PythonMCPTool;
+import org.apache.flink.agents.plan.resourceprovider.PythonResourceProvider;
+import org.apache.flink.agents.plan.resourceprovider.ResourceProvider;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.flink.agents.api.resource.ResourceType.MCP_SERVER;
+import static org.apache.flink.agents.api.resource.ResourceType.PROMPT;
+import static org.apache.flink.agents.api.resource.ResourceType.TOOL;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class PythonMCPResourceDiscoveryTest {
+
+ @Test
+ void cachesServerAndDiscoveredResourcesForNormalShutdown() throws
Exception {
+ PythonResourceAdapter adapter = mock(PythonResourceAdapter.class);
+ PythonResourceProvider serverProvider =
mock(PythonResourceProvider.class);
+ PythonMCPServer server = mock(PythonMCPServer.class);
+ PythonMCPTool tool = mock(PythonMCPTool.class);
+ PythonMCPPrompt prompt = mock(PythonMCPPrompt.class);
+ Map<ResourceType, Map<String, ResourceProvider>> providers =
+ Map.of(MCP_SERVER, Map.of("server", serverProvider));
+ ResourceCache cache = new ResourceCache(providers);
+
+ when(serverProvider.getName()).thenReturn("server");
+ when(serverProvider.provide(any())).thenReturn(server);
+ when(server.listTools("server")).thenReturn(List.of(tool));
+ when(server.listPrompts()).thenReturn(List.of(prompt));
+ when(tool.getName()).thenReturn("tool");
+ when(prompt.getName()).thenReturn("prompt");
+
+ PythonMCPResourceDiscovery.discoverPythonMCPResources(providers,
adapter, cache);
+
+ assertThat(cache.getResource("server", MCP_SERVER)).isSameAs(server);
+ assertThat(cache.getResource("tool", TOOL)).isSameAs(tool);
+ assertThat(cache.getResource("prompt", PROMPT)).isSameAs(prompt);
+
+ cache.close();
+
+ verify(server).close();
+ verify(tool).close();
+ verify(prompt).close();
+ }
+}
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemoryTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemoryTest.java
index 1554be96..696b2a89 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemoryTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/Mem0LongTermMemoryTest.java
@@ -37,6 +37,7 @@ import static
org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -102,7 +103,7 @@ public class Mem0LongTermMemoryTest {
}
@Test
- void testAddForwardsKwargsAndReturnsIds() throws Exception {
+ void testAddForwardsKwargsAndReleasesTemporaryMemorySet() throws Exception
{
MemorySet ms = ltm.getMemorySet("notes");
when(mockAdapter.callMethod(eq(mockPyMem0), eq("add"), any()))
.thenReturn(List.of("a", "b"));
@@ -114,6 +115,7 @@ public class Mem0LongTermMemoryTest {
assertThat(captureKwargs("add"))
.containsKeys("memory_set", "memory_items", "metadatas")
.containsEntry("memory_set", mockPyMemorySet);
+ verify(mockPyMemorySet).close();
}
@Test
@@ -131,8 +133,10 @@ public class Mem0LongTermMemoryTest {
@Test
void testGetWithIdsAndFiltersConvertsItems() throws Exception {
MemorySet ms = ltm.getMemorySet("notes");
- when(mockAdapter.callMethod(eq(mockPyMem0), eq("get"),
any())).thenReturn("py_items");
- when(mockAdapter.invoke(eq("python_java_utils.mem0_items_to_java"),
eq("py_items")))
+ PyObject pythonItem = mock(PyObject.class);
+ List<PyObject> pythonItems = List.of(pythonItem);
+ when(mockAdapter.callMethod(eq(mockPyMem0), eq("get"),
any())).thenReturn(pythonItems);
+ when(mockAdapter.invoke(eq("python_java_utils.mem0_items_to_java"),
eq(pythonItems)))
.thenReturn(
List.of(
Map.of(
@@ -149,6 +153,7 @@ public class Mem0LongTermMemoryTest {
assertThat(item.getId()).isEqualTo("id1");
assertThat(item.getValue()).isEqualTo("hello");
assertThat(item.getAdditionalMetadata()).containsEntry("k", "v");
+ verify(pythonItem).close();
assertThat(item.getCreatedAt()).isNull();
assertThat(captureKwargs("get"))
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
index 5bdac5dc..b2ac5060 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonActionExecutorTest.java
@@ -18,24 +18,33 @@
package org.apache.flink.agents.runtime.python.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.flink.agents.api.InputEvent;
import org.apache.flink.agents.api.agents.AgentExecutionOptions;
import org.apache.flink.agents.plan.AgentPlan;
+import org.apache.flink.agents.plan.PythonFunction;
import org.apache.flink.agents.runtime.python.context.PythonRunnerContextImpl;
import org.apache.flink.types.Row;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import pemja.core.PythonInterpreter;
import pemja.core.object.PyObject;
import java.lang.reflect.Field;
import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -51,6 +60,9 @@ class PythonActionExecutorTest {
"flink_runner_context.create_flink_runner_context";
private static final String CLOSE_FLINK_RUNNER_CONTEXT =
"flink_runner_context.close_flink_runner_context";
+ private static final String CONVERT_JSON_TO_PYTHON_EVENT =
+ "python_java_utils.convert_json_to_python_event";
+ private static final String CALL_PYTHON_AWAITABLE =
"function.call_python_awaitable";
@Test
void resolvesPickledPythonKeyTextFromPyFlinkKeyRow() throws Exception {
@@ -243,9 +255,95 @@ class PythonActionExecutorTest {
fixture.interpreter, fixture.asyncThreadPool,
fixture.runnerContextObject);
}
+ @Test
+ void closesPythonEventAfterSynchronousAction() throws Exception {
+ PythonInterpreter interpreter = mock(PythonInterpreter.class);
+ PythonRunnerContextImpl runnerContext =
mock(PythonRunnerContextImpl.class);
+ PythonActionExecutor executor = newExecutor(interpreter,
runnerContext);
+ PythonFunction function = mock(PythonFunction.class);
+ PyObject pythonEvent = mock(PyObject.class);
+ when(interpreter.invoke(same(CONVERT_JSON_TO_PYTHON_EVENT),
anyString()))
+ .thenReturn(pythonEvent);
+ when(function.call(same(pythonEvent), isNull())).thenReturn(null);
+
+ assertThat(executor.executePythonFunction(function, new
InputEvent(1L))).isNull();
+
+ verify(function).setInterpreter(interpreter);
+ verify(pythonEvent).close();
+ }
+
+ @Test
+ void closesTemporaryWrappersAfterStoringAwaitable() throws Exception {
+ PythonInterpreter interpreter = mock(PythonInterpreter.class);
+ PythonRunnerContextImpl runnerContext =
mock(PythonRunnerContextImpl.class);
+ PythonActionExecutor executor = newExecutor(interpreter,
runnerContext);
+ PythonFunction function = mock(PythonFunction.class);
+ PyObject pythonEvent = mock(PyObject.class);
+ PyObject pythonAwaitable = mock(PyObject.class);
+ when(interpreter.invoke(same(CONVERT_JSON_TO_PYTHON_EVENT),
anyString()))
+ .thenReturn(pythonEvent);
+ when(function.call(same(pythonEvent),
isNull())).thenReturn(pythonAwaitable);
+
+ String pythonAwaitableRef = executor.executePythonFunction(function,
new InputEvent(1L));
+
+ ArgumentCaptor<String> refCaptor =
ArgumentCaptor.forClass(String.class);
+ verify(interpreter).set(refCaptor.capture(), same(pythonAwaitable));
+ assertThat(pythonAwaitableRef)
+ .isEqualTo(refCaptor.getValue())
+ .startsWith("python_awaitable_");
+ InOrder closeOrder = inOrder(interpreter, pythonAwaitable,
pythonEvent);
+ closeOrder.verify(interpreter).set(pythonAwaitableRef,
pythonAwaitable);
+ closeOrder.verify(pythonAwaitable).close();
+ closeOrder.verify(pythonEvent).close();
+ }
+
+ @Test
+ void closesRetrievedAwaitableWhileItIsPending() throws Exception {
+ PythonInterpreter interpreter = mock(PythonInterpreter.class);
+ PythonActionExecutor executor = newExecutor(interpreter);
+ PyObject pythonAwaitable = mock(PyObject.class);
+ PyObject yieldedValue = mock(PyObject.class);
+ String pythonAwaitableRef = "python_awaitable_1";
+ when(interpreter.get(pythonAwaitableRef)).thenReturn(pythonAwaitable);
+ when(interpreter.invoke(CALL_PYTHON_AWAITABLE, pythonAwaitable))
+ .thenReturn(
+ new Object[] {false, Map.of("items",
List.of(yieldedValue, yieldedValue))});
+
+ assertThat(executor.callPythonAwaitable(pythonAwaitableRef)).isFalse();
+
+ verify(pythonAwaitable).close();
+ verify(yieldedValue).close();
+ verify(interpreter, never()).exec(anyString());
+ }
+
+ @Test
+ void deletesCompletedAwaitableAndClosesRetrievedWrapper() throws Exception
{
+ PythonInterpreter interpreter = mock(PythonInterpreter.class);
+ PythonActionExecutor executor = newExecutor(interpreter);
+ PyObject pythonAwaitable = mock(PyObject.class);
+ PyObject returnedValue = mock(PyObject.class);
+ String pythonAwaitableRef = "python_awaitable_1";
+ when(interpreter.get(pythonAwaitableRef)).thenReturn(pythonAwaitable);
+ when(interpreter.invoke(CALL_PYTHON_AWAITABLE, pythonAwaitable))
+ .thenReturn(new Object[] {true, new Object[] {returnedValue}});
+
+ assertThat(executor.callPythonAwaitable(pythonAwaitableRef)).isTrue();
+
+ InOrder interpreterOrder = inOrder(interpreter);
+ interpreterOrder.verify(interpreter).invoke(CALL_PYTHON_AWAITABLE,
pythonAwaitable);
+ interpreterOrder.verify(interpreter).exec("del " + pythonAwaitableRef);
+ verify(returnedValue).close();
+ verify(pythonAwaitable).close();
+ }
+
private static PythonActionExecutor newExecutor(PythonInterpreter
interpreter)
throws Exception {
- return new PythonActionExecutor(interpreter, null, null, null,
"test-job");
+ return newExecutor(interpreter, null);
+ }
+
+ private static PythonActionExecutor newExecutor(
+ PythonInterpreter interpreter, PythonRunnerContextImpl
runnerContext) throws Exception {
+ return new PythonActionExecutor(interpreter, null, null,
runnerContext, "test-job");
}
private static TestFixture createOpenedExecutor() throws Exception {
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImplTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImplTest.java
index f372821a..b2225fdb 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImplTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/python/utils/PythonResourceAdapterImplTest.java
@@ -25,6 +25,7 @@ import org.apache.flink.agents.api.resource.ResourceContext;
import org.apache.flink.agents.api.resource.ResourceType;
import org.apache.flink.agents.api.resource.python.PythonResourceWrapper;
import org.apache.flink.agents.api.tools.Tool;
+import org.apache.flink.agents.api.vectorstores.VectorStoreQueryResult;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -34,6 +35,7 @@ import pemja.core.PythonInterpreter;
import pemja.core.object.PyObject;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@@ -196,6 +198,22 @@ public class PythonResourceAdapterImplTest {
.invoke(PythonResourceAdapterImpl.CALL_METHOD, obj,
methodName, kwargs);
}
+ @Test
+ void closesRetrievedDocumentsAfterQueryResultConversion() throws Exception
{
+ PyObject pythonResult = mock(PyObject.class);
+ PyObject pythonDocument = mock(PyObject.class);
+ when(pythonResult.getAttr("documents",
List.class)).thenReturn(List.of(pythonDocument));
+ when(pythonDocument.getAttr("content")).thenReturn("content");
+ when(pythonDocument.getAttr("metadata",
Map.class)).thenReturn(Map.of("source", "test"));
+ when(pythonDocument.getAttr("id")).thenReturn("doc-1");
+
+ VectorStoreQueryResult result =
+
pythonResourceAdapter.fromPythonVectorStoreQueryResult(pythonResult);
+
+
assertThat(result.getDocuments().get(0).getContent()).isEqualTo("content");
+ verify(pythonDocument).close();
+ }
+
@Test
void testSetMetricGroup() {
Object pythonResource = new Object();