Repository: langchain4j/langchain4j (2026-07-28T01:57:50Z)

Issue:
[BUG] Async agent returning null crashes the workflow with a NullPointerException

**Describe the bug**

An agent declared async (`@Agent(async = true)`) whose method returns `null` makes the
workflow fail with a `NullPointerException` after all agents have completed, thrown from
`DefaultAgenticScope.rootCallEnded` via `ConcurrentHashMap.replaceAll`. The same agent
run synchronously completes normally.

**Log and Stack trace**

```
java.lang.NullPointerException
	at java.base/java.util.concurrent.ConcurrentHashMap.replaceAll(ConcurrentHashMap.java:1623)
	at dev.langchain4j.agentic.scope.DefaultAgenticScope.rootCallEnded(DefaultAgenticScope.java:229)
	at dev.langchain4j.agentic.internal.PlannerBasedInvocationHandler.executeAgentMethod(PlannerBasedInvocationHandler.java:243)
	at dev.langchain4j.agentic.internal.PlannerBasedInvocationHandler.invoke(PlannerBasedInvocationHandler.java:194)
```

**To Reproduce**

A sequence with an async agent that returns `null` and whose output is not read by a
later agent (so the async response stays unresolved until the root call ends):

```java
public static class NullAgent {
    @Agent(async = true, outputKey = "unread")
    public String run() { return null; }
}
public static class ResultAgent {
    @Agent(outputKey = "result")
    public String run() { return "done"; }
}

UntypedAgent workflow = AgenticServices.sequenceBuilder()
        .subAgents(new NullAgent(), new ResultAgent())
        .outputKey("result")
        .build();

workflow.invoke(Map.of()); // NullPointerException
```

Declaring `NullAgent` synchronous makes the same workflow complete normally.

**Expected behavior**

The async agent returning `null` behaves like the synchronous case: the workflow
completes and the null output is simply absent from the state.

**Please complete the following information:**
- LangChain4j version: 1.19.0-beta (current main)
- LLM(s) used: none (plain POJO agents)
- Java version: 17



Discussion:

Patch:
diff --git a/langchain4j-agentic/src/main/java/dev/langchain4j/agentic/scope/DefaultAgenticScope.java b/langchain4j-agentic/src/main/java/dev/langchain4j/agentic/scope/DefaultAgenticScope.java
index 7cb7ad7133e..666f774294c 100644
--- a/langchain4j-agentic/src/main/java/dev/langchain4j/agentic/scope/DefaultAgenticScope.java
+++ b/langchain4j-agentic/src/main/java/dev/langchain4j/agentic/scope/DefaultAgenticScope.java
@@ -9,8 +9,8 @@
 import dev.langchain4j.agentic.agent.ErrorContext;
 import dev.langchain4j.agentic.agent.ErrorRecoveryResult;
 import dev.langchain4j.agentic.declarative.TypedKey;
-import dev.langchain4j.agentic.internal.DelayedResponse;
 import dev.langchain4j.agentic.internal.DeferredResponse;
+import dev.langchain4j.agentic.internal.DelayedResponse;
 import dev.langchain4j.agentic.observability.AgentListener;
 import dev.langchain4j.agentic.planner.AgentInstance;
 import dev.langchain4j.data.message.AiMessage;
@@ -20,6 +20,7 @@
 import dev.langchain4j.memory.ChatMemory;
 import dev.langchain4j.service.TokenStream;
 import dev.langchain4j.service.memory.ChatMemoryAccess;
+import dev.langchain4j.service.tool.ToolExecution;
 import java.lang.reflect.Proxy;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -35,7 +36,6 @@
 import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
-import dev.langchain4j.service.tool.ToolExecution;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -53,7 +53,8 @@ public record AgentMessage(String agentName, String agentId, ChatMessage message
 
     private final transient Map<String, Object> agents = new ConcurrentHashMap<>();
     private final transient Map<String, Object> executionContexts = new ConcurrentHashMap<>();
-    private final transient List<CompensableExecution> compensableExecutions = Collections.synchronizedList(new ArrayList<>());
+    private final transient List<CompensableExecution> compensableExecutions =
+            Collections.synchronizedList(new ArrayList<>());
 
     private static final Function<ErrorContext, ErrorRecoveryResult> DEFAULT_ERROR_RECOVERY =
             errorContext -> ErrorRecoveryResult.throwException();
@@ -61,7 +62,8 @@ public record AgentMessage(String agentName, String agentId, ChatMessage message
     private transient Function<ErrorContext, ErrorRecoveryResult> errorHandler = DEFAULT_ERROR_RECOVERY;
 
     private static Predicate<Object> serializableStateFilter = Predicate.not(DefaultAgenticScope::isProxy)
-            .and(Predicate.not(DefaultAgenticScope::isTokenStream)).and(Predicate.not(DefaultAgenticScope::isFuture));
+            .and(Predicate.not(DefaultAgenticScope::isTokenStream))
+            .and(Predicate.not(DefaultAgenticScope::isFuture));
 
     private static boolean isProxy(Object obj) {
         return Proxy.isProxyClass(obj.getClass());
@@ -226,7 +228,11 @@ public void rootCallStarted(AgenticScopeRegistry registry) {}
 
     public void rootCallEnded(AgenticScopeRegistry registry, AgentListener agentListener) {
         // ensure that all pending async operations are completed before ending the root call
-        state.replaceAll(this::readStateBlocking);
+        Map.copyOf(state).forEach((key, value) -> {
+            if (value instanceof DelayedResponse<?> pending) {
+                writeState(key, pending.blockingGet());
+            }
+        });
 
         if (kind == Kind.EPHEMERAL) {
             // Ephemeral agenticScope are for single-use and can be evicted immediately
@@ -386,8 +392,11 @@ public void compensate() {
             try {
                 compensatingAction.accept(toolExecution());
             } catch (Exception e) {
-                LOG.warn("Cross-agent compensating action failed for tool '{}': {}",
-                        toolExecution().request().name(), e.getMessage(), e);
+                LOG.warn(
+                        "Cross-agent compensating action failed for tool '{}': {}",
+                        toolExecution().request().name(),
+                        e.getMessage(),
+                        e);
             }
         }
     }


Test patch:
diff --git a/langchain4j-agentic/src/test/java/dev/langchain4j/agentic/AsyncAgentNullReturnParityTest.java b/langchain4j-agentic/src/test/java/dev/langchain4j/agentic/AsyncAgentNullReturnParityTest.java
new file mode 100644
index 00000000000..708805510f3
--- /dev/null
+++ b/langchain4j-agentic/src/test/java/dev/langchain4j/agentic/AsyncAgentNullReturnParityTest.java
@@ -0,0 +1,50 @@
+package dev.langchain4j.agentic;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class AsyncAgentNullReturnParityTest {
+
+    public static class NullReturningSyncAgent {
+        @Agent(outputKey = "unread")
+        public String run() {
+            return null;
+        }
+    }
+
+    public static class NullReturningAsyncAgent {
+        @Agent(async = true, outputKey = "unread")
+        public String run() {
+            return null;
+        }
+    }
+
+    public static class ResultAgent {
+        @Agent(outputKey = "result")
+        public String run() {
+            return "done";
+        }
+    }
+
+    @Test
+    void sync_agent_returning_null_completes() {
+        UntypedAgent workflow = AgenticServices.sequenceBuilder()
+                .subAgents(new NullReturningSyncAgent(), new ResultAgent())
+                .outputKey("result")
+                .build();
+
+        assertThatCode(() -> workflow.invoke(Map.of())).doesNotThrowAnyException();
+    }
+
+    @Test
+    void async_agent_returning_null_completes_like_sync() {
+        UntypedAgent workflow = AgenticServices.sequenceBuilder()
+                .subAgents(new NullReturningAsyncAgent(), new ResultAgent())
+                .outputKey("result")
+                .build();
+
+        assertThatCode(() -> workflow.invoke(Map.of())).doesNotThrowAnyException();
+    }
+}
diff --git a/langchain4j-agentic/src/test/java/dev/langchain4j/agentic/scope/DefaultAgenticScopeRootCallEndedTest.java b/langchain4j-agentic/src/test/java/dev/langchain4j/agentic/scope/DefaultAgenticScopeRootCallEndedTest.java
new file mode 100644
index 00000000000..454f3461062
--- /dev/null
+++ b/langchain4j-agentic/src/test/java/dev/langchain4j/agentic/scope/DefaultAgenticScopeRootCallEndedTest.java
@@ -0,0 +1,24 @@
+package dev.langchain4j.agentic.scope;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+import dev.langchain4j.agentic.internal.AsyncResponse;
+import org.junit.jupiter.api.Test;
+
+class DefaultAgenticScopeRootCallEndedTest {
+
+    @Test
+    void resolves_async_state_returning_null_without_throwing() {
+        DefaultAgenticScope scope = new DefaultAgenticScope(DefaultAgenticScope.Kind.REGISTERED);
+        scope.writeState("nullOutput", new AsyncResponse<>(() -> null));
+        scope.writeState("realOutput", new AsyncResponse<>(() -> "value"));
+        scope.writeState("plainOutput", "kept");
+
+        assertThatCode(() -> scope.rootCallEnded(null, null)).doesNotThrowAnyException();
+
+        assertThat(scope.readState("nullOutput")).isNull();
+        assertThat(scope.readState("realOutput")).isEqualTo("value");
+        assertThat(scope.readState("plainOutput")).isEqualTo("kept");
+    }
+}