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
35 changes: 35 additions & 0 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,19 @@ private Mono<Msg> acting(int iter) {
buildSuspendedMsg(pendingPairs));
}

// Return directly: if any tool result has
// return_directly flag, breaking iterations
boolean hasReturnDirect =
successPairs.stream()
.anyMatch(
entry ->
entry.getValue()
.isReturnDirect());
if (hasReturnDirect) {
return Mono.just(
buildReturnDirectMsg(successPairs));
}

// Continue next iteration
return executeIteration(iter + 1);
});
Expand Down Expand Up @@ -753,6 +766,28 @@ private Msg buildSuspendedMsg(List<Map.Entry<ToolUseBlock, ToolResultBlock>> pen
.build();
}

/**
* Build a return_direct message from tool results.
*
* @param successPairs List of (ToolUseBlock, ToolResultBlock) pairs for successful results
* @return Msg with {@link GenerateReason#TOOL_RETURN_DIRECT}
*/
private Msg buildReturnDirectMsg(List<Map.Entry<ToolUseBlock, ToolResultBlock>> successPairs) {
List<ContentBlock> content = new ArrayList<>();
for (Map.Entry<ToolUseBlock, ToolResultBlock> pair : successPairs) {
content.addAll(pair.getValue().getOutput());
}
Msg returnDirectMsg =
Msg.builder()
.name(getName())
.role(MsgRole.ASSISTANT)
.content(content)
.generateReason(GenerateReason.TOOL_RETURN_DIRECT)
.build();
memory.addMessage(returnDirectMsg);
return returnDirectMsg;
}

/**
* Execute tool calls and return paired results.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public enum GenerateReason {
/** Tool execution was suspended, waiting for user to provide results. */
TOOL_SUSPENDED,

/** Tool result marked with return_directly, breaking ReAct iterations. */
TOOL_RETURN_DIRECT,

/** Reasoning phase was stopped by a Hook (PostReasoningEvent.stopAgent()). */
REASONING_STOP_REQUESTED,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,10 @@ private static double toDouble(Object value) {
* <p>This method helps users understand the context of agent execution:
* <ul>
* <li>{@link GenerateReason#MODEL_STOP} - Task completed normally</li>
* <li>{@link GenerateReason#TOOL_CALLS} - Model returned tool calls (internal tools)</li>
* <li>{@link GenerateReason#STRUCTURED_OUTPUT} - Structured output completed</li>
* <li>{@link GenerateReason#TOOL_SUSPENDED} - External tools need user execution</li>
* <li>{@link GenerateReason#TOOL_RETURN_DIRECT} - Tool result marked return_direct</li>
* <li>{@link GenerateReason#REASONING_STOP_REQUESTED} - HITL stop in reasoning phase</li>
* <li>{@link GenerateReason#ACTING_STOP_REQUESTED} - HITL stop in acting phase</li>
* <li>{@link GenerateReason#INTERRUPTED} - Agent was interrupted</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import io.agentscope.core.tool.ToolSuspendException;
import java.beans.Transient;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

Expand All @@ -37,6 +38,9 @@ public final class ToolResultBlock extends ContentBlock {
/** Metadata key indicating this result is suspended for external execution. */
public static final String METADATA_SUSPENDED = "agentscope_suspended";

/** Metadata key indicating this result should be returned directly, breaking ReAct iterations. */
public static final String METADATA_RETURN_DIRECT = "agentscope_return_direct";

private final String id;
private final String name;
private final List<ContentBlock> output;
Expand Down Expand Up @@ -126,6 +130,20 @@ public boolean isSuspended() {
return Boolean.TRUE.equals(metadata.get(METADATA_SUSPENDED));
}

/**
* Checks if this result should be returned directly, breaking ReAct iterations.
*
* <p>When true, the agent will return this tool result as the final response immediately
* without sending it back to the model for additional reasoning.
*
* @return true if this result should be returned directly
*/
@Transient
@JsonInclude
public boolean isReturnDirect() {
return Boolean.TRUE.equals(metadata.get(METADATA_RETURN_DIRECT));
}

/**
* Creates a suspended tool result from a ToolSuspendException.
*
Expand Down Expand Up @@ -289,6 +307,17 @@ public ToolResultBlock withIdAndName(String id, String name) {
return new ToolResultBlock(id, name, this.output, this.metadata);
}

/**
* Creates a new ToolResultBlock with the return_directly metadata flag set.
*
* @return A ToolResultBlock with agentscope_return_directly=true
*/
public ToolResultBlock withReturnDirect() {
Map<String, Object> newMetadata = new HashMap<>(this.metadata);
newMetadata.put(METADATA_RETURN_DIRECT, true);
return of(this.id, this.name, this.output, newMetadata);
}

/**
* Creates a new builder for constructing ToolResultBlock instances.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ default Map<String, Object> getOutputSchema() {
return null;
}

/**
* Whether this tool's result should be returned directly, breaking ReAct iterations.
*
* <p>When {@code true}, after this tool executes successfully, the agent returns the tool
* result as the final response without sending it back to the model for additional reasoning.
* This corresponds to the {@code returnDirect} attribute on the {@link Tool @Tool} annotation.
*
* @return true if this tool's result should be returned directly, false by default
*/
default boolean isReturnDirect() {
return false;
}

/**
* Execute the tool with the given parameters (asynchronous).
*
Expand Down
11 changes: 11 additions & 0 deletions agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@
*/
boolean strict() default false;

/**
* Whether this tool's result should be returned directly to the caller, breaking ReAct loop iterations.
*
* <p>When {@code true}, after this tool executes successfully, the agent will return the tool
* result as the final response immediately — without sending it back to the model for
* additional reasoning.
*
* @return true to enable return directly for this tool
*/
boolean returnDirect() default false;

/**
* Custom result converter for this tool.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@ private Mono<ToolResultBlock> executeCore(ToolCallParam param) {
.build();

return tool.callAsync(executionParam)
.map(
result -> {
if (tool.isReturnDirect()) {
return result.withReturnDirect();
}
return result;
})
.onErrorResume(
ToolSuspendException.class,
e -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,11 @@ public Boolean getStrict() {
return toolAnnotation.strict() ? Boolean.TRUE : null;
}

@Override
public boolean isReturnDirect() {
return toolAnnotation.returnDirect();
}

@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
// Pass custom converter to method invoker
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,170 @@ void testMultipleToolExecution() {
TestConstants.TEST_TOOL_NAME, history.get(1), "Test tool should be called second");
}

@Test
@DisplayName("Should return tool result directly when tool is marked as returnDirect")
void testToolCallingReturnDirect() {
// Register a returnDirect tool
mockToolkit.withReturnDirectTool(
TestConstants.RETURN_DIRECT_TOOL_NAME, "ReturnDirect tool result");

final int[] callCount = {0};
MockModel toolModel =
new MockModel(
messages -> {
int currentCall = callCount[0]++;
if (currentCall == 0) {
// Return tool call for the returnDirect tool
return List.of(
createToolCallResponseHelper(
TestConstants.RETURN_DIRECT_TOOL_NAME,
"tool_call_return_direct_1",
TestUtils.createToolArguments()));
}
// This should never be reached because returnDirect breaks the loop
return List.of(
ChatResponse.builder()
.content(
List.of(
TextBlock.builder()
.text("Should not reach this")
.build()))
.usage(new ChatUsage(10, 20, 30))
.build());
});

agent =
ReActAgent.builder()
.name(TestConstants.TEST_REACT_AGENT_NAME)
.sysPrompt(TestConstants.DEFAULT_SYS_PROMPT)
.model(toolModel)
.toolkit(mockToolkit)
.memory(memory)
.build();

Msg userMsg = TestUtils.createUserMessage("User", "Call the returnDirect tool");
Msg response =
agent.call(userMsg).block(Duration.ofMillis(TestConstants.DEFAULT_TEST_TIMEOUT_MS));

// Verify response
assertNotNull(response, "Response should not be null");
assertEquals(
GenerateReason.TOOL_RETURN_DIRECT,
response.getGenerateReason(),
"GenerateReason should be TOOL_RETURN_DIRECT");

// Verify the response contains the tool result content
String text = TestUtils.extractTextContent(response);
assertTrue(
text.contains("ReturnDirect tool result"),
"Response should contain the return direct tool result");

// Verify the model was called only once (no second reasoning iteration)
assertEquals(1, toolModel.getCallCount(), "Model should be called only once");

// Verify tool was called
assertTrue(
mockToolkit.wasToolCalled(TestConstants.RETURN_DIRECT_TOOL_NAME),
"ReturnDirect tool should be called");
}

@Test
@DisplayName(
"Should return multiple tool results directly when a tool is marked as returnDirect")
void testMultipleToolCallingReturnDirect() {
mockToolkit.withTool(TestConstants.TEST_TOOL_NAME, "Test tool result");
mockToolkit.withReturnDirectTool(
TestConstants.RETURN_DIRECT_TOOL_NAME, "ReturnDirect tool result");

final int[] callCount = {0};
MockModel toolModel =
new MockModel(
messages -> {
int currentCall = callCount[0]++;
if (currentCall == 0) {
Map<String, Object> args1 = new HashMap<>();
args1.put("param1", "value1");
Map<String, Object> args2 = new HashMap<>();
args2.put("param2", "value2");
return List.of(
ChatResponse.builder()
.content(
List.of(
ToolUseBlock.builder()
.id("tool_call_1")
.name(
TestConstants
.TEST_TOOL_NAME)
.input(args1)
.content(
JsonUtils
.getJsonCodec()
.toJson(
args1))
.build(),
ToolUseBlock.builder()
.id(
"tool_call_return_direct_1")
.name(
TestConstants
.RETURN_DIRECT_TOOL_NAME)
.input(args2)
.content(
JsonUtils
.getJsonCodec()
.toJson(
args2))
.build()))
.usage(new ChatUsage(10, 20, 30))
.build());
}
return List.of(
ChatResponse.builder()
.content(
List.of(
TextBlock.builder()
.text("Should not reach this")
.build()))
.usage(new ChatUsage(10, 20, 30))
.build());
});

agent =
ReActAgent.builder()
.name(TestConstants.TEST_REACT_AGENT_NAME)
.sysPrompt(TestConstants.DEFAULT_SYS_PROMPT)
.model(toolModel)
.toolkit(mockToolkit)
.memory(memory)
.build();

Msg userMsg = TestUtils.createUserMessage("User", "Call multiple returnDirect tools");
Msg response =
agent.call(userMsg).block(Duration.ofMillis(TestConstants.DEFAULT_TEST_TIMEOUT_MS));

assertNotNull(response, "Response should not be null");
assertEquals(
GenerateReason.TOOL_RETURN_DIRECT,
response.getGenerateReason(),
"GenerateReason should be TOOL_RETURN_DIRECT");

String text = TestUtils.extractTextContent(response);
assertTrue(text.contains("Test tool result"), "Response should contain test tool result");
assertTrue(
text.contains("ReturnDirect tool result"),
"Response should contain return direct tool result");

assertEquals(1, toolModel.getCallCount(), "Model should be called only once");

assertTrue(
mockToolkit.wasToolCalled(TestConstants.TEST_TOOL_NAME),
"Test tool should be called");
assertTrue(
mockToolkit.wasToolCalled(TestConstants.RETURN_DIRECT_TOOL_NAME),
"ReturnDirect tool should be called");
assertEquals(2, mockToolkit.getCallCount(), "Two tools should be called");
}

@Test
@DisplayName("Should handle tool execution errors in acting phase")
void testToolExecutionError() {
Expand Down
Loading
Loading