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 d65968f0e..85f850261 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -724,6 +724,19 @@ private Mono 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); }); @@ -753,6 +766,28 @@ private Msg buildSuspendedMsg(List> 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> successPairs) { + List content = new ArrayList<>(); + for (Map.Entry 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. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/GenerateReason.java b/agentscope-core/src/main/java/io/agentscope/core/message/GenerateReason.java index 564a2b13d..d160327df 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/GenerateReason.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/GenerateReason.java @@ -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, diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java b/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java index f5c7e291c..869bf233b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/Msg.java @@ -453,7 +453,10 @@ private static double toDouble(Object value) { *

This method helps users understand the context of agent execution: *

    *
  • {@link GenerateReason#MODEL_STOP} - Task completed normally
  • + *
  • {@link GenerateReason#TOOL_CALLS} - Model returned tool calls (internal tools)
  • + *
  • {@link GenerateReason#STRUCTURED_OUTPUT} - Structured output completed
  • *
  • {@link GenerateReason#TOOL_SUSPENDED} - External tools need user execution
  • + *
  • {@link GenerateReason#TOOL_RETURN_DIRECT} - Tool result marked return_direct
  • *
  • {@link GenerateReason#REASONING_STOP_REQUESTED} - HITL stop in reasoning phase
  • *
  • {@link GenerateReason#ACTING_STOP_REQUESTED} - HITL stop in acting phase
  • *
  • {@link GenerateReason#INTERRUPTED} - Agent was interrupted
  • diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java index f1caf26c3..0b1fde887 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java @@ -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; @@ -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 output; @@ -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. + * + *

    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. * @@ -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 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. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java index f4dfa1b15..70450d5b2 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java @@ -100,6 +100,19 @@ default Map getOutputSchema() { return null; } + /** + * Whether this tool's result should be returned directly, breaking ReAct iterations. + * + *

    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). * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java index 25584abd5..3146b2c6a 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java @@ -96,6 +96,17 @@ */ boolean strict() default false; + /** + * Whether this tool's result should be returned directly to the caller, breaking ReAct loop iterations. + * + *

    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. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java index 5ba4af8f1..34cb06c6c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java @@ -242,6 +242,13 @@ private Mono executeCore(ToolCallParam param) { .build(); return tool.callAsync(executionParam) + .map( + result -> { + if (tool.isReturnDirect()) { + return result.withReturnDirect(); + } + return result; + }) .onErrorResume( ToolSuspendException.class, e -> { diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java index 88a890636..9a081e2e4 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java @@ -381,6 +381,11 @@ public Boolean getStrict() { return toolAnnotation.strict() ? Boolean.TRUE : null; } + @Override + public boolean isReturnDirect() { + return toolAnnotation.returnDirect(); + } + @Override public Mono callAsync(ToolCallParam param) { // Pass custom converter to method invoker diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentTest.java index 0e66b171b..7c346ceef 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentTest.java @@ -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 args1 = new HashMap<>(); + args1.put("param1", "value1"); + Map 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() { diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/test/MockToolkit.java b/agentscope-core/src/test/java/io/agentscope/core/agent/test/MockToolkit.java index 8648d922e..17dee3945 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/test/MockToolkit.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/test/MockToolkit.java @@ -64,6 +64,15 @@ public MockToolkit withTool(String toolName, Function, Strin return this; } + /** + * Register a tool that returns its result directly (breaking ReAct loop). + */ + public MockToolkit withReturnDirectTool(String toolName, String result) { + toolBehaviors.put(toolName, args -> result); + registerMockTool(toolName, "Mock returnDirect tool: " + toolName, true); + return this; + } + /** * Register a tool that throws an error. */ @@ -117,6 +126,13 @@ private void registerDefaultTools() { * Register a mock tool with the toolkit. */ private void registerMockTool(String name, String description) { + registerMockTool(name, description, false); + } + + /** + * Register a mock tool with the toolkit, optionally marking it as returnDirect. + */ + private void registerMockTool(String name, String description, boolean returnDirect) { AgentTool tool = new AgentTool() { @Override @@ -137,6 +153,11 @@ public Map getParameters() { return schema; } + @Override + public boolean isReturnDirect() { + return returnDirect; + } + @Override public Mono callAsync(ToolCallParam param) { return Mono.fromCallable( diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/test/TestConstants.java b/agentscope-core/src/test/java/io/agentscope/core/agent/test/TestConstants.java index 48802ac46..aa11d031b 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/test/TestConstants.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/test/TestConstants.java @@ -42,6 +42,7 @@ public class TestConstants { public static final String TEST_TOOL_NAME = "test_tool"; public static final String CALCULATOR_TOOL_NAME = "calculator"; public static final String FINISH_TOOL_NAME = "generate_response"; + public static final String RETURN_DIRECT_TOOL_NAME = "return_direct_tool"; // Timeouts public static final long DEFAULT_TEST_TIMEOUT_MS = 5000L; diff --git a/agentscope-core/src/test/java/io/agentscope/core/message/GenerateReasonTest.java b/agentscope-core/src/test/java/io/agentscope/core/message/GenerateReasonTest.java index fa14369d1..6d00589a1 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/message/GenerateReasonTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/message/GenerateReasonTest.java @@ -35,13 +35,14 @@ class GenerateReasonTest { @DisplayName("Should have all expected enum values") void testEnumValues() { GenerateReason[] values = GenerateReason.values(); - assertEquals(8, values.length); + assertEquals(9, values.length); // Verify all expected values exist assertNotNull(GenerateReason.MODEL_STOP); assertNotNull(GenerateReason.TOOL_CALLS); assertNotNull(GenerateReason.STRUCTURED_OUTPUT); assertNotNull(GenerateReason.TOOL_SUSPENDED); + assertNotNull(GenerateReason.TOOL_RETURN_DIRECT); assertNotNull(GenerateReason.REASONING_STOP_REQUESTED); assertNotNull(GenerateReason.ACTING_STOP_REQUESTED); assertNotNull(GenerateReason.INTERRUPTED);