Repository: langchain4j/langchain4j (2026-07-27T04:12:59Z)

Issue:
[BUG] Parallel mapper agent returns null instead of an empty result for an empty input collection

**Describe the bug**

A parallel mapper agent returns `null` when its input collection is empty.

`ParallelMapperPlanner.firstAction()` returns a bare `done()` for an empty collection, so no result is carried and nothing is written to `outputKey`. The normal path, `nextAction()`, builds a list (or array), writes it to `outputKey`, and returns `done(result)`.

For a typed mapper without an `@Output` method, `ParallelMapperServiceImpl.isArrayResult()` rejects any return type that is not a `Collection` or an array, so `null` is never valid.

**Log and Stack trace**

N/A. Callers get a `NullPointerException` when iterating the result.

**To Reproduce**

```java
public interface BatchMapperAgent {
    @Agent(outputKey = "mappedItems")
    List<String> mapAll(@V("items") List<String> items);
}

AgenticServices.parallelMapperBuilder(BatchMapperAgent.class)
        .subAgents(itemAgent)
        .build()
        .mapAll(List.of()); // null
```

Same for array return types and the untyped builder. Non-empty input works.

**Expected behavior**

An empty input maps to an empty result: an empty `List` or zero-length array, also written to `outputKey`.

**Please complete the following information:**
- LangChain4j version: 1.19.0-beta29-SNAPSHOT (`main`, 1da7e9cba)
- LLM(s) used: N/A (stub `ChatModel`)
- Java version: 17
- Spring Boot version (if applicable): N/A

**Additional context**

An empty collection is normal when the list comes from an upstream agent. Neither the code nor the docs says an empty input yields `null`.




Discussion:

Patch:
diff --git a/langchain4j-agentic/src/main/java/dev/langchain4j/agentic/workflow/impl/ParallelMapperPlanner.java b/langchain4j-agentic/src/main/java/dev/langchain4j/agentic/workflow/impl/ParallelMapperPlanner.java
index 4a44173d771..ed1f4a0e2e6 100644
--- a/langchain4j-agentic/src/main/java/dev/langchain4j/agentic/workflow/impl/ParallelMapperPlanner.java
+++ b/langchain4j-agentic/src/main/java/dev/langchain4j/agentic/workflow/impl/ParallelMapperPlanner.java
@@ -1,5 +1,7 @@
 package dev.langchain4j.agentic.workflow.impl;
 
+import static java.util.Arrays.copyOf;
+
 import dev.langchain4j.agentic.internal.AgentExecutor;
 import dev.langchain4j.agentic.internal.MapperAgentInvoker;
 import dev.langchain4j.agentic.planner.Action;
@@ -13,8 +15,6 @@
 import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
 
-import static java.util.Arrays.copyOf;
-
 public class ParallelMapperPlanner implements Planner {
 
     private final String itemsProvider;
@@ -49,7 +49,7 @@ public Action firstAction(PlanningContext planningContext) {
         List<?> items = collectItems(collectionObj);
 
         if (items.isEmpty()) {
-            return done();
+            return doneWithResults(planningContext, List.of());
         }
 
         this.itemCount = items.size();
@@ -89,15 +89,19 @@ public Action nextAction(PlanningContext planningContext) {
                 results.add(planningContext.agenticScope().readState(resultKeyPrefix + "_" + i));
                 planningContext.agenticScope().writeState(resultKeyPrefix + "_" + i, null);
             }
-            Object result = isArrayResult ? copyOf(results.toArray(), results.size(), arrayclass) : results;
-            if (outputKey != null && !outputKey.isBlank()) {
-                planningContext.agenticScope().writeState(outputKey, result);
-            }
-            return done(result);
+            return doneWithResults(planningContext, results);
         }
         return done();
     }
 
+    private Action doneWithResults(PlanningContext planningContext, List<Object> results) {
+        Object result = isArrayResult ? copyOf(results.toArray(), results.size(), arrayclass) : results;
+        if (outputKey != null && !outputKey.isBlank()) {
+            planningContext.agenticScope().writeState(outputKey, result);
+        }
+        return done(result);
+    }
+
     @Override
     public AgenticSystemTopology topology() {
         return AgenticSystemTopology.PARALLEL;


Test patch:
diff --git a/langchain4j-agentic/src/test/java/dev/langchain4j/agentic/ParallelMapperEmptyInputTest.java b/langchain4j-agentic/src/test/java/dev/langchain4j/agentic/ParallelMapperEmptyInputTest.java
new file mode 100644
index 00000000000..df572aa6a9d
--- /dev/null
+++ b/langchain4j-agentic/src/test/java/dev/langchain4j/agentic/ParallelMapperEmptyInputTest.java
@@ -0,0 +1,82 @@
+package dev.langchain4j.agentic;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.service.UserMessage;
+import dev.langchain4j.service.V;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class ParallelMapperEmptyInputTest {
+
+    static final ChatModel DUMMY_MODEL = new ChatModel() {
+        @Override
+        public ChatResponse chat(ChatRequest chatRequest) {
+            return ChatResponse.builder()
+                    .aiMessage(AiMessage.from("test-response"))
+                    .build();
+        }
+    };
+
+    public interface ItemAgent {
+
+        @UserMessage("Map: {{item}}")
+        @Agent(description = "Maps a single item", outputKey = "mapped")
+        String map(@V("item") String item);
+    }
+
+    public interface ListReturningMapperAgent {
+
+        @Agent(outputKey = "mappedItems")
+        List<String> mapAll(@V("items") List<String> items);
+    }
+
+    public interface ArrayReturningMapperAgent {
+
+        @Agent
+        String[] mapAll(@V("items") String... items);
+    }
+
+    @Test
+    void empty_list_is_mapped_to_empty_list() {
+        assertThat(listMapper().mapAll(List.of())).isNotNull().isEmpty();
+    }
+
+    @Test
+    void non_empty_list_is_mapped_item_by_item() {
+        assertThat(listMapper().mapAll(List.of("first", "second"))).containsExactly("test-response", "test-response");
+    }
+
+    @Test
+    void empty_array_is_mapped_to_empty_array() {
+        assertThat(arrayMapper().mapAll()).isNotNull().isEmpty();
+    }
+
+    @Test
+    void non_empty_array_is_mapped_item_by_item() {
+        assertThat(arrayMapper().mapAll("first", "second")).containsExactly("test-response", "test-response");
+    }
+
+    private static ListReturningMapperAgent listMapper() {
+        return AgenticServices.parallelMapperBuilder(ListReturningMapperAgent.class)
+                .subAgents(itemAgent())
+                .build();
+    }
+
+    private static ArrayReturningMapperAgent arrayMapper() {
+        return AgenticServices.parallelMapperBuilder(ArrayReturningMapperAgent.class)
+                .subAgents(itemAgent())
+                .build();
+    }
+
+    private static ItemAgent itemAgent() {
+        return AgenticServices.agentBuilder(ItemAgent.class)
+                .chatModel(DUMMY_MODEL)
+                .outputKey("mapped")
+                .build();
+    }
+}