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
19 changes: 19 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 @@ -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;
Expand Down Expand Up @@ -2487,6 +2488,16 @@ private Flux<AgentEvent> runToolBatch(
.getName(),
state));
}
List<ToolUseBlock>
suspendedCalls =
getSuspendedToolCalls(
results);
if (!suspendedCalls.isEmpty()) {
sink.next(
new RequireExternalExecutionEvent(
replyId,
suspendedCalls));
}
sink.complete();
},
sink::error);
Expand Down Expand Up @@ -2582,6 +2593,14 @@ private Mono<PermissionVerdict> evaluateOne(ToolUseBlock use, boolean useEngine)

private record PermissionVerdict(ToolUseBlock use, PermissionBehavior behavior) {}

private List<ToolUseBlock> getSuspendedToolCalls(
List<Map.Entry<ToolUseBlock, ToolResultBlock>> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,11 @@
* An external tool implementation that only contains schema definition without execution logic.
*
* <p>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.
*
* <p>The {@link #callAsync(ToolCallParam)} method throws a {@link ToolSuspendException}
* to signal that this tool requires external execution.
* <p>The {@link #callAsync(ToolCallParam)} method still throws a {@link ToolSuspendException}
* for direct invocations outside the Toolkit execution path.
*
* <p>Example usage:
* <pre>{@code
Expand Down Expand Up @@ -124,9 +123,8 @@ public Boolean getStrict() {
/**
* Throws a ToolSuspendException to signal that this tool requires external execution.
*
* <p>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.
* <p>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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,8 @@
/**
* Whether the tool is executed outside the framework.
*
* <p>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.
* <p>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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,6 @@ private Mono<ToolResultBlock> 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())) {
Expand All @@ -206,6 +199,13 @@ private Mono<ToolResultBlock> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<ToolResultBlock> 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<AgentEvent> 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<Flux<ChatResponse>> loop = () -> Flux.just(toolUseResponse("tc", "echo", "x"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -173,6 +174,89 @@ public Mono<ToolResultBlock> 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<String, Object> input = Map.of("endpoint", "/users");
ToolUseBlock externalCall =
ToolUseBlock.builder()
.id("call-external")
.name("external_api")
.input(input)
.content(JsonUtils.getJsonCodec().toJson(input))
.build();

List<ToolResultBlock> 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<String, Object> 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<ToolResultBlock> 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() {
Expand Down
Loading