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 0fd76fed6..3d4b12756 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -35,6 +35,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; @@ -2487,6 +2488,16 @@ private Flux runToolBatch( .getName(), state)); } + List + suspendedCalls = + getSuspendedToolCalls( + results); + if (!suspendedCalls.isEmpty()) { + sink.next( + new RequireExternalExecutionEvent( + replyId, + suspendedCalls)); + } sink.complete(); }, sink::error); @@ -2582,6 +2593,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(); + } + /** * Emit delta events for tool results that were NOT already streamed via the chunk * callback. For non-streaming tools the chunk callback is never invoked, so the 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 cf656749b..63ca56d44 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 22f6ee6aa..010617cee 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 cd9fb9146..aaa672c82 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.error(new ToolSuspendException()); - } - // 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/agent/ReActAgentNewLoopReplyTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentNewLoopReplyTest.java index 37f5d68a8..9c4885f41 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() { @@ -220,6 +239,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 b123c3ac9..0beefb303 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,89 @@ 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 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() {