From 409c3cb3746176fe3b8ad20178825e2b31cefc78 Mon Sep 17 00:00:00 2001 From: xiaozh Date: Sun, 7 Jun 2026 15:56:03 +0800 Subject: [PATCH 1/2] fix(tool): return suspended results for external tools --- .../java/io/agentscope/core/ReActAgent.java | 22 ++++- .../agentscope/core/tool/SchemaOnlyTool.java | 14 ++- .../java/io/agentscope/core/tool/Tool.java | 5 +- .../io/agentscope/core/tool/ToolExecutor.java | 2 +- .../agent/ReActAgentNewLoopReplyTest.java | 87 +++++++++++++++++++ .../core/tool/ToolExecutorTest.java | 38 ++++++++ 6 files changed, 154 insertions(+), 14 deletions(-) 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 79842d60d1..301aa739a0 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -32,6 +32,7 @@ import io.agentscope.core.event.ModelCallEndEvent; import io.agentscope.core.event.ModelCallStartEvent; import io.agentscope.core.event.RequestStopEvent; +import io.agentscope.core.event.RequireExternalExecutionEvent; import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.TextBlockEndEvent; @@ -1297,9 +1298,9 @@ private String resolveToolCallId(ToolUseBlock tub, ReasoningContext context) { * notifies hooks for successful tool results, and decides whether to continue iteration * or return (HITL stop, suspended tools, or structured output). * - *

For tools that throw {@link io.agentscope.core.tool.ToolSuspendException}: + *

For tools that require external execution: *

@@ -1592,6 +1593,15 @@ private Flux runToolBatch( .getId(), state)); } + List suspendedCalls = + getSuspendedToolCalls( + results); + if (!suspendedCalls.isEmpty()) { + sink.next( + new RequireExternalExecutionEvent( + replyId, + suspendedCalls)); + } sink.complete(); }, sink::error); @@ -1685,6 +1695,14 @@ private Mono evaluateOne(ToolUseBlock use, boolean useEngine) private record PermissionVerdict(ToolUseBlock use, PermissionBehavior behavior) {} + private List getSuspendedToolCalls( + List> results) { + return results.stream() + .filter(entry -> entry.getValue().isSuspended()) + .map(Map.Entry::getKey) + .toList(); + } + private ToolResultState determineToolResultState(ToolResultBlock result) { if (result.isSuspended()) { return ToolResultState.RUNNING; diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/SchemaOnlyTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/SchemaOnlyTool.java index cf656749b6..63ca56d44d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/SchemaOnlyTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/SchemaOnlyTool.java @@ -27,12 +27,11 @@ * An external tool implementation that only contains schema definition without execution logic. * *

This class is used for registering external tools that will be executed outside the framework. - * When a model returns a call to a SchemaOnlyTool, the framework will catch the - * {@link ToolSuspendException} thrown by {@link #callAsync(ToolCallParam)} and convert it to a - * pending {@link ToolResultBlock}, then return a suspended message to the user. + * When a model returns a call to a SchemaOnlyTool, Toolkit surfaces the call as a pending + * {@link ToolResultBlock}, then the agent returns a suspended message to the user. * - *

The {@link #callAsync(ToolCallParam)} method throws a {@link ToolSuspendException} - * to signal that this tool requires external execution. + *

The {@link #callAsync(ToolCallParam)} method still throws a {@link ToolSuspendException} + * for direct invocations outside the Toolkit execution path. * *

Example usage: *

{@code
@@ -124,9 +123,8 @@ public Boolean getStrict() {
     /**
      * Throws a ToolSuspendException to signal that this tool requires external execution.
      *
-     * 

The framework will catch this exception and convert it to a pending {@link ToolResultBlock}. - * The agent will then return a suspended message with {@code GenerateReason.TOOL_SUSPENDED} - * containing the tool use blocks that need external execution. + *

Toolkit execution short-circuits external tools before invoking this method. Direct + * invocations keep the same suspension signal. * * @param param The tool call parameters (ignored) * @return Never returns normally 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 22f6ee6aa3..010617cee5 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 @@ -119,9 +119,8 @@ /** * Whether the tool is executed outside the framework. * - *

External tools never run their {@code callAsync} body. Instead the framework throws a - * {@code ToolSuspendException} so the agent loop can surface the call to the caller via a - * {@code TOOL_SUSPENDED} message. + *

External tools never run their {@code callAsync} body through Toolkit execution. The + * agent loop surfaces the call to the caller via a {@code TOOL_SUSPENDED} message. * * @return true if this tool should be surfaced as a suspended call */ 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 cd9fb91469..eb942041ea 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 @@ -193,7 +193,7 @@ private Mono executeCore(ToolCallParam param) { // validation, preset injection, or scheduling. SchemaOnlyTool and any // @Tool(externalTool=true) method end up here. if (tool instanceof ToolBase tb && tb.isExternalTool()) { - return Mono.error(new ToolSuspendException()); + return Mono.just(ToolResultBlock.suspended(toolCall)); } // Check tool activation 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 5372a79f70..3e5c796799 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 @@ -26,15 +26,18 @@ import io.agentscope.core.event.ExceedMaxItersEvent; import io.agentscope.core.event.ModelCallEndEvent; import io.agentscope.core.event.ModelCallStartEvent; +import io.agentscope.core.event.RequireExternalExecutionEvent; import io.agentscope.core.event.ToolCallEndEvent; import io.agentscope.core.event.ToolCallStartEvent; import io.agentscope.core.event.ToolResultEndEvent; import io.agentscope.core.event.ToolResultStartEvent; import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.GenerateReason; 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.model.ChatModelBase; import io.agentscope.core.model.ChatResponse; @@ -110,6 +113,22 @@ private static Toolkit toolkitWith(AgentTool tool) { return toolkit; } + private static Toolkit toolkitWithExternalSchema() { + Toolkit toolkit = new Toolkit(); + toolkit.registerSchema( + ToolSchema.builder() + .name("external_api") + .description("Execute API outside the agent runtime") + .parameters( + Map.of( + "type", + "object", + "properties", + Map.of("query", Map.of("type", "string")))) + .build()); + return toolkit; + } + private static final class EchoTool implements AgentTool { @Override public String getName() { @@ -218,6 +237,74 @@ void toolCallIterationThenTextTerminates() { assertTrue(secondModelStart > iToolResultEnd, "second iteration should follow tool result"); } + @Test + void externalToolCallReturnsSuspendedMessage() { + ChatModelBase model = + new ScriptedModel( + List.of( + () -> + Flux.just( + toolUseResponse( + "ext1", "external_api", "/users")))); + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(model) + .toolkit(toolkitWithExternalSchema()) + .build(); + + Msg result = agent.call(List.of()).block(); + + assertNotNull(result); + assertEquals(GenerateReason.TOOL_SUSPENDED, result.getGenerateReason()); + assertEquals(1, result.getContentBlocks(ToolUseBlock.class).size()); + + List toolResults = result.getContentBlocks(ToolResultBlock.class); + assertEquals(1, toolResults.size()); + ToolResultBlock toolResult = toolResults.get(0); + assertEquals("ext1", toolResult.getId()); + assertEquals("external_api", toolResult.getName()); + assertEquals(ToolResultState.RUNNING, toolResult.getState()); + assertTrue(toolResult.isSuspended()); + } + + @Test + void externalToolCallEmitsRequireExternalExecutionEvent() { + ChatModelBase model = + new ScriptedModel( + List.of( + () -> + Flux.just( + toolUseResponse( + "ext1", "external_api", "/users")))); + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(model) + .toolkit(toolkitWithExternalSchema()) + .build(); + + List events = agent.streamEvents(List.of()).collectList().block(); + assertNotNull(events); + + int iToolResultEnd = indexOf(events, ToolResultEndEvent.class); + int iRequireExternal = indexOf(events, RequireExternalExecutionEvent.class); + + assertTrue(iToolResultEnd >= 0, "ToolResultEndEvent expected"); + assertTrue( + iRequireExternal > iToolResultEnd, + "RequireExternalExecutionEvent must follow suspended tool result"); + + ToolResultEndEvent end = (ToolResultEndEvent) events.get(iToolResultEnd); + assertEquals(ToolResultState.RUNNING, end.getState()); + + RequireExternalExecutionEvent event = + (RequireExternalExecutionEvent) events.get(iRequireExternal); + assertEquals(1, event.getToolCalls().size()); + assertEquals("ext1", event.getToolCalls().get(0).getId()); + assertEquals("external_api", event.getToolCalls().get(0).getName()); + } + @Test void maxItersOverflowEmitsExceedMaxItersEvent() { Supplier> loop = () -> Flux.just(toolUseResponse("tc", "echo", "x")); diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java index b123c3ac90..21d74004de 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java @@ -23,6 +23,7 @@ import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ToolSchema; import io.agentscope.core.tool.test.SampleTools; import io.agentscope.core.tool.test.ToolTestUtils; import io.agentscope.core.util.JsonUtils; @@ -173,6 +174,43 @@ public Mono callAsync(ToolCallParam param) { extractFirstText(responses.get(0))); } + @Test + @DisplayName("Should return suspended result for external tools") + void shouldReturnSuspendedResultForExternalTools() { + toolkit.registerSchema( + ToolSchema.builder() + .name("external_api") + .description("Execute API outside the agent runtime") + .parameters( + Map.of( + "type", + "object", + "properties", + Map.of("endpoint", Map.of("type", "string")))) + .build()); + + Map input = Map.of("endpoint", "/users"); + ToolUseBlock externalCall = + ToolUseBlock.builder() + .id("call-external") + .name("external_api") + .input(input) + .content(JsonUtils.getJsonCodec().toJson(input)) + .build(); + + List responses = + toolkit.callTools(List.of(externalCall), null, null, null).block(TIMEOUT); + + assertNotNull(responses, "Executor should return a suspended response"); + assertEquals(1, responses.size(), "Single external call should yield one response"); + + ToolResultBlock response = responses.get(0); + assertEquals("call-external", response.getId(), "Response should keep tool call id"); + assertEquals("external_api", response.getName(), "Response should keep tool name"); + assertTrue(response.isSuspended(), "External tool should surface as suspended"); + assertEquals("[Awaiting external execution]", extractFirstText(response)); + } + @Test @DisplayName("Should NOT specially handle InterruptedException in error path") void testToolErrorWithoutInterruptSpecialCase() { From 76abfd187837f7800674f10ebcb08651116d83fe Mon Sep 17 00:00:00 2001 From: xiaozh Date: Tue, 9 Jun 2026 11:03:22 +0800 Subject: [PATCH 2/2] fix(tool): enforce activation before external suspension --- .../io/agentscope/core/tool/ToolExecutor.java | 14 +++--- .../core/tool/ToolExecutorTest.java | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) 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 eb942041ea..aaa672c82e 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 @@ -189,13 +189,6 @@ private Mono executeCore(ToolCallParam param) { return Mono.just(ToolResultBlock.error("Tool not found: " + toolCall.getName())); } - // External tool short-circuit: surface the call to the caller without running schema - // validation, preset injection, or scheduling. SchemaOnlyTool and any - // @Tool(externalTool=true) method end up here. - if (tool instanceof ToolBase tb && tb.isExternalTool()) { - return Mono.just(ToolResultBlock.suspended(toolCall)); - } - // Check tool activation RegisteredToolFunction registered = toolRegistry.getRegisteredTool(toolCall.getName()); if (registered != null && !groupManager.isActiveTool(toolCall.getName())) { @@ -206,6 +199,13 @@ private Mono executeCore(ToolCallParam param) { return Mono.just(ToolResultBlock.error(errorMsg)); } + // External tool short-circuit: once availability is authorized, surface the call without + // running schema validation, preset injection, or local invocation. SchemaOnlyTool and any + // @Tool(externalTool=true) method end up here. + if (tool instanceof ToolBase tb && tb.isExternalTool()) { + return Mono.just(ToolResultBlock.suspended(toolCall)); + } + // Validate input against schema String validationError = ToolValidator.validateInput(toolCall.getContent(), tool.getParameters()); diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java index 21d74004de..0beefb3037 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java @@ -211,6 +211,52 @@ void shouldReturnSuspendedResultForExternalTools() { assertEquals("[Awaiting external execution]", extractFirstText(response)); } + @Test + @DisplayName("Should reject inactive grouped external tools before suspension") + void shouldRejectInactiveGroupedExternalToolsBeforeSuspension() { + toolkit.createToolGroup("inactiveExternal", "Inactive external tools", false); + toolkit.registration() + .agentTool( + new SchemaOnlyTool( + ToolSchema.builder() + .name("external_inactive") + .description("Inactive external API") + .parameters( + Map.of( + "type", + "object", + "properties", + Map.of( + "endpoint", + Map.of("type", "string")))) + .build())) + .group("inactiveExternal") + .apply(); + + Map input = Map.of("endpoint", "/users"); + ToolUseBlock externalCall = + ToolUseBlock.builder() + .id("call-inactive-external") + .name("external_inactive") + .input(input) + .content(JsonUtils.getJsonCodec().toJson(input)) + .build(); + + List responses = + toolkit.callTools(List.of(externalCall), null, null, null).block(TIMEOUT); + + assertNotNull(responses, "Executor should return an authorization response"); + assertEquals(1, responses.size(), "Single external call should yield one response"); + + ToolResultBlock response = responses.get(0); + assertEquals("call-inactive-external", response.getId(), "Response should keep call id"); + assertEquals("external_inactive", response.getName(), "Response should keep tool name"); + assertTrue(!response.isSuspended(), "Inactive external tool must not suspend"); + assertEquals( + "Error: Unauthorized tool call: 'external_inactive' is not available", + extractFirstText(response)); + } + @Test @DisplayName("Should NOT specially handle InterruptedException in error path") void testToolErrorWithoutInterruptSpecialCase() {