From d5a76b7121f2a6eb4f9be95ac5d67633dd763cef Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Sat, 29 Aug 2026 19:59:45 +0800 Subject: [PATCH] feat(core): support returnDirect tool result short-circuit --- .../java/io/agentscope/core/ReActAgent.java | 103 ++- .../core/message/GenerateReason.java | 5 +- .../core/message/MessageMetadataKeys.java | 8 + .../java/io/agentscope/core/message/Msg.java | 1 + .../io/agentscope/core/tool/AgentTool.java | 8 + .../core/tool/ReflectiveFunctionTool.java | 3 +- .../java/io/agentscope/core/tool/Tool.java | 12 + .../io/agentscope/core/tool/ToolBase.java | 48 +- .../agent/ReActAgentReturnDirectTest.java | 600 ++++++++++++++++++ .../core/message/GenerateReasonTest.java | 4 +- 10 files changed, 786 insertions(+), 6 deletions(-) create mode 100644 agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentReturnDirectTest.java 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 f2b532d1b8..6e03bd8c03 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -1692,6 +1692,11 @@ final class CallExecution { /** The tool result message from the successful {@code generate_response} call. */ Msg soResultMsg; + /** Placeholder sentence written to the tool_result of a returnDirect tool. */ + private static final String RETURN_DIRECT_PLACEHOLDER = + "Tool call completed. The result has been presented to the user as the final output" + + " of this turn."; + /** Native structured-output format set on the per-call scope for native-path calls. */ ResponseFormat nativeResponseFormat; @@ -2785,8 +2790,14 @@ private Mono acting(int iter) { return executeIteration(iter + 1); } + boolean returnDirect = + pendingPairs.isEmpty() + && !successPairs.isEmpty() + && successPairs.stream() + .allMatch(this::isReturnDirectToolCall); + return Flux.fromIterable(successPairs) - .concatMap(this::notifyPostActingHook) + .concatMap(e -> notifyPostActingHook(e, returnDirect)) .last() .flatMap( event -> { @@ -2798,6 +2809,15 @@ private Mono acting(int iter) { .ACTING_STOP_REQUESTED)); } + if (returnDirect) { + Msg result = + buildReturnDirectResultMsg( + successPairs); + state.contextMutable().add(result); + logReturnDirect(successPairs); + return Mono.just(result); + } + if (!pendingPairs.isEmpty()) { return Mono.just( buildSuspendedMsg(pendingPairs)); @@ -3461,9 +3481,14 @@ private Mono executeStructuredTool(ToolUseBlock use) { /** * Fire PostActingEvent for a single tool result, build message and add to context. + * + *

When {@code returnDirect} is {@code true} (the whole batch is being returned + * directly) and the hook did not stop, the tool result written to context is replaced + * with the placeholder sentence; the full result is kept on the hook event for + * auditing and later lifted into the closing assistant message. */ private Mono notifyPostActingHook( - Map.Entry entry) { + Map.Entry entry, boolean returnDirect) { ToolUseBlock toolUse = entry.getKey(); ToolResultBlock result = entry.getValue(); @@ -3491,10 +3516,84 @@ private Mono notifyPostActingHook( e.stopAgent(); } Msg resultMsg = e.getToolResultMsg(); + if (returnDirect && !e.isStopRequested()) { + resultMsg = + buildReturnDirectPlaceholderMsg(toolUse, updatedResult); + log.debug( + "returnDirect: replaced tool result with placeholder" + + " for tool '{}'", + toolUse.getName()); + } state.contextMutable().add(resultMsg); }); } + /** + * Whether a single tool result may participate in the returnDirect short-circuit: the tool + * declared {@code returnDirect = true} and its result actually executed successfully. + * DENIED / ERROR / INTERRUPTED results must never be presented as the final answer. + */ + private boolean isReturnDirectToolCall(Map.Entry entry) { + AgentTool tool = toolkit.getTool(entry.getKey().getName()); + if (tool == null || !tool.isReturnDirect()) { + return false; + } + return determineToolResultState(entry.getValue()) == ToolResultState.SUCCESS; + } + + /** Builds the placeholder tool_result, replacing only the output while keeping id/name/state. */ + private Msg buildReturnDirectPlaceholderMsg(ToolUseBlock toolUse, ToolResultBlock result) { + ToolResultBlock placeholder = + ToolResultBlock.builder() + .id(toolUse.getId()) + .name(toolUse.getName()) + .output(TextBlock.builder().text(RETURN_DIRECT_PLACEHOLDER).build()) + .state(result.getState()) + .build(); + return ToolResultMessageBuilder.buildToolResultMsg(placeholder, toolUse, getName()); + } + + /** + * Logs the returnDirect short-circuit: {@code info} marks that the tool result(s) were + * returned directly as the final answer, {@code debug} adds the tool count and names. + */ + private void logReturnDirect(List> pairs) { + List names = pairs.stream().map(e -> e.getKey().getName()).toList(); + log.info( + "returnDirect: returning tool result(s) for {} directly as the final answer", + String.join(", ", names)); + log.debug("returnDirect: {} tool result(s) from tools {}", pairs.size(), names); + } + + /** + * Synthesises the closing assistant message carrying the full tool result, mimicking the + * final answer the model would otherwise have produced. + * + *

For multiple tools the output blocks are concatenated in execution order (= {@code + * pairs} order = the model's tool_calls order) without inlining tool names or adding + * separator blocks; clients correlate blocks to tools via block order + the event stream's + * tool id/name. A tool with empty output contributes a {@code "(no output)"} text block so + * ordering never shifts and the closing message is never empty. + */ + private Msg buildReturnDirectResultMsg( + List> pairs) { + List content = new ArrayList<>(); + for (Map.Entry pair : pairs) { + List output = pair.getValue().getOutput(); + if (output.isEmpty()) { + content.add(TextBlock.builder().text("(no output)").build()); + } else { + content.addAll(output); + } + } + return AssistantMessage.builder() + .name(getName()) + .content(content) + .metadata(Map.of(MessageMetadataKeys.TOOL_RETURN_DIRECT, true)) + .generateReason(GenerateReason.TOOL_RETURN_DIRECT) + .build(); + } + /** * Generate summary when max iterations reached. */ 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 8d15df4d53..cdcfcb485c 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 @@ -82,5 +82,8 @@ public enum GenerateReason { INTERRUPTED, /** Maximum iterations reached. */ - MAX_ITERATIONS + MAX_ITERATIONS, + + /** Tool result returned directly to the caller without a follow-up model call. */ + TOOL_RETURN_DIRECT } diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/MessageMetadataKeys.java b/agentscope-core/src/main/java/io/agentscope/core/message/MessageMetadataKeys.java index 55243785ad..d65d1a6cab 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/MessageMetadataKeys.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/MessageMetadataKeys.java @@ -133,4 +133,12 @@ private MessageMetadataKeys() { * } */ public static final String CACHE_CONTROL = "_cache_control"; + + /** + * Metadata key marking an assistant message synthesized by the {@code returnDirect} + * tool path (the tool result presented as the turn's final answer). + * + *

Type: Boolean + */ + public static final String TOOL_RETURN_DIRECT = "_tool_return_direct"; } 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 82df1e01cf..3642701ee8 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 @@ -623,6 +623,7 @@ private static double toDouble(Object value) { *

  • {@link GenerateReason#ACTING_STOP_REQUESTED} - HITL stop in acting phase
  • *
  • {@link GenerateReason#INTERRUPTED} - Agent was interrupted
  • *
  • {@link GenerateReason#MAX_ITERATIONS} - Maximum iterations reached
  • + *
  • {@link GenerateReason#TOOL_RETURN_DIRECT} - Tool result returned directly to the caller
  • * * * @return The generate reason, defaults to {@link GenerateReason#MODEL_STOP} if not set 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 ba2d9014e5..110008df30 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 @@ -112,6 +112,14 @@ default boolean isReadOnly() { return false; } + /** + * Whether this tool's result should be returned directly to the caller, + * skipping the next reasoning iteration. Defaults to {@code false}. + */ + 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/ReflectiveFunctionTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ReflectiveFunctionTool.java index c92ef950df..4dc89740c6 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ReflectiveFunctionTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ReflectiveFunctionTool.java @@ -137,7 +137,8 @@ static ReflectiveFunctionTool create( .readOnly(annotation.readOnly()) .concurrencySafe(annotation.concurrencySafe()) .externalTool(annotation.externalTool()) - .stateInjected(annotation.stateInjected()); + .stateInjected(annotation.stateInjected()) + .returnDirect(annotation.returnDirect()); if (annotation.dangerousFiles().length > 0) { builder.dangerousFiles(List.of(annotation.dangerousFiles())); } 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 010617cee5..84e863aa1b 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 @@ -137,6 +137,18 @@ */ boolean stateInjected() default false; + /** + * Whether to return this tool's result directly to the caller as the turn's final + * assistant message, bypassing the next model reasoning iteration. + * + *

    In a batch, this takes effect only when every executed tool in the + * round has {@code returnDirect = true} and the round yields only + * {@code SUCCESS} results; otherwise all results are fed back to the model. + * + * @return true to short-circuit the ReAct loop after execution + */ + boolean returnDirect() default false; + /** * Sensitive filenames that must require explicit permission for this tool. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java index 72130582e6..ed4f744c16 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java @@ -68,6 +68,7 @@ public abstract class ToolBase implements AgentTool { private final boolean stateInjected; private final boolean mcp; private final String mcpName; + private final boolean returnDirect; /** Sensitive files; subclasses may replace this list to widen or narrow protection. */ protected List dangerousFiles = ToolDangerousPathConstants.DEFAULT_DANGEROUS_FILES; @@ -87,7 +88,8 @@ protected ToolBase(Builder builder) { builder.mcp, builder.mcpName, builder.externalTool, - builder.stateInjected); + builder.stateInjected, + builder.returnDirect); if (builder.dangerousFiles != null) { this.dangerousFiles = List.copyOf(builder.dangerousFiles); } @@ -110,6 +112,30 @@ protected ToolBase( String mcpName, boolean externalTool, boolean stateInjected) { + this( + name, + description, + inputSchema, + readOnly, + concurrencySafe, + mcp, + mcpName, + externalTool, + stateInjected, + false); + } + + private ToolBase( + String name, + String description, + Map inputSchema, + boolean readOnly, + boolean concurrencySafe, + boolean mcp, + String mcpName, + boolean externalTool, + boolean stateInjected, + boolean returnDirect) { this.name = Objects.requireNonNull(name, "name must not be null"); this.description = Objects.requireNonNull(description, "description must not be null"); this.inputSchema = Objects.requireNonNull(inputSchema, "inputSchema must not be null"); @@ -119,6 +145,7 @@ protected ToolBase( this.mcpName = mcpName; this.externalTool = externalTool; this.stateInjected = stateInjected; + this.returnDirect = returnDirect; if (mcp && (mcpName == null || mcpName.isBlank())) { throw new IllegalArgumentException("mcpName is required when mcp is true"); } @@ -148,6 +175,18 @@ public final boolean isReadOnly() { return readOnly; } + /** + * Whether this tool's result should short-circuit the ReAct loop and be returned directly + * to the caller as the turn's final answer, instead of being fed back to the model for + * another reasoning iteration. Defaults to {@code false}. + * + * @return {@code true} to return the result directly and skip the follow-up model call + */ + @Override + public final boolean isReturnDirect() { + return returnDirect; + } + public final boolean isExternalTool() { return externalTool; } @@ -280,6 +319,7 @@ public static final class Builder { private boolean concurrencySafe = true; private boolean externalTool = false; private boolean stateInjected = false; + private boolean returnDirect = false; private boolean mcp = false; private String mcpName; private List dangerousFiles; @@ -322,6 +362,12 @@ public Builder stateInjected(boolean stateInjected) { return this; } + /** Sets whether this tool's result should be returned directly to the caller. */ + public Builder returnDirect(boolean returnDirect) { + this.returnDirect = returnDirect; + return this; + } + /** Marks the tool as an MCP tool and records the MCP server name. */ public Builder mcp(String mcpName) { this.mcp = true; diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentReturnDirectTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentReturnDirectTest.java new file mode 100644 index 0000000000..0d0b71ee31 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentReturnDirectTest.java @@ -0,0 +1,600 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostActingEvent; +import io.agentscope.core.message.DataBlock; +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.MessageMetadataKeys; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolResultState; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.message.URLSource; +import io.agentscope.core.model.ChatModelBase; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.permission.PermissionContextState; +import io.agentscope.core.permission.PermissionDecision; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolBase; +import io.agentscope.core.tool.ToolCallParam; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.util.JsonUtils; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * End-to-end tests for the {@code returnDirect} tool semantics implemented in + * {@link ReActAgent.CallExecution#acting(int)}. + * + *

    A tool that declares {@code returnDirect = true} short-circuits the ReAct loop: instead of + * feeding the tool result back to the model for another reasoning round, the agent lifts the full + * result into a synthetic closing assistant message and returns it as the turn's final answer. + */ +class ReActAgentReturnDirectTest { + + private static final String RETURN_DIRECT_PLACEHOLDER = + "Tool call completed. The result has been presented to the user as the final output" + + " of this turn."; + + /** Scripted model returning one {@link ChatResponse} stream per sequential model call. */ + private static final class ScriptedModel extends ChatModelBase { + + private final List>> scripts; + private final AtomicInteger idx = new AtomicInteger(0); + + ScriptedModel(List>> scripts) { + this.scripts = scripts; + } + + @Override + public String getModelName() { + return "scripted"; + } + + @Override + protected Flux doStream( + List messages, List tools, GenerateOptions options) { + int i = idx.getAndIncrement(); + if (i >= scripts.size()) { + return Flux.just(textResponse("")); + } + return scripts.get(i).get(); + } + + int callCount() { + return idx.get(); + } + } + + /** Test tool whose returnDirect flag and result are injected via the builder. */ + private static final class TestTool extends ToolBase { + + private ToolResultBlock result; + private boolean deny; + private boolean suspended; + + TestTool(String name, boolean returnDirect, ToolResultBlock result) { + super( + ToolBase.builder() + .name(name) + .description(name) + .inputSchema(schemaFor()) + .returnDirect(returnDirect)); + this.result = result; + } + + TestTool deny() { + this.deny = true; + return this; + } + + TestTool suspended() { + this.suspended = true; + return this; + } + + @Override + public Mono checkPermissions( + Map toolInput, PermissionContextState context) { + if (deny) { + return Mono.just(PermissionDecision.deny("deny: " + getName())); + } + return Mono.just(PermissionDecision.passthrough(getName())); + } + + @Override + public Mono callAsync(ToolCallParam param) { + if (suspended) { + return Mono.just(ToolResultBlock.suspended(param.getToolUseBlock())); + } + return Mono.just(result); + } + } + + static final class AnnotatedReturnDirectTools { + @Tool(name = "annotated_direct", description = "returns directly", returnDirect = true) + public String direct(@ToolParam(name = "q", description = "q") String q) { + return "direct:" + q; + } + } + + private static Map schemaFor() { + Map schema = new HashMap<>(); + schema.put("type", "object"); + Map props = new HashMap<>(); + Map q = new HashMap<>(); + q.put("type", "string"); + props.put("query", q); + schema.put("properties", props); + return schema; + } + + private static ChatResponse textResponse(String text) { + return ChatResponse.builder() + .content(List.of(TextBlock.builder().text(text).build())) + .build(); + } + + private static ChatResponse toolUseResponse( + String toolId, String toolName, Map arguments) { + return ChatResponse.builder() + .content( + List.of( + ToolUseBlock.builder() + .id(toolId) + .name(toolName) + .input(arguments == null ? Map.of() : arguments) + .content( + JsonUtils.getJsonCodec() + .toJson( + arguments == null + ? Map.of() + : arguments)) + .build())) + .build(); + } + + private static ChatResponse toolUsesResponse(List toolUses) { + return ChatResponse.builder().content(List.copyOf(toolUses)).build(); + } + + private static Toolkit toolkitWith(ToolBase... tools) { + Toolkit tk = new Toolkit(); + for (ToolBase t : tools) { + tk.registerAgentTool(t); + } + return tk; + } + + private static ReActAgent buildAgent(ChatModelBase model, Toolkit toolkit) { + return ReActAgent.builder().name("asst").model(model).toolkit(toolkit).build(); + } + + private static List contextOf(ReActAgent agent) { + return agent.getAgentState().getContext(); + } + + private static List toolResults(ReActAgent agent) { + List results = new ArrayList<>(); + for (Msg msg : contextOf(agent)) { + results.addAll(msg.getContentBlocks(ToolResultBlock.class)); + } + return results; + } + + private static Msg findToolResultMsg(ReActAgent agent, String toolId) { + for (Msg msg : contextOf(agent)) { + if (msg.getRole() != MsgRole.TOOL) { + continue; + } + List blocks = msg.getContentBlocks(ToolResultBlock.class); + if (blocks.stream().anyMatch(b -> toolId.equals(b.getId()))) { + return msg; + } + } + return null; + } + + private static Msg findLastAssistantMsg(ReActAgent agent) { + for (int i = contextOf(agent).size() - 1; i >= 0; i--) { + Msg m = contextOf(agent).get(i); + if (m.getRole() == MsgRole.ASSISTANT) { + return m; + } + } + return null; + } + + private static long countText(ReActAgent agent, String text) { + return contextOf(agent).stream() + .flatMap(m -> m.getContentBlocks(TextBlock.class).stream()) + .filter(b -> text.equals(b.getText())) + .count(); + } + + // ==== Tests ==== + + @Test + void singleReturnDirectToolBypassesModelAndReturnsOnce() { + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.just(toolUseResponse("tc1", "weather", Map.of())))); + Toolkit toolkit = toolkitWith(new TestTool("weather", true, ToolResultBlock.text("sunny"))); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + assertEquals(1, model.callCount(), "returnDirect must skip the follow-up model call"); + } + + @Test + void sequenceIsCompleteAndFullResultAppearsOnlyInClosingAssistant() { + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.just(toolUseResponse("tc1", "weather", Map.of())))); + Toolkit toolkit = toolkitWith(new TestTool("weather", true, ToolResultBlock.text("sunny"))); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + // Tool result keeps id/name/state=SUCCESS but only carries the placeholder. + Msg toolMsg = findToolResultMsg(agent, "tc1"); + assertNotNull(toolMsg); + ToolResultBlock toolResult = toolMsg.getContentBlocks(ToolResultBlock.class).get(0); + assertEquals("tc1", toolResult.getId()); + assertEquals("weather", toolResult.getName()); + assertEquals(ToolResultState.SUCCESS, toolResult.getState()); + assertEquals( + RETURN_DIRECT_PLACEHOLDER, + ((TextBlock) toolResult.getOutput().get(0)).getText(), + "tool_result must be replaced with the placeholder"); + + // Full result appears exactly once, in the closing assistant message. + assertEquals(1, countText(agent, "sunny"), "full result must not be duplicated"); + Msg closing = findLastAssistantMsg(agent); + assertEquals("sunny", closing.getTextContent()); + assertEquals( + Boolean.TRUE, closing.getMetadata().get(MessageMetadataKeys.TOOL_RETURN_DIRECT)); + assertNotNull(result); + assertEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + } + + @Test + void nonReturnDirectToolContinuesLoopToSummarize() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> Flux.just(toolUseResponse("tc1", "weather", Map.of())), + () -> Flux.just(textResponse("it is sunny outside")))); + Toolkit toolkit = + toolkitWith(new TestTool("weather", false, ToolResultBlock.text("sunny"))); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals(GenerateReason.MODEL_STOP, result.getGenerateReason()); + assertEquals("it is sunny outside", result.getTextContent()); + } + + @Test + void mixedBatchKeepsRealResultAndDoesNotReturnDirect() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> + Flux.just( + toolUsesResponse( + List.of( + ToolUseBlock.builder() + .id("tc1") + .name("direct") + .input(Map.of()) + .build(), + ToolUseBlock.builder() + .id("tc2") + .name("normal") + .input(Map.of()) + .build()))), + () -> Flux.just(textResponse("done")))); + Toolkit toolkit = + toolkitWith( + new TestTool("direct", true, ToolResultBlock.text("direct-out")), + new TestTool("normal", false, ToolResultBlock.text("normal-out"))); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertNotEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + assertEquals(GenerateReason.MODEL_STOP, result.getGenerateReason()); + + // The returnDirect tool's real result must remain in context (not placeholder-ized). + Msg directResultMsg = findToolResultMsg(agent, "tc1"); + assertNotNull(directResultMsg); + assertEquals( + "direct-out", + ((TextBlock) + directResultMsg + .getContentBlocks(ToolResultBlock.class) + .get(0) + .getOutput() + .get(0)) + .getText()); + } + + @Test + void allReturnDirectToolsAggregateInExecutionOrder() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> + Flux.just( + toolUsesResponse( + List.of( + ToolUseBlock.builder() + .id("tc1") + .name("first") + .input(Map.of()) + .build(), + ToolUseBlock.builder() + .id("tc2") + .name("second") + .input(Map.of()) + .build()))))); + Toolkit toolkit = + toolkitWith( + new TestTool("first", true, ToolResultBlock.text("one")), + new TestTool("second", true, ToolResultBlock.text("two"))); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + List textBlocks = result.getContentBlocks(TextBlock.class); + assertEquals(2, textBlocks.size()); + assertEquals("one", textBlocks.get(0).getText()); + assertEquals("two", textBlocks.get(1).getText()); + } + + @Test + void returnDirectToolThatSuspendsPublishesSuspended() { + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.just(toolUseResponse("tc1", "external", Map.of())))); + Toolkit toolkit = toolkitWith(new TestTool("external", true, null).suspended()); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals(GenerateReason.TOOL_SUSPENDED, result.getGenerateReason()); + } + + @Test + void hitlStopTakesPriorityOverReturnDirect() { + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.just(toolUseResponse("tc1", "weather", Map.of())))); + Toolkit toolkit = toolkitWith(new TestTool("weather", true, ToolResultBlock.text("sunny"))); + + Hook stoppingHook = + new Hook() { + @Override + public Mono onEvent(T event) { + if (event instanceof PostActingEvent pa) { + pa.stopAgent(); + } + return Mono.just(event); + } + }; + + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(model) + .toolkit(toolkit) + .hook(stoppingHook) + .build(); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals(GenerateReason.ACTING_STOP_REQUESTED, result.getGenerateReason()); + } + + @Test + void failingReturnDirectToolDoesNotReturnDirect() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> Flux.just(toolUseResponse("tc1", "weather", Map.of())), + () -> Flux.just(textResponse("recovered")))); + Toolkit toolkit = toolkitWith(new TestTool("weather", true, ToolResultBlock.error("boom"))); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertNotEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + assertEquals(GenerateReason.MODEL_STOP, result.getGenerateReason()); + assertEquals("recovered", result.getTextContent()); + } + + @Test + void deniedReturnDirectToolDoesNotReturnDirect() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> Flux.just(toolUseResponse("tc1", "weather", Map.of())), + () -> Flux.just(textResponse("done")))); + Toolkit toolkit = + toolkitWith(new TestTool("weather", true, ToolResultBlock.text("sunny")).deny()); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertNotEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + assertEquals(GenerateReason.MODEL_STOP, result.getGenerateReason()); + // "Permission denied by rules" must be fed back to the model, not returned as the answer. + assertEquals("done", result.getTextContent()); + + List results = toolResults(agent); + assertTrue( + results.stream().anyMatch(b -> b.getState() == ToolResultState.DENIED), + "the denied result must be recorded in context"); + } + + @Test + void nonTextOutputIsForwardedVerbatim() { + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.just(toolUseResponse("tc1", "image", Map.of())))); + DataBlock image = + DataBlock.builder().source(new URLSource("https://example.com/img.png")).build(); + Toolkit toolkit = toolkitWith(new TestTool("image", true, ToolResultBlock.of(image))); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + List dataBlocks = result.getContentBlocks(DataBlock.class); + assertEquals(1, dataBlocks.size()); + assertEquals( + "https://example.com/img.png", + ((URLSource) dataBlocks.get(0).getSource()).getUrl()); + } + + @Test + void emptyOutputIsPaddedWithPlaceholder() { + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.just(toolUseResponse("tc1", "silent", Map.of())))); + Toolkit toolkit = toolkitWith(new TestTool("silent", true, ToolResultBlock.of(List.of()))); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + assertEquals(1, model.callCount()); + assertFalse(result.getContent().isEmpty(), "closing message must not be empty"); + assertEquals("(no output)", result.getTextContent()); + } + + @Test + void errorTextPrefixIsDetectedDespiteRunningState() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> Flux.just(toolUseResponse("tc1", "weather", Map.of())), + () -> Flux.just(textResponse("recovered")))); + ToolResultBlock errorText = + new ToolResultBlock( + null, null, List.of(TextBlock.builder().text("[ERROR] bad").build()), null); + Toolkit toolkit = toolkitWith(new TestTool("weather", true, errorText)); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertNotEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + assertEquals(GenerateReason.MODEL_STOP, result.getGenerateReason()); + } + + @Test + void postActingRewriteDoesNotAffectReturnDirectResult() { + ScriptedModel model = + new ScriptedModel( + List.of(() -> Flux.just(toolUseResponse("tc1", "weather", Map.of())))); + Toolkit toolkit = toolkitWith(new TestTool("weather", true, ToolResultBlock.text("sunny"))); + + Hook rewritingHook = + new Hook() { + @Override + public Mono onEvent(T event) { + if (event instanceof PostActingEvent pa) { + pa.setToolResult(ToolResultBlock.error("redacted")); + } + return Mono.just(event); + } + }; + + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(model) + .toolkit(toolkit) + .hook(rewritingHook) + .build(); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + assertEquals("sunny", result.getTextContent()); + } + + @Test + void annotatedReturnDirectToolTakesEffectEndToEnd() { + ScriptedModel model = + new ScriptedModel( + List.of( + () -> + Flux.just( + toolUseResponse( + "tc1", + "annotated_direct", + Map.of("q", "hi"))))); + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(new AnnotatedReturnDirectTools()); + ReActAgent agent = buildAgent(model, toolkit); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals(GenerateReason.TOOL_RETURN_DIRECT, result.getGenerateReason()); + // The default @Tool converter JSON-serializes the String result, hence the quotes. + assertTrue(result.getTextContent().contains("direct:hi")); + } +} 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 e30cc97a10..71787fa3e2 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,7 +35,7 @@ class GenerateReasonTest { @DisplayName("Should have all expected enum values") void testEnumValues() { GenerateReason[] values = GenerateReason.values(); - assertEquals(11, values.length); + assertEquals(12, values.length); // Verify all expected values exist assertNotNull(GenerateReason.MODEL_STOP); @@ -46,8 +46,10 @@ void testEnumValues() { assertNotNull(GenerateReason.ACTING_STOP_REQUESTED); assertNotNull(GenerateReason.PERMISSION_ASKING); assertNotNull(GenerateReason.MIDDLEWARE_STOP_REQUESTED); + assertNotNull(GenerateReason.ALL_TOOLS_DENIED); assertNotNull(GenerateReason.INTERRUPTED); assertNotNull(GenerateReason.MAX_ITERATIONS); + assertNotNull(GenerateReason.TOOL_RETURN_DIRECT); } @Test