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 220cbd4e [api][plan][runtime][python] Rebuild registered sub-agent
setups from descriptors (#1132)
220cbd4e is described below
commit 220cbd4e550362361cdba8f9bd7ddb5303c9a9b0
Author: yunfengzhou-hub <[email protected]>
AuthorDate: Tue Sep 29 10:07:36 2026 +0800
[api][plan][runtime][python] Rebuild registered sub-agent setups from
descriptors (#1132)
Generated-by: Qoder 1.29.0 (Qwen3.8-Max)
Co-authored-by: Qoder <[email protected]>
---
.../flink/agents/api/subagent/SubagentSetup.java | 86 +++++++++---
.../chat/model/BaseChatModelSetupSubagentTest.java | 25 +++-
.../agents/api/subagent/SubagentRegisterTest.java | 12 +-
.../agents/api/subagent/SubagentSetupTest.java | 91 ++++++++++---
.../agents/api/subagent/TestSubagentSetup.java | 28 ++--
.../org/apache/flink/agents/plan/AgentPlan.java | 9 +-
.../agents/plan/AgentPlanSubagentResourceTest.java | 2 +-
.../plan/AgentPlanSubagentSerializationTest.java | 148 +++++++++++++++++++++
.../plan/actions/ToolCallActionSubagentTest.java | 30 ++++-
python/flink_agents/plan/agent_plan.py | 16 ++-
.../tests/test_agent_plan_subagent_resources.py | 33 +++++
.../runtime/subagent/BaseAsyncSubagentSetup.java | 13 +-
.../subagent/BaseDeferredSubagentSetup.java | 8 ++
.../agents/runtime/subagent/BaseSubagentSetup.java | 10 ++
.../flink/agents/runtime/ResourceCacheTest.java | 11 ++
.../subagent/BaseAsyncSubagentSetupTest.java | 13 +-
.../runtime/subagent/BaseSubagentSetupTest.java | 24 ++++
.../subagent/DeferredSubagentSubmitTest.java | 18 ++-
.../runtime/subagent/MockAsyncSubagentSetup.java | 64 ++++++---
.../subagent/MockDeferredSubagentSetup.java | 16 +++
.../external/ExternalAgentSubagentSetupTest.java | 7 +
.../external/ExternalAsyncSubagentSetup.java | 38 +++++-
.../external/ExternalDeferredSubagentSetup.java | 37 +++++-
23 files changed, 628 insertions(+), 111 deletions(-)
diff --git
a/api/src/main/java/org/apache/flink/agents/api/subagent/SubagentSetup.java
b/api/src/main/java/org/apache/flink/agents/api/subagent/SubagentSetup.java
index 364db659..130ea034 100644
--- a/api/src/main/java/org/apache/flink/agents/api/subagent/SubagentSetup.java
+++ b/api/src/main/java/org/apache/flink/agents/api/subagent/SubagentSetup.java
@@ -19,13 +19,16 @@
package org.apache.flink.agents.api.subagent;
import com.fasterxml.jackson.annotation.JsonIgnore;
-import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.flink.agents.api.context.RunnerContext;
+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.SerializableResource;
import javax.annotation.Nullable;
+import java.util.Objects;
+
/**
* Caller-facing definition of a sub-agent, registered in the agent plan as an
{@code AGENT}
* resource.
@@ -40,34 +43,77 @@ public abstract class SubagentSetup extends
SerializableResource {
public static final String CALLABLE_NAME_PREFIX = "_subagent_";
/**
- * Tells a caller what this sub-agent is for, so that it can decide
whether to delegate to it.
- * This is routing information for the caller, not an instruction for the
sub-agent itself.
+ * Descriptor argument key carrying the caller-facing {@link
#getDescription()}. It is the
+ * single source of truth shared by the constructor that reads it back and
the subclasses whose
+ * descriptors write it, and the cross-language wire key the Python side
reads.
*/
- @JsonProperty("description")
- private String description;
+ public static final String FIELD_DESCRIPTION = "description";
/**
- * JSON Schema of the arguments this sub-agent accepts, as declared
explicitly. Null when it was
- * not, in which case {@link #getInputSchema()} derives it from {@link
#getInputType()}.
+ * Descriptor argument key carrying the explicitly declared {@link
#getInputSchema()}, absent
+ * when it was not declared. Shared like {@link #FIELD_DESCRIPTION} across
the write and read
+ * ends so they cannot drift apart.
*/
- @JsonProperty("input_schema")
- @Nullable
- private String inputSchema;
+ public static final String FIELD_INPUT_SCHEMA = "input_schema";
- protected SubagentSetup() {
- this("");
- }
+ /**
+ * The descriptor capturing this setup's construction configuration. A
compiled plan carries
+ * this descriptor across the JobManager to TaskManager transfer, and a
remote task rebuilds an
+ * equivalent setup from it through the {@code (ResourceDescriptor,
ResourceContext)}
+ * constructor. Every setup carries one, so a registered sub-agent is
always rebuildable.
+ */
+ private final ResourceDescriptor descriptor;
- protected SubagentSetup(String description) {
- this(description, null);
- }
+ /**
+ * Tells a caller what this sub-agent is for, so that it can decide
whether to delegate to it.
+ * This is routing information for the caller, not an instruction for the
sub-agent itself. It
+ * travels as the descriptor's {@value #FIELD_DESCRIPTION} argument, so a
remote task reads it
+ * back instead of losing it with the live object.
+ */
+ private final String description;
- protected SubagentSetup(String description, @Nullable String inputSchema) {
- this.description = description == null ? "" : description;
- if (inputSchema != null && inputSchema.isBlank()) {
+ /**
+ * JSON Schema of the arguments this sub-agent accepts, as declared
explicitly. Null when it was
+ * not, in which case {@link #getInputSchema()} derives it from {@link
#getInputType()}. It
+ * travels as the descriptor's {@value #FIELD_INPUT_SCHEMA} argument.
+ */
+ @Nullable private final String inputSchema;
+
+ /**
+ * Constructs the setup from the descriptor carrying its configuration.
This is the only
+ * construction path: concrete subclasses expose a public form of it so
the framework can
+ * rebuild them from a descriptor on a remote task. The descriptor must
name this setup's own
+ * concrete type as its clazz, because that name is what the remote
rebuild reflects over; a
+ * mismatch is rejected here rather than surfacing as a wrong-class
rebuild on a far task. The
+ * caller-facing metadata travels as descriptor arguments, so it is read
back here rather than
+ * passed alongside the descriptor.
+ */
+ protected SubagentSetup(ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ this.descriptor =
+ Objects.requireNonNull(
+ descriptor,
+ "A SubagentSetup must carry a ResourceDescriptor so it
can be rebuilt on a"
+ + " remote task.");
+ if (!getClass().getName().equals(this.descriptor.getClazz())) {
+ throw new IllegalArgumentException(
+ String.format(
+ "A %s must carry a descriptor naming its own type,
but the descriptor"
+ + " names %s; a remote task would rebuild
the wrong class.",
+ getClass().getName(), this.descriptor.getClazz()));
+ }
+ String declaredDescription =
this.descriptor.getArgument(FIELD_DESCRIPTION);
+ this.description = declaredDescription == null ? "" :
declaredDescription;
+ String declaredInputSchema =
this.descriptor.getArgument(FIELD_INPUT_SCHEMA);
+ if (declaredInputSchema != null && declaredInputSchema.isBlank()) {
throw new IllegalArgumentException("Sub-agent input schema must
not be blank.");
}
- this.inputSchema = inputSchema;
+ this.inputSchema = declaredInputSchema;
+ }
+
+ /** The descriptor this setup is rebuilt from on a remote task. */
+ @JsonIgnore
+ public ResourceDescriptor getDescriptor() {
+ return descriptor;
}
@Override
diff --git
a/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetupSubagentTest.java
b/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetupSubagentTest.java
index 54e8e50d..abbc9aab 100644
---
a/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetupSubagentTest.java
+++
b/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelSetupSubagentTest.java
@@ -138,11 +138,30 @@ class BaseChatModelSetupSubagentTest {
private static final long serialVersionUID = 1L;
StubSubagentSetup(String description) {
- super(description);
+ this(description, null);
}
StubSubagentSetup(String description, String inputSchema) {
- super(description, inputSchema);
+ this(metadataDescriptor(StubSubagentSetup.class, description,
inputSchema), null);
+ }
+
+ StubSubagentSetup(ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
+ /**
+ * Builds the metadata-only descriptor naming {@code concreteClass},
so a subclass that adds
+ * no configuration of its own still carries a descriptor naming its
own concrete type.
+ */
+ static ResourceDescriptor metadataDescriptor(
+ Class<?> concreteClass, String description, String
inputSchema) {
+ ResourceDescriptor.Builder builder =
+
ResourceDescriptor.Builder.newBuilder(concreteClass.getName())
+ .addInitialArgument(FIELD_DESCRIPTION,
description);
+ if (inputSchema != null) {
+ builder.addInitialArgument(FIELD_INPUT_SCHEMA, inputSchema);
+ }
+ return builder.build();
}
@Override
@@ -168,7 +187,7 @@ class BaseChatModelSetupSubagentTest {
private static final long serialVersionUID = 1L;
TypedStubSubagentSetup(String description) {
- super(description);
+ super(metadataDescriptor(TypedStubSubagentSetup.class,
description, null), null);
}
@Override
diff --git
a/api/src/test/java/org/apache/flink/agents/api/subagent/SubagentRegisterTest.java
b/api/src/test/java/org/apache/flink/agents/api/subagent/SubagentRegisterTest.java
index a0dfb7a2..4b4056f3 100644
---
a/api/src/test/java/org/apache/flink/agents/api/subagent/SubagentRegisterTest.java
+++
b/api/src/test/java/org/apache/flink/agents/api/subagent/SubagentRegisterTest.java
@@ -34,7 +34,7 @@ class SubagentRegisterTest {
@Test
void registerSubagentSetupAsResource() {
Agent agent = new Agent();
- TestSubagentSetup setup = new TestSubagentSetup();
+ TestSubagentSetup setup = new TestSubagentSetup(null);
agent.addResource("reviewer", ResourceType.AGENT, setup);
Map<String, Object> agentResources =
agent.getResources().get(ResourceType.AGENT);
@@ -46,17 +46,19 @@ class SubagentRegisterTest {
@Test
void duplicateNameThrows() {
Agent agent = new Agent();
- agent.addResource("reviewer", ResourceType.AGENT, new
TestSubagentSetup());
+ agent.addResource("reviewer", ResourceType.AGENT, new
TestSubagentSetup(null));
assertThrows(
IllegalArgumentException.class,
- () -> agent.addResource("reviewer", ResourceType.AGENT, new
TestSubagentSetup()));
+ () ->
+ agent.addResource(
+ "reviewer", ResourceType.AGENT, new
TestSubagentSetup(null)));
}
@Test
void multipleSubagentsRegistered() {
Agent agent = new Agent();
- TestSubagentSetup reviewer = new TestSubagentSetup();
- TestSubagentSetup coder = new TestSubagentSetup();
+ TestSubagentSetup reviewer = new TestSubagentSetup(null);
+ TestSubagentSetup coder = new TestSubagentSetup(null);
agent.addResource("reviewer", ResourceType.AGENT, reviewer);
agent.addResource("coder", ResourceType.AGENT, coder);
diff --git
a/api/src/test/java/org/apache/flink/agents/api/subagent/SubagentSetupTest.java
b/api/src/test/java/org/apache/flink/agents/api/subagent/SubagentSetupTest.java
index 1c342ba4..525abbaf 100644
---
a/api/src/test/java/org/apache/flink/agents/api/subagent/SubagentSetupTest.java
+++
b/api/src/test/java/org/apache/flink/agents/api/subagent/SubagentSetupTest.java
@@ -22,6 +22,8 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.junit.jupiter.api.Test;
import javax.annotation.Nullable;
@@ -33,7 +35,13 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-/** Pins the routing metadata {@link SubagentSetup} carries for a caller. */
+/**
+ * Pins the construction contract of {@link SubagentSetup} and the routing
metadata it carries for a
+ * caller. Every setup carries the descriptor a remote task rebuilds it from,
and that descriptor
+ * names the setup's own type, so a registered sub-agent is always rebuildable
into the right class.
+ * The metadata travels as descriptor arguments, the single wire both a remote
task and the Python
+ * side read, so it is pinned there rather than on a serialized object.
+ */
public class SubagentSetupTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@@ -44,15 +52,35 @@ public class SubagentSetupTest {
private static final long serialVersionUID = 1L;
MetadataOnlySetup() {
- super();
+ this("");
}
MetadataOnlySetup(String description) {
- super(description);
+ this(description, null);
}
MetadataOnlySetup(String description, @Nullable String inputSchema) {
- super(description, inputSchema);
+ this(metadataDescriptor(MetadataOnlySetup.class, description,
inputSchema), null);
+ }
+
+ /** Descriptor-based construction, the path a remote task rebuilds the
setup through. */
+ MetadataOnlySetup(ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
+ /**
+ * Builds the metadata-only descriptor naming {@code concreteClass},
so a subclass that adds
+ * no configuration of its own still carries a descriptor naming its
own concrete type.
+ */
+ static ResourceDescriptor metadataDescriptor(
+ Class<?> concreteClass, String description, @Nullable String
inputSchema) {
+ ResourceDescriptor.Builder builder =
+
ResourceDescriptor.Builder.newBuilder(concreteClass.getName())
+ .addInitialArgument(FIELD_DESCRIPTION,
description);
+ if (inputSchema != null) {
+ builder.addInitialArgument(FIELD_INPUT_SCHEMA, inputSchema);
+ }
+ return builder.build();
}
@Override
@@ -81,7 +109,7 @@ public class SubagentSetupTest {
private final Class<?> resultType;
TypedSetup(Class<?> inputType, Class<?> resultType) {
- super("Reviews a file.");
+ super(metadataDescriptor(TypedSetup.class, "Reviews a file.",
null), null);
this.inputType = inputType;
this.resultType = resultType;
}
@@ -310,32 +338,55 @@ public class SubagentSetupTest {
.hasMessageContaining("is self-referential");
}
- /** The declared types drive behavior, so they must not leak into the
cross-language plan. */
+ /**
+ * The declared types drive behavior, so they are not descriptor state:
they never travel to a
+ * remote task or across to Python, which reads only the descriptor's
arguments.
+ */
@Test
- void theDeclaredTypesStayOutOfThePlanJson() throws Exception {
- String json = MAPPER.writeValueAsString(new TypedSetup(Review.class,
Review.class));
+ void theDeclaredTypesStayOutOfTheDescriptor() {
+ Map<String, Object> arguments =
+ new TypedSetup(Review.class,
Review.class).getDescriptor().getInitialArguments();
-
assertThat(json).doesNotContain("inputType").doesNotContain("resultType");
+ assertThat(arguments).doesNotContainKeys("inputType", "resultType");
}
/**
- * The plan JSON is a cross-language contract: these two keys are what the
Python side reads, so
- * they are pinned literally rather than through the getters.
+ * The descriptor's arguments are the cross-language wire the Python side
reads, so the metadata
+ * keys are pinned literally there rather than through the getters, and
the descriptor names the
+ * concrete type a remote task reflects over to rebuild the setup.
*/
@Test
- void theMetadataSerializesUnderTheCrossLanguageKeys() throws Exception {
+ void theMetadataTravelsUnderTheCrossLanguageDescriptorKeys() {
String customSchema =
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}";
- String json =
- MAPPER.writeValueAsString(new MetadataOnlySetup("Reviews a
file.", customSchema));
+ MetadataOnlySetup setup = new MetadataOnlySetup("Reviews a file.",
customSchema);
+ Map<String, Object> arguments =
setup.getDescriptor().getInitialArguments();
- assertThat(json)
- .contains("\"description\":\"Reviews a file.\"")
- .contains("\"input_schema\"");
- assertThat(json).doesNotContain("inputSchema");
- Map<String, Object> parsed = MAPPER.readValue(json, Map.class);
- assertThat(parsed).containsEntry("input_schema", customSchema);
+ assertThat(arguments).containsEntry("description", "Reviews a file.");
+ assertThat(arguments).containsEntry("input_schema", customSchema);
+
assertThat(setup.getDescriptor().getClazz()).isEqualTo(MetadataOnlySetup.class.getName());
+ }
+
+ @Test
+ void nullDescriptorIsRejectedAtConstruction() {
+ // The descriptor is what a remote task rebuilds the setup from, so
construction requires
+ // one and reports a missing descriptor at the point of the mistake.
+ assertThatThrownBy(() -> new TestSubagentSetup((ResourceDescriptor)
null, null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("must carry a ResourceDescriptor");
+ }
+
+ @Test
+ void mismatchedDescriptorClazzIsRejectedAtConstruction() {
+ // The descriptor names the class a remote task reflects over to
rebuild the setup, so it
+ // must name this setup's own type; a copy-paste or aliasing mistake
is caught here rather
+ // than rebuilding the wrong class on a far task.
+ ResourceDescriptor mismatched =
+
ResourceDescriptor.Builder.newBuilder("com.example.SomeOtherSubagent").build();
+ assertThatThrownBy(() -> new TestSubagentSetup(mismatched, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must carry a descriptor naming its own
type");
}
private static List<String> textValues(JsonNode array) {
diff --git
a/api/src/test/java/org/apache/flink/agents/api/subagent/TestSubagentSetup.java
b/api/src/test/java/org/apache/flink/agents/api/subagent/TestSubagentSetup.java
index c9ee099f..5cbb290e 100644
---
a/api/src/test/java/org/apache/flink/agents/api/subagent/TestSubagentSetup.java
+++
b/api/src/test/java/org/apache/flink/agents/api/subagent/TestSubagentSetup.java
@@ -18,8 +18,6 @@
package org.apache.flink.agents.api.subagent;
-import com.fasterxml.jackson.annotation.JsonCreator;
-import com.fasterxml.jackson.annotation.JsonProperty;
import org.apache.flink.agents.api.context.RunnerContext;
import org.apache.flink.agents.api.resource.ResourceContext;
import org.apache.flink.agents.api.resource.ResourceDescriptor;
@@ -35,30 +33,30 @@ public class TestSubagentSetup extends SubagentSetup {
private static final long serialVersionUID = 1L;
+ private static final String FIELD_ENDPOINT = "endpoint";
+ private static final String FIELD_FAIL_ON_CALL = "fail_on_call";
+
@Nullable private final String endpoint;
private final boolean failOnCall;
- public TestSubagentSetup() {
- this(null, false);
- }
-
public TestSubagentSetup(@Nullable String endpoint) {
this(endpoint, false);
}
- @JsonCreator
- public TestSubagentSetup(
- @JsonProperty("endpoint") @Nullable String endpoint,
- @JsonProperty("failOnCall") boolean failOnCall) {
- this.endpoint = endpoint;
- this.failOnCall = failOnCall;
+ public TestSubagentSetup(@Nullable String endpoint, boolean failOnCall) {
+ this(
+
ResourceDescriptor.Builder.newBuilder(TestSubagentSetup.class.getName())
+ .addInitialArgument(FIELD_ENDPOINT, endpoint)
+ .addInitialArgument(FIELD_FAIL_ON_CALL, failOnCall)
+ .build(),
+ null);
}
/** Descriptor-based construction, as used by YAML-declared {@code
subagents:} entries. */
public TestSubagentSetup(ResourceDescriptor descriptor, ResourceContext
resourceContext) {
- this(
- (String) descriptor.getArgument("endpoint"),
- Boolean.TRUE.equals(descriptor.getArgument("fail_on_call")));
+ super(descriptor, resourceContext);
+ this.endpoint = descriptor.getArgument(FIELD_ENDPOINT);
+ this.failOnCall =
Boolean.TRUE.equals(descriptor.getArgument(FIELD_FAIL_ON_CALL));
}
@Nullable
diff --git a/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java
b/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java
index 89fb9c2c..90fe54a9 100644
--- a/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java
+++ b/plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java
@@ -599,9 +599,14 @@ public class AgentPlan implements Serializable {
String name = kv.getKey();
Object value = kv.getValue();
if (value instanceof SubagentSetup) {
+ // A live setup registered programmatically travels as
its descriptor, so a
+ // remote task rebuilds it through the
(ResourceDescriptor, ResourceContext)
+ // constructor.
addResourceProvider(
-
JavaSerializableResourceProvider.createResourceProvider(
- name, ResourceType.AGENT,
(SubagentSetup) value));
+ createDescriptorResourceProvider(
+ name,
+ ResourceType.AGENT,
+ ((SubagentSetup)
value).getDescriptor()));
} else if (value instanceof ResourceDescriptor) {
// Declared via YAML: the descriptor names a
SubagentSetup subclass that is
// instantiated when the resource is first resolved.
diff --git
a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanSubagentResourceTest.java
b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanSubagentResourceTest.java
index d7be8ad4..e943c408 100644
---
a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanSubagentResourceTest.java
+++
b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanSubagentResourceTest.java
@@ -42,7 +42,7 @@ public class AgentPlanSubagentResourceTest {
@Test
void subagentSetupInstanceCompilesIntoAgentProvider() throws Exception {
Agent agent = new Agent();
- agent.addResource("reviewer", ResourceType.AGENT, new
TestSubagentSetup());
+ agent.addResource("reviewer", ResourceType.AGENT, new
TestSubagentSetup(null));
AgentPlan plan = new AgentPlan(agent);
diff --git
a/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanSubagentSerializationTest.java
b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanSubagentSerializationTest.java
new file mode 100644
index 00000000..6665a59f
--- /dev/null
+++
b/plan/src/test/java/org/apache/flink/agents/plan/AgentPlanSubagentSerializationTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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;
+
+import org.apache.flink.agents.api.agents.Agent;
+import org.apache.flink.agents.api.context.RunnerContext;
+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.subagent.SubagentFuture;
+import org.apache.flink.agents.api.subagent.SubagentSetup;
+import org.apache.flink.agents.plan.resourceprovider.ResourceProvider;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * An external sub-agent registered programmatically as a live {@link
SubagentSetup} instance must
+ * survive the plan's serialization round-trip — the transfer that carries a
compiled plan from the
+ * JobManager to a remote TaskManager — and resolve there to an equivalent
setup carrying the same
+ * configuration. In-process the plan resolves to the live object, so only the
round-trip exercises
+ * the descriptor-based rebuild on the far side.
+ */
+public class AgentPlanSubagentSerializationTest {
+
+ @Test
+ void liveExternalSubagentIsRebuiltAfterPlanSerializationRoundTrip() throws
Exception {
+ Agent agent = new Agent();
+ agent.addResource(
+ "reviewer",
+ ResourceType.AGENT,
+ new ExternalReviewSubagent("http://review.internal:8080", 3));
+
+ AgentPlan plan = new AgentPlan(agent);
+
+ // The JobManager -> TaskManager transfer: AgentPlan crosses the
process boundary through
+ // its Serializable hooks, so the live setup object does not travel
with it and must be
+ // rebuilt from what does.
+ AgentPlan restored = serializeRoundTrip(plan);
+
+ Map<String, ResourceProvider> agentProviders =
+ restored.getResourceProviders().get(ResourceType.AGENT);
+ assertThat(agentProviders).containsKey("reviewer");
+
+ Resource resolved = agentProviders.get("reviewer").provide(null);
+
+ assertThat(resolved).isInstanceOf(ExternalReviewSubagent.class);
+ ExternalReviewSubagent reviewer = (ExternalReviewSubagent) resolved;
+
assertThat(reviewer.getEndpoint()).isEqualTo("http://review.internal:8080");
+ assertThat(reviewer.getMaxRetries()).isEqualTo(3);
+ assertThat(resolved.getResourceType()).isEqualTo(ResourceType.AGENT);
+ }
+
+ /**
+ * Round-trips the plan through Java serialization, the transfer that
carries it to a remote
+ * TaskManager. {@link AgentPlan} exposes no {@code serialize()} method:
its public contract is
+ * {@code Serializable}, whose private {@code writeObject}/{@code
readObject} hooks Java invokes
+ * automatically, so writing the plan to an {@link ObjectOutputStream} is
that entry point.
+ */
+ private static AgentPlan serializeRoundTrip(AgentPlan plan)
+ throws IOException, ClassNotFoundException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
+ out.writeObject(plan);
+ }
+ try (ObjectInputStream in =
+ new ObjectInputStream(new
ByteArrayInputStream(bytes.toByteArray()))) {
+ return (AgentPlan) in.readObject();
+ }
+ }
+
+ /**
+ * A user-defined external sub-agent in the documented shape: construction
configuration held in
+ * final fields and set through a parameterized constructor, with no
Jackson creator.
+ */
+ public static class ExternalReviewSubagent extends SubagentSetup {
+
+ private static final String FIELD_ENDPOINT = "endpoint";
+ private static final String FIELD_MAX_RETRIES = "max_retries";
+
+ private final String endpoint;
+ private final int maxRetries;
+
+ public ExternalReviewSubagent(String endpoint, int maxRetries) {
+ this(
+
ResourceDescriptor.Builder.newBuilder(ExternalReviewSubagent.class.getName())
+ .addInitialArgument(FIELD_ENDPOINT, endpoint)
+ .addInitialArgument(FIELD_MAX_RETRIES, maxRetries)
+ .build(),
+ null);
+ }
+
+ public ExternalReviewSubagent(
+ ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ this.endpoint = descriptor.getArgument(FIELD_ENDPOINT);
+ this.maxRetries = descriptor.getArgument(FIELD_MAX_RETRIES, 0);
+ }
+
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ public int getMaxRetries() {
+ return maxRetries;
+ }
+
+ @Override
+ public SubagentFuture submit(RunnerContext ctx, Object prompt) {
+ throw new UnsupportedOperationException("serialization is under
test, not invocation");
+ }
+
+ @Override
+ public SubagentFuture submit(RunnerContext ctx, Object prompt, String
sessionId) {
+ throw new UnsupportedOperationException("serialization is under
test, not invocation");
+ }
+
+ @Override
+ public SubagentFuture submit(
+ RunnerContext ctx, Object prompt, String sessionId, String
callId) {
+ throw new UnsupportedOperationException("serialization is under
test, not invocation");
+ }
+ }
+}
diff --git
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ToolCallActionSubagentTest.java
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ToolCallActionSubagentTest.java
index 16858efa..d4ec380a 100644
---
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ToolCallActionSubagentTest.java
+++
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ToolCallActionSubagentTest.java
@@ -30,6 +30,7 @@ import org.apache.flink.agents.api.event.ToolResponseEvent;
import org.apache.flink.agents.api.memory.BaseLongTermMemory;
import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
import org.apache.flink.agents.api.resource.Resource;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.resource.ResourceType;
import org.apache.flink.agents.api.subagent.SubagentFuture;
import org.apache.flink.agents.api.subagent.SubagentFutures;
@@ -497,6 +498,17 @@ class ToolCallActionSubagentTest {
Map.of("name", secondTool, "arguments",
Map.of("q", "y")))));
}
+ /**
+ * Builds a metadata-only descriptor naming {@code concreteClass}, so each
in-process double
+ * carries a descriptor naming its own type as the {@link SubagentSetup}
constructor requires.
+ */
+ private static ResourceDescriptor subagentDescriptor(
+ Class<?> concreteClass, String description) {
+ return ResourceDescriptor.Builder.newBuilder(concreteClass.getName())
+ .addInitialArgument(SubagentSetup.FIELD_DESCRIPTION,
description)
+ .build();
+ }
+
/** Captures every prompt it is handed and resolves to a preset outcome. */
private static class RecordingSubagentSetup extends SubagentSetup {
private final SubagentResult outcome;
@@ -504,7 +516,11 @@ class ToolCallActionSubagentTest {
private Exception submitFailure;
RecordingSubagentSetup(SubagentResult outcome) {
- super("Reviews a diff.");
+ this(subagentDescriptor(RecordingSubagentSetup.class, "Reviews a
diff."), outcome);
+ }
+
+ RecordingSubagentSetup(ResourceDescriptor descriptor, SubagentResult
outcome) {
+ super(descriptor, null);
this.outcome = outcome;
}
@@ -534,7 +550,9 @@ class ToolCallActionSubagentTest {
/** Declares a result type, so its result is read through it. */
private static class TypedRecordingSubagentSetup extends
RecordingSubagentSetup {
TypedRecordingSubagentSetup(SubagentResult outcome) {
- super(outcome);
+ super(
+ subagentDescriptor(TypedRecordingSubagentSetup.class,
"Reviews a diff."),
+ outcome);
}
@Override
@@ -602,7 +620,7 @@ class ToolCallActionSubagentTest {
private final List<String> ops;
OrderRecordingSubagentSetup(String label, List<String> ops) {
- super("Orders a diff.");
+ super(subagentDescriptor(OrderRecordingSubagentSetup.class,
"Orders a diff."), null);
this.label = label;
this.ops = ops;
}
@@ -664,7 +682,9 @@ class ToolCallActionSubagentTest {
private final List<String> ops;
InterruptingSubagentSetup(String label, List<String> ops) {
- super("Interrupts on await.");
+ super(
+ subagentDescriptor(InterruptingSubagentSetup.class,
"Interrupts on await."),
+ null);
this.label = label;
this.ops = ops;
}
@@ -723,7 +743,7 @@ class ToolCallActionSubagentTest {
/** Its await overflows the stack, to drive the {@link StackOverflowError}
absorption path. */
private static class OverflowingSubagentSetup extends SubagentSetup {
OverflowingSubagentSetup() {
- super("Overflows on await.");
+ super(subagentDescriptor(OverflowingSubagentSetup.class,
"Overflows on await."), null);
}
@Override
diff --git a/python/flink_agents/plan/agent_plan.py
b/python/flink_agents/plan/agent_plan.py
index 41d80c8a..a8034b16 100644
--- a/python/flink_agents/plan/agent_plan.py
+++ b/python/flink_agents/plan/agent_plan.py
@@ -409,23 +409,25 @@ def _get_resource_providers(
for name, value in agent.resources[ResourceType.AGENT].items():
if isinstance(value, SubagentSetup):
- resource_providers.append(
- PythonSerializableResourceProvider.from_resource(
- name=name, resource=value
- )
+ # A live setup travels as its descriptor, so a remote task rebuilds
+ # it from the class name and configuration.
+ descriptor = ResourceDescriptor(
+
clazz=f"{value.__class__.__module__}.{value.__class__.__name__}",
+ arguments=value.model_dump(),
)
elif isinstance(value, ResourceDescriptor):
# Declared via YAML: the descriptor names a SubagentSetup subclass
# that is instantiated when the resource is first resolved.
- resource_providers.append(
- PythonResourceProvider.get(name=name, descriptor=value)
- )
+ descriptor = value
else:
msg = (
f"AGENT resource '{name}' must be a SubagentSetup or a "
f"ResourceDescriptor, but got {type(value).__name__}."
)
raise TypeError(msg)
+ resource_providers.append(
+ PythonResourceProvider.get(name=name, descriptor=descriptor)
+ )
for resource_type in [
ResourceType.CHAT_MODEL,
diff --git
a/python/flink_agents/plan/tests/test_agent_plan_subagent_resources.py
b/python/flink_agents/plan/tests/test_agent_plan_subagent_resources.py
index 818e109c..6e255352 100644
--- a/python/flink_agents/plan/tests/test_agent_plan_subagent_resources.py
+++ b/python/flink_agents/plan/tests/test_agent_plan_subagent_resources.py
@@ -24,6 +24,7 @@ from flink_agents.api.subagent import SubagentSetup
from flink_agents.api.tests.subagent_test_utils import TestSubagentSetup
from flink_agents.plan.agent_plan import AgentPlan
from flink_agents.plan.configuration import AgentConfiguration
+from flink_agents.plan.resource_provider import PythonResourceProvider
def test_subagent_setup_compiles_into_agent_provider() -> None:
@@ -70,3 +71,35 @@ def test_non_setup_agent_resource_is_rejected() -> None:
with pytest.raises(TypeError, match="must be a SubagentSetup"):
AgentPlan.from_agent(agent, AgentConfiguration())
+
+
+def test_live_external_subagent_is_rebuilt_after_plan_json_round_trip() ->
None:
+ """A live setup crosses the plan JSON boundary as its descriptor and is
+ rebuilt with its configuration on the other side.
+ """
+ agent = Agent()
+ agent.add_resource(
+ "reviewer",
+ ResourceType.AGENT,
+ TestSubagentSetup(
+ endpoint_url="http://review.internal:8080", fail_on_call=True
+ ),
+ )
+
+ plan = AgentPlan.from_agent(agent, AgentConfiguration())
+
+ # The transfer that carries a compiled plan between processes: the live
+ # setup does not travel with it and must be rebuilt from what does.
+ restored = AgentPlan.model_validate_json(
+ plan.model_dump_json(serialize_as_any=True)
+ )
+
+ provider = restored.resource_providers[ResourceType.AGENT]["reviewer"]
+ assert isinstance(provider, PythonResourceProvider)
+ assert provider.descriptor.clazz is TestSubagentSetup
+
+ resolved = provider.provide(resource_context=None,
config=AgentConfiguration())
+ assert isinstance(resolved, TestSubagentSetup)
+ assert resolved.endpoint_url == "http://review.internal:8080"
+ assert resolved.fail_on_call is True
+ assert resolved.resource_type() == ResourceType.AGENT
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseAsyncSubagentSetup.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseAsyncSubagentSetup.java
index 4bd66dd8..a4eab648 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseAsyncSubagentSetup.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseAsyncSubagentSetup.java
@@ -36,6 +36,14 @@ import java.util.concurrent.Callable;
*/
public abstract class BaseAsyncSubagentSetup extends BaseSubagentSetup {
+ /**
+ * Descriptor argument key for the status-poll interval, and the single
source of truth for it:
+ * the descriptor-based constructor below reads this key, while subclasses
that capture a poll
+ * interval into their descriptor write it. Sharing the constant keeps the
read and write ends
+ * from drifting apart.
+ */
+ protected static final String FIELD_STATUS_POLL_INTERVAL_MILLIS =
"status_poll_interval_millis";
+
/**
* Delay between status probes while waiting for the run to reach a
terminal state. Defaults to
* {@code 500}. The descriptor-based constructor reads the optional {@code
@@ -43,8 +51,6 @@ public abstract class BaseAsyncSubagentSetup extends
BaseSubagentSetup {
*/
protected long statusPollIntervalMillis = 500;
- protected BaseAsyncSubagentSetup() {}
-
/**
* Descriptor-based construction, as used by YAML-declared {@code
subagents:} entries: reads the
* optional {@code status_poll_interval_millis} argument, falling back to
the default of {@code
@@ -52,7 +58,8 @@ public abstract class BaseAsyncSubagentSetup extends
BaseSubagentSetup {
*/
protected BaseAsyncSubagentSetup(
ResourceDescriptor descriptor, ResourceContext resourceContext) {
- Number statusPollInterval =
descriptor.getArgument("status_poll_interval_millis");
+ super(descriptor, resourceContext);
+ Number statusPollInterval =
descriptor.getArgument(FIELD_STATUS_POLL_INTERVAL_MILLIS);
if (statusPollInterval != null) {
this.statusPollIntervalMillis = statusPollInterval.longValue();
}
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseDeferredSubagentSetup.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseDeferredSubagentSetup.java
index 666898eb..56b7cedf 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseDeferredSubagentSetup.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseDeferredSubagentSetup.java
@@ -20,6 +20,8 @@ package org.apache.flink.agents.runtime.subagent;
import org.apache.flink.agents.api.context.DurableCallable;
import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.subagent.SubagentFuture;
import org.apache.flink.agents.api.subagent.SubagentResult;
@@ -31,6 +33,12 @@ import org.apache.flink.agents.api.subagent.SubagentResult;
*/
public abstract class BaseDeferredSubagentSetup extends BaseSubagentSetup {
+ /** Descriptor-based construction, forwarding to the sub-agent base. */
+ protected BaseDeferredSubagentSetup(
+ ResourceDescriptor descriptor, ResourceContext resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
/** Registers the invocation and returns its deferred handle. */
@Override
public SubagentFuture submit(RunnerContext ctx, Object prompt, String
sessionId, String callId)
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseSubagentSetup.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseSubagentSetup.java
index a3b07c3c..9dba697c 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseSubagentSetup.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseSubagentSetup.java
@@ -19,6 +19,8 @@
package org.apache.flink.agents.runtime.subagent;
import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.subagent.SubagentFuture;
import org.apache.flink.agents.api.subagent.SubagentSetup;
import org.apache.flink.agents.runtime.lifecycle.TaskLifecycleListener;
@@ -42,6 +44,14 @@ public abstract class BaseSubagentSetup extends
SubagentSetup implements TaskLif
/** The task whose execution is currently issuing calls. */
@Nullable private ActionTask currentTask;
+ /**
+ * Descriptor-based construction for a sub-agent rebuilt on a remote task
from the configuration
+ * its descriptor carries.
+ */
+ protected BaseSubagentSetup(ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
@Override
public void onActionPrepared(ActionTask task) {
currentTask = task;
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java
index c39498d2..d82cc3ec 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/ResourceCacheTest.java
@@ -528,6 +528,17 @@ public class ResourceCacheTest {
/** Test Java sub-agent setup, registered as an AGENT resource. */
public static class TestAgentSetup extends BaseSubagentSetup {
+
+ public TestAgentSetup() {
+ this(
+
ResourceDescriptor.Builder.newBuilder(TestAgentSetup.class.getName()).build(),
+ null);
+ }
+
+ public TestAgentSetup(ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
@Override
public SubagentFuture submit(
RunnerContext ctx, Object prompt, String sessionId, String
callId) {
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/BaseAsyncSubagentSetupTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/BaseAsyncSubagentSetupTest.java
index 3bc24138..411f9e88 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/BaseAsyncSubagentSetupTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/BaseAsyncSubagentSetupTest.java
@@ -38,6 +38,7 @@ import org.apache.flink.api.java.functions.KeySelector;
import org.apache.flink.api.java.typeutils.TypeExtractor;
import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
import org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -57,6 +58,11 @@ public class BaseAsyncSubagentSetupTest {
private static final String RESOURCE_NAME = "ext-agent";
+ @BeforeEach
+ void resetCounters() {
+ MockAsyncSubagentSetup.reset();
+ }
+
//
------------------------------------------------------------------------------------------
// The pub: one durable POST, issued immediately
//
------------------------------------------------------------------------------------------
@@ -181,7 +187,9 @@ public class BaseAsyncSubagentSetupTest {
assertThat(original.statusQueryCount()).isEqualTo(3);
// Replay: the run has already reached a terminal state, so the same
await takes a
- // shorter path — fewer probes — to the same result.
+ // shorter path — fewer probes — to the same result. The endpoint
counters are shared
+ // with the mock service, so restart them to measure the replay on its
own.
+ MockAsyncSubagentSetup.reset();
MockAsyncSubagentSetup replay = new MockAsyncSubagentSetup(2, false);
replay.seedRun("sid-1", "call-1", "done:ping", null, 0);
SubagentResult after = replay.awaitResultForTest("sid-1",
"call-1").call();
@@ -467,6 +475,9 @@ public class BaseAsyncSubagentSetupTest {
@Test
void descriptorArgumentSetsTheStatusPollInterval() {
+ // The key is spelled out rather than referencing the base's protected
constant on purpose:
+ // this asserts the wire contract (the exact argument name a
descriptor-built setup reads),
+ // so it stays a literal to catch any change to that name.
DescriptorAsyncSetup setup =
new DescriptorAsyncSetup(
new ResourceDescriptor(
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/BaseSubagentSetupTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/BaseSubagentSetupTest.java
index 8cca1acf..326a4e10 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/BaseSubagentSetupTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/BaseSubagentSetupTest.java
@@ -23,6 +23,8 @@ import org.apache.flink.agents.api.InputEvent;
import org.apache.flink.agents.api.OutputEvent;
import org.apache.flink.agents.api.agents.Agent;
import org.apache.flink.agents.api.context.RunnerContext;
+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.subagent.SubagentFuture;
import org.apache.flink.agents.api.subagent.SubagentFutures;
@@ -68,6 +70,18 @@ public class BaseSubagentSetupTest {
/** Captures every assigned {@code (sessionId, callId)} pair like a
collecting setup. */
public static class AllocatingCaptureSetup extends BaseSubagentSetup {
+ public AllocatingCaptureSetup() {
+ this(
+
ResourceDescriptor.Builder.newBuilder(AllocatingCaptureSetup.class.getName())
+ .build(),
+ null);
+ }
+
+ public AllocatingCaptureSetup(
+ ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
private static final List<String[]> CAPTURES =
Collections.synchronizedList(new ArrayList<>());
@@ -231,6 +245,16 @@ public class BaseSubagentSetupTest {
/** Capture setup exposing the current task's registry for direct
tracking. */
public static class RegistryExposingSetup extends AllocatingCaptureSetup {
+ // Names its own type in the descriptor: SubagentSetup's construction
check requires the
+ // descriptor clazz to match the concrete type, which the inherited
convenience constructor
+ // would otherwise name as the superclass.
+ public RegistryExposingSetup() {
+ super(
+
ResourceDescriptor.Builder.newBuilder(RegistryExposingSetup.class.getName())
+ .build(),
+ null);
+ }
+
PendingSubagentCallRegistry exposedRegistry() {
return currentTaskRegistry();
}
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/DeferredSubagentSubmitTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/DeferredSubagentSubmitTest.java
index cfc46c50..b274e90e 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/DeferredSubagentSubmitTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/DeferredSubagentSubmitTest.java
@@ -24,6 +24,8 @@ import org.apache.flink.agents.api.OutputEvent;
import org.apache.flink.agents.api.agents.Agent;
import org.apache.flink.agents.api.context.DurableCallable;
import org.apache.flink.agents.api.context.RunnerContext;
+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.subagent.SubagentFuture;
import org.apache.flink.agents.api.subagent.SubagentResult;
@@ -269,7 +271,21 @@ public class DeferredSubagentSubmitTest {
}
/** Deferred setup whose prepared callable throws a system-level failure
instead of folding. */
- private static final class ThrowingDeferredSubagentSetup extends
BaseDeferredSubagentSetup {
+ public static final class ThrowingDeferredSubagentSetup extends
BaseDeferredSubagentSetup {
+
+ public ThrowingDeferredSubagentSetup() {
+ this(
+ ResourceDescriptor.Builder.newBuilder(
+
ThrowingDeferredSubagentSetup.class.getName())
+ .build(),
+ null);
+ }
+
+ public ThrowingDeferredSubagentSetup(
+ ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
@Override
protected DurableCallable<SubagentResult> prepare(
RunnerContext ctx, Object prompt, String sessionId, String
callId) {
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/MockAsyncSubagentSetup.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/MockAsyncSubagentSetup.java
index f5d9818e..d52b7866 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/MockAsyncSubagentSetup.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/MockAsyncSubagentSetup.java
@@ -19,12 +19,15 @@
package org.apache.flink.agents.runtime.subagent;
import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.subagent.SubagentResult;
import javax.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
/**
* Example integration of {@link BaseAsyncSubagentSetup}: an in-memory
asynchronous agent service.
@@ -35,6 +38,9 @@ import java.util.Map;
*/
public class MockAsyncSubagentSetup extends BaseAsyncSubagentSetup {
+ private static final String FIELD_QUERIES_UNTIL_COMPLETE =
"queries_until_complete";
+ private static final String FIELD_FAIL_ON_POST = "fail_on_post";
+
/** One recorded remote run, keyed by {@code sessionId#callId}. */
private static final class Run {
private final Object result;
@@ -52,10 +58,22 @@ public class MockAsyncSubagentSetup extends
BaseAsyncSubagentSetup {
private final int queriesUntilComplete;
private final boolean failOnPost;
- private int postCount;
- private int statusQueryCount;
- private int fetchCount;
- private int cancelCount;
+ // Counters are static because the operator materializes a rebuilt
instance from the
+ // descriptor, so the registered setup a test holds is not the one that
runs; call reset()
+ // before each independent scenario. They are atomic because the framework
bumps them on
+ // async pool threads, where batched calls run concurrently.
+ private static final AtomicInteger POST_COUNT = new AtomicInteger();
+ private static final AtomicInteger STATUS_QUERY_COUNT = new
AtomicInteger();
+ private static final AtomicInteger FETCH_COUNT = new AtomicInteger();
+ private static final AtomicInteger CANCEL_COUNT = new AtomicInteger();
+
+ /** Clears every endpoint counter. Call before each independent scenario.
*/
+ public static void reset() {
+ POST_COUNT.set(0);
+ STATUS_QUERY_COUNT.set(0);
+ FETCH_COUNT.set(0);
+ CANCEL_COUNT.set(0);
+ }
public MockAsyncSubagentSetup() {
this(2, false);
@@ -66,16 +84,28 @@ public class MockAsyncSubagentSetup extends
BaseAsyncSubagentSetup {
* terminal; {@code failOnPost} makes every submission fail.
*/
public MockAsyncSubagentSetup(int queriesUntilComplete, boolean
failOnPost) {
- this.queriesUntilComplete = queriesUntilComplete;
- this.failOnPost = failOnPost;
- // Runs turn terminal after a fixed number of probes rather than after
elapsed time, so
- // probing without a delay keeps the counts identical and the tests
fast.
- this.statusPollIntervalMillis = 0;
+ this(
+
ResourceDescriptor.Builder.newBuilder(MockAsyncSubagentSetup.class.getName())
+ .addInitialArgument(FIELD_QUERIES_UNTIL_COMPLETE,
queriesUntilComplete)
+ .addInitialArgument(FIELD_FAIL_ON_POST, failOnPost)
+ // Runs turn terminal after a fixed number of probes
rather than after
+ // elapsed time, so probing without a delay keeps the
counts identical and
+ // the tests fast.
+ .addInitialArgument(FIELD_STATUS_POLL_INTERVAL_MILLIS,
0L)
+ .build(),
+ null);
+ }
+
+ /** Descriptor-based construction, reading the config the convenience
constructor captured. */
+ public MockAsyncSubagentSetup(ResourceDescriptor descriptor,
ResourceContext resourceContext) {
+ super(descriptor, resourceContext);
+ this.queriesUntilComplete =
descriptor.getArgument(FIELD_QUERIES_UNTIL_COMPLETE, 2);
+ this.failOnPost = descriptor.getArgument(FIELD_FAIL_ON_POST, false);
}
@Override
protected void callSubmitRequest(String sessionId, String callId, Object
prompt) {
- postCount++;
+ POST_COUNT.incrementAndGet();
if (failOnPost) {
throw new IllegalStateException("post failed");
}
@@ -84,7 +114,7 @@ public class MockAsyncSubagentSetup extends
BaseAsyncSubagentSetup {
@Override
protected RunStatus callQueryStatus(String sessionId, String callId) {
- statusQueryCount++;
+ STATUS_QUERY_COUNT.incrementAndGet();
Run run = runs.get(sessionId + "#" + callId);
if (run == null) {
return RunStatus.notStarted();
@@ -98,7 +128,7 @@ public class MockAsyncSubagentSetup extends
BaseAsyncSubagentSetup {
@Override
protected SubagentResult callFetchResult(String sessionId, String callId) {
- fetchCount++;
+ FETCH_COUNT.incrementAndGet();
Run run = runs.get(sessionId + "#" + callId);
if (run == null) {
return SubagentResult.error("no run on record");
@@ -108,7 +138,7 @@ public class MockAsyncSubagentSetup extends
BaseAsyncSubagentSetup {
@Override
protected void callCancelRequest(String sessionId, String callId) {
- cancelCount++;
+ CANCEL_COUNT.incrementAndGet();
}
/** Test hook: injects a run that already exists remotely, exercising
reconciler reuse. */
@@ -123,22 +153,22 @@ public class MockAsyncSubagentSetup extends
BaseAsyncSubagentSetup {
/** Number of times the POST endpoint has been hit. */
public int postCount() {
- return postCount;
+ return POST_COUNT.get();
}
/** Number of times the status endpoint has been probed. */
public int statusQueryCount() {
- return statusQueryCount;
+ return STATUS_QUERY_COUNT.get();
}
/** Number of times the result endpoint has been fetched. */
public int fetchCount() {
- return fetchCount;
+ return FETCH_COUNT.get();
}
/** Number of times the cancel hook has been invoked. */
public int cancelCount() {
- return cancelCount;
+ return CANCEL_COUNT.get();
}
/** Exposes the pub durable call for unit-style POST and reconciler
assertions. */
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/MockDeferredSubagentSetup.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/MockDeferredSubagentSetup.java
index 0a8b69a7..087f95c2 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/MockDeferredSubagentSetup.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/MockDeferredSubagentSetup.java
@@ -20,6 +20,8 @@ package org.apache.flink.agents.runtime.subagent;
import org.apache.flink.agents.api.context.DurableCallable;
import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.subagent.SubagentResult;
import java.util.ArrayList;
@@ -38,6 +40,18 @@ import java.util.concurrent.atomic.AtomicInteger;
*/
public class MockDeferredSubagentSetup extends BaseDeferredSubagentSetup {
+ public MockDeferredSubagentSetup() {
+ this(
+
ResourceDescriptor.Builder.newBuilder(MockDeferredSubagentSetup.class.getName())
+ .build(),
+ null);
+ }
+
+ public MockDeferredSubagentSetup(
+ ResourceDescriptor descriptor, ResourceContext resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
/** One {@code (sessionId, callId)} assignment captured at prepare time. */
public static final class Capture {
public final String sessionId;
@@ -62,6 +76,8 @@ public class MockDeferredSubagentSetup extends
BaseDeferredSubagentSetup {
}
}
+ // Static observation state (see class doc), reset() each scenario.
EXECUTION_COUNT is atomic
+ // because call bodies run on async pool threads, where batched calls run
concurrently.
private static final List<Capture> CAPTURES =
Collections.synchronizedList(new ArrayList<>());
private static final AtomicInteger EXECUTION_COUNT = new AtomicInteger();
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalAgentSubagentSetupTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalAgentSubagentSetupTest.java
index e42b9a2f..4e652384 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalAgentSubagentSetupTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalAgentSubagentSetupTest.java
@@ -39,6 +39,7 @@ import
org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -135,6 +136,12 @@ public class ExternalAgentSubagentSetupTest {
}
}
+ @BeforeEach
+ void resetObservationState() {
+ ExternalAsyncSubagentSetup.reset();
+ ExternalDeferredSubagentSetup.reset();
+ }
+
//
------------------------------------------------------------------------------------------
// Mode 1: async pub/sub through BaseAsyncSubagentSetup
//
------------------------------------------------------------------------------------------
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalAsyncSubagentSetup.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalAsyncSubagentSetup.java
index 29c25d7d..3991bdf5 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalAsyncSubagentSetup.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalAsyncSubagentSetup.java
@@ -18,6 +18,8 @@
package org.apache.flink.agents.runtime.subagent.external;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.subagent.SubagentResult;
import org.apache.flink.agents.runtime.subagent.BaseAsyncSubagentSetup;
import org.slf4j.Logger;
@@ -47,14 +49,38 @@ public class ExternalAsyncSubagentSetup extends
BaseAsyncSubagentSetup {
private static final Logger LOG =
LoggerFactory.getLogger(ExternalAsyncSubagentSetup.class);
+ private static final String FIELD_BASE_URL = "base_url";
+
private final String baseUrl;
- private final List<Long> probeTimestamps =
Collections.synchronizedList(new ArrayList<>());
+
+ // Static because the operator materializes a rebuilt instance from the
descriptor; the
+ // registered setup a test holds is not the one that probes. Call reset()
before each scenario.
+ // Synchronized because probes are recorded on async pool threads, where
batched calls run
+ // concurrently.
+ private static final List<Long> PROBE_TIMESTAMPS =
+ Collections.synchronizedList(new ArrayList<>());
@Nullable private transient ExternalAgentClient client;
+ /** Clears the recorded probe timestamps. Call before each independent
scenario. */
+ public static void reset() {
+ PROBE_TIMESTAMPS.clear();
+ }
+
public ExternalAsyncSubagentSetup(String baseUrl, long
probeIntervalMillis) {
- this.baseUrl = baseUrl;
- this.statusPollIntervalMillis = probeIntervalMillis;
+ this(
+
ResourceDescriptor.Builder.newBuilder(ExternalAsyncSubagentSetup.class.getName())
+ .addInitialArgument(FIELD_BASE_URL, baseUrl)
+ .addInitialArgument(FIELD_STATUS_POLL_INTERVAL_MILLIS,
probeIntervalMillis)
+ .build(),
+ null);
+ }
+
+ /** Descriptor-based construction, reading the config the convenience
constructor captured. */
+ public ExternalAsyncSubagentSetup(
+ ResourceDescriptor descriptor, ResourceContext resourceContext) {
+ super(descriptor, resourceContext);
+ this.baseUrl = descriptor.getArgument(FIELD_BASE_URL);
}
private ExternalAgentClient client() {
@@ -73,7 +99,7 @@ public class ExternalAsyncSubagentSetup extends
BaseAsyncSubagentSetup {
@Override
protected RunStatus callQueryStatus(String sessionId, String callId) {
- probeTimestamps.add(System.currentTimeMillis());
+ PROBE_TIMESTAMPS.add(System.currentTimeMillis());
ExternalAgentClient.TaskStatus probe;
try {
probe = client().status(taskId(sessionId, callId));
@@ -116,8 +142,8 @@ public class ExternalAsyncSubagentSetup extends
BaseAsyncSubagentSetup {
/** Timestamps of every status probe, for asserting the probe pacing in
tests. */
public List<Long> probeTimestamps() {
- synchronized (probeTimestamps) {
- return new ArrayList<>(probeTimestamps);
+ synchronized (PROBE_TIMESTAMPS) {
+ return new ArrayList<>(PROBE_TIMESTAMPS);
}
}
}
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalDeferredSubagentSetup.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalDeferredSubagentSetup.java
index 439b86a6..3d6037a9 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalDeferredSubagentSetup.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/external/ExternalDeferredSubagentSetup.java
@@ -20,6 +20,8 @@ package org.apache.flink.agents.runtime.subagent.external;
import org.apache.flink.agents.api.context.DurableCallable;
import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
import org.apache.flink.agents.api.subagent.SubagentResult;
import org.apache.flink.agents.runtime.subagent.BaseDeferredSubagentSetup;
import org.slf4j.Logger;
@@ -43,15 +45,40 @@ public class ExternalDeferredSubagentSetup extends
BaseDeferredSubagentSetup {
private static final Logger LOG =
LoggerFactory.getLogger(ExternalDeferredSubagentSetup.class);
+ private static final String FIELD_BASE_URL = "base_url";
+ private static final String FIELD_POLL_INTERVAL_MILLIS =
"poll_interval_millis";
+
private final String baseUrl;
private final long pollIntervalMillis;
- private final AtomicInteger pollCount = new AtomicInteger();
+
+ // Static because the operator materializes a rebuilt instance from the
descriptor; the
+ // registered setup a test holds is not the one that polls. Call reset()
before each scenario.
+ // Atomic because polls are counted on async pool threads, where batched
calls run
+ // concurrently.
+ private static final AtomicInteger POLL_COUNT = new AtomicInteger();
@Nullable private transient ExternalAgentClient client;
+ /** Clears the poll counter. Call before each independent scenario. */
+ public static void reset() {
+ POLL_COUNT.set(0);
+ }
+
public ExternalDeferredSubagentSetup(String baseUrl, long
pollIntervalMillis) {
- this.baseUrl = baseUrl;
- this.pollIntervalMillis = pollIntervalMillis;
+ this(
+
ResourceDescriptor.Builder.newBuilder(ExternalDeferredSubagentSetup.class.getName())
+ .addInitialArgument(FIELD_BASE_URL, baseUrl)
+ .addInitialArgument(FIELD_POLL_INTERVAL_MILLIS,
pollIntervalMillis)
+ .build(),
+ null);
+ }
+
+ /** Descriptor-based construction, reading the config the convenience
constructor captured. */
+ public ExternalDeferredSubagentSetup(
+ ResourceDescriptor descriptor, ResourceContext resourceContext) {
+ super(descriptor, resourceContext);
+ this.baseUrl = descriptor.getArgument(FIELD_BASE_URL);
+ this.pollIntervalMillis =
descriptor.getArgument(FIELD_POLL_INTERVAL_MILLIS, 0L);
}
private ExternalAgentClient client() {
@@ -90,7 +117,7 @@ public class ExternalDeferredSubagentSetup extends
BaseDeferredSubagentSetup {
callId,
taskId);
while (true) {
- int check = pollCount.incrementAndGet();
+ int check = POLL_COUNT.incrementAndGet();
ExternalAgentClient.TaskStatus probe =
client().status(taskId);
LOG.info(
"[deferred] {}#{} check #{} -> {}",
@@ -125,6 +152,6 @@ public class ExternalDeferredSubagentSetup extends
BaseDeferredSubagentSetup {
/** Total number of status polls across all invocations, for test pacing
assertions. */
public int pollCount() {
- return pollCount.get();
+ return POLL_COUNT.get();
}
}