From aa5d85f4fe81ca6366a3ac983a980f5197477a6b Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Wed, 7 Oct 2026 23:28:08 +0800 Subject: [PATCH 1/2] fix(core): stop fabricating tool results for suspended external tool calls --- .../java/io/agentscope/core/ReActAgent.java | 10 ++- .../core/message/ToolResultBlock.java | 60 ++++++++++--- .../core/message/ToolResultState.java | 8 +- .../agent/ReActAgentNewLoopReplyTest.java | 23 +++-- .../core/tool/ToolExecutorTest.java | 6 +- .../core/tool/ToolSuspendExceptionTest.java | 34 +++++++- .../AgentLifecycleEventConverter.java | 21 ++--- .../strategy/ToolResultEventConverter.java | 4 +- .../agui/adapter/AguiAgentAdapterV2Test.java | 85 +++++++++---------- .../AgentLifecycleEventConverterTest.java | 4 + .../docs/building-blocks/message-and-event.md | 4 +- .../docs/building-blocks/message-and-event.md | 4 +- 12 files changed, 169 insertions(+), 94 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 0e215ca828..6c42275da1 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -3374,6 +3374,10 @@ private Flux runToolBatch( ToolResultBlock> entry : results) { + if (entry.getValue() + .isSuspended()) { + continue; + } emitToolResultDelta( sink, replyId, @@ -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(); @@ -3570,8 +3574,8 @@ private ToolResultState determineToolResultState(ToolResultBlock result) { /** * Build a message containing suspended tool calls for user execution. * - *

The message contains both the ToolUseBlocks and corresponding pending ToolResultBlocks - * for the suspended tools. + *

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 diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java index 16592ed751..6231e9348c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java @@ -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; @@ -35,9 +36,22 @@ */ public final class ToolResultBlock extends ContentBlock { - /** Metadata key indicating this result is suspended for external execution. */ + /** + * Metadata key indicating this result is suspended for external execution. + * + *

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(since = "2.0.4") 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). * Such results (e.g. Anthropic's web_search_tool_result) are returned by the provider inside @@ -150,15 +164,17 @@ public ToolResultBlock withState(ToolResultState state) { /** * Checks if this result is suspended for external execution. * - *

A suspended result is created when a tool throws {@link ToolSuspendException}, - * indicating that the tool execution needs to be handled externally by the user. + *

Recognises the canonical encoding ({@link ToolResultState#SUSPENDED}) and the legacy + * pre-2.0.4 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)); } /** @@ -174,23 +190,28 @@ public boolean isServerTool() { /** * Creates a suspended tool result from a ToolSuspendException. * - *

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. + *

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 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); } /** @@ -203,6 +224,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). * diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultState.java b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultState.java index 10a729e256..133d0ee528 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultState.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultState.java @@ -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; 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 2aa91f88f8..90ac4ee133 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 @@ -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; @@ -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 @@ -303,16 +307,17 @@ void externalToolCallEmitsRequireExternalExecutionEvent() { 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()); + 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); 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 55ae0cb786..2de7bc9c90 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 @@ -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; @@ -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 diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSuspendExceptionTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSuspendExceptionTest.java index 06e6c521eb..e88fcce75d 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSuspendExceptionTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSuspendExceptionTest.java @@ -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; @@ -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 @@ -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(), + "pre-2.0.4 blocks deserialize without a state and default to RUNNING"); + assertTrue(legacy.isSuspended(), "the legacy wire marker must stay recognised"); } @Test diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java index 5201ed6e67..c8c68fb2be 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java @@ -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; @@ -51,6 +50,8 @@ */ final class AgentLifecycleEventConverter implements AgentEventConverter { + private static final String DEFAULT_SUSPEND_MESSAGE = "Awaiting external execution"; + @Override public Set> eventTypes() { return Set.of( @@ -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, @@ -156,20 +159,6 @@ private static String interruptId(Msg result, String toolCallId) { return toolCallId; } - private static String extractText(List 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 frontendToolNames(AguiStreamContext context) { RunAgentInput runInput = context.getRunInput(); if (runInput == null || runInput.getTools() == null || runInput.getTools().isEmpty()) { diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolResultEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolResultEventConverter.java index 3a7d37ac32..2be5ab85ae 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolResultEventConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/ToolResultEventConverter.java @@ -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; } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java index 177da4950c..91f20654b0 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterV2Test.java @@ -920,6 +920,24 @@ void testParallelToolCallsKeepIndependentArgsEndAndResultOrder() { @Test void testSuspendedToolResultDoesNotEmitToolCallResult() { + ToolUseBlock toolUse = ToolUseBlock.builder().id("tool-1").name("lookup").build(); + List events = + runReActEvents( + new ToolCallStartEvent("reply-tool", "tool-1", "lookup"), + new ToolCallEndEvent("reply-tool", "tool-1", "lookup"), + new RequireExternalExecutionEvent("reply-tool", List.of(toolUse)), + new AgentEndEvent("reply-tool")); + + assertEquals( + List.of( + AguiEventType.TOOL_CALL_START, + AguiEventType.TOOL_CALL_END, + AguiEventType.RUN_FINISHED), + types(events)); + } + + @Test + void testSuspendedToolResultEndEventStillDiscardsBufferedContent() { List events = runReActEvents( new ToolCallStartEvent("reply-tool", "tool-1", "lookup"), @@ -928,7 +946,7 @@ void testSuspendedToolResultDoesNotEmitToolCallResult() { new ToolResultTextDeltaEvent( "reply-tool", "tool-1", "lookup", "needs external work"), new ToolResultEndEvent( - "reply-tool", "tool-1", "lookup", ToolResultState.RUNNING)); + "reply-tool", "tool-1", "lookup", ToolResultState.SUSPENDED)); assertEquals( List.of(AguiEventType.TOOL_CALL_START, AguiEventType.TOOL_CALL_END), @@ -950,11 +968,11 @@ void testSuspendedToolResultFinishesRunWithToolCallInterrupt() { ToolResultBlock.builder() .id("tool-1") .name("lookup") - .output( - TextBlock.builder() - .text("Execute lookup externally") - .build()) - .metadata(Map.of(ToolResultBlock.METADATA_SUSPENDED, true)) + .state(ToolResultState.SUSPENDED) + .metadata( + Map.of( + ToolResultBlock.METADATA_SUSPEND_REASON, + "Execute lookup externally")) .build()); List events = @@ -963,14 +981,8 @@ void testSuspendedToolResultFinishesRunWithToolCallInterrupt() { new ToolCallStartEvent("reply-suspended", "tool-1", "lookup"), new ToolCallDeltaEvent( "reply-suspended", "tool-1", "lookup", "{\"city\":\"Paris\"}"), - new ToolResultStartEvent("reply-suspended", "tool-1", "lookup"), - new ToolResultTextDeltaEvent( - "reply-suspended", - "tool-1", - "lookup", - "Execute lookup externally"), - new ToolResultEndEvent( - "reply-suspended", "tool-1", "lookup", ToolResultState.RUNNING), + new ToolCallEndEvent("reply-suspended", "tool-1", "lookup"), + new RequireExternalExecutionEvent("reply-suspended", List.of(toolUse)), new AgentResultEvent(suspendedResult), new AgentEndEvent("reply-suspended")); @@ -1016,11 +1028,7 @@ void testSuspendedFrontendToolDoesNotEmitInterrupt() { ToolResultBlock.builder() .id("tool-1") .name("lookup") - .output( - TextBlock.builder() - .text("Execute lookup on the client") - .build()) - .metadata(Map.of(ToolResultBlock.METADATA_SUSPENDED, true)) + .state(ToolResultState.SUSPENDED) .build()); List events = @@ -1061,28 +1069,17 @@ void testSuspendedBackendToolStillInterruptsWhenFrontendToolsArePresent() { ToolResultBlock.builder() .id("tool-1") .name("lookup") - .output( - TextBlock.builder() - .text("client lookup") - .build()) - .metadata( - Map.of( - ToolResultBlock - .METADATA_SUSPENDED, - true)) + .state(ToolResultState.SUSPENDED) .build(), ToolResultBlock.builder() .id("tool-2") .name("requestHumanApproval") - .output( - TextBlock.builder() - .text("Approve refund?") - .build()) + .state(ToolResultState.SUSPENDED) .metadata( Map.of( ToolResultBlock - .METADATA_SUSPENDED, - true)) + .METADATA_SUSPEND_REASON, + "Approve refund?")) .build())) .generateReason(GenerateReason.TOOL_SUSPENDED) .build(); @@ -1120,11 +1117,7 @@ void testSuspendedToolResultWithoutStableToolCallIdFailsRun() { ToolResultBlock.builder() .id("") .name("lookup") - .output( - TextBlock.builder() - .text("Execute lookup externally") - .build()) - .metadata(Map.of(ToolResultBlock.METADATA_SUSPENDED, true)) + .state(ToolResultState.SUSPENDED) .build()); List events = @@ -1156,8 +1149,11 @@ void testOnlySuspendedToolIsInterruptedWhenParallelToolCallsPartiallyComplete() ToolResultBlock.builder() .id("tool-2") .name("search") - .output(TextBlock.builder().text("Search externally").build()) - .metadata(Map.of(ToolResultBlock.METADATA_SUSPENDED, true)) + .state(ToolResultState.SUSPENDED) + .metadata( + Map.of( + ToolResultBlock.METADATA_SUSPEND_REASON, + "Search externally")) .build()); List events = @@ -1171,11 +1167,8 @@ void testOnlySuspendedToolIsInterruptedWhenParallelToolCallsPartiallyComplete() "reply-parallel", "tool-1", "lookup", "done"), new ToolResultEndEvent("reply-parallel", "tool-1", "lookup", null), new ToolCallEndEvent("reply-parallel", "tool-2", "search"), - new ToolResultStartEvent("reply-parallel", "tool-2", "search"), - new ToolResultTextDeltaEvent( - "reply-parallel", "tool-2", "search", "Search externally"), - new ToolResultEndEvent( - "reply-parallel", "tool-2", "search", ToolResultState.RUNNING), + new RequireExternalExecutionEvent( + "reply-parallel", List.of(suspendedTool)), new AgentResultEvent(suspendedResult), new AgentEndEvent("reply-parallel")); diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverterTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverterTest.java index e0f2118108..a2baba981f 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverterTest.java @@ -39,6 +39,10 @@ /** Unit tests for {@link AgentLifecycleEventConverter} edge branches. */ class AgentLifecycleEventConverterTest { + // The suspended blocks below intentionally use the pre-2.0.4 wire shape (legacy + // agentscope_suspended metadata, no SUSPENDED state): they pin compatibility with + // messages serialized by older framework versions. + @Test void suspendedResultWithoutMatchingToolUseDoesNotEmitInterrupt() { Msg result = diff --git a/docs/v2/en/docs/building-blocks/message-and-event.md b/docs/v2/en/docs/building-blocks/message-and-event.md index 9b4cddb968..9526b0e29e 100644 --- a/docs/v2/en/docs/building-blocks/message-and-event.md +++ b/docs/v2/en/docs/building-blocks/message-and-event.md @@ -309,7 +309,7 @@ Events are grouped below; unless noted otherwise, every event also carries `getR |--------|------|-------------| | `getReplyId()` | `String` | Reply message ID | | `getToolCallId()` | `String` | The matching tool call ID | - | `getState()` | `ToolResultState` | Final state: `SUCCESS`, `ERROR`, `INTERRUPTED`, `DENIED`, `RUNNING` | + | `getState()` | `ToolResultState` | Final state: `SUCCESS`, `ERROR`, `INTERRUPTED`, `DENIED`, `RUNNING`, `SUSPENDED` | @@ -334,7 +334,7 @@ Events are grouped below; unless noted otherwise, every event also carries `getR | `getReplyId()` | `String` | Reply message ID | | `getToolCalls()` | `List` | Tool calls awaiting confirmation | - **RequireExternalExecutionEvent** — agent pauses for external execution. + **RequireExternalExecutionEvent** — agent pauses for external execution. A suspended call produces no `ToolResult*` events: the tool has not executed, and the real result is supplied by the caller and surfaced through `ExternalExecutionResultEvent`. | Method | Type | Description | |--------|------|-------------| diff --git a/docs/v2/zh/docs/building-blocks/message-and-event.md b/docs/v2/zh/docs/building-blocks/message-and-event.md index ac5d212ba6..a166e18f53 100644 --- a/docs/v2/zh/docs/building-blocks/message-and-event.md +++ b/docs/v2/zh/docs/building-blocks/message-and-event.md @@ -309,7 +309,7 @@ sequenceDiagram |------|------|------| | `getReplyId()` | `String` | 回复消息 ID | | `getToolCallId()` | `String` | 对应工具调用的 ID | - | `getState()` | `ToolResultState` | 最终状态:`SUCCESS`、`ERROR`、`INTERRUPTED`、`DENIED`、`RUNNING` | + | `getState()` | `ToolResultState` | 最终状态:`SUCCESS`、`ERROR`、`INTERRUPTED`、`DENIED`、`RUNNING`、`SUSPENDED` | @@ -334,7 +334,7 @@ sequenceDiagram | `getReplyId()` | `String` | 回复消息 ID | | `getToolCalls()` | `List` | 待用户确认的工具调用列表 | - **RequireExternalExecutionEvent** — 智能体暂停等待外部执行。 + **RequireExternalExecutionEvent** — 智能体暂停等待外部执行。挂起的工具调用不会产生 `ToolResult*` 事件:工具尚未执行,真实结果由调用方回传后通过 `ExternalExecutionResultEvent` 表达。 | 方法 | 类型 | 描述 | |------|------|------| From 3e3e0d56eff2e0f3d62103eba0b94237faf636e0 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Thu, 8 Oct 2026 12:08:55 +0800 Subject: [PATCH 2/2] fix: improve --- .../java/io/agentscope/core/message/ToolResultBlock.java | 5 ++--- .../io/agentscope/core/tool/ToolSuspendExceptionTest.java | 2 +- .../adapter/strategy/AgentLifecycleEventConverterTest.java | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java index 6231e9348c..128175c3d0 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/ToolResultBlock.java @@ -43,8 +43,7 @@ public final class ToolResultBlock extends ContentBlock { * #isSuspended()} still recognises it for cross-version compatibility. New code should rely * on {@link ToolResultState#SUSPENDED} or {@link #isSuspended()} instead of this key. */ - @Deprecated(since = "2.0.4") - public static final String METADATA_SUSPENDED = "agentscope_suspended"; + @Deprecated public static final String METADATA_SUSPENDED = "agentscope_suspended"; /** * Metadata key carrying the suspend reason (the {@code ToolSuspendException} message) when @@ -165,7 +164,7 @@ public ToolResultBlock withState(ToolResultState state) { * Checks if this result is suspended for external execution. * *

Recognises the canonical encoding ({@link ToolResultState#SUSPENDED}) and the legacy - * pre-2.0.4 wire marker ({@link #METADATA_SUSPENDED}), so blocks serialized by older + * wire marker ({@link #METADATA_SUSPENDED}), so blocks serialized by older * versions keep working. * * @return true if this result is suspended, false otherwise diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSuspendExceptionTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSuspendExceptionTest.java index e88fcce75d..b6ce68171f 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSuspendExceptionTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolSuspendExceptionTest.java @@ -123,7 +123,7 @@ void testSuspendedRecognitionAcceptsStateAndLegacyMetadata() { assertEquals( ToolResultState.RUNNING, legacy.getState(), - "pre-2.0.4 blocks deserialize without a state and default to RUNNING"); + "legacy blocks deserialize without a state and default to RUNNING"); assertTrue(legacy.isSuspended(), "the legacy wire marker must stay recognised"); } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverterTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverterTest.java index a2baba981f..00d7d6669b 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverterTest.java @@ -39,7 +39,7 @@ /** Unit tests for {@link AgentLifecycleEventConverter} edge branches. */ class AgentLifecycleEventConverterTest { - // The suspended blocks below intentionally use the pre-2.0.4 wire shape (legacy + // The suspended blocks below intentionally use the legacy wire shape (legacy // agentscope_suspended metadata, no SUSPENDED state): they pin compatibility with // messages serialized by older framework versions.