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 dda10f62 [api][runtime][python] Support running a ReActAgent as a
general-purpose sub-agent (#1206)
dda10f62 is described below
commit dda10f6259caefe60b5a8beea076b855eb116e48
Author: Yi Zhang <[email protected]>
AuthorDate: Wed Oct 14 11:27:06 2026 +0800
[api][runtime][python] Support running a ReActAgent as a general-purpose
sub-agent (#1206)
Generated-by: Qoder
---
.../apache/flink/agents/api/agents/ReActAgent.java | 127 ++++--
.../flink/agents/api/agents/ReActAgentTest.java | 218 ++++++++++
.../docs/development/multi-agent/subagents.md | 203 +++++++++
docs/content/docs/development/react_agent.md | 4 +
python/flink_agents/api/agents/react_agent.py | 77 +++-
.../api/agents/tests/test_react_agent.py | 120 ++++++
.../react_agent_subagent_test.py | 344 ++++++++++++++++
.../flink_agents/runtime/flink_runner_context.py | 18 +-
.../runtime/subagent/ReActAgentSubagentTest.java | 458 +++++++++++++++++++++
9 files changed, 1515 insertions(+), 54 deletions(-)
diff --git
a/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
b/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
index 70a858bb..372a80d7 100644
--- a/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
+++ b/api/src/main/java/org/apache/flink/agents/api/agents/ReActAgent.java
@@ -81,6 +81,15 @@ public class ReActAgent extends Agent {
private static final String SKILLS_SOURCES_ARGUMENT = "skills_sources";
private static final String SKILLS_RESOURCE_NAME = "_react_agent_skills";
+ /**
+ * Action config flag recording that this agent registered {@link
#DEFAULT_USER_PROMPT}, as
+ * {@code output_schema} does for {@link #DEFAULT_SCHEMA_PROMPT}. A
sub-agent's resource lookup
+ * falls back to the root agent's resources, so {@link #startAction} looks
up a built-in prompt
+ * only when the running scope's own action config declares it; otherwise
an unconfigured child
+ * would pick up a parent ReActAgent's prompt under the same name.
+ */
+ private static final String HAS_USER_PROMPT = "has_user_prompt";
+
private static final ObjectMapper mapper = new ObjectMapper();
public ReActAgent(
@@ -171,6 +180,7 @@ public class ReActAgent extends Agent {
if (prompt != null) {
this.addResource(DEFAULT_USER_PROMPT, ResourceType.PROMPT, prompt);
+ actionConfig.put(HAS_USER_PROMPT, true);
}
try {
@@ -260,21 +270,33 @@ public class ReActAgent extends Agent {
descriptor.getLanguage(), descriptor.getModule(),
descriptor.getClazz(), arguments);
}
+ /**
+ * Whether {@code input} is a map holding a single "input" entry - the one
structured shape
+ * {@link #startAction} converts without a user-supplied prompt, so a
sub-agent declared with a
+ * single "input" field needs no prompt. Keyed by equals, so a non-String
key never matches.
+ */
+ private static boolean isSingleInputMap(Object input) {
+ return input instanceof Map
+ && ((Map<?, ?>) input).size() == 1
+ && ((Map<?, ?>) input).containsKey("input");
+ }
+
public static void startAction(Event event, RunnerContext ctx) {
// Built-in events reach an action as their concrete subclass
(restored at the JSON
// boundary), so the dispatched InputEvent is cast directly.
InputEvent inputEvent = (InputEvent) event;
Object input = inputEvent.getInput();
- Prompt userPrompt;
- try {
- userPrompt = (Prompt) ctx.getResource(DEFAULT_USER_PROMPT,
ResourceType.PROMPT);
- } catch (Exception e) {
- userPrompt = null;
- }
+ Prompt userPrompt =
+ Boolean.TRUE.equals(ctx.getActionConfigValue(HAS_USER_PROMPT))
+ ? declaredPrompt(ctx, DEFAULT_USER_PROMPT)
+ : null;
List<ChatMessage> inputMessages = new ArrayList<>();
- if (ClassUtils.isPrimitiveOrWrapper(input.getClass())) {
+ // A String is not a primitive wrapper, but the documented input
contract covers it like
+ // one: without a prompt it becomes the user message, with a prompt it
fills the {input}
+ // placeholder.
+ if (input instanceof String ||
ClassUtils.isPrimitiveOrWrapper(input.getClass())) {
if (userPrompt != null) {
inputMessages =
userPrompt.formatMessages(
@@ -284,38 +306,65 @@ public class ReActAgent extends Agent {
}
} else {
if (userPrompt == null) {
- throw new RuntimeException(
- String.format(
- "The input type is %s, which is not primitive
types,"
- + " user should provide prompt to help
convert it to ChatMessage",
- input.getClass()));
- }
-
- Map<String, String> fields = new HashMap<>();
- if (input instanceof Row) {
- Row userInput = (Row) input;
- for (String name :
Objects.requireNonNull(userInput.getFieldNames(true))) {
- fields.put(name, String.valueOf(userInput.getField(name)));
- }
- } else { // regard as pojo
- ObjectMapper objectMapper = new ObjectMapper();
- try {
- fields =
mapper.readValue(objectMapper.writeValueAsString(input), Map.class);
- } catch (JsonProcessingException e) {
+ if (isSingleInputMap(input)) {
+ // The one structured shape that works without a
user-supplied prompt: a
+ // single "input" entry, the arguments of a model-driven
call against an
+ // input schema declaring only that field. The value
becomes the user message
+ // directly, mirroring the prompt-less primitive branch.
+ inputMessages.add(
+ new ChatMessage(
+ MessageRole.USER,
+ String.valueOf(((Map<?, ?>)
input).get("input"))));
+ } else {
throw new RuntimeException(
String.format(
- "Input must be primitive type, Row or
Pojo, but is %s",
+ "The input type is %s, which is not
primitive types,"
+ + " user should provide prompt to
help convert it to"
+ + " ChatMessage",
input.getClass()));
}
- }
+ } else {
+ Map<String, String> fields = new HashMap<>();
+ if (input instanceof Row) {
+ Row userInput = (Row) input;
+ for (String name :
Objects.requireNonNull(userInput.getFieldNames(true))) {
+ fields.put(name,
String.valueOf(userInput.getField(name)));
+ }
+ } else if (input instanceof Map) {
+ for (Map.Entry<?, ?> entry : ((Map<?, ?>)
input).entrySet()) {
+ fields.put(
+ String.valueOf(entry.getKey()),
String.valueOf(entry.getValue()));
+ }
+ } else { // regard as pojo
+ ObjectMapper objectMapper = new ObjectMapper();
+ try {
+ // A read into the raw Map.class ignores the declared
String values, so JSON
+ // numbers and booleans come back as Integer/Boolean.
Stringify every value
+ // like the Map branch above, otherwise Prompt#format
fails with a bare
+ // ClassCastException when it casts a placeholder
value to String.
+ Map<?, ?> pojoFields =
+
mapper.readValue(objectMapper.writeValueAsString(input), Map.class);
+ for (Map.Entry<?, ?> entry : pojoFields.entrySet()) {
+ fields.put(
+ String.valueOf(entry.getKey()),
+ String.valueOf(entry.getValue()));
+ }
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(
+ String.format(
+ "Input must be primitive type, Row,
Map or Pojo, but is %s",
+ input.getClass()));
+ }
+ }
- inputMessages = userPrompt.formatMessages(MessageRole.USER,
fields);
+ inputMessages = userPrompt.formatMessages(MessageRole.USER,
fields);
+ }
}
Object outputSchema = ctx.getActionConfigValue("output_schema");
- Prompt schemaPrompt = promptOrNull(ctx, DEFAULT_SCHEMA_PROMPT);
- if (schemaPrompt != null) {
+ if (outputSchema != null) {
+ Prompt schemaPrompt = declaredPrompt(ctx, DEFAULT_SCHEMA_PROMPT);
// A plan built before the native prompt existed has none, and
then adds no
// instruction on the native path.
Prompt instruction =
@@ -367,6 +416,24 @@ public class ReActAgent extends Agent {
}
}
+ /**
+ * Resolves a built-in prompt the running scope's action config declares.
The constructor
+ * registers the prompt together with its declaration, so a failed lookup
is a broken plan and
+ * is reported rather than silently dropping the prompt.
+ */
+ private static Prompt declaredPrompt(RunnerContext ctx, String name) {
+ try {
+ return (Prompt) ctx.getResource(name, ResourceType.PROMPT);
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ String.format(
+ "Prompt %s is declared by this agent's action
config but cannot be"
+ + " resolved.",
+ name),
+ e);
+ }
+ }
+
@Action(EventType.ChatResponseEvent)
public static void stopAction(Event event, RunnerContext ctx) {
ChatResponseEvent chatResponse = (ChatResponseEvent) event;
diff --git
a/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
b/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
index d4474a5c..b4561398 100644
--- a/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
+++ b/api/src/test/java/org/apache/flink/agents/api/agents/ReActAgentTest.java
@@ -43,6 +43,7 @@ import org.apache.flink.agents.api.tools.Tool;
import org.apache.flink.api.common.typeinfo.BasicTypeInfo;
import org.apache.flink.api.common.typeinfo.TypeInformation;
import org.apache.flink.api.java.typeutils.RowTypeInfo;
+import org.apache.flink.types.Row;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -62,6 +63,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -312,6 +314,7 @@ public class ReActAgentTest {
when(ctx.getResource("_default_user_prompt", ResourceType.PROMPT))
.thenReturn((Resource) prompts.get("_default_user_prompt"));
when(ctx.getActionConfigValue("output_schema")).thenReturn(config.get("output_schema"));
+
when(ctx.getActionConfigValue("has_user_prompt")).thenReturn(config.get("has_user_prompt"));
return ctx;
}
@@ -474,6 +477,221 @@ public class ReActAgentTest {
assertThat(actual.<List<String>>getArgument("skills")).containsExactly("a",
"b");
}
+ // --- startAction input dispatch ---
+
+ @Test
+ @DisplayName("A primitive input without a prompt becomes the user message")
+ public void testPrimitiveInputWithoutPromptBecomesUserMessage() {
+ RunnerContext ctx = mock(RunnerContext.class);
+
+ ReActAgent.startAction(new InputEvent(42), ctx);
+
+ assertThat(soleUserMessage(ctx).getText()).isEqualTo("42");
+ }
+
+ @Test
+ @DisplayName("A string input without a prompt becomes the user message")
+ public void testStringInputWithoutPromptBecomesUserMessage() {
+ RunnerContext ctx = mock(RunnerContext.class);
+
+ ReActAgent.startAction(new InputEvent("analyze this review"), ctx);
+
+ assertThat(soleUserMessage(ctx).getText()).isEqualTo("analyze this
review");
+ }
+
+ @Test
+ @DisplayName("A string input with a prompt fills the {input} placeholder")
+ public void testStringInputWithPromptFillsInputPlaceholder() throws
Exception {
+ RunnerContext ctx = contextWithPrompt(Prompt.fromText("Task:
{input}"));
+
+ ReActAgent.startAction(new InputEvent("analyze this review"), ctx);
+
+ assertThat(soleUserMessage(ctx).getText()).isEqualTo("Task: analyze
this review");
+ }
+
+ @Test
+ @DisplayName("A map input without a prompt reports that a prompt is
required")
+ public void testMapInputWithoutPromptReportsPromptRequirement() {
+ RunnerContext ctx = mock(RunnerContext.class);
+
+ assertThatThrownBy(
+ () ->
+ ReActAgent.startAction(
+ new InputEvent(Map.of("task", "analyze
this review")), ctx))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("not primitive types");
+ }
+
+ @Test
+ @DisplayName("A map input with a prompt uses the keys as placeholders")
+ public void testMapInputWithPromptUsesKeysAsPlaceholders() throws
Exception {
+ RunnerContext ctx = contextWithPrompt(Prompt.fromText("Task: {task}"));
+
+ ReActAgent.startAction(new InputEvent(Map.of("task", "analyze this
review")), ctx);
+
+ assertThat(soleUserMessage(ctx).getText()).isEqualTo("Task: analyze
this review");
+ }
+
+ @Test
+ @DisplayName("A Row input with a prompt uses the field names as
placeholders")
+ public void testRowInputWithPromptUsesFieldNamesAsPlaceholders() throws
Exception {
+ RunnerContext ctx = contextWithPrompt(Prompt.fromText("Task: {task}"));
+ Row row = Row.withNames();
+ row.setField("task", "analyze this review");
+
+ ReActAgent.startAction(new InputEvent(row), ctx);
+
+ assertThat(soleUserMessage(ctx).getText()).isEqualTo("Task: analyze
this review");
+ }
+
+ @Test
+ @DisplayName("A Pojo with a non-String field fills placeholders with its
stringified value")
+ public void
testPojoWithNonStringFieldFillsPlaceholdersWithStringifiedValue() throws
Exception {
+ RunnerContext ctx = contextWithPrompt(Prompt.fromText("Task:
{count}"));
+ WithCount input = new WithCount();
+ input.count = 5;
+
+ ReActAgent.startAction(new InputEvent(input), ctx);
+
+ assertThat(soleUserMessage(ctx).getText()).isEqualTo("Task: 5");
+ }
+
+ @Test
+ @DisplayName("An input of an unsupported kind reports the accepted kinds")
+ public void testInputOfUnsupportedKindReportsAcceptedKinds() throws
Exception {
+ RunnerContext ctx = contextWithPrompt(Prompt.fromText("Task:
{input}"));
+
+ assertThatThrownBy(() -> ReActAgent.startAction(new InputEvent(new
FieldLess()), ctx))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Row, Map or Pojo")
+ .hasMessageContaining("FieldLess");
+ }
+
+ // --- built-in prompt scoping ---
+
+ @Test
+ @DisplayName("A configured agent declares its built-in prompts in the
start action config")
+ public void testConfiguredAgentDeclaresBuiltInPromptsInActionConfig() {
+ ReActAgent agent =
+ new ReActAgent(
+ chatModelDescriptor(), Prompt.fromText("Task:
{input}"), WithCount.class);
+
+ Map<String, Object> config = agent.getActions().get("startAction").f2;
+ assertThat(config).containsEntry("has_user_prompt", true);
+ assertThat(config).containsEntry("output_schema", WithCount.class);
+ }
+
+ @Test
+ @DisplayName("A bare agent declares no built-in prompt in the start action
config")
+ public void testBareAgentDeclaresNoBuiltInPrompt() {
+ Map<String, Object> config =
bareAgent().getActions().get("startAction").f2;
+
+ assertThat(config).doesNotContainKeys("has_user_prompt",
"output_schema");
+ }
+
+ @Test
+ @DisplayName("A prompt reachable but not declared by this agent is not
looked up")
+ public void testUndeclaredPromptsAreNotLookedUp() throws Exception {
+ // As a sub-agent, a lookup missing locally falls back to the root
agent's resources, so
+ // a parent ReActAgent's prompts are reachable under the same names.
+ RunnerContext ctx = mock(RunnerContext.class);
+ when(ctx.getResource("_default_user_prompt", ResourceType.PROMPT))
+ .thenReturn(Prompt.fromText("PARENT ONLY: {input}"));
+ when(ctx.getResource("_default_schema_prompt", ResourceType.PROMPT))
+ .thenReturn(Prompt.fromText("PARENT SCHEMA"));
+
+ ReActAgent.startAction(new InputEvent("review the diff"), ctx);
+
+ assertThat(soleUserMessage(ctx).getText()).isEqualTo("review the
diff");
+ verify(ctx, never()).getResource(anyString(), any());
+ }
+
+ @Test
+ @DisplayName("A declared output schema inserts the schema instruction
before the user message")
+ public void testDeclaredOutputSchemaInsertsSchemaInstruction() throws
Exception {
+ RunnerContext ctx = mock(RunnerContext.class);
+
when(ctx.getActionConfigValue("output_schema")).thenReturn(WithCount.class);
+ when(ctx.getResource("_default_schema_prompt", ResourceType.PROMPT))
+ .thenReturn(Prompt.fromText("match the schema"));
+
+ ReActAgent.startAction(new InputEvent("review the diff"), ctx);
+
+ ChatRequestEvent request = soleRequest(ctx);
+ assertThat(request.getMessages()).hasSize(2);
+
assertThat(request.getMessages().get(0).getRole()).isEqualTo(MessageRole.SYSTEM);
+ assertThat(request.getMessages().get(0).getText()).isEqualTo("match
the schema");
+ assertThat(request.getMessages().get(1).getText()).isEqualTo("review
the diff");
+ assertThat(request.getOutputSchema()).isEqualTo(WithCount.class);
+ }
+
+ @Test
+ @DisplayName("A declared prompt that cannot be resolved is reported, not
dropped")
+ public void testDeclaredPromptThatCannotBeResolvedIsReported() throws
Exception {
+ RunnerContext ctx = mock(RunnerContext.class);
+ when(ctx.getActionConfigValue("has_user_prompt")).thenReturn(true);
+ when(ctx.getResource("_default_user_prompt", ResourceType.PROMPT))
+ .thenThrow(new IllegalArgumentException("Resource not found"));
+
+ assertThatThrownBy(() -> ReActAgent.startAction(new InputEvent("review
the diff"), ctx))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("_default_user_prompt")
+ .hasCauseInstanceOf(IllegalArgumentException.class);
+ }
+
+ // --- single-input map ---
+
+ @Test
+ @DisplayName("A single-input map without a prompt passes the value
through")
+ public void testSingleInputMapWithoutPromptBecomesUserMessage() {
+ RunnerContext ctx = mock(RunnerContext.class);
+
+ ReActAgent.startAction(new InputEvent(Map.of("input", "analyze this
review")), ctx);
+
+ assertThat(soleUserMessage(ctx).getText()).isEqualTo("analyze this
review");
+ }
+
+ @Test
+ @DisplayName("A multi-key map without a prompt still reports that a prompt
is required")
+ public void testMultiKeyMapWithoutPromptStillReportsPromptRequirement() {
+ RunnerContext ctx = mock(RunnerContext.class);
+
+ assertThatThrownBy(
+ () ->
+ ReActAgent.startAction(
+ new InputEvent(Map.of("input", "x",
"other", "y")), ctx))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("not primitive types");
+ }
+
+ private static ReActAgent bareAgent() {
+ return new ReActAgent(chatModelDescriptor(), null, null);
+ }
+
+ private static ResourceDescriptor chatModelDescriptor() {
+ return
ResourceDescriptor.Builder.newBuilder("com.example.ChatModel").build();
+ }
+
+ private static RunnerContext contextWithPrompt(Prompt prompt) throws
Exception {
+ RunnerContext ctx = mock(RunnerContext.class);
+ when(ctx.getActionConfigValue("has_user_prompt")).thenReturn(true);
+ when(ctx.getResource("_default_user_prompt",
ResourceType.PROMPT)).thenReturn(prompt);
+ return ctx;
+ }
+
+ private static ChatRequestEvent soleRequest(RunnerContext ctx) {
+ ArgumentCaptor<Event> captor = ArgumentCaptor.forClass(Event.class);
+ verify(ctx).sendEvent(captor.capture());
+ return (ChatRequestEvent) captor.getValue();
+ }
+
+ private static ChatMessage soleUserMessage(RunnerContext ctx) {
+ ChatRequestEvent request = soleRequest(ctx);
+ assertThat(request.getMessages()).hasSize(1);
+ ChatMessage message = request.getMessages().get(0);
+ assertThat(message.getRole()).isEqualTo(MessageRole.USER);
+ return message;
+ }
+
private static ReActAgent agentWithSchema(Object outputSchema) {
return agentWithSchema(outputSchema, null);
}
diff --git a/docs/content/docs/development/multi-agent/subagents.md
b/docs/content/docs/development/multi-agent/subagents.md
index 86e61089..0871c679 100644
--- a/docs/content/docs/development/multi-agent/subagents.md
+++ b/docs/content/docs/development/multi-agent/subagents.md
@@ -94,6 +94,10 @@ This shorthand does not declare an input schema. Use the
builder when exposing
the child to a model. Recursive registration, such as a child registering its
parent, is rejected when the plan is built.
+Register the resources a child uses, such as its chat model connection, on the
+child itself. In Python, a child resolves resources against its own plan only,
+so a resource registered solely on the parent is not visible to the child.
+
## Declare an external sub-agent
Implement `SubagentSetup` (or the appropriate runtime base class) and register
a
@@ -131,3 +135,202 @@ The Python setup declares these configuration fields and
implements the
invocation contract. Both languages construct the setup at runtime from the
descriptor. Existing code that passes a setup instance to `addResource` or
`add_resource` must instead pass a descriptor with the same configuration.
+
+## Delegate from a chat model
+
+List the child's registered name under `subagents` of the parent's chat model:
+
+```java
+ResourceDescriptor.Builder.newBuilder(ResourceName.ChatModel.OLLAMA_SETUP)
+ .addInitialArgument("connection", "ollamaConnection")
+ .addInitialArgument("model", "qwen3:8b")
+ .addInitialArgument("subagents", List.of("reviewer"))
+ .build();
+```
+
+```python
+ResourceDescriptor(
+ clazz=ResourceName.ChatModel.OLLAMA_SETUP,
+ connection="ollama_connection",
+ model="qwen3:8b",
+ subagents=["reviewer"],
+)
+```
+
+The model sees the child as a tool named `_subagent_reviewer`, described by the
+registration's description and input schema. The arguments the model sends
+become the child's input, and the child's outputs return to the model as the
+tool observation, so it can continue reasoning over them. For an internal
+sub-agent, the observation is the JSON encoding of the full output list
+described in [Call from an action](#call-from-an-action), even when the child
+emits a single output, so its shape does not depend on how many outputs the
+child emits.
+
+## Call from an action
+
+An action can submit input to a child directly and await its outcome. This path
+does not need an input schema.
+
+```java
+public class DelegatingAgent extends Agent {
+
+ @Action(EventType.InputEvent)
+ public static void delegate(Event event, RunnerContext ctx) throws
Exception {
+ SubagentSetup child =
+ (SubagentSetup) ctx.getResource("reviewer",
ResourceType.AGENT);
+ SubagentResult result = child.submit(ctx, "review the diff").await();
+ if (result.isSuccess()) {
+ String answer = String.valueOf(((List<?>)
result.getResult()).get(0));
+ ctx.sendEvent(new OutputEvent(answer));
+ } else {
+ ctx.sendEvent(new OutputEvent("failed: " +
result.getErrorMessage()));
+ }
+ }
+}
+```
+
+```python
+class DelegatingAgent(Agent):
+ @action(InputEvent.EVENT_TYPE)
+ @staticmethod
+ async def delegate(event: Event, ctx: RunnerContext) -> None:
+ child = ctx.get_resource("reviewer", ResourceType.AGENT)
+ future = await child.submit(ctx, "review the diff")
+ result = await future
+ if result.success:
+ ctx.send_event(OutputEvent(output=result.result[0]))
+ else:
+ ctx.send_event(OutputEvent(output=f"failed:
{result.error_message}"))
+```
+
+The awaited value is a `SubagentResult`. On success, its `result` field
+(`getResult()` in Java) carries the child's result. For an internal sub-agent,
+such as `reviewer` here, that is the list of outputs the child emitted, which
is
+why the example takes the first element. An external sub-agent returns whatever
+its `SubagentSetup` implementation produces, such as a string or an object. On
+failure, `success` is `False` (`isSuccess()` is `false` in Java), and
+`error_message` (`getErrorMessage()`) carries the reason.
+
+## Use a ReActAgent as the child
+
+A ReActAgent runs as a child like any other agent, keeping its own event loop,
+resources and conversation. It uses only its own prompt and output schema, even
+when the parent is also a ReActAgent.
+
+```java
+ReActAgent reviewer =
+ new ReActAgent(
+
ResourceDescriptor.Builder.newBuilder(ResourceName.ChatModel.OLLAMA_SETUP)
+ .addInitialArgument("connection", "reviewerConnection")
+ .addInitialArgument("model", "qwen3:8b")
+ .build(),
+ null,
+ null);
+reviewer.addResource(
+ "reviewerConnection",
+ ResourceType.CHAT_MODEL_CONNECTION,
+
ResourceDescriptor.Builder.newBuilder(ResourceName.ChatModel.OLLAMA_CONNECTION)
+ .build());
+
+parent.addResource(
+ "reviewer",
+ ResourceType.AGENT,
+ new InternalSubagentResourceDescriptorBuilder()
+ .agent(reviewer)
+ .description("Reviews a code diff and reports problems.")
+ .inputSchema(
+
"{\"type\":\"object\",\"properties\":{\"input\":{\"type\":\"string\"}},"
+ + "\"required\":[\"input\"]}")
+ .build());
+```
+
+```python
+reviewer = ReActAgent(
+ chat_model=ResourceDescriptor(
+ clazz=ResourceName.ChatModel.OLLAMA_SETUP,
+ connection="reviewer_connection",
+ model="qwen3:8b",
+ ),
+)
+reviewer.add_resource(
+ "reviewer_connection",
+ ResourceType.CHAT_MODEL_CONNECTION,
+ ResourceDescriptor(clazz=ResourceName.ChatModel.OLLAMA_CONNECTION),
+)
+
+parent.add_resource(
+ "reviewer",
+ ResourceType.AGENT,
+ InternalSubagentResourceDescriptorBuilder()
+ .agent(reviewer)
+ .description("Reviews a code diff and reports problems.")
+ .input_schema(
+ '{"type":"object","properties":{"input":{"type":"string"}},'
+ '"required":["input"]}'
+ )
+ .build(),
+)
+```
+
+Called from an action, the child takes a string input as its user message, or
+uses it to fill the `{input}` placeholder of its prompt. Delegated to by a
+model, the child receives the arguments as a map keyed by the input schema's
+field names and turns that map into its user message, so the schema and the
+child's prompt form one contract:
+
+- **With a prompt**, the schema's field names must match the prompt's
+ placeholders: each argument fills the placeholder of the same name, as
+ described in [Prompt]({{< ref "docs/development/react_agent#prompt" >}}).
+- **Without a prompt**, declare a single `input` field, as in the example
above.
+ A map holding only `input` passes its value through as the user message; any
+ other map is rejected because there is no prompt to format it.
+
+A child with a prompt declares the fields its placeholders use:
+
+```java
+ReActAgent reviewer =
+ new ReActAgent(
+ chatModelDescriptor,
+ Prompt.fromText(
+ "Review the following code diff and report any
problems:\n\n{diff}"),
+ null);
+
+parent.addResource(
+ "reviewer",
+ ResourceType.AGENT,
+ new InternalSubagentResourceDescriptorBuilder()
+ .agent(reviewer)
+ .description("Reviews a code diff and reports problems.")
+ // "diff" matches the {diff} placeholder of the child's prompt.
+ .inputSchema(
+
"{\"type\":\"object\",\"properties\":{\"diff\":{\"type\":\"string\"}},"
+ + "\"required\":[\"diff\"]}")
+ .build());
+```
+
+```python
+reviewer = ReActAgent(
+ chat_model=chat_model_descriptor,
+ prompt=Prompt.from_text(
+ "Review the following code diff and report any problems:\n\n{diff}"
+ ),
+)
+
+parent.add_resource(
+ "reviewer",
+ ResourceType.AGENT,
+ InternalSubagentResourceDescriptorBuilder()
+ .agent(reviewer)
+ .description("Reviews a code diff and reports problems.")
+ # "diff" matches the {diff} placeholder of the child's prompt.
+ .input_schema(
+ '{"type":"object","properties":{"diff":{"type":"string"}},'
+ '"required":["diff"]}'
+ )
+ .build(),
+)
+```
+
+The framework does not check the schema against the prompt. A field the prompt
+does not use is ignored, and a placeholder no field fills is left in the prompt
+as literal text, so keep the two in step when changing either.
diff --git a/docs/content/docs/development/react_agent.md
b/docs/content/docs/development/react_agent.md
index 32ae5518..ba7b68ca 100644
--- a/docs/content/docs/development/react_agent.md
+++ b/docs/content/docs/development/react_agent.md
@@ -399,3 +399,7 @@ The constructor registers those sources on the agent, so
they do not also need `
**Which declaration wins.** Sources reach the agent from three levels.
`@Skills`/`@skills` methods are merged first, then the execution environment's
`SKILLS` resources, then the agent's own. A declaration from a later level wins
when two of them resolve to the same skill name.
+## As a Sub-Agent
+
+A ReActAgent can run as a sub-agent of another agent, delegated to by the
parent's chat model or called from the parent's actions. See [Use a ReActAgent
as the child]({{< ref
"docs/development/multi-agent/subagents#use-a-reactagent-as-the-child" >}}).
+
diff --git a/python/flink_agents/api/agents/react_agent.py
b/python/flink_agents/api/agents/react_agent.py
index d1661a87..c7052a94 100644
--- a/python/flink_agents/api/agents/react_agent.py
+++ b/python/flink_agents/api/agents/react_agent.py
@@ -52,6 +52,12 @@ _DEFAULT_CHAT_MODEL = "_default_chat_model"
_DEFAULT_SCHEMA_PROMPT = "_default_schema_prompt"
_DEFAULT_NATIVE_SCHEMA_PROMPT = "_default_native_schema_prompt"
_DEFAULT_USER_PROMPT = "_default_user_prompt"
+# Action config flag recording that the agent registered _DEFAULT_USER_PROMPT,
+# as output_schema does for _DEFAULT_SCHEMA_PROMPT. start_action looks up a
+# built-in prompt only when the running scope's own action config declares it,
+# keeping a sub-agent's prompts its own on a runtime whose child scope falls
+# back to the root agent's resources.
+_HAS_USER_PROMPT = "has_user_prompt"
_OUTPUT_SCHEMA = "_output_schema"
_SKILLS_ARGUMENT = "skills"
_SKILLS_SOURCES_ARGUMENT = "skills_sources"
@@ -252,6 +258,7 @@ class ReActAgent(Agent):
output_schema=OutputSchema(output_schema=output_schema)
if output_schema
else None,
+ has_user_prompt=bool(prompt),
)
@staticmethod
@@ -261,12 +268,11 @@ class ReActAgent(Agent):
# at the JSON boundary), so the dispatched InputEvent is used directly.
usr_input = cast("InputEvent", event).input
- try:
- prompt = cast(
- "Prompt", ctx.get_resource(_DEFAULT_USER_PROMPT,
ResourceType.PROMPT)
- )
- except KeyError:
- prompt = None
+ prompt = (
+ _declared_prompt(ctx, _DEFAULT_USER_PROMPT)
+ if ctx.get_action_config_value(key=_HAS_USER_PROMPT)
+ else None
+ )
if isinstance(usr_input, bool | str | int | float | type(None)):
usr_input = str(usr_input)
@@ -278,26 +284,36 @@ class ReActAgent(Agent):
usr_msgs = [ChatMessage.user(usr_input)]
else:
if not prompt:
- err_msg = (
- f"Input type is {usr_input.__class__}, which is not
primitive types. "
- f"User should provide prompt to help convert it to
ChatMessage."
+ if isinstance(usr_input, dict) and set(usr_input.keys()) ==
{"input"}:
+ # The one structured shape that works without a
user-supplied
+ # prompt: a single "input" entry, the arguments of a
model-driven
+ # call against an input schema declaring only that field.
The
+ # value becomes the user message directly, mirroring the
+ # prompt-less primitive branch.
+ usr_msgs = [ChatMessage.user(str(usr_input["input"]))]
+ else:
+ err_msg = (
+ f"Input type is {usr_input.__class__}, which is not
primitive types. "
+ f"User should provide prompt to help convert it to
ChatMessage."
+ )
+ raise RuntimeError(err_msg)
+ else:
+ if isinstance(usr_input, Row):
+ usr_input = usr_input.as_dict(recursive=True)
+ elif isinstance(usr_input, dict):
+ pass
+ else: # regard as pojo
+ usr_input = usr_input.__dict__
+ # Convert Any values to str to match format_messages signature
+ str_usr_input = {k: str(v) for k, v in usr_input.items()}
+ usr_msgs = prompt.format_messages(
+ role=MessageRole.USER, **str_usr_input
)
- raise RuntimeError(err_msg)
- if isinstance(usr_input, Row):
- usr_input = usr_input.as_dict(recursive=True)
- elif isinstance(usr_input, dict):
- pass
- else: # regard as pojo
- usr_input = usr_input.__dict__
- # Convert Any values to str to match format_messages signature
- str_usr_input = {k: str(v) for k, v in usr_input.items()}
- usr_msgs = prompt.format_messages(role=MessageRole.USER,
**str_usr_input)
-
- schema_prompt = ReActAgent._prompt_or_none(ctx, _DEFAULT_SCHEMA_PROMPT)
output_schema = ctx.get_action_config_value(key="output_schema")
- if schema_prompt is not None:
+ if output_schema is not None:
+ schema_prompt = _declared_prompt(ctx, _DEFAULT_SCHEMA_PROMPT)
# A plan built before the native prompt existed has none, and then
adds
# no instruction on the native path.
instruction = (
@@ -364,3 +380,20 @@ class ReActAgent(Agent):
output = response.text
ctx.send_event(OutputEvent(output=output))
+
+
+def _declared_prompt(ctx: RunnerContext, name: str) -> Prompt:
+ """Resolve a built-in prompt the running scope's action config declares.
+
+ The constructor registers the prompt together with its declaration, so a
+ failed lookup is a broken plan and is reported rather than silently
dropping
+ the prompt.
+ """
+ try:
+ return cast("Prompt", ctx.get_resource(name, ResourceType.PROMPT))
+ except KeyError as e:
+ err_msg = (
+ f"Prompt {name} is declared by this agent's action config but
cannot "
+ f"be resolved."
+ )
+ raise RuntimeError(err_msg) from e
diff --git a/python/flink_agents/api/agents/tests/test_react_agent.py
b/python/flink_agents/api/agents/tests/test_react_agent.py
index 3435a47b..0fc71a44 100644
--- a/python/flink_agents/api/agents/tests/test_react_agent.py
+++ b/python/flink_agents/api/agents/tests/test_react_agent.py
@@ -16,6 +16,7 @@
# limitations under the License.
#################################################################################
from typing import Any, Callable
+from unittest.mock import MagicMock
import pytest
from pydantic import BaseModel, Field
@@ -27,6 +28,9 @@ from flink_agents.api.agents.react_agent import (
_DEFAULT_SCHEMA_PROMPT,
ReActAgent,
)
+from flink_agents.api.chat_message import MessageRole
+from flink_agents.api.events.event import InputEvent
+from flink_agents.api.prompts.prompt import Prompt
from flink_agents.api.resource import (
JavaResourceDescriptor,
ResourceDescriptor,
@@ -94,6 +98,122 @@ def _expected_prompt(rendered: Any) -> str:
return f"The final response should be json format, and match the schema
{rendered}."
+def _bare_agent() -> ReActAgent:
+ """A ReActAgent with neither a prompt nor an output schema."""
+ return ReActAgent(
+ chat_model=ResourceDescriptor(clazz=_CHAT_MODEL_CLASS,
model="qwen3:8b")
+ )
+
+
+def _context_without_prompt() -> tuple[MagicMock, list]:
+ """A mock context owning no prompt resource, collecting sent events."""
+ ctx = MagicMock()
+ ctx.get_resource = MagicMock(side_effect=KeyError("no such resource"))
+ ctx.get_action_config_value = MagicMock(return_value=None)
+ sent_events: list = []
+ ctx.send_event = MagicMock(side_effect=lambda e: sent_events.append(e))
+ return ctx, sent_events
+
+
+def _sole_user_text(sent_events: list) -> str:
+ """The text of the single user message of the emitted chat request."""
+ assert len(sent_events) == 1
+ messages = sent_events[0].messages
+ assert len(messages) == 1
+ assert messages[0].role == MessageRole.USER
+ return messages[0].text
+
+
+def _start_action_config(agent: ReActAgent) -> dict:
+ """The action config the agent registered for its start action."""
+ return agent._actions["start_action"][2]
+
+
+# --- built-in prompt scoping ---
+
+
+def test_configured_agent_declares_built_in_prompts_in_action_config() -> None:
+ """A configured agent declares its built-in prompts in the start action
config."""
+ agent = ReActAgent(
+ chat_model=ResourceDescriptor(clazz=_CHAT_MODEL_CLASS,
model="qwen3:8b"),
+ prompt=Prompt.from_text("Task: {input}"),
+ output_schema=Person,
+ )
+
+ config = _start_action_config(agent)
+ assert config["has_user_prompt"] is True
+ assert config["output_schema"] is not None
+
+
+def test_bare_agent_declares_no_built_in_prompt() -> None:
+ """A bare agent declares no built-in prompt in the start action config."""
+ config = _start_action_config(_bare_agent())
+
+ assert config["has_user_prompt"] is False
+ assert config["output_schema"] is None
+
+
+def test_undeclared_prompts_are_not_looked_up() -> None:
+ """A prompt reachable but not declared by this agent is not looked up."""
+ ctx, sent_events = _context_without_prompt()
+ # A runtime whose child scope falls back to the root agent's resources
makes a
+ # parent ReActAgent's prompts reachable under the same names.
+ ctx.get_resource = MagicMock(return_value=Prompt.from_text("PARENT ONLY:
{input}"))
+
+ ReActAgent.start_action(InputEvent(input="review the diff"), ctx)
+
+ assert _sole_user_text(sent_events) == "review the diff"
+ ctx.get_resource.assert_not_called()
+
+
+def test_declared_output_schema_inserts_schema_instruction() -> None:
+ """A declared output schema inserts the schema instruction before the user
message."""
+ ctx, sent_events = _context_without_prompt()
+ output_schema = _start_action_config(_agent(Person))["output_schema"]
+ ctx.get_action_config_value = MagicMock(
+ side_effect=lambda key: output_schema if key == "output_schema" else
None
+ )
+ ctx.get_resource = MagicMock(return_value=Prompt.from_text("match the
schema"))
+
+ ReActAgent.start_action(InputEvent(input="review the diff"), ctx)
+
+ messages = sent_events[0].messages
+ assert [m.text for m in messages] == ["match the schema", "review the
diff"]
+ assert messages[1].role == MessageRole.USER
+ ctx.get_resource.assert_any_call(_DEFAULT_SCHEMA_PROMPT,
ResourceType.PROMPT)
+
+
+def test_declared_prompt_that_cannot_be_resolved_is_reported() -> None:
+ """A declared prompt that cannot be resolved is reported, not dropped."""
+ ctx, _ = _context_without_prompt()
+ ctx.get_action_config_value = MagicMock(
+ side_effect=lambda key: key == "has_user_prompt" or None
+ )
+
+ with pytest.raises(RuntimeError, match="_default_user_prompt"):
+ ReActAgent.start_action(InputEvent(input="review the diff"), ctx)
+
+
+# --- start_action input dispatch ---
+
+
+def test_single_input_map_without_prompt_becomes_user_message() -> None:
+ """A single-input map without a prompt passes the value through."""
+ ctx, sent_events = _context_without_prompt()
+
+ ReActAgent.start_action(InputEvent(input={"input": "analyze this
review"}), ctx)
+
+ assert _sole_user_text(sent_events) == "analyze this review"
+
+
+def test_multi_key_map_without_prompt_still_reports_prompt_requirement() ->
None:
+ """A multi-key map without a prompt still reports that a prompt is
required."""
+ ctx, _ = _context_without_prompt()
+
+ with pytest.raises(RuntimeError, match="not primitive types"):
+ ReActAgent.start_action(InputEvent(input={"input": "x", "other":
"y"}), ctx)
+
+
def test_unrenderable_output_schema_raises_naming_the_model() -> None:
"""A schema that cannot be rendered fails at construction, not at the
provider."""
with pytest.raises(TypeError, match="Unrenderable cannot be rendered"):
diff --git
a/python/flink_agents/e2e_tests/e2e_tests_integration/react_agent_subagent_test.py
b/python/flink_agents/e2e_tests/e2e_tests_integration/react_agent_subagent_test.py
new file mode 100644
index 00000000..c7e8681e
--- /dev/null
+++
b/python/flink_agents/e2e_tests/e2e_tests_integration/react_agent_subagent_test.py
@@ -0,0 +1,344 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# 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.
+################################################################################
+"""E2E tests for a real ReActAgent running as an internal sub-agent.
+
+The children here are the built-in agent itself, not hand-written echo agents:
+its start action, the framework's chat model action and its stop action must
+all run inside the child scope, driven by scripted connections instead of a
+live model. The model-driven test additionally runs the whole delegation loop:
+the parent model issues a ``_subagent_child`` tool call, the framework routes
+it to the child, and the child's answer flows back as the tool observation
+that closes the loop.
+
+The delegated child is registered with a single-field ``input`` schema, so
+that test also proves the declared schema survives compilation and the
+child's prompt-free ``{input}`` map path accepts the model's arguments as-is.
+
+The third test lets the child declare a tool of its own and scripts two model
+rounds for it: the first reply asks for the tool, the tool executes inside
+the child scope, and the second round sees the tool result, closing the
+reasoning/tool loop in isolation.
+"""
+
+import os
+import sysconfig
+from pathlib import Path
+from typing import Any
+
+from pyflink.common import Encoder
+from pyflink.common.typeinfo import Types
+from pyflink.datastream import KeySelector, StreamExecutionEnvironment
+from pyflink.datastream.connectors.file_system import StreamingFileSink
+
+from flink_agents.api.agents.agent import Agent
+from flink_agents.api.agents.react_agent import ReActAgent
+from flink_agents.api.chat_message import (
+ ChatMessage,
+ MessageRole,
+ TextBlock,
+ ToolCallBlock,
+ ToolResultBlock,
+)
+from flink_agents.api.chat_models.chat_model import (
+ BaseChatModelConnection,
+ BaseChatModelSetup,
+)
+from flink_agents.api.chat_result import ChatResult
+from flink_agents.api.decorators import action
+from flink_agents.api.events.event import Event, InputEvent, OutputEvent
+from flink_agents.api.execution_environment import AgentsExecutionEnvironment
+from flink_agents.api.resource import ResourceDescriptor, ResourceType
+from flink_agents.api.runner_context import RunnerContext
+from flink_agents.api.subagent import InternalSubagentResourceDescriptorBuilder
+from flink_agents.api.tools.tool import Tool
+
+os.environ["PYTHONPATH"] = sysconfig.get_paths()["purelib"]
+
+CHILD_SCOPE = "child"
+
+
+class InputKeySelector(KeySelector):
+ """Keys every element by itself."""
+
+ def get_key(self, value: Any) -> Any:
+ """Return the element itself as key."""
+ return value
+
+
+class ScriptedModelSetup(BaseChatModelSetup):
+ """A setup whose behavior lives entirely in its scripted connection."""
+
+ @property
+ def model_kwargs(self) -> dict[str, Any]:
+ """Return chat model settings."""
+ return {}
+
+
+class ParentScriptedConnection(BaseChatModelConnection):
+ """Delegates once, then answers with the observed child output."""
+
+ def chat(
+ self,
+ messages: Any,
+ tools: list | None = None,
+ output_schema: Any = None,
+ **kwargs: Any,
+ ) -> ChatResult:
+ """Issue the delegation tool call, then echo the observation back."""
+ self._reject_unsupported_output_schema(output_schema)
+ tool_messages = [m for m in messages if m.role == MessageRole.TOOL]
+ if not tool_messages:
+ # The child is declared with a single "input" field, so the model
+ # builds the one argument the agent's prompt-free path accepts.
+ tool_call = ToolCallBlock(
+ call_id="call-1",
+ name="_subagent_child",
+ input={"input": "review the diff"},
+ )
+ return ChatResult(
+ message=ChatMessage.assistant(
+ [TextBlock(text="delegating"), tool_call]
+ )
+ )
+ result = tool_messages[0].blocks[0]
+ assert isinstance(result, ToolResultBlock)
+ return ChatResult(
+ message=ChatMessage.assistant(f"observed: {result.text}")
+ )
+
+
+class ChildScriptedConnection(BaseChatModelConnection):
+ """Echoes the last user message, revealing what the child agent
received."""
+
+ def chat(
+ self,
+ messages: Any,
+ tools: list | None = None,
+ output_schema: Any = None,
+ **kwargs: Any,
+ ) -> ChatResult:
+ """Answer with the child's own user message."""
+ self._reject_unsupported_output_schema(output_schema)
+ return ChatResult(
+ message=ChatMessage.assistant(f"child saw: {messages[-1].text}")
+ )
+
+
+def shout(text: str) -> str:
+ """Return the text in upper case, marked as tool-produced."""
+ return f"{text.upper()}!"
+
+
+class ToolCallingChildConnection(BaseChatModelConnection):
+ """Asks for the child's tool first, then answers from the tool result."""
+
+ def chat(
+ self,
+ messages: Any,
+ tools: list | None = None,
+ output_schema: Any = None,
+ **kwargs: Any,
+ ) -> ChatResult:
+ """Emit the tool call on the first round, consume the result on the
second."""
+ self._reject_unsupported_output_schema(output_schema)
+ tool_messages = [m for m in messages if m.role == MessageRole.TOOL]
+ if not tool_messages:
+ tool_call = ToolCallBlock(
+ call_id="call-1", name="shout", input={"text": "hello world"}
+ )
+ return ChatResult(
+ message=ChatMessage.assistant(
+ [TextBlock(text="let me check"), tool_call]
+ )
+ )
+ result = tool_messages[0].blocks[0]
+ assert isinstance(result, ToolResultBlock)
+ return ChatResult(
+ message=ChatMessage.assistant(f"tool says: {result.text}")
+ )
+
+
+def _child_react_agent() -> ReActAgent:
+ """A ReActAgent with neither a prompt nor an output schema.
+
+ The connection is registered on the child itself, keeping the child plan
+ self-contained: a child scope resolves resources against the child plan
+ only, so the connection must live there.
+ """
+ child = ReActAgent(
+ chat_model=ResourceDescriptor(
+
clazz=f"{ScriptedModelSetup.__module__}.{ScriptedModelSetup.__name__}",
+ connection="child_connection",
+ model="mock-model",
+ ),
+ )
+ child.add_resource(
+ "child_connection",
+ ResourceType.CHAT_MODEL_CONNECTION,
+ ResourceDescriptor(
+
clazz=f"{ChildScriptedConnection.__module__}.{ChildScriptedConnection.__name__}",
+ ),
+ )
+ return child
+
+
+def _tool_calling_child_react_agent() -> ReActAgent:
+ """A ReActAgent whose model asks for the child's own tool across two
rounds."""
+ child = ReActAgent(
+ chat_model=ResourceDescriptor(
+
clazz=f"{ScriptedModelSetup.__module__}.{ScriptedModelSetup.__name__}",
+ connection="tool_child_connection",
+ model="mock-model",
+ tools=["shout"],
+ ),
+ )
+ child.add_resource(
+ "tool_child_connection",
+ ResourceType.CHAT_MODEL_CONNECTION,
+ ResourceDescriptor(
+
clazz=f"{ToolCallingChildConnection.__module__}.{ToolCallingChildConnection.__name__}",
+ ),
+ )
+ child.add_resource("shout", ResourceType.TOOL, Tool.from_callable(shout))
+ return child
+
+
+class CallerRootAgent(Agent):
+ """Caller agent awaiting the ReAct child from an async action."""
+
+ @action(InputEvent.EVENT_TYPE)
+ @staticmethod
+ async def orchestrate(event: Event, ctx: RunnerContext) -> None:
+ """Await the child and emit its outcome."""
+ child = ctx.get_resource(CHILD_SCOPE, ResourceType.AGENT)
+ future = await child.submit(ctx, "review the diff")
+ result = await future
+ if result.success:
+ ctx.send_event(OutputEvent(output=result.result[0]))
+ else:
+ msg = f"failed:{result.error_message}"
+ ctx.send_event(OutputEvent(output=msg))
+
+
+def _delegating_parent() -> ReActAgent:
+ """A bare ReActAgent parent that declares the child to its model.
+
+ Sub-classing ReActAgent would trip the plan compiler, which rejects
+ inherited ``@action`` methods on a concrete agent, so the parent is a
+ plain instance carrying instance-level resources instead.
+ """
+ parent = ReActAgent(
+ chat_model=ResourceDescriptor(
+
clazz=f"{ScriptedModelSetup.__module__}.{ScriptedModelSetup.__name__}",
+ connection="parent_connection",
+ model="mock-model",
+ subagents=[CHILD_SCOPE],
+ ),
+ )
+ parent.add_resource(
+ "parent_connection",
+ ResourceType.CHAT_MODEL_CONNECTION,
+ ResourceDescriptor(
+
clazz=f"{ParentScriptedConnection.__module__}.{ParentScriptedConnection.__name__}",
+ ),
+ )
+ # Only a declared input schema lets the parent model call the child.
+ parent.add_resource(
+ CHILD_SCOPE,
+ ResourceType.AGENT,
+ InternalSubagentResourceDescriptorBuilder()
+ .agent(_child_react_agent())
+ .description("Reviews a code diff.")
+ .input_schema(
+ '{"type":"object","properties":{"input":{"type":"string"}},'
+ '"required":["input"]}'
+ )
+ .build(),
+ )
+ return parent
+
+
+def _run_and_collect(root: Agent, tmp_path: Path) -> str:
+ """Run the root agent over one element and collect what the job emitted."""
+ env = StreamExecutionEnvironment.get_execution_environment()
+ env.set_parallelism(1)
+ input_stream = env.from_collection(["hello"])
+
+ agents_env = AgentsExecutionEnvironment.get_execution_environment(env=env)
+ output_datastream = (
+ agents_env.from_datastream(input=input_stream,
key_selector=InputKeySelector())
+ .apply(root)
+ .to_datastream()
+ )
+
+ result_dir = tmp_path / "results"
+ result_dir.mkdir(parents=True, exist_ok=True)
+ output_datastream.map(str, Types.STRING()).add_sink(
+ StreamingFileSink.for_row_format(
+ base_path=str(result_dir.absolute()),
+ encoder=Encoder.simple_string_encoder(),
+ ).build()
+ )
+ agents_env.execute()
+ return "".join(p.read_text() for p in result_dir.rglob("*") if p.is_file())
+
+
+def test_caller_driven_react_subagent(tmp_path: Path) -> None:
+ """A submitted string runs the child's ReAct loop and returns its
answer."""
+ root = CallerRootAgent()
+ root.add_resource(CHILD_SCOPE, ResourceType.AGENT, _child_react_agent())
+
+ contents = _run_and_collect(root, tmp_path)
+
+ # The child connection saw the submitted string as its user message, which
is
+ # the prompt-free primitive path, and the child's reply is the call result.
+ assert "child saw: review the diff" in contents, (
+ f"missing child output; collected: {contents!r}"
+ )
+
+
+def test_model_driven_react_subagent_delegation(tmp_path: Path) -> None:
+ """A model-issued delegation reaches the declared child and closes the
loop."""
+ contents = _run_and_collect(_delegating_parent(), tmp_path)
+
+ # The final answer echoes the tool observation, proving the whole loop: the
+ # model's {"input": ...} arguments became the child's user message through
+ # the prompt-free {input} map path, and the child's reply came back as the
+ # observation. A child's own output never reaches the sink directly -- only
+ # this parent answer does -- so both fragments here mean the round-trip
+ # completed; the assertion stays split because the observation carries the
+ # result in the framework's string form, not verbatim.
+ assert "observed:" in contents, f"no observation echoed; collected:
{contents!r}"
+ assert "child saw: review the diff" in contents, (
+ f"child output missing from the loop; collected: {contents!r}"
+ )
+
+
+def test_child_tool_call_loop_runs_inside_the_child_scope(tmp_path: Path) ->
None:
+ """The child's own tool executes inside the child scope across two
rounds."""
+ root = CallerRootAgent()
+ root.add_resource(
+ CHILD_SCOPE, ResourceType.AGENT, _tool_calling_child_react_agent()
+ )
+
+ contents = _run_and_collect(root, tmp_path)
+
+ # The tool result flowed back into the child's second model round, which
+ # answered from it; the child's tool never touches the parent's resources.
+ assert "tool says: HELLO WORLD!" in contents, (
+ f"tool loop output missing; collected: {contents!r}"
+ )
diff --git a/python/flink_agents/runtime/flink_runner_context.py
b/python/flink_agents/runtime/flink_runner_context.py
index 23e0654c..21485641 100644
--- a/python/flink_agents/runtime/flink_runner_context.py
+++ b/python/flink_agents/runtime/flink_runner_context.py
@@ -767,6 +767,20 @@ class FlinkRunnerContext(RunnerContext, ExecutionReporter):
self.__scoped_resource_caches[plan_json] = entry
return entry
+ def __active_plan(self) -> Any:
+ """The plan in effect: the child plan's while inside a sub-agent call,
else
+ the root plan's.
+
+ Mirrors the Java context's ``currentPlan()``: action-config lookups
must
+ resolve against the plan whose action is executing, so a child agent's
+ action reads its own config instead of failing on the root plan, which
+ does not carry that action.
+ """
+ plan_json = self._j_runner_context.getActiveScopePlanJson()
+ if plan_json is None:
+ return self.__agent_plan
+ return self.__scoped_plan_and_cache(plan_json)[0]
+
def __observe_subagent_setup(self, resource: Any) -> None:
"""Wire a lazily materialized sub-agent handle into the task lifecycle.
@@ -902,14 +916,14 @@ class FlinkRunnerContext(RunnerContext,
ExecutionReporter):
@override
def action_config(self) -> Dict[str, Any]:
"""Get config of the action."""
- return self.__agent_plan.get_action_config(
+ return self.__active_plan().get_action_config(
self._j_runner_context.getActionName()
)
@override
def get_action_config_value(self, key: str) -> Any:
"""Get config of the action."""
- return self.__agent_plan.get_action_config_value(
+ return self.__active_plan().get_action_config_value(
action_name=self._j_runner_context.getActionName(), key=key
)
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/ReActAgentSubagentTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/ReActAgentSubagentTest.java
new file mode 100644
index 00000000..f27e0680
--- /dev/null
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/subagent/ReActAgentSubagentTest.java
@@ -0,0 +1,458 @@
+/*
+ * 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.subagent;
+
+import org.apache.flink.agents.api.Event;
+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.agents.ReActAgent;
+import org.apache.flink.agents.api.annotation.ToolParam;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.ChatResult;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.messages.TextBlock;
+import org.apache.flink.agents.api.chat.messages.ToolCallBlock;
+import org.apache.flink.agents.api.chat.messages.ToolResultBlock;
+import org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
+import org.apache.flink.agents.api.chat.model.BaseChatModelSetup;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.prompt.Prompt;
+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.InternalSubagentResourceDescriptorBuilder;
+import org.apache.flink.agents.api.subagent.SubagentResult;
+import org.apache.flink.agents.api.subagent.SubagentSetup;
+import org.apache.flink.agents.api.tools.Tool;
+import org.apache.flink.agents.plan.AgentPlan;
+import org.apache.flink.agents.runtime.operator.ActionExecutionOperator;
+import org.apache.flink.agents.runtime.operator.ActionExecutionOperatorFactory;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.java.functions.KeySelector;
+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 org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end coverage for a real {@link ReActAgent} running as an internal
sub-agent, over the
+ * operator harness. Unlike {@link InternalSubagentCallTest}, whose children
are hand-written echo
+ * agents, the child here is the built-in agent itself: its start action, the
framework's chat model
+ * action and its stop action must all run inside the child scope, driven by a
scripted connection
+ * instead of a live model.
+ *
+ * <p>The second test drives the model path: the parent model issues a {@code
_subagent_child} tool
+ * call, the framework routes it to the child, and the child's answer flows
back as the tool
+ * observation that closes the loop. The child is registered with a
single-field {@code input}
+ * schema, so this also proves the declared schema survives compilation and
the child's prompt-free
+ * {@code {input}} map path accepts the model's arguments as-is.
+ *
+ * <p>The third test lets the child declare a tool of its own and scripts two
model rounds for it:
+ * the first reply asks for the tool, the tool executes inside the child
scope, and the second round
+ * sees the tool result, closing the reasoning/tool loop in isolation.
+ *
+ * <p>The last two tests configure the parent ReActAgent with a prompt and an
output schema and
+ * leave the child bare. A child scope's resource lookup falls back to the
root agent's resources,
+ * so they pin that the child's built-in prompts stay its own rather than
resolving to the parent's
+ * under the same names.
+ *
+ * <p>Requires JDK 21+ for the same mailbox-suspension reason as {@link
InternalSubagentCallTest}.
+ */
+@EnabledForJreRange(min = JRE.JAVA_21)
+public class ReActAgentSubagentTest {
+
+ @BeforeEach
+ void resetScripts() {
+ ParentScriptedConnection.reset();
+ ChildScriptedConnection.reset();
+ }
+
+ // --- scripted chat model ---
+
+ /** Materialized from a descriptor; the connection carries the script. */
+ public static class ScriptedModelSetup extends BaseChatModelSetup {
+
+ public ScriptedModelSetup(ResourceDescriptor descriptor,
ResourceContext resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
+ @Override
+ public Map<String, Object> getParameters() {
+ return new HashMap<>();
+ }
+ }
+
+ /** Serves the parent agent; replies come from a static script. */
+ public static class ParentScriptedConnection extends
BaseChatModelConnection {
+
+ static final Deque<ChatMessage> REPLIES = new ArrayDeque<>();
+ static final List<List<ChatMessage>> CONVERSATIONS = new ArrayList<>();
+
+ public ParentScriptedConnection(
+ ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
+ static void reset() {
+ REPLIES.clear();
+ CONVERSATIONS.clear();
+ }
+
+ @Override
+ public ChatResult chat(
+ List<ChatMessage> messages, List<Tool> tools, Map<String,
Object> modelParams) {
+ CONVERSATIONS.add(new ArrayList<>(messages));
+ return new ChatResult(
+ REPLIES.isEmpty() ? ChatMessage.assistant("no script
left") : REPLIES.poll());
+ }
+ }
+
+ /** Serves the child agent; replies come from a static script. */
+ public static class ChildScriptedConnection extends
BaseChatModelConnection {
+
+ static final Deque<ChatMessage> REPLIES = new ArrayDeque<>();
+ static final List<List<ChatMessage>> CONVERSATIONS = new ArrayList<>();
+ static final List<List<String>> OFFERED_TOOLS = new ArrayList<>();
+
+ public ChildScriptedConnection(
+ ResourceDescriptor descriptor, ResourceContext
resourceContext) {
+ super(descriptor, resourceContext);
+ }
+
+ static void reset() {
+ REPLIES.clear();
+ CONVERSATIONS.clear();
+ OFFERED_TOOLS.clear();
+ }
+
+ @Override
+ public ChatResult chat(
+ List<ChatMessage> messages, List<Tool> tools, Map<String,
Object> modelParams) {
+ CONVERSATIONS.add(new ArrayList<>(messages));
+ OFFERED_TOOLS.add(
+ tools == null
+ ? List.of()
+ :
tools.stream().map(Tool::getName).collect(Collectors.toList()));
+ return new ChatResult(
+ REPLIES.isEmpty() ? ChatMessage.assistant("no script
left") : REPLIES.poll());
+ }
+ }
+
+ // --- caller actions ---
+
+ @SuppressWarnings("unused")
+ public static void callReActChild(Event event, RunnerContext ctx) throws
Exception {
+ SubagentSetup child = (SubagentSetup) ctx.getResource("child",
ResourceType.AGENT);
+ SubagentResult result = child.submit(ctx, "review the diff").await();
+ ctx.sendEvent(
+ new OutputEvent(
+ result.isSuccess()
+ ? firstOutput(result)
+ : "unexpected: " + result.getErrorMessage()));
+ }
+
+ // --- tests ---
+
+ @Test
+ @Timeout(60)
+ void callerDrivenCallRunsTheReActLoopInsideTheChildScope() throws
Exception {
+ ChildScriptedConnection.REPLIES.add(new
ChatMessage(MessageRole.ASSISTANT, "reviewed: ok"));
+
+ Agent parent = new Agent();
+ registerConnections(parent);
+ parent.addResource("child", ResourceType.AGENT,
bareReactAgent("child_conn"));
+ parent.addAction(
+ new String[] {InputEvent.EVENT_TYPE},
+ ReActAgentSubagentTest.class.getMethod(
+ "callReActChild", Event.class, RunnerContext.class));
+
+ List<Object> output = run(parent, 7L);
+
+ // The child's stop action output is the caller-visible result, and
the submitted string
+ // became the child's user message through the prompt-free primitive
path.
+ assertThat(output).containsExactly("reviewed: ok");
+ assertThat(ChildScriptedConnection.CONVERSATIONS).hasSize(1);
+
assertThat(latestUserText(ChildScriptedConnection.CONVERSATIONS.get(0)))
+ .isEqualTo("review the diff");
+ }
+
+ @Test
+ @Timeout(60)
+ void modelDrivenDelegationRoutesToTheDeclaredChildAndClosesTheLoop()
throws Exception {
+ ToolCallBlock delegation = delegation();
+
ParentScriptedConnection.REPLIES.add(assistantWithToolCall("delegating",
delegation));
+ ParentScriptedConnection.REPLIES.add(
+ new ChatMessage(MessageRole.ASSISTANT, "delegated and
reviewed"));
+ ChildScriptedConnection.REPLIES.add(new
ChatMessage(MessageRole.ASSISTANT, "reviewed: ok"));
+
+ // The registration declares the child's schema, which carries it
through open().
+ ReActAgent parent =
+ new ReActAgent(
+
ResourceDescriptor.Builder.newBuilder(ScriptedModelSetup.class.getName())
+ .addInitialArgument("connection",
"parent_conn")
+ .addInitialArgument("subagents",
List.of("child"))
+ .build(),
+ null,
+ null);
+ registerConnections(parent);
+ parent.addResource(
+ "child", ResourceType.AGENT,
declaredChild(bareReactAgent("child_conn")));
+
+ List<Object> output = run(parent, 7L);
+
+ assertThat(output).containsExactly("delegated and reviewed");
+ // The model's arguments reached the child as its user message through
the prompt-free
+ // {input} map path, and the child's answer came back as the closing
observation.
+ assertThat(ChildScriptedConnection.CONVERSATIONS).hasSize(1);
+
assertThat(latestUserText(ChildScriptedConnection.CONVERSATIONS.get(0)))
+ .isEqualTo("review the diff");
+ assertThat(ParentScriptedConnection.CONVERSATIONS).hasSize(2);
+ assertThat(allTexts(ParentScriptedConnection.CONVERSATIONS.get(1)))
+ .anySatisfy(text -> assertThat(text).contains("reviewed: ok"));
+ }
+
+ @Test
+ @Timeout(60)
+ void toolCallLoopRunsInsideTheChildScope() throws Exception {
+ ToolCallBlock lookup = new ToolCallBlock("call-1", "shout",
Map.of("text", "hello world"));
+ ChildScriptedConnection.REPLIES.add(assistantWithToolCall("let me
check", lookup));
+ ChildScriptedConnection.REPLIES.add(
+ new ChatMessage(MessageRole.ASSISTANT, "tool says: HELLO
WORLD!"));
+
+ Agent parent = new Agent();
+ registerConnections(parent);
+ parent.addResource("child", ResourceType.AGENT,
toolCallingReactAgent());
+ parent.addAction(
+ new String[] {InputEvent.EVENT_TYPE},
+ ReActAgentSubagentTest.class.getMethod(
+ "callReActChild", Event.class, RunnerContext.class));
+
+ List<Object> output = run(parent, 7L);
+
+ // The tool schema was offered on the first round, the tool executed
inside the child
+ // scope, and the second round saw the tool result before answering.
+ assertThat(output).containsExactly("tool says: HELLO WORLD!");
+
assertThat(ChildScriptedConnection.OFFERED_TOOLS.get(0)).containsExactly("shout");
+ assertThat(ChildScriptedConnection.CONVERSATIONS).hasSize(2);
+ assertThat(ChildScriptedConnection.CONVERSATIONS.get(1))
+ .anySatisfy(
+ message -> {
+
assertThat(message.getRole()).isEqualTo(MessageRole.TOOL);
+ ToolResultBlock result = (ToolResultBlock)
message.getBlocks().get(0);
+ assertThat(result.getText()).isEqualTo("HELLO
WORLD!");
+ });
+ }
+
+ @Test
+ @Timeout(60)
+ void bareChildDoesNotInheritTheParentPrompt() throws Exception {
+
ParentScriptedConnection.REPLIES.add(assistantWithToolCall("delegating",
delegation()));
+ ParentScriptedConnection.REPLIES.add(
+ new ChatMessage(MessageRole.ASSISTANT, "delegated and
reviewed"));
+ ChildScriptedConnection.REPLIES.add(new
ChatMessage(MessageRole.ASSISTANT, "reviewed: ok"));
+
+ ReActAgent parent =
+ delegatingParent(Prompt.fromText("PARENT ONLY: {input}"), /*
outputSchema= */ null);
+
+ List<Object> output = run(parent, 7L);
+
+ assertThat(output).containsExactly("delegated and reviewed");
+ // The parent prompt applies to the parent's own input only; the bare
child takes the
+ // delegated value through its prompt-free {input} map path.
+
assertThat(latestUserText(ParentScriptedConnection.CONVERSATIONS.get(0)))
+ .isEqualTo("PARENT ONLY: 7");
+ assertThat(ChildScriptedConnection.CONVERSATIONS).hasSize(1);
+
assertThat(latestUserText(ChildScriptedConnection.CONVERSATIONS.get(0)))
+ .isEqualTo("review the diff");
+ }
+
+ @Test
+ @Timeout(60)
+ void bareChildDoesNotInheritTheParentOutputSchema() throws Exception {
+
ParentScriptedConnection.REPLIES.add(assistantWithToolCall("delegating",
delegation()));
+ ParentScriptedConnection.REPLIES.add(
+ new ChatMessage(MessageRole.ASSISTANT,
"{\"verdict\":\"ok\"}"));
+ ChildScriptedConnection.REPLIES.add(new
ChatMessage(MessageRole.ASSISTANT, "reviewed: ok"));
+
+ ReActAgent parent = delegatingParent(/* prompt= */ null,
Verdict.class);
+
+ run(parent, 7L);
+
+ // The parent's own request carries its schema instruction, which
proves the instruction
+ // is reachable by name; the bare child's request must not.
+ assertThat(allTexts(ParentScriptedConnection.CONVERSATIONS.get(0)))
+ .anySatisfy(text ->
assertThat(text).contains(SCHEMA_INSTRUCTION));
+ assertThat(ChildScriptedConnection.CONVERSATIONS).hasSize(1);
+ List<ChatMessage> childConversation =
ChildScriptedConnection.CONVERSATIONS.get(0);
+ assertThat(allTexts(childConversation))
+ .noneSatisfy(text ->
assertThat(text).contains(SCHEMA_INSTRUCTION));
+ assertThat(childConversation)
+ .extracting(ChatMessage::getRole)
+ .containsExactly(MessageRole.USER);
+ }
+
+ // --- helpers ---
+
+ private static final String SCHEMA_INSTRUCTION = "The final response
should be json format";
+
+ /** The parent's structured answer, giving it an output schema the bare
child must not see. */
+ public static class Verdict {
+ public String verdict;
+ }
+
+ private static ToolCallBlock delegation() {
+ return new ToolCallBlock("call-1", "_subagent_child", Map.of("input",
"review the diff"));
+ }
+
+ private static ChatMessage assistantWithToolCall(String text,
ToolCallBlock toolCall) {
+ return ChatMessage.assistant(List.of(new TextBlock(text), toolCall));
+ }
+
+ /** A ReActAgent parent configured as given, declaring a bare ReActAgent
child to its model. */
+ private static ReActAgent delegatingParent(
+ @Nullable Prompt prompt, @Nullable Class<?> outputSchema) {
+ ReActAgent parent =
+ new ReActAgent(
+
ResourceDescriptor.Builder.newBuilder(ScriptedModelSetup.class.getName())
+ .addInitialArgument("connection",
"parent_conn")
+ .addInitialArgument("subagents",
List.of("child"))
+ .build(),
+ prompt,
+ outputSchema);
+ registerConnections(parent);
+ parent.addResource(
+ "child", ResourceType.AGENT,
declaredChild(bareReactAgent("child_conn")));
+ return parent;
+ }
+
+ /**
+ * Declares {@code child} with a single {@code input} field, the arguments
shape the prompt-free
+ * map path of a ReActAgent passes through as the user message.
+ */
+ private static ResourceDescriptor declaredChild(Agent child) {
+ return new InternalSubagentResourceDescriptorBuilder()
+ .agent(child)
+ .description("Reviews a code diff.")
+ .inputSchema(
+
"{\"type\":\"object\",\"properties\":{\"input\":{\"type\":\"string\"}},"
+ + "\"required\":[\"input\"]}")
+ .build();
+ }
+
+ private static ReActAgent toolCallingReactAgent() throws Exception {
+ ReActAgent child =
+ new ReActAgent(
+
ResourceDescriptor.Builder.newBuilder(ScriptedModelSetup.class.getName())
+ .addInitialArgument("connection", "child_conn")
+ .addInitialArgument("tools", List.of("shout"))
+ .build(),
+ null,
+ null);
+ child.addResource(
+ "shout",
+ ResourceType.TOOL,
+
Tool.fromMethod(ReActAgentSubagentTest.class.getMethod("shout", String.class)));
+ return child;
+ }
+
+ @SuppressWarnings("unused")
+ public static String shout(@ToolParam(name = "text") String text) {
+ return text.toUpperCase(Locale.ROOT) + "!";
+ }
+
+ private static ReActAgent bareReactAgent(String connectionName) {
+ return new ReActAgent(
+
ResourceDescriptor.Builder.newBuilder(ScriptedModelSetup.class.getName())
+ .addInitialArgument("connection", connectionName)
+ .build(),
+ null,
+ null);
+ }
+
+ private static String firstOutput(SubagentResult result) {
+ return String.valueOf(((List<?>) result.getResult()).get(0));
+ }
+
+ private static String latestUserText(List<ChatMessage> messages) {
+ for (int i = messages.size() - 1; i >= 0; i--) {
+ ChatMessage message = messages.get(i);
+ if (message.getRole() == MessageRole.USER) {
+ return message.getText();
+ }
+ }
+ throw new AssertionError("no user message in " + messages);
+ }
+
+ private static List<String> allTexts(List<ChatMessage> messages) {
+ return messages.stream()
+ .flatMap(message -> message.getBlocks().stream())
+ .filter(block -> block instanceof TextBlock || block
instanceof ToolResultBlock)
+ .map(
+ block ->
+ block instanceof TextBlock
+ ? ((TextBlock) block).getText()
+ : ((ToolResultBlock) block).getText())
+ .collect(Collectors.toList());
+ }
+
+ private static void registerConnections(Agent agent) {
+ agent.addResource(
+ "parent_conn",
+ ResourceType.CHAT_MODEL_CONNECTION,
+
ResourceDescriptor.Builder.newBuilder(ParentScriptedConnection.class.getName())
+ .build());
+ agent.addResource(
+ "child_conn",
+ ResourceType.CHAT_MODEL_CONNECTION,
+
ResourceDescriptor.Builder.newBuilder(ChildScriptedConnection.class.getName())
+ .build());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List<Object> run(Agent root, long input) throws Exception {
+ try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object>
harness =
+ new KeyedOneInputStreamOperatorTestHarness<>(
+ new ActionExecutionOperatorFactory<>(new
AgentPlan(root), true),
+ (KeySelector<Long, Long>) value -> value,
+ TypeInformation.of(Long.class))) {
+ harness.open();
+ harness.processElement(new StreamRecord<>(input));
+ ((ActionExecutionOperator<Long, Object>) harness.getOperator())
+ .waitInFlightEventsFinished();
+ return ((List<StreamRecord<Object>>) harness.getRecordOutput())
+
.stream().map(StreamRecord::getValue).collect(Collectors.toList());
+ }
+ }
+}