diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 2fd0eef59a..92b1687cab 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -2306,6 +2306,7 @@ private Mono reasoning(int iter, boolean ignoreMaxIters) { } ReasoningContext context = new ReasoningContext(getName()); + AtomicBoolean retryEmptyResponse = new AtomicBoolean(false); return checkInterrupted() .then( @@ -2380,7 +2381,10 @@ private Mono reasoning(int iter, boolean ignoreMaxIters) { context.buildFinalMessage(); RequestStopEvent rs = stopRequested.get(); - if (rs != null && finalMsg != null) { + if (rs != null) { + if (finalMsg == null) { + return Mono.empty(); + } // Persist the reasoning message // before // returning so the next call can @@ -2393,7 +2397,11 @@ private Mono reasoning(int iter, boolean ignoreMaxIters) { rs .getGenerateReason())); } - return Mono.justOrEmpty(finalMsg); + if (finalMsg == null) { + retryEmptyResponse.set(true); + return Mono.empty(); + } + return Mono.just(finalMsg); })); }) .onErrorResume( @@ -2427,7 +2435,15 @@ private Mono reasoning(int iter, boolean ignoreMaxIters) { return Mono.just(msg); } return runPostReasoningPipeline(msg, iter); - }); + }) + .switchIfEmpty( + Mono.defer( + () -> { + if (retryEmptyResponse.get()) { + return executeIteration(iter + 1); + } + return Mono.empty(); + })); } @SuppressWarnings("deprecation") @@ -2460,6 +2476,12 @@ private Mono runPostReasoningPipeline(Msg msg, int iter) { return reasoning(iter + 1, true); } + // Continue the bounded ReAct loop when the model produced only + // blank text. The message has already been persisted above. + if (hasBlankTextResponse(eventMsg)) { + return executeIteration(iter + 1); + } + // Check finish conditions if (isFinished(eventMsg)) { return Mono.justOrEmpty(eventMsg); @@ -3778,6 +3800,24 @@ private static Msg buildEmptyResponseReminder() { .build(); } + /** + * Check whether a model response contains only blank text and no tool calls. + * + * @param msg The reasoning message + * @return true if the response contains one or more blank text blocks + */ + private boolean hasBlankTextResponse(Msg msg) { + if (msg == null || !msg.getContentBlocks(ToolUseBlock.class).isEmpty()) { + return false; + } + + List textBlocks = msg.getContentBlocks(TextBlock.class); + return !textBlocks.isEmpty() + && textBlocks.stream() + .map(TextBlock::getText) + .noneMatch(text -> !text.isBlank()); + } + /** * Check whether every tool call in the given list has a DENIED result in context. */ diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopE2ETest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopE2ETest.java index 4ac86e753d..f0107ddfd9 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopE2ETest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopE2ETest.java @@ -24,19 +24,25 @@ import io.agentscope.core.event.AgentEvent; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.ModelCallEndEvent; +import io.agentscope.core.event.RequestStopEvent; import io.agentscope.core.event.ToolCallEndEvent; import io.agentscope.core.event.ToolResultEndEvent; import io.agentscope.core.event.ToolResultTextDeltaEvent; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostReasoningEvent; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolResultState; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.middleware.ActingInput; import io.agentscope.core.middleware.AgentInput; import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.middleware.ReasoningInput; import io.agentscope.core.model.ChatModelBase; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.GenerateOptions; @@ -324,6 +330,229 @@ void toolReturningErrorBlockEmitsErrorResultEndState() { assertEquals(ToolResultState.ERROR, end.getState()); } + @Test + void retriesAfterModelStreamCompletesWithoutAResponse() { + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.empty(), () -> Flux.just(textResponse("recovered")))); + ReActAgent agent = + ReActAgent.builder().name("asst").sysPrompt("you are helpful").model(model).build(); + + List events = + agent.streamEvents( + List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("continue after an empty response") + .build())) + .collectList() + .block(); + + assertNotNull(events); + assertEquals(2, model.calls.get()); + assertTrue( + agent.getAgentState().getContext().stream() + .flatMap(message -> message.getContentBlocks(TextBlock.class).stream()) + .anyMatch(text -> "recovered".equals(text.getText()))); + } + + @Test + void retriesAfterModelReturnsOnlyBlankText() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> Flux.just(textResponse(" ")), + () -> Flux.just(textResponse("recovered")))); + ReActAgent agent = + ReActAgent.builder().name("asst").sysPrompt("you are helpful").model(model).build(); + + List events = + agent.streamEvents( + List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("continue after a blank response") + .build())) + .collectList() + .block(); + + assertNotNull(events); + assertEquals(2, model.calls.get()); + assertTrue( + agent.getAgentState().getContext().stream() + .flatMap(message -> message.getContentBlocks(TextBlock.class).stream()) + .anyMatch(text -> "recovered".equals(text.getText()))); + } + + @Test + void stopsWithoutRetryWhenMiddlewareStopsAnEmptyResponse() { + ScriptedModel model = new ScriptedModel(List.of(Flux::empty)); + MiddlewareBase stoppingMiddleware = + new MiddlewareBase() { + @Override + public Flux onReasoning( + Agent agent, + RuntimeContext ctx, + ReasoningInput input, + Function> next) { + return Flux.concat( + next.apply(input), Flux.just(new RequestStopEvent("test stop"))); + } + }; + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .sysPrompt("you are helpful") + .model(model) + .middleware(stoppingMiddleware) + .build(); + + List events = + agent.streamEvents( + List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("stop the empty response") + .build())) + .collectList() + .block(); + + assertNotNull(events); + assertEquals(1, model.calls.get()); + assertTrue(events.stream().anyMatch(RequestStopEvent.class::isInstance)); + } + + @Test + void retriesAfterModelReturnsEmptyText() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> Flux.just(textResponse("")), + () -> Flux.just(textResponse("recovered")))); + ReActAgent agent = + ReActAgent.builder().name("asst").sysPrompt("you are helpful").model(model).build(); + + agent.streamEvents( + List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("continue after a null text response") + .build())) + .collectList() + .block(); + + assertEquals(2, model.calls.get()); + assertTrue( + agent.getAgentState().getContext().stream() + .flatMap(message -> message.getContentBlocks(TextBlock.class).stream()) + .anyMatch(text -> "recovered".equals(text.getText()))); + } + + @Test + void preservesModelResponseWhenMiddlewareRequestsStop() { + ScriptedModel model = new ScriptedModel(List.of(() -> Flux.just(textResponse("paused")))); + MiddlewareBase stoppingMiddleware = + new MiddlewareBase() { + @Override + public Flux onReasoning( + Agent agent, + RuntimeContext ctx, + ReasoningInput input, + Function> next) { + return Flux.concat( + next.apply(input), Flux.just(new RequestStopEvent("test stop"))); + } + }; + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .sysPrompt("you are helpful") + .model(model) + .middleware(stoppingMiddleware) + .build(); + + List events = + agent.streamEvents( + List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("stop with a response") + .build())) + .collectList() + .block(); + + assertNotNull(events); + assertEquals(1, model.calls.get()); + assertTrue(events.stream().anyMatch(RequestStopEvent.class::isInstance)); + assertTrue( + agent.getAgentState().getContext().stream() + .flatMap(message -> message.getContentBlocks(TextBlock.class).stream()) + .anyMatch(text -> "paused".equals(text.getText()))); + } + + @Test + void retriesAfterModelReturnsOnlyThinking() { + ChatResponse thinkingResponse = + ChatResponse.builder() + .content( + List.of( + ThinkingBlock.builder() + .thinking("internal thought") + .build())) + .build(); + ScriptedModel model = new ScriptedModel(List.of(() -> Flux.just(thinkingResponse))); + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .sysPrompt("you are helpful") + .model(model) + .maxIters(2) + .build(); + + List events = + agent.streamEvents( + List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("continue after a thinking response") + .build())) + .collectList() + .block(); + + assertNotNull(events); + // Main treats a thinking-only response as an empty final response. Two reasoning attempts + // are followed by one bounded summary call instead of silently completing with no reply. + assertEquals(3, model.calls.get()); + } + + @Test + void doesNotRetryWhenPostReasoningHookDiscardsTheMessage() { + ScriptedModel model = + new ScriptedModel(List.of(() -> Flux.just(textResponse("discarded")))); + Hook discardingHook = + new Hook() { + @Override + public Mono onEvent(T event) { + if (event instanceof PostReasoningEvent postReasoningEvent) { + postReasoningEvent.setReasoningMessage(null); + } + return Mono.just(event); + } + }; + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .sysPrompt("you are helpful") + .model(model) + .hook(discardingHook) + .build(); + + List events = agent.streamEvents(List.of()).collectList().block(); + + assertNotNull(events); + assertEquals(1, model.calls.get()); + } + @Test void toolResultMetadataPropagatesToEvents() { ScriptedModel model = diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java index 5179068c0f..b78f6be50e 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java @@ -37,6 +37,9 @@ import io.agentscope.core.event.ToolCallStartEvent; import io.agentscope.core.event.ToolResultEndEvent; import io.agentscope.core.event.ToolResultStartEvent; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostReasoningEvent; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.GenerateReason; import io.agentscope.core.message.Msg; @@ -96,6 +99,10 @@ protected Flux doStream( } return scripts.get(i).get(); } + + int calls() { + return idx.get(); + } } private static ChatResponse textResponse(String text) { @@ -212,6 +219,92 @@ void callResolvesToFinalAssistantMsg() { "user + assistant expected in state"); } + @Test + void emptyModelStreamContinuesToNextIteration() { + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.empty(), () -> Flux.just(textResponse("answer")))); + ReActAgent agent = + ReActAgent.builder().name("asst").model(model).toolkit(new Toolkit()).build(); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals("answer", result.getContentBlocks(TextBlock.class).get(0).getText()); + assertEquals(2, model.calls()); + } + + @Test + void emptyModelStreamDoesNotRepeatPostReasoningForRecoveredResponse() { + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.empty(), () -> Flux.just(textResponse("answer")))); + AtomicInteger postReasoningCalls = new AtomicInteger(); + Hook hook = + new Hook() { + @Override + public Mono onEvent(T event) { + if (event instanceof PostReasoningEvent) { + postReasoningCalls.incrementAndGet(); + } + return Mono.just(event); + } + }; + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(model) + .toolkit(new Toolkit()) + .hook(hook) + .build(); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals("answer", result.getContentBlocks(TextBlock.class).get(0).getText()); + assertEquals(1, postReasoningCalls.get()); + } + + @Test + void emptyTextResponseContinuesToNextIteration() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> Flux.just(textResponse("")), + () -> Flux.just(textResponse("answer")))); + ReActAgent agent = + ReActAgent.builder().name("asst").model(model).toolkit(new Toolkit()).build(); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals("answer", result.getContentBlocks(TextBlock.class).get(0).getText()); + assertEquals(2, model.calls()); + } + + @Test + void emptyResponsesReachMaxIterationsAndSummarize() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> Flux.empty(), + () -> Flux.empty(), + () -> Flux.just(textResponse("summary")))); + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(model) + .toolkit(new Toolkit()) + .maxIters(2) + .build(); + + List events = agent.streamEvents(List.of()).collectList().block(); + + assertNotNull(events); + assertTrue(indexOf(events, ExceedMaxItersEvent.class) >= 0); + assertEquals(3, model.calls()); + } + @Test void toolCallIterationThenTextTerminates() { ChatModelBase model =