Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -2306,6 +2306,7 @@ private Mono<Msg> reasoning(int iter, boolean ignoreMaxIters) {
}

ReasoningContext context = new ReasoningContext(getName());
AtomicBoolean retryEmptyResponse = new AtomicBoolean(false);

return checkInterrupted()
.then(
Expand Down Expand Up @@ -2380,7 +2381,10 @@ private Mono<Msg> 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
Expand All @@ -2393,7 +2397,11 @@ private Mono<Msg> reasoning(int iter, boolean ignoreMaxIters) {
rs
.getGenerateReason()));
}
return Mono.justOrEmpty(finalMsg);
if (finalMsg == null) {
retryEmptyResponse.set(true);
return Mono.empty();
}
return Mono.just(finalMsg);
Comment thread
Zbhbb marked this conversation as resolved.
}));
})
.onErrorResume(
Expand Down Expand Up @@ -2427,7 +2435,15 @@ private Mono<Msg> reasoning(int iter, boolean ignoreMaxIters) {
return Mono.just(msg);
}
return runPostReasoningPipeline(msg, iter);
});
})
.switchIfEmpty(
Mono.defer(
Comment thread
Zbhbb marked this conversation as resolved.
Comment thread
Zbhbb marked this conversation as resolved.
Comment thread
Zbhbb marked this conversation as resolved.
() -> {
if (retryEmptyResponse.get()) {
return executeIteration(iter + 1);
}
return Mono.empty();
}));
}

@SuppressWarnings("deprecation")
Expand Down Expand Up @@ -2460,6 +2476,12 @@ private Mono<Msg> 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)) {
Comment thread
Zbhbb marked this conversation as resolved.
Comment thread
Zbhbb marked this conversation as resolved.
Comment thread
Zbhbb marked this conversation as resolved.
return executeIteration(iter + 1);
}

// Check finish conditions
if (isFinished(eventMsg)) {
return Mono.justOrEmpty(eventMsg);
Expand Down Expand Up @@ -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) {
Comment thread
Zbhbb marked this conversation as resolved.
Comment thread
Zbhbb marked this conversation as resolved.
Comment thread
Zbhbb marked this conversation as resolved.
if (msg == null || !msg.getContentBlocks(ToolUseBlock.class).isEmpty()) {
return false;
}

List<TextBlock> 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AgentEvent> 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<AgentEvent> 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<AgentEvent> onReasoning(
Agent agent,
RuntimeContext ctx,
ReasoningInput input,
Function<ReasoningInput, Flux<AgentEvent>> 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<AgentEvent> 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<AgentEvent> onReasoning(
Agent agent,
RuntimeContext ctx,
ReasoningInput input,
Function<ReasoningInput, Flux<AgentEvent>> 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<AgentEvent> 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.<ContentBlock>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<AgentEvent> 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 <T extends HookEvent> Mono<T> 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<AgentEvent> events = agent.streamEvents(List.of()).collectList().block();

assertNotNull(events);
assertEquals(1, model.calls.get());
}

@Test
void toolResultMetadataPropagatesToEvents() {
ScriptedModel model =
Expand Down
Loading
Loading