Skip to content
Merged
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
10 changes: 7 additions & 3 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -3374,6 +3374,10 @@ private Flux<AgentEvent> runToolBatch(
ToolResultBlock>
entry :
results) {
if (entry.getValue()
.isSuspended()) {
continue;
}
emitToolResultDelta(
sink,
replyId,
Expand Down Expand Up @@ -3550,7 +3554,7 @@ private void emitToolResultDelta(

private ToolResultState determineToolResultState(ToolResultBlock result) {
if (result.isSuspended()) {
return ToolResultState.RUNNING;
return ToolResultState.SUSPENDED;
}
if (result.getState() != null && result.getState() != ToolResultState.RUNNING) {
return result.getState();
Expand All @@ -3570,8 +3574,8 @@ private ToolResultState determineToolResultState(ToolResultBlock result) {
/**
* Build a message containing suspended tool calls for user execution.
*
* <p>The message contains both the ToolUseBlocks and corresponding pending ToolResultBlocks
* for the suspended tools.
* <p>The message contains each suspended call's ToolUseBlock paired with a marker
* ToolResultBlock (empty output, {@link ToolResultState#SUSPENDED}).
*
* @param pendingPairs List of (ToolUseBlock, pending ToolResultBlock) pairs
* @return Msg with GenerateReason.TOOL_SUSPENDED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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;

Expand All @@ -35,8 +36,20 @@
*/
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 is suspended for external execution.
*
* <p>Legacy wire encoding from before {@link ToolResultState#SUSPENDED} existed. {@link
* #isSuspended()} still recognises it for cross-version compatibility. New code should rely
* on {@link ToolResultState#SUSPENDED} or {@link #isSuspended()} instead of this key.
*/
@Deprecated public static final String METADATA_SUSPENDED = "agentscope_suspended";

/**
* Metadata key carrying the suspend reason (the {@code ToolSuspendException} message) when
* the tool supplied one. Control-plane information; never rendered through {@code output}.
*/
public static final String METADATA_SUSPEND_REASON = "agentscope_suspend_reason";

/**
* Metadata key marking this result as produced by a provider server tool (Boolean value).
Expand Down Expand Up @@ -150,15 +163,17 @@ public ToolResultBlock withState(ToolResultState state) {
/**
* Checks if this result is suspended for external execution.
*
* <p>A suspended result is created when a tool throws {@link ToolSuspendException},
* indicating that the tool execution needs to be handled externally by the user.
* <p>Recognises the canonical encoding ({@link ToolResultState#SUSPENDED}) and the legacy
* wire marker ({@link #METADATA_SUSPENDED}), so blocks serialized by older
* versions keep working.
*
* @return true if this result is suspended, false otherwise
*/
@Transient
@JsonInclude
public boolean isSuspended() {
return Boolean.TRUE.equals(metadata.get(METADATA_SUSPENDED));
return state == ToolResultState.SUSPENDED
|| Boolean.TRUE.equals(metadata.get(METADATA_SUSPENDED));
}

/**
Expand All @@ -174,23 +189,28 @@ public boolean isServerTool() {
/**
* Creates a suspended tool result from a ToolSuspendException.
*
* <p>This method is used by the framework to convert a {@link ToolSuspendException}
* into a suspended result that will be returned to the user for external execution.
* <p>The result carries no output: the tool has not executed, and the real result arrives
* from the caller on resume. The state is {@link ToolResultState#SUSPENDED} and, when the
* exception supplied a reason, it travels in {@link #METADATA_SUSPEND_REASON} metadata.
*
* @param toolUse The tool use block that triggered the exception
* @param exception The exception thrown by the tool
* @return A suspended ToolResultBlock
*/
public static ToolResultBlock suspended(ToolUseBlock toolUse, ToolSuspendException exception) {
String content =
exception.getReason() != null
? exception.getReason()
: "[Awaiting external execution]";
Map<String, Object> metadata = new HashMap<>();
// Kept for one transition window: older readers still detect suspension via this
// legacy marker, while current code reads the SUSPENDED state above.
metadata.put(METADATA_SUSPENDED, true);
if (exception.getReason() != null) {
metadata.put(METADATA_SUSPEND_REASON, exception.getReason());
}
return new ToolResultBlock(
toolUse.getId(),
toolUse.getName(),
List.of(TextBlock.builder().text(content).build()),
Map.of(METADATA_SUSPENDED, true));
List.of(),
Map.copyOf(metadata),
ToolResultState.SUSPENDED);
}

/**
Expand All @@ -203,6 +223,21 @@ public static ToolResultBlock suspended(ToolUseBlock toolUse) {
return suspended(toolUse, new ToolSuspendException());
}

/**
* Gets the suspend reason when this result is suspended and the tool supplied one.
*
* @return the reason, or null when absent or when this result is not suspended
*/
@Transient
@JsonIgnore
public String getSuspendReason() {
if (!isSuspended()) {
return null;
}
Object reason = metadata.get(METADATA_SUSPEND_REASON);
return reason instanceof String s ? s : null;
}

/**
* Create a simple text result (for tool method return values).
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ public enum ToolResultState {

DENIED("denied"),

RUNNING("running");
RUNNING("running"),

/**
* The tool call was suspended and awaits execution outside the agent runtime. A suspended
* result carries no output: the real result is supplied by the caller on resume.
*/
SUSPENDED("suspended");

private final String value;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import io.agentscope.core.event.ToolCallStartEvent;
import io.agentscope.core.event.ToolResultEndEvent;
import io.agentscope.core.event.ToolResultStartEvent;
import io.agentscope.core.event.ToolResultTextDeltaEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.GenerateReason;
import io.agentscope.core.message.MessageMetadataKeys;
Expand Down Expand Up @@ -280,8 +281,11 @@ void externalToolCallReturnsSuspendedMessage() {
ToolResultBlock toolResult = toolResults.get(0);
assertEquals("ext1", toolResult.getId());
assertEquals("external_api", toolResult.getName());
assertEquals(ToolResultState.RUNNING, toolResult.getState());
assertTrue(toolResult.isSuspended());
assertEquals(ToolResultState.SUSPENDED, toolResult.getState());
assertTrue(
toolResult.getOutput().isEmpty(),
"suspended marker must not fabricate tool output");
}

@Test
Expand All @@ -303,16 +307,17 @@ void externalToolCallEmitsRequireExternalExecutionEvent() {
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());
assertEquals(
-1,
indexOf(events, ToolResultEndEvent.class),
"a suspended tool must not emit a result end event");
assertEquals(
-1,
indexOf(events, ToolResultTextDeltaEvent.class),
"a suspended tool must not emit result content");
assertTrue(iRequireExternal >= 0, "RequireExternalExecutionEvent expected");

RequireExternalExecutionEvent event =
(RequireExternalExecutionEvent) events.get(iRequireExternal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import io.agentscope.core.message.ContentBlock;
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.ToolSchema;
import io.agentscope.core.tool.test.SampleTools;
Expand Down Expand Up @@ -208,7 +209,10 @@ void shouldReturnSuspendedResultForExternalTools() {
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));
assertEquals(ToolResultState.SUSPENDED, response.getState());
assertTrue(
response.getOutput().isEmpty(),
"External tool suspension must not fabricate output");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolResultState;
import io.agentscope.core.message.ToolUseBlock;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
Expand Down Expand Up @@ -75,7 +76,9 @@ void testSuspendedToolResultBlock() {
assertEquals("tool-1", suspended.getId());
assertEquals("external_api", suspended.getName());
assertTrue(suspended.isSuspended());
assertEquals(1, suspended.getOutput().size());
assertEquals(ToolResultState.SUSPENDED, suspended.getState());
assertTrue(suspended.getOutput().isEmpty(), "suspended result must not fabricate output");
assertEquals("Waiting for API response", suspended.getSuspendReason());
}

@Test
Expand All @@ -93,6 +96,35 @@ void testSuspendedToolResultBlockDefaultMessage() {
assertEquals("tool-2", suspended.getId());
assertEquals("database_query", suspended.getName());
assertTrue(suspended.isSuspended());
assertEquals(ToolResultState.SUSPENDED, suspended.getState());
assertTrue(suspended.getOutput().isEmpty(), "suspended result must not fabricate output");
assertNull(suspended.getSuspendReason());
}

@Test
@DisplayName("Should recognise suspended results by state and by legacy metadata")
@SuppressWarnings("deprecation")
void testSuspendedRecognitionAcceptsStateAndLegacyMetadata() {
ToolResultBlock canonical =
ToolResultBlock.builder()
.id("tool-3")
.name("external_api")
.state(ToolResultState.SUSPENDED)
.build();
assertTrue(canonical.isSuspended(), "the SUSPENDED state is the canonical encoding");
assertNull(canonical.getSuspendReason());

ToolResultBlock legacy =
ToolResultBlock.builder()
.id("tool-3")
.name("external_api")
.metadata(Map.of(ToolResultBlock.METADATA_SUSPENDED, true))
.build();
assertEquals(
ToolResultState.RUNNING,
legacy.getState(),
"legacy blocks deserialize without a state and default to RUNNING");
assertTrue(legacy.isSuspended(), "the legacy wire marker must stay recognised");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.GenerateReason;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.util.JsonUtils;
Expand All @@ -51,6 +50,8 @@
*/
final class AgentLifecycleEventConverter implements AgentEventConverter {

private static final String DEFAULT_SUSPEND_MESSAGE = "Awaiting external execution";

@Override
public Set<Class<? extends AgentEvent>> eventTypes() {
return Set.of(
Expand Down Expand Up @@ -142,7 +143,9 @@ private static AguiEvent.Interrupt buildToolCallInterrupt(
return new AguiEvent.Interrupt(
interruptId(result, toolCallId),
TOOL_CALL_INTERRUPT_REASON,
extractText(toolResult.getOutput()),
toolResult.getSuspendReason() != null
? toolResult.getSuspendReason()
: DEFAULT_SUSPEND_MESSAGE,
toolCallId,
null,
null,
Expand All @@ -156,20 +159,6 @@ private static String interruptId(Msg result, String toolCallId) {
return toolCallId;
}

private static String extractText(List<ContentBlock> blocks) {
if (blocks == null || blocks.isEmpty()) {
return null;
}
String text =
blocks.stream()
.filter(TextBlock.class::isInstance)
.map(TextBlock.class::cast)
.map(TextBlock::getText)
.filter(value -> value != null && !value.isEmpty())
.collect(Collectors.joining("\n"));
return text.isEmpty() ? null : text;
}

private static Set<String> frontendToolNames(AguiStreamContext context) {
RunAgentInput runInput = context.getRunInput();
if (runInput == null || runInput.getTools() == null || runInput.getTools().isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ public void convert(AgentEvent event, AguiStreamContext context) {
context.beginToolResult(start.getToolCallId());
} else {
ToolResultEndEvent end = (ToolResultEndEvent) event;
if (end.getState() == ToolResultState.RUNNING) {
// SUSPENDED is the current encoding; RUNNING is the legacy one.
if (end.getState() == ToolResultState.SUSPENDED
|| end.getState() == ToolResultState.RUNNING) {
context.markToolCallSuspended(end.getToolCallId());
return;
}
Expand Down
Loading
Loading