From c49dc19959119f52040d0e8832f4c101c8763519 Mon Sep 17 00:00:00 2001 From: wzq-xzwj <34700058+wzq-xzwj@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:20:10 +0800 Subject: [PATCH 1/2] fix(agui): emit AG-UI interrupt for permission-type HITL tool confirmation (#2437) The AG-UI adapter only handled suspend-type HITL (ToolSuspendException / frontend tools, GenerateReason.TOOL_SUSPENDED). Permission-type HITL -- where a non-readonly tool under DEFAULT permission mode makes ReActAgent emit RequireUserConfirmEvent + RequestStopEvent(PERMISSION_ASKING) -- was dropped: RequireUserConfirmEvent fell through to the RAW fallback and no Interrupt reached RUN_FINISHED, so the frontend could never confirm/deny. Building on the converter-registry architecture introduced by #2306: - Add PermissionConfirmEventConverter that maps RequireUserConfirmEvent to one AG-UI Interrupt per pending ToolUseBlock (reason "tool_confirmation"), carrying toolName/toolInput/toolContent/replyId metadata, and register it in AgentEventConverterRegistry. Interrupt id keeps the "replyId:toolCallId" format so resume can recover the tool call id. - AguiResumeCoordinator now tracks "tool_confirmation" interrupts alongside "tool_call" ones and exposes them via a new resume-interrupts runtime key. - AguiAgentAdapter forwards the resume interrupts to the message converter. - AguiMessageConverter builds a ConfirmResult (approved/denied) from the resume payload for confirmation interrupts, reconstructing the ToolUseBlock with non-null JSON content to avoid the tool-input validation NPE on resume; non-confirmation resumes keep the existing ToolResultBlock path. - Add unit tests covering resolved/cancelled/explicit-false confirmations and the non-confirmation fallback. --- .../core/agui/adapter/AguiAgentAdapter.java | 24 ++- .../strategy/AgentEventConverterRegistry.java | 1 + .../PermissionConfirmEventConverter.java | 128 ++++++++++++++ .../agui/converter/AguiMessageConverter.java | 120 ++++++++++++- .../agui/processor/AguiResumeCoordinator.java | 19 +- .../agui/adapter/AguiAgentAdapterV2Test.java | 118 +++++++++++++ .../converter/AguiMessageConverterTest.java | 164 ++++++++++++++++++ .../processor/AguiResumeCoordinatorTest.java | 63 +++++++ 8 files changed, 631 insertions(+), 6 deletions(-) create mode 100644 agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/PermissionConfirmEventConverter.java diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java index ec8397aa69..91fa6aac2c 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java @@ -86,6 +86,7 @@ public class AguiAgentAdapter { public static final String RUNTIME_CONTEXT_FORWARDED_PROPS_KEY = "agui.forwardedProps"; public static final String RUNTIME_CONTEXT_RESUME_KEY = "agui.resume"; public static final String RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY = "agui.resume.toolCallIds"; + public static final String RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY = "agui.resume.interrupts"; private final Agent agent; private final AguiAdapterConfig config; @@ -147,7 +148,9 @@ public Flux run(RunAgentInput input, RuntimeContext runtimeContext) { // Convert AG-UI messages and official resume entries to AgentScope messages. List msgs = messageConverter.toMsgList( - input, resumeToolCallIds(effectiveRuntimeContext)); + input, + resumeToolCallIds(effectiveRuntimeContext), + resumeInterrupts(effectiveRuntimeContext)); // Create stream options - use incremental mode for true streaming StreamOptions options = @@ -329,6 +332,25 @@ private Map resumeToolCallIds(RuntimeContext runtimeContext) { return Map.copyOf(toolCallIds); } + @SuppressWarnings("unchecked") + private Map resumeInterrupts(RuntimeContext runtimeContext) { + if (runtimeContext == null) { + return Map.of(); + } + Object value = runtimeContext.get(RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY); + if (!(value instanceof Map map)) { + return Map.of(); + } + Map interrupts = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() instanceof String key + && entry.getValue() instanceof AguiEvent.Interrupt interrupt) { + interrupts.put(key, interrupt); + } + } + return Map.copyOf(interrupts); + } + private ToolInjection injectFrontendTools(RunAgentInput input) { if (!input.hasTools()) { return ToolInjection.empty(); diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java index 57d5aafb68..e720d6d4bf 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java @@ -68,6 +68,7 @@ public AgentEventConverterRegistry( boolean emitSubagentEventsAsNative) { Map, AgentEventConverter> map = new LinkedHashMap<>(); register(map, new AgentLifecycleEventConverter()); + register(map, new PermissionConfirmEventConverter()); register(map, new TextBlockEventConverter()); register(map, new ThinkingBlockEventConverter()); register(map, new ToolCallEventConverter()); diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/PermissionConfirmEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/PermissionConfirmEventConverter.java new file mode 100644 index 0000000000..6e1d25e631 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/PermissionConfirmEventConverter.java @@ -0,0 +1,128 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agui.adapter.strategy; + +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.RequireUserConfirmEvent; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.util.JsonUtils; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Converts permission-mode HITL {@link RequireUserConfirmEvent}s into AG-UI interrupt outcomes. + * + *

When an agent runs under {@code PermissionMode.DEFAULT}, the permission engine returns {@code + * ASK} for non-readonly tools and {@code ReActAgent} emits a {@link RequireUserConfirmEvent} (paired + * with a {@code RequestStopEvent(PERMISSION_ASKING)}) instead of executing the tool. This is a + * distinct path from the tool-suspension flow handled by {@code AgentLifecycleEventConverter} (which + * is gated on {@code GenerateReason.TOOL_SUSPENDED}). Without a dedicated converter these + * permission-confirmation events fall through to the raw fallback and are lost to standard AG-UI + * clients. + * + *

Each pending {@link ToolUseBlock} is surfaced as one {@link AguiEvent.Interrupt} added to the + * stream context; {@code AgentLifecycleEventConverter} drains them into the {@code RUN_FINISHED} + * interrupt outcome on {@code AgentEndEvent}. The interrupt id reuses the {@code replyId:toolCallId} + * format so the resume path can recover the reply/tool-call identity. + * + *

The interrupt metadata carries {@code toolContent} — a valid JSON-object string of the tool + * arguments — so the resume path can rebuild a {@link ToolUseBlock} whose {@code content} is + * non-null. This matters because {@code ReActAgent.applyConfirmResults} fully replaces the stored + * {@code ToolUseBlock}, and tool-input validation reads {@code content} directly with no fallback to + * {@code input}; a null content would fail the resume with {@code argument "content" is null}. + */ +final class PermissionConfirmEventConverter implements AgentEventConverter { + + /** Interrupt reason used for permission-mode tool confirmations. */ + static final String CONFIRM_INTERRUPT_REASON = "tool_confirmation"; + + /** Metadata key: the tool name. */ + static final String METADATA_TOOL_NAME = "toolName"; + + /** Metadata key: the parsed tool arguments ({@code Map}). */ + static final String METADATA_TOOL_INPUT = "toolInput"; + + /** Metadata key: the tool arguments serialized as a JSON-object string. */ + static final String METADATA_TOOL_CONTENT = "toolContent"; + + /** Metadata key: the originating reply id. */ + static final String METADATA_REPLY_ID = "replyId"; + + @Override + public Set> eventTypes() { + return Set.of(RequireUserConfirmEvent.class); + } + + @Override + public void convert(AgentEvent event, AguiStreamContext context) { + if (!(event instanceof RequireUserConfirmEvent confirmEvent)) { + return; + } + String replyId = confirmEvent.getReplyId(); + List toolCalls = confirmEvent.getToolCalls(); + if (toolCalls == null) { + return; + } + for (ToolUseBlock toolUse : toolCalls) { + if (toolUse == null || isBlank(toolUse.getId())) { + continue; + } + context.addInterrupt(buildInterrupt(replyId, toolUse)); + } + } + + private static AguiEvent.Interrupt buildInterrupt(String replyId, ToolUseBlock toolUse) { + String toolCallId = toolUse.getId(); + Map metadata = new LinkedHashMap<>(); + if (!isBlank(toolUse.getName())) { + metadata.put(METADATA_TOOL_NAME, toolUse.getName()); + } + if (toolUse.getInput() != null && !toolUse.getInput().isEmpty()) { + metadata.put(METADATA_TOOL_INPUT, toolUse.getInput()); + } + metadata.put(METADATA_TOOL_CONTENT, JsonUtils.resolveToolCallArgsJson(toolUse)); + if (!isBlank(replyId)) { + metadata.put(METADATA_REPLY_ID, replyId); + } + return new AguiEvent.Interrupt( + interruptId(replyId, toolCallId), + CONFIRM_INTERRUPT_REASON, + confirmMessage(toolUse), + toolCallId, + null, + null, + Map.copyOf(metadata)); + } + + private static String confirmMessage(ToolUseBlock toolUse) { + String name = isBlank(toolUse.getName()) ? "tool" : toolUse.getName(); + return "Tool '" + name + "' requires user confirmation before execution"; + } + + private static String interruptId(String replyId, String toolCallId) { + if (!isBlank(replyId)) { + return replyId + ":" + toolCallId; + } + return toolCallId; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java index bacfcb97fc..1f8bd9e18b 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java @@ -16,6 +16,7 @@ package io.agentscope.core.agui.converter; import com.fasterxml.jackson.core.type.TypeReference; +import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.AguiFunctionCall; import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.AguiResume; @@ -30,6 +31,7 @@ import io.agentscope.core.agui.model.RunAgentInput; import io.agentscope.core.agui.model.TextInputContent; import io.agentscope.core.agui.model.VideoInputContent; +import io.agentscope.core.event.ConfirmResult; import io.agentscope.core.message.AudioBlock; import io.agentscope.core.message.Base64Source; import io.agentscope.core.message.ContentBlock; @@ -58,6 +60,19 @@ * message format and AgentScope's internal message format. */ public class AguiMessageConverter { + + /** Interrupt reason emitted for permission-mode tool confirmations. */ + private static final String CONFIRM_INTERRUPT_REASON = "tool_confirmation"; + + /** Interrupt metadata key: the tool name. */ + private static final String METADATA_TOOL_NAME = "toolName"; + + /** Interrupt metadata key: the parsed tool arguments. */ + private static final String METADATA_TOOL_INPUT = "toolInput"; + + /** Interrupt metadata key: the tool arguments serialized as a JSON-object string. */ + private static final String METADATA_TOOL_CONTENT = "toolContent"; + /** * Creates a new AguiMessageConverter */ @@ -168,11 +183,40 @@ public List toMsgList(RunAgentInput input) { * @return The converted AgentScope messages */ public List toMsgList(RunAgentInput input, Map resumeToolCallIds) { + return toMsgList(input, resumeToolCallIds, Map.of()); + } + + /** + * Convert an AG-UI run input to AgentScope messages, resolving resume entries through known + * interrupt-to-tool-call mappings and the originating interrupts. + * + *

Resume entries whose originating interrupt was a permission-mode tool confirmation (reason + * {@code tool_confirmation}) are converted into a {@code ConfirmResult}-carrying message so + * {@code ReActAgent} can promote/deny the ASKING tool call and resume. All other resume entries + * fall back to the tool-result message form used by the tool-suspension flow. + * + * @param input The AG-UI run input + * @param resumeToolCallIds Mapping from interrupt ID to tool call ID + * @param resumeInterrupts Mapping from interrupt ID to the originating interrupt + * @return The converted AgentScope messages + */ + public List toMsgList( + RunAgentInput input, + Map resumeToolCallIds, + Map resumeInterrupts) { Objects.requireNonNull(input, "input cannot be null"); List msgs = new ArrayList<>(toMsgList(input.getMessages())); + Map interrupts = + resumeInterrupts != null ? resumeInterrupts : Map.of(); for (AguiResume resume : input.getResume()) { String toolCallId = resolveToolCallId(resume.getInterruptId(), resumeToolCallIds); - if (toolCallId != null && !toolCallId.isBlank()) { + if (toolCallId == null || toolCallId.isBlank()) { + continue; + } + AguiEvent.Interrupt interrupt = interrupts.get(resume.getInterruptId()); + if (interrupt != null && CONFIRM_INTERRUPT_REASON.equals(interrupt.reason())) { + msgs.add(toConfirmResultMsg(resume, toolCallId, interrupt)); + } else { msgs.add(toToolResultMsg(resume, toolCallId)); } } @@ -361,6 +405,80 @@ private Msg toToolResultMsg(AguiResume resume, String toolCallId) { .build(); } + /** + * Build a {@link Msg} carrying a {@link ConfirmResult} for a permission-mode tool confirmation + * resume. + * + *

The resulting Msg is a USER-role message whose metadata contains a single-entry {@code + * List} under {@link Msg#METADATA_CONFIRM_RESULTS}. The {@code ToolUseBlock} + * inside the {@link ConfirmResult} is reconstructed from the originating interrupt metadata so + * that its {@code content} field (the tool arguments as a JSON-object string) is guaranteed + * non-null, avoiding the {@code "argument content is null"} bug in + * {@code ReActAgent.applyConfirmResults}. + * + * @param resume the AG-UI resume entry + * @param toolCallId the resolved tool call ID + * @param interrupt the originating interrupt containing tool metadata + * @return a USER-role Msg with the confirmation result + */ + @SuppressWarnings("unchecked") + private Msg toConfirmResultMsg( + AguiResume resume, String toolCallId, AguiEvent.Interrupt interrupt) { + boolean approved = isApproved(resume); + Map metadata = interrupt.metadata(); + String toolName = metadata != null ? stringValue(metadata.get(METADATA_TOOL_NAME)) : null; + Map toolInput = null; + String toolContent = null; + if (metadata != null) { + Object inputObj = metadata.get(METADATA_TOOL_INPUT); + if (inputObj instanceof Map) { + toolInput = (Map) inputObj; + } + toolContent = stringValue(metadata.get(METADATA_TOOL_CONTENT)); + } + + ToolUseBlock toolUseBlock = + ToolUseBlock.builder() + .id(toolCallId) + .name(toolName) + .input(toolInput) + .content(toolContent) + .build(); + + ConfirmResult confirmResult = new ConfirmResult(approved, toolUseBlock); + return Msg.builder() + .id("agui-confirm-" + resume.getInterruptId()) + .role(MsgRole.USER) + .textContent(approved ? "approved" : "denied") + .metadata(Map.of(Msg.METADATA_CONFIRM_RESULTS, List.of(confirmResult))) + .build(); + } + + /** + * Determine whether the user approved the tool. + * + *

If the status is {@code cancelled}, the tool is denied. Otherwise, the payload is checked + * for an explicit {@code approved} boolean field; if absent, the tool is approved. + */ + @SuppressWarnings("unchecked") + private static boolean isApproved(AguiResume resume) { + if (resume.isCancelled()) { + return false; + } + Object payload = resume.getPayload(); + if (payload instanceof Map map) { + Object approved = map.get("approved"); + if (approved instanceof Boolean) { + return (Boolean) approved; + } + } + return true; + } + + private static String stringValue(Object value) { + return value instanceof String s ? s : null; + } + private String resumeContent(AguiResume resume) { if (resume.isCancelled()) { return "Interrupt cancelled by user"; diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiResumeCoordinator.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiResumeCoordinator.java index 86be3b82e0..b78b6d1e26 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiResumeCoordinator.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiResumeCoordinator.java @@ -40,6 +40,9 @@ final class AguiResumeCoordinator { static final String CONTRACT_ERROR_CODE = "AGUI_INTERRUPT_CONTRACT_ERROR"; + /** Interrupt reason emitted for permission-mode tool confirmations. */ + private static final String CONFIRM_INTERRUPT_REASON = "tool_confirmation"; + private final ConcurrentMap> pendingInterruptsByThread = new ConcurrentHashMap<>(); private final ConcurrentMap activeRunsByThread = new ConcurrentHashMap<>(); @@ -150,13 +153,18 @@ RuntimeContext addResumeToolCallIds(RunAgentInput input, RuntimeContext runtimeC return runtimeContext; } Map toolCallIds = new LinkedHashMap<>(); + Map resumeInterrupts = new LinkedHashMap<>(); for (AguiResume resume : input.getResume()) { AguiEvent.Interrupt interrupt = pending.get(resume.getInterruptId()); - if (interrupt != null - && "tool_call".equals(interrupt.reason()) - && interrupt.toolCallId() != null - && !interrupt.toolCallId().isBlank()) { + if (interrupt == null + || interrupt.toolCallId() == null + || interrupt.toolCallId().isBlank()) { + continue; + } + if ("tool_call".equals(interrupt.reason()) + || CONFIRM_INTERRUPT_REASON.equals(interrupt.reason())) { toolCallIds.put(resume.getInterruptId(), interrupt.toolCallId()); + resumeInterrupts.put(resume.getInterruptId(), interrupt); } } if (toolCallIds.isEmpty()) { @@ -166,6 +174,9 @@ RuntimeContext addResumeToolCallIds(RunAgentInput input, RuntimeContext runtimeC .put( AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY, Map.copyOf(toolCallIds)) + .put( + AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY, + Map.copyOf(resumeInterrupts)) .build(); } 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 a8c1288af2..dbc89f799e 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 @@ -48,6 +48,7 @@ import io.agentscope.core.event.CustomEvent; import io.agentscope.core.event.DataBlockStartEvent; import io.agentscope.core.event.ModelCallEndEvent; +import io.agentscope.core.event.RequireUserConfirmEvent; import io.agentscope.core.event.TextBlockDeltaEvent; import io.agentscope.core.event.TextBlockEndEvent; import io.agentscope.core.event.TextBlockStartEvent; @@ -821,6 +822,123 @@ void testOnlySuspendedToolIsInterruptedWhenParallelToolCallsPartiallyComplete() assertEquals("tool-2", outcome.interrupts().get(0).toolCallId()); } + @Test + void testPermissionConfirmEventFinishesRunWithToolConfirmationInterrupt() { + ToolUseBlock pending = + ToolUseBlock.builder() + .id("tool-1") + .name("echo") + .input(Map.of("message", "hello")) + .build(); + + List events = + runReActEvents( + new AgentStartEvent("thread-v2", "reply-confirm", "react"), + new RequireUserConfirmEvent("reply-confirm", List.of(pending)), + new AgentEndEvent("reply-confirm")); + + assertEquals( + List.of(AguiEventType.RUN_STARTED, AguiEventType.RUN_FINISHED), types(events)); + + AguiEvent.RunFinished finished = + assertInstanceOf(AguiEvent.RunFinished.class, events.get(1)); + AguiEvent.RunFinishedInterruptOutcome outcome = + assertInstanceOf( + AguiEvent.RunFinishedInterruptOutcome.class, finished.outcome()); + assertEquals(1, outcome.interrupts().size()); + AguiEvent.Interrupt interrupt = outcome.interrupts().get(0); + assertEquals("reply-confirm:tool-1", interrupt.id()); + assertEquals("tool_confirmation", interrupt.reason()); + assertEquals("tool-1", interrupt.toolCallId()); + assertNull(interrupt.responseSchema()); + assertNull(interrupt.expiresAt()); + assertTrue(interrupt.message().contains("echo")); + assertEquals("echo", interrupt.metadata().get("toolName")); + assertEquals(Map.of("message", "hello"), interrupt.metadata().get("toolInput")); + assertEquals("reply-confirm", interrupt.metadata().get("replyId")); + assertTrue(interrupt.metadata().get("toolContent").toString().contains("hello")); + } + + @Test + void testPermissionConfirmEventEmitsOneInterruptPerPendingToolCall() { + ToolUseBlock first = + ToolUseBlock.builder() + .id("tool-1") + .name("echo") + .input(Map.of("message", "one")) + .build(); + ToolUseBlock second = + ToolUseBlock.builder() + .id("tool-2") + .name("echo") + .input(Map.of("message", "two")) + .build(); + + List events = + runReActEvents( + new AgentStartEvent("thread-v2", "reply-confirm", "react"), + new RequireUserConfirmEvent("reply-confirm", List.of(first, second)), + new AgentEndEvent("reply-confirm")); + + AguiEvent.RunFinished finished = + assertInstanceOf(AguiEvent.RunFinished.class, events.get(1)); + AguiEvent.RunFinishedInterruptOutcome outcome = + assertInstanceOf( + AguiEvent.RunFinishedInterruptOutcome.class, finished.outcome()); + assertEquals( + List.of("reply-confirm:tool-1", "reply-confirm:tool-2"), + outcome.interrupts().stream().map(AguiEvent.Interrupt::id).toList()); + } + + @Test + void testPermissionConfirmEventWithoutReplyIdUsesToolCallIdAsInterruptId() { + ToolUseBlock pending = ToolUseBlock.builder().id("tool-1").name("echo").build(); + + List events = + runReActEvents( + new AgentStartEvent("thread-v2", "reply-confirm", "react"), + new RequireUserConfirmEvent(null, List.of(pending)), + new AgentEndEvent("reply-confirm")); + + AguiEvent.RunFinished finished = + assertInstanceOf(AguiEvent.RunFinished.class, events.get(1)); + AguiEvent.RunFinishedInterruptOutcome outcome = + assertInstanceOf( + AguiEvent.RunFinishedInterruptOutcome.class, finished.outcome()); + AguiEvent.Interrupt interrupt = outcome.interrupts().get(0); + assertEquals("tool-1", interrupt.id()); + assertNull(interrupt.metadata().get("replyId")); + assertFalse(interrupt.metadata().containsKey("toolInput")); + } + + @Test + void testPermissionConfirmEventSkipsToolCallsWithoutStableId() { + ToolUseBlock invalid = ToolUseBlock.builder().id("").name("echo").build(); + + List events = + runReActEvents( + new AgentStartEvent("thread-v2", "reply-confirm", "react"), + new RequireUserConfirmEvent("reply-confirm", List.of(invalid)), + new AgentEndEvent("reply-confirm")); + + AguiEvent.RunFinished finished = + assertInstanceOf(AguiEvent.RunFinished.class, events.get(1)); + assertNull(finished.outcome()); + } + + @Test + void testPermissionConfirmEventWithNullToolCallsFinishesRunWithoutInterrupt() { + List events = + runReActEvents( + new AgentStartEvent("thread-v2", "reply-confirm", "react"), + new RequireUserConfirmEvent("reply-confirm", null), + new AgentEndEvent("reply-confirm")); + + AguiEvent.RunFinished finished = + assertInstanceOf(AguiEvent.RunFinished.class, events.get(1)); + assertNull(finished.outcome()); + } + @Test void testToolCallWithoutArgsStillEmitsStartAndEnd() { List events = diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java index 4838d51591..8250b91776 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.AguiFunctionCall; import io.agentscope.core.agui.model.AguiMessage; import io.agentscope.core.agui.model.AguiResume; @@ -35,6 +36,7 @@ import io.agentscope.core.agui.model.RunAgentInput; import io.agentscope.core.agui.model.TextInputContent; import io.agentscope.core.agui.model.VideoInputContent; +import io.agentscope.core.event.ConfirmResult; import io.agentscope.core.message.AudioBlock; import io.agentscope.core.message.Base64Source; import io.agentscope.core.message.ImageBlock; @@ -635,6 +637,168 @@ void testConvertBlocksContentEmptyArray() { assertTrue(msg.getContent().isEmpty()); } + @Test + void testConvertConfirmationResumeResolvedBuildsConfirmResultWithNonNullContent() { + AguiEvent.Interrupt interrupt = + new AguiEvent.Interrupt( + "reply-1:tool-call-1", + "tool_confirmation", + "Tool 'echo' requires user confirmation before execution", + "tool-call-1", + null, + null, + Map.of( + "toolName", "echo", + "toolInput", Map.of("message", "hello"), + "toolContent", "{\"message\":\"hello\"}", + "replyId", "reply-1")); + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-2") + .resume( + List.of( + new AguiResume( + "reply-1:tool-call-1", + AguiResume.STATUS_RESOLVED, + Map.of("approved", true)))) + .build(); + + List msgs = + converter.toMsgList( + input, + Map.of("reply-1:tool-call-1", "tool-call-1"), + Map.of("reply-1:tool-call-1", interrupt)); + + assertEquals(1, msgs.size()); + Msg confirmMsg = msgs.get(0); + assertEquals(MsgRole.USER, confirmMsg.getRole()); + + Object raw = confirmMsg.getMetadata().get(Msg.METADATA_CONFIRM_RESULTS); + assertNotNull(raw); + assertTrue(raw instanceof List); + List results = (List) raw; + assertEquals(1, results.size()); + + ConfirmResult cr = (ConfirmResult) results.get(0); + assertTrue(cr.isConfirmed()); + ToolUseBlock toolCall = cr.getToolCall(); + assertNotNull(toolCall); + assertEquals("tool-call-1", toolCall.getId()); + assertEquals("echo", toolCall.getName()); + // Content must be non-null to avoid the resume validation failure. + assertNotNull(toolCall.getContent()); + assertTrue(toolCall.getContent().contains("hello")); + assertEquals("hello", toolCall.getInput().get("message")); + } + + @Test + void testConvertConfirmationResumeCancelledBuildsDeniedConfirmResult() { + AguiEvent.Interrupt interrupt = + new AguiEvent.Interrupt( + "reply-1:tool-call-1", + "tool_confirmation", + "confirm", + "tool-call-1", + null, + null, + Map.of("toolName", "echo", "toolContent", "{\"message\":\"hi\"}")); + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-2") + .resume( + List.of( + new AguiResume( + "reply-1:tool-call-1", + AguiResume.STATUS_CANCELLED, + null))) + .build(); + + List msgs = + converter.toMsgList( + input, + Map.of("reply-1:tool-call-1", "tool-call-1"), + Map.of("reply-1:tool-call-1", interrupt)); + + ConfirmResult cr = + (ConfirmResult) + ((List) msgs.get(0).getMetadata().get(Msg.METADATA_CONFIRM_RESULTS)) + .get(0); + assertFalse(cr.isConfirmed()); + assertNotNull(cr.getToolCall().getContent()); + } + + @Test + void testConfirmationResumeRespectsExplicitApprovedFalsePayload() { + AguiEvent.Interrupt interrupt = + new AguiEvent.Interrupt( + "reply-1:tool-call-1", + "tool_confirmation", + "confirm", + "tool-call-1", + null, + null, + Map.of("toolName", "echo", "toolContent", "{\"message\":\"hi\"}")); + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-2") + .resume( + List.of( + new AguiResume( + "reply-1:tool-call-1", + AguiResume.STATUS_RESOLVED, + Map.of("approved", false)))) + .build(); + + List msgs = + converter.toMsgList( + input, + Map.of("reply-1:tool-call-1", "tool-call-1"), + Map.of("reply-1:tool-call-1", interrupt)); + + ConfirmResult cr = + (ConfirmResult) + ((List) msgs.get(0).getMetadata().get(Msg.METADATA_CONFIRM_RESULTS)) + .get(0); + assertFalse(cr.isConfirmed()); + } + + @Test + void testResumeWithoutConfirmationInterruptStillUsesToolResultPath() { + AguiEvent.Interrupt suspendInterrupt = + new AguiEvent.Interrupt( + "reply-1:tool-call-1", + "tool_call", + "suspended", + "tool-call-1", + null, + null, + null); + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-2") + .resume( + List.of( + new AguiResume( + "reply-1:tool-call-1", + AguiResume.STATUS_RESOLVED, + "done"))) + .build(); + + List msgs = + converter.toMsgList( + input, + Map.of("reply-1:tool-call-1", "tool-call-1"), + Map.of("reply-1:tool-call-1", suspendInterrupt)); + + // Non-confirmation interrupt must still produce a TOOL-role ToolResultBlock message. + assertEquals(MsgRole.TOOL, msgs.get(0).getRole()); + assertNotNull(msgs.get(0).getFirstContentBlock(ToolResultBlock.class)); + } + private static String resultText(ToolResultBlock result) { return result.getOutput().stream() .filter(TextBlock.class::isInstance) diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiResumeCoordinatorTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiResumeCoordinatorTest.java index 1a71b7c87a..d31701c965 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiResumeCoordinatorTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiResumeCoordinatorTest.java @@ -18,6 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import io.agentscope.core.agent.RuntimeContext; @@ -100,6 +101,68 @@ void addResumeToolCallIdsAddsKnownToolMappingsToRuntimeContext() { context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY)); } + @Test + void addResumeToolCallIdsAddsConfirmationInterruptToRuntimeContext() { + AguiResumeCoordinator coordinator = new AguiResumeCoordinator(); + AguiEvent.Interrupt confirmation = + new AguiEvent.Interrupt( + "interrupt-1", + "tool_confirmation", + "confirm echo", + "tool-call-1", + null, + null, + Map.of("toolName", "echo")); + track(coordinator, "run-1", interruptedFinished("run-1", confirmation), false); + + RuntimeContext context = + coordinator.addResumeToolCallIds( + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-2") + .resume( + List.of( + new AguiResume( + "interrupt-1", + AguiResume.STATUS_RESOLVED, + Map.of("approved", true)))) + .build(), + null); + + assertEquals( + Map.of("interrupt-1", "tool-call-1"), + context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY)); + Object interrupts = context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY); + assertEquals(Map.of("interrupt-1", confirmation), interrupts); + } + + @Test + void addResumeToolCallIdsIgnoresInterruptsWithoutToolCallId() { + AguiResumeCoordinator coordinator = new AguiResumeCoordinator(); + AguiEvent.Interrupt custom = + new AguiEvent.Interrupt( + "interrupt-1", "custom_reason", "no tool", null, null, null, null); + track(coordinator, "run-1", interruptedFinished("run-1", custom), false); + + RuntimeContext context = + coordinator.addResumeToolCallIds( + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-2") + .resume( + List.of( + new AguiResume( + "interrupt-1", + AguiResume.STATUS_RESOLVED, + Map.of("approved", true)))) + .build(), + RuntimeContext.builder().put("tenant", "tenant-a").build()); + + assertEquals("tenant-a", context.get("tenant")); + assertNull(context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY)); + assertNull(context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY)); + } + @Test void trackDoesNotClearPendingInterruptsAfterRunError() { AguiResumeCoordinator coordinator = new AguiResumeCoordinator(); From 11892293957284cab59a67266ad25dab1b306b1b Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 8 Aug 2026 22:44:01 +0800 Subject: [PATCH 2/2] fix(agui): emit AG-UI interrupt for permission-type HITL tool confirmation --- .../core/agui/AguiInterruptConstants.java | 46 ++++ .../core/agui/adapter/AguiAgentAdapter.java | 23 +- .../AgentLifecycleEventConverter.java | 33 +-- .../PermissionConfirmEventConverter.java | 61 ++--- .../agui/converter/AguiMessageConverter.java | 119 ++++++---- .../agui/processor/AguiRequestProcessor.java | 2 +- .../agui/processor/AguiResumeCoordinator.java | 32 ++- .../agui/adapter/AguiAgentAdapterV2Test.java | 67 +++++- .../converter/AguiMessageConverterTest.java | 211 +++++++++++++++--- .../processor/AguiResumeCoordinatorTest.java | 25 +-- docs/v2/en/integration/protocol/agui.md | 36 ++- docs/v2/zh/integration/protocol/agui.md | 34 ++- 12 files changed, 503 insertions(+), 186 deletions(-) create mode 100644 agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/AguiInterruptConstants.java diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/AguiInterruptConstants.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/AguiInterruptConstants.java new file mode 100644 index 0000000000..77c5c057aa --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/AguiInterruptConstants.java @@ -0,0 +1,46 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.agui; + +/** Constants for AG-UI interrupt reasons and AgentScope interrupt metadata. */ +public final class AguiInterruptConstants { + + private AguiInterruptConstants() {} + + /** AG-UI interrupt reason for tool-bound decisions. */ + public static final String TOOL_CALL_INTERRUPT_REASON = "tool_call"; + + /** AG-UI interrupt reason for structured input requests. */ + public static final String INPUT_REQUIRED_INTERRUPT_REASON = "input_required"; + + /** Interrupt metadata key: AgentScope-specific interrupt kind. */ + public static final String METADATA_AGENTSCOPE_INTERRUPT_KIND = "agentscope.interruptKind"; + + /** Interrupt metadata value for permission-mode tool confirmations. */ + public static final String INTERRUPT_KIND_PERMISSION_CONFIRM = "permission_confirm"; + + /** Interrupt metadata key: the tool name. */ + public static final String METADATA_TOOL_NAME = "toolName"; + + /** Interrupt metadata key: the parsed tool arguments. */ + public static final String METADATA_TOOL_INPUT = "toolInput"; + + /** Interrupt metadata key: the tool arguments serialized as a JSON-object string. */ + public static final String METADATA_TOOL_CONTENT = "toolContent"; + + /** Interrupt metadata key: the originating reply id. */ + public static final String METADATA_REPLY_ID = "replyId"; +} diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java index 91fa6aac2c..5fbb24187e 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java @@ -85,7 +85,6 @@ public class AguiAgentAdapter { public static final String RUNTIME_CONTEXT_STATE_KEY = "agui.state"; public static final String RUNTIME_CONTEXT_FORWARDED_PROPS_KEY = "agui.forwardedProps"; public static final String RUNTIME_CONTEXT_RESUME_KEY = "agui.resume"; - public static final String RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY = "agui.resume.toolCallIds"; public static final String RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY = "agui.resume.interrupts"; private final Agent agent; @@ -148,9 +147,7 @@ public Flux run(RunAgentInput input, RuntimeContext runtimeContext) { // Convert AG-UI messages and official resume entries to AgentScope messages. List msgs = messageConverter.toMsgList( - input, - resumeToolCallIds(effectiveRuntimeContext), - resumeInterrupts(effectiveRuntimeContext)); + input, resumeInterrupts(effectiveRuntimeContext)); // Create stream options - use incremental mode for true streaming StreamOptions options = @@ -314,24 +311,6 @@ protected RuntimeContext buildRuntimeContext( .build(); } - @SuppressWarnings("unchecked") - private Map resumeToolCallIds(RuntimeContext runtimeContext) { - if (runtimeContext == null) { - return Map.of(); - } - Object value = runtimeContext.get(RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY); - if (!(value instanceof Map map)) { - return Map.of(); - } - Map toolCallIds = new LinkedHashMap<>(); - for (Map.Entry entry : map.entrySet()) { - if (entry.getKey() instanceof String key && entry.getValue() instanceof String id) { - toolCallIds.put(key, id); - } - } - return Map.copyOf(toolCallIds); - } - @SuppressWarnings("unchecked") private Map resumeInterrupts(RuntimeContext runtimeContext) { if (runtimeContext == null) { 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 62ce4617c8..16e07edc71 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 @@ -15,6 +15,12 @@ */ package io.agentscope.core.agui.adapter.strategy; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_REPLY_ID; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_CONTENT; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_INPUT; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_NAME; +import static io.agentscope.core.agui.AguiInterruptConstants.TOOL_CALL_INTERRUPT_REASON; + import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; @@ -27,6 +33,7 @@ import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.util.JsonUtils; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -98,11 +105,14 @@ private static void collectSuspendedToolInterrupts( } for (ContentBlock block : result.getContent()) { - if (!(block instanceof ToolResultBlock toolResult) - || !toolResult.isSuspended() - || isBlank(toolResult.getId())) { + if (!(block instanceof ToolResultBlock toolResult) || !toolResult.isSuspended()) { continue; } + if (isBlank(toolResult.getId())) { + throw new IllegalStateException( + "TOOL_SUSPENDED result contains a suspended tool result without a stable" + + " id"); + } context.addInterrupt( buildToolCallInterrupt(result, toolUses.get(toolResult.getId()), toolResult)); } @@ -112,28 +122,25 @@ private static AguiEvent.Interrupt buildToolCallInterrupt( Msg result, ToolUseBlock toolUse, ToolResultBlock toolResult) { String toolCallId = toolResult.getId(); Map metadata = new LinkedHashMap<>(); - String toolName = - toolUse != null && !isBlank(toolUse.getName()) - ? toolUse.getName() - : toolResult.getName(); - if (!isBlank(toolName)) { - metadata.put("toolName", toolName); + if (!isBlank(toolUse.getName())) { + metadata.put(METADATA_TOOL_NAME, toolUse.getName()); } if (toolUse != null && toolUse.getInput() != null && !toolUse.getInput().isEmpty()) { - metadata.put("toolInput", toolUse.getInput()); + metadata.put(METADATA_TOOL_INPUT, toolUse.getInput()); } + metadata.put(METADATA_TOOL_CONTENT, JsonUtils.resolveToolCallArgsJson(toolUse)); if (!isBlank(result.getId())) { - metadata.put("replyId", result.getId()); + metadata.put(METADATA_REPLY_ID, result.getId()); } return new AguiEvent.Interrupt( interruptId(result, toolCallId), - "tool_call", + TOOL_CALL_INTERRUPT_REASON, extractText(toolResult.getOutput()), toolCallId, null, null, - metadata.isEmpty() ? null : Map.copyOf(metadata)); + Map.copyOf(metadata)); } private static String interruptId(Msg result, String toolCallId) { diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/PermissionConfirmEventConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/PermissionConfirmEventConverter.java index 6e1d25e631..ea1d5bef30 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/PermissionConfirmEventConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/PermissionConfirmEventConverter.java @@ -15,6 +15,14 @@ */ package io.agentscope.core.agui.adapter.strategy; +import static io.agentscope.core.agui.AguiInterruptConstants.INTERRUPT_KIND_PERMISSION_CONFIRM; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_AGENTSCOPE_INTERRUPT_KIND; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_REPLY_ID; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_CONTENT; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_INPUT; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_NAME; +import static io.agentscope.core.agui.AguiInterruptConstants.TOOL_CALL_INTERRUPT_REASON; + import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.event.AgentEvent; import io.agentscope.core.event.RequireUserConfirmEvent; @@ -32,9 +40,7 @@ * ASK} for non-readonly tools and {@code ReActAgent} emits a {@link RequireUserConfirmEvent} (paired * with a {@code RequestStopEvent(PERMISSION_ASKING)}) instead of executing the tool. This is a * distinct path from the tool-suspension flow handled by {@code AgentLifecycleEventConverter} (which - * is gated on {@code GenerateReason.TOOL_SUSPENDED}). Without a dedicated converter these - * permission-confirmation events fall through to the raw fallback and are lost to standard AG-UI - * clients. + * is gated on {@code GenerateReason.TOOL_SUSPENDED}). * *

Each pending {@link ToolUseBlock} is surfaced as one {@link AguiEvent.Interrupt} added to the * stream context; {@code AgentLifecycleEventConverter} drains them into the {@code RUN_FINISHED} @@ -49,20 +55,22 @@ */ final class PermissionConfirmEventConverter implements AgentEventConverter { - /** Interrupt reason used for permission-mode tool confirmations. */ - static final String CONFIRM_INTERRUPT_REASON = "tool_confirmation"; - - /** Metadata key: the tool name. */ - static final String METADATA_TOOL_NAME = "toolName"; - - /** Metadata key: the parsed tool arguments ({@code Map}). */ - static final String METADATA_TOOL_INPUT = "toolInput"; - - /** Metadata key: the tool arguments serialized as a JSON-object string. */ - static final String METADATA_TOOL_CONTENT = "toolContent"; - - /** Metadata key: the originating reply id. */ - static final String METADATA_REPLY_ID = "replyId"; + private static final Map CONFIRM_RESPONSE_SCHEMA = + Map.of( + "type", + "object", + "properties", + Map.of( + "approved", + Map.of("type", "boolean"), + "editedArgs", + Map.of( + "type", + "object", + "description", + "Full replacement of the tool args. Not merged.")), + "required", + List.of("approved")); @Override public Set> eventTypes() { @@ -71,17 +79,15 @@ public Set> eventTypes() { @Override public void convert(AgentEvent event, AguiStreamContext context) { - if (!(event instanceof RequireUserConfirmEvent confirmEvent)) { - return; - } + RequireUserConfirmEvent confirmEvent = (RequireUserConfirmEvent) event; String replyId = confirmEvent.getReplyId(); + List toolCalls = confirmEvent.getToolCalls(); - if (toolCalls == null) { - return; - } for (ToolUseBlock toolUse : toolCalls) { - if (toolUse == null || isBlank(toolUse.getId())) { - continue; + // toolUse will not be null, see RequireUserConfirmEvent constructor + if (isBlank(toolUse.getId())) { + throw new IllegalStateException( + "RequireUserConfirmEvent contains a tool call without a stable id"); } context.addInterrupt(buildInterrupt(replyId, toolUse)); } @@ -97,15 +103,16 @@ private static AguiEvent.Interrupt buildInterrupt(String replyId, ToolUseBlock t metadata.put(METADATA_TOOL_INPUT, toolUse.getInput()); } metadata.put(METADATA_TOOL_CONTENT, JsonUtils.resolveToolCallArgsJson(toolUse)); + metadata.put(METADATA_AGENTSCOPE_INTERRUPT_KIND, INTERRUPT_KIND_PERMISSION_CONFIRM); if (!isBlank(replyId)) { metadata.put(METADATA_REPLY_ID, replyId); } return new AguiEvent.Interrupt( interruptId(replyId, toolCallId), - CONFIRM_INTERRUPT_REASON, + TOOL_CALL_INTERRUPT_REASON, confirmMessage(toolUse), toolCallId, - null, + CONFIRM_RESPONSE_SCHEMA, null, Map.copyOf(metadata)); } diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java index 1f8bd9e18b..1aaaf3c448 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java @@ -15,6 +15,13 @@ */ package io.agentscope.core.agui.converter; +import static io.agentscope.core.agui.AguiInterruptConstants.INTERRUPT_KIND_PERMISSION_CONFIRM; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_AGENTSCOPE_INTERRUPT_KIND; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_CONTENT; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_INPUT; +import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_NAME; +import static io.agentscope.core.agui.AguiInterruptConstants.TOOL_CALL_INTERRUPT_REASON; + import com.fasterxml.jackson.core.type.TypeReference; import io.agentscope.core.agui.event.AguiEvent; import io.agentscope.core.agui.model.AguiFunctionCall; @@ -48,6 +55,8 @@ import io.agentscope.core.util.JsonException; import io.agentscope.core.util.JsonUtils; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -61,17 +70,11 @@ */ public class AguiMessageConverter { - /** Interrupt reason emitted for permission-mode tool confirmations. */ - private static final String CONFIRM_INTERRUPT_REASON = "tool_confirmation"; - - /** Interrupt metadata key: the tool name. */ - private static final String METADATA_TOOL_NAME = "toolName"; - - /** Interrupt metadata key: the parsed tool arguments. */ - private static final String METADATA_TOOL_INPUT = "toolInput"; + /** AG-UI resume payload key: user approved the tool call. */ + private static final String RESUME_PAYLOAD_APPROVED = "approved"; - /** Interrupt metadata key: the tool arguments serialized as a JSON-object string. */ - private static final String METADATA_TOOL_CONTENT = "toolContent"; + /** AG-UI resume payload key: full replacement tool arguments. */ + private static final String RESUME_PAYLOAD_EDITED_ARGS = "editedArgs"; /** * Creates a new AguiMessageConverter @@ -176,45 +179,25 @@ public List toMsgList(RunAgentInput input) { /** * Convert an AG-UI run input to AgentScope messages, resolving resume entries through known - * interrupt-to-tool-call mappings when available. - * - * @param input The AG-UI run input - * @param resumeToolCallIds Mapping from interrupt ID to tool call ID - * @return The converted AgentScope messages - */ - public List toMsgList(RunAgentInput input, Map resumeToolCallIds) { - return toMsgList(input, resumeToolCallIds, Map.of()); - } - - /** - * Convert an AG-UI run input to AgentScope messages, resolving resume entries through known - * interrupt-to-tool-call mappings and the originating interrupts. - * - *

Resume entries whose originating interrupt was a permission-mode tool confirmation (reason - * {@code tool_confirmation}) are converted into a {@code ConfirmResult}-carrying message so - * {@code ReActAgent} can promote/deny the ASKING tool call and resume. All other resume entries - * fall back to the tool-result message form used by the tool-suspension flow. + * originating interrupts when available. * * @param input The AG-UI run input - * @param resumeToolCallIds Mapping from interrupt ID to tool call ID * @param resumeInterrupts Mapping from interrupt ID to the originating interrupt * @return The converted AgentScope messages */ public List toMsgList( - RunAgentInput input, - Map resumeToolCallIds, - Map resumeInterrupts) { + RunAgentInput input, Map resumeInterrupts) { Objects.requireNonNull(input, "input cannot be null"); List msgs = new ArrayList<>(toMsgList(input.getMessages())); Map interrupts = resumeInterrupts != null ? resumeInterrupts : Map.of(); for (AguiResume resume : input.getResume()) { - String toolCallId = resolveToolCallId(resume.getInterruptId(), resumeToolCallIds); + AguiEvent.Interrupt interrupt = interrupts.get(resume.getInterruptId()); + String toolCallId = resolveToolCallId(resume.getInterruptId(), interrupt); if (toolCallId == null || toolCallId.isBlank()) { continue; } - AguiEvent.Interrupt interrupt = interrupts.get(resume.getInterruptId()); - if (interrupt != null && CONFIRM_INTERRUPT_REASON.equals(interrupt.reason())) { + if (isPermissionConfirmInterrupt(interrupt)) { msgs.add(toConfirmResultMsg(resume, toolCallId, interrupt)); } else { msgs.add(toToolResultMsg(resume, toolCallId)); @@ -426,16 +409,22 @@ private Msg toConfirmResultMsg( AguiResume resume, String toolCallId, AguiEvent.Interrupt interrupt) { boolean approved = isApproved(resume); Map metadata = interrupt.metadata(); + String toolName = metadata != null ? stringValue(metadata.get(METADATA_TOOL_NAME)) : null; Map toolInput = null; String toolContent = null; if (metadata != null) { Object inputObj = metadata.get(METADATA_TOOL_INPUT); if (inputObj instanceof Map) { - toolInput = (Map) inputObj; + toolInput = toStringObjectMap(inputObj, METADATA_TOOL_INPUT); } toolContent = stringValue(metadata.get(METADATA_TOOL_CONTENT)); } + Map editedArgs = editedArgs(resume); + if (editedArgs != null) { + toolInput = editedArgs; + toolContent = serializeArguments(editedArgs); + } ToolUseBlock toolUseBlock = ToolUseBlock.builder() @@ -457,8 +446,8 @@ private Msg toConfirmResultMsg( /** * Determine whether the user approved the tool. * - *

If the status is {@code cancelled}, the tool is denied. Otherwise, the payload is checked - * for an explicit {@code approved} boolean field; if absent, the tool is approved. + *

If the status is {@code cancelled}, the tool is denied. Otherwise, only an explicit + * {@code approved: true} boolean field approves the tool. */ @SuppressWarnings("unchecked") private static boolean isApproved(AguiResume resume) { @@ -467,18 +456,48 @@ private static boolean isApproved(AguiResume resume) { } Object payload = resume.getPayload(); if (payload instanceof Map map) { - Object approved = map.get("approved"); - if (approved instanceof Boolean) { - return (Boolean) approved; + return Boolean.TRUE.equals(map.get(RESUME_PAYLOAD_APPROVED)); + } + return false; + } + + private static Map editedArgs(AguiResume resume) { + Object payload = resume.getPayload(); + if (!(payload instanceof Map map) || !map.containsKey(RESUME_PAYLOAD_EDITED_ARGS)) { + return null; + } + return toStringObjectMap(map.get(RESUME_PAYLOAD_EDITED_ARGS), RESUME_PAYLOAD_EDITED_ARGS); + } + + private static Map toStringObjectMap(Object value, String fieldName) { + if (!(value instanceof Map map)) { + throw new IllegalArgumentException(fieldName + " must be a JSON object when present"); + } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw new IllegalArgumentException( + fieldName + " must contain only string property names"); } + result.put(key, entry.getValue()); } - return true; + return Collections.unmodifiableMap(result); } private static String stringValue(Object value) { return value instanceof String s ? s : null; } + private static boolean isPermissionConfirmInterrupt(AguiEvent.Interrupt interrupt) { + if (interrupt == null || !TOOL_CALL_INTERRUPT_REASON.equals(interrupt.reason())) { + return false; + } + Map metadata = interrupt.metadata(); + return metadata != null + && INTERRUPT_KIND_PERMISSION_CONFIRM.equals( + metadata.get(METADATA_AGENTSCOPE_INTERRUPT_KIND)); + } + private String resumeContent(AguiResume resume) { if (resume.isCancelled()) { return "Interrupt cancelled by user"; @@ -497,15 +516,17 @@ private String resumeContent(AguiResume resume) { } } - private String resolveToolCallId(String interruptId, Map resumeToolCallIds) { - if (interruptId == null || interruptId.isBlank()) { + private String resolveToolCallId(String interruptId, AguiEvent.Interrupt interrupt) { + if (interrupt != null + && interrupt.toolCallId() != null + && !interrupt.toolCallId().isBlank()) { + return interrupt.toolCallId(); + } + if (interrupt != null && !TOOL_CALL_INTERRUPT_REASON.equals(interrupt.reason())) { return null; } - if (resumeToolCallIds != null) { - String mapped = resumeToolCallIds.get(interruptId); - if (mapped != null && !mapped.isBlank()) { - return mapped; - } + if (interruptId == null || interruptId.isBlank()) { + return null; } int separator = interruptId.lastIndexOf(':'); if (separator >= 0 && separator < interruptId.length() - 1) { diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java index 9574d48eee..18b52f0100 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiRequestProcessor.java @@ -147,7 +147,7 @@ public ProcessResult process( } RuntimeContext effectiveRuntimeContext = - resumeCoordinator.addResumeToolCallIds( + resumeCoordinator.addResumeInterrupts( input, runtimeContext); // Create adapter and run diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiResumeCoordinator.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiResumeCoordinator.java index b78b6d1e26..d14749905f 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiResumeCoordinator.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/processor/AguiResumeCoordinator.java @@ -15,6 +15,8 @@ */ package io.agentscope.core.agui.processor; +import static io.agentscope.core.agui.AguiInterruptConstants.TOOL_CALL_INTERRUPT_REASON; + import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.agui.adapter.AguiAgentAdapter; import io.agentscope.core.agui.event.AguiEvent; @@ -40,9 +42,6 @@ final class AguiResumeCoordinator { static final String CONTRACT_ERROR_CODE = "AGUI_INTERRUPT_CONTRACT_ERROR"; - /** Interrupt reason emitted for permission-mode tool confirmations. */ - private static final String CONFIRM_INTERRUPT_REASON = "tool_confirmation"; - private final ConcurrentMap> pendingInterruptsByThread = new ConcurrentHashMap<>(); private final ConcurrentMap activeRunsByThread = new ConcurrentHashMap<>(); @@ -137,13 +136,13 @@ void finishRun(String threadId, String runId) { } /** - * Add known interrupt-to-tool-call mappings to the runtime context for resume conversion. + * Add known originating interrupts to the runtime context for resume conversion. * * @param input The run input containing resume entries * @param runtimeContext The caller-provided runtime context, if any - * @return A runtime context with AG-UI resume tool-call mappings when available + * @return A runtime context with AG-UI resume interrupts when available */ - RuntimeContext addResumeToolCallIds(RunAgentInput input, RuntimeContext runtimeContext) { + RuntimeContext addResumeInterrupts(RunAgentInput input, RuntimeContext runtimeContext) { if (!input.hasResume()) { return runtimeContext; } @@ -152,28 +151,20 @@ RuntimeContext addResumeToolCallIds(RunAgentInput input, RuntimeContext runtimeC if (pending == null || pending.isEmpty()) { return runtimeContext; } - Map toolCallIds = new LinkedHashMap<>(); Map resumeInterrupts = new LinkedHashMap<>(); for (AguiResume resume : input.getResume()) { AguiEvent.Interrupt interrupt = pending.get(resume.getInterruptId()); - if (interrupt == null - || interrupt.toolCallId() == null - || interrupt.toolCallId().isBlank()) { + if (interrupt == null) { continue; } - if ("tool_call".equals(interrupt.reason()) - || CONFIRM_INTERRUPT_REASON.equals(interrupt.reason())) { - toolCallIds.put(resume.getInterruptId(), interrupt.toolCallId()); + if (shouldPassResumeInterrupt(interrupt)) { resumeInterrupts.put(resume.getInterruptId(), interrupt); } } - if (toolCallIds.isEmpty()) { + if (resumeInterrupts.isEmpty()) { return runtimeContext; } return RuntimeContext.builder(runtimeContext) - .put( - AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY, - Map.copyOf(toolCallIds)) .put( AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY, Map.copyOf(resumeInterrupts)) @@ -238,6 +229,13 @@ private ResumeContractResult validateResumeStatuses(List resumes) { return ResumeContractResult.proceed(); } + private boolean shouldPassResumeInterrupt(AguiEvent.Interrupt interrupt) { + if (TOOL_CALL_INTERRUPT_REASON.equals(interrupt.reason())) { + return interrupt.toolCallId() != null && !interrupt.toolCallId().isBlank(); + } + return false; + } + record ResumeContractResult(boolean error, String message) { boolean isError() { 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 dbc89f799e..337561be2c 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 @@ -763,6 +763,46 @@ void testSuspendedToolResultFinishesRunWithToolCallInterrupt() { assertEquals("reply-suspended", interrupt.metadata().get("replyId")); } + @Test + void testSuspendedToolResultWithoutStableToolCallIdFailsRun() { + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id("tool-1") + .name("lookup") + .input(Map.of("city", "Paris")) + .build(); + Msg suspendedResult = + suspendedToolResult( + "reply-suspended", + toolUse, + ToolResultBlock.builder() + .id("") + .name("lookup") + .output( + TextBlock.builder() + .text("Execute lookup externally") + .build()) + .metadata(Map.of(ToolResultBlock.METADATA_SUSPENDED, true)) + .build()); + + List events = + runReActEvents( + new AgentStartEvent("thread-v2", "reply-suspended", "react"), + new AgentResultEvent(suspendedResult), + new AgentEndEvent("reply-suspended")); + + assertEquals( + List.of( + AguiEventType.RUN_STARTED, + AguiEventType.RUN_ERROR, + AguiEventType.RUN_FINISHED), + types(events)); + assertErrorRun( + events.subList(1, 3), + "TOOL_SUSPENDED result contains a suspended tool result without a stable id", + "INVALID_INPUT_ERROR"); + } + @Test void testOnlySuspendedToolIsInterruptedWhenParallelToolCallsPartiallyComplete() { ToolUseBlock suspendedTool = @@ -848,15 +888,23 @@ void testPermissionConfirmEventFinishesRunWithToolConfirmationInterrupt() { assertEquals(1, outcome.interrupts().size()); AguiEvent.Interrupt interrupt = outcome.interrupts().get(0); assertEquals("reply-confirm:tool-1", interrupt.id()); - assertEquals("tool_confirmation", interrupt.reason()); + assertEquals("tool_call", interrupt.reason()); assertEquals("tool-1", interrupt.toolCallId()); - assertNull(interrupt.responseSchema()); + assertNotNull(interrupt.responseSchema()); + assertEquals(List.of("approved"), interrupt.responseSchema().get("required")); + @SuppressWarnings("unchecked") + Map properties = + (Map) interrupt.responseSchema().get("properties"); + assertTrue(properties.containsKey("approved")); + assertTrue(properties.containsKey("editedArgs")); assertNull(interrupt.expiresAt()); assertTrue(interrupt.message().contains("echo")); assertEquals("echo", interrupt.metadata().get("toolName")); assertEquals(Map.of("message", "hello"), interrupt.metadata().get("toolInput")); assertEquals("reply-confirm", interrupt.metadata().get("replyId")); assertTrue(interrupt.metadata().get("toolContent").toString().contains("hello")); + assertEquals( + "permission_confirm", interrupt.metadata().get("agentscope.interruptKind")); } @Test @@ -912,7 +960,7 @@ void testPermissionConfirmEventWithoutReplyIdUsesToolCallIdAsInterruptId() { } @Test - void testPermissionConfirmEventSkipsToolCallsWithoutStableId() { + void testPermissionConfirmEventWithoutStableToolCallIdFailsRun() { ToolUseBlock invalid = ToolUseBlock.builder().id("").name("echo").build(); List events = @@ -921,9 +969,16 @@ void testPermissionConfirmEventSkipsToolCallsWithoutStableId() { new RequireUserConfirmEvent("reply-confirm", List.of(invalid)), new AgentEndEvent("reply-confirm")); - AguiEvent.RunFinished finished = - assertInstanceOf(AguiEvent.RunFinished.class, events.get(1)); - assertNull(finished.outcome()); + assertEquals( + List.of( + AguiEventType.RUN_STARTED, + AguiEventType.RUN_ERROR, + AguiEventType.RUN_FINISHED), + types(events)); + assertErrorRun( + events.subList(1, 3), + "RequireUserConfirmEvent contains a tool call without a stable id", + "INVALID_INPUT_ERROR"); } @Test diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java index 8250b91776..971cda6108 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java @@ -307,6 +307,9 @@ void testToMsgListEmpty() { @Test void testConvertRunInputResumeToToolResultMsgUsingKnownInterruptMapping() { + AguiEvent.Interrupt interrupt = + new AguiEvent.Interrupt( + "int-abc", "tool_call", "suspended", "tool-call-1", null, null, Map.of()); RunAgentInput input = RunAgentInput.builder() .threadId("thread-1") @@ -319,7 +322,7 @@ void testConvertRunInputResumeToToolResultMsgUsingKnownInterruptMapping() { Map.of("approved", true)))) .build(); - List msgs = converter.toMsgList(input, Map.of("int-abc", "tool-call-1")); + List msgs = converter.toMsgList(input, Map.of("int-abc", interrupt)); assertEquals(1, msgs.size()); assertEquals(MsgRole.TOOL, msgs.get(0).getRole()); @@ -642,7 +645,7 @@ void testConvertConfirmationResumeResolvedBuildsConfirmResultWithNonNullContent( AguiEvent.Interrupt interrupt = new AguiEvent.Interrupt( "reply-1:tool-call-1", - "tool_confirmation", + "tool_call", "Tool 'echo' requires user confirmation before execution", "tool-call-1", null, @@ -651,7 +654,8 @@ void testConvertConfirmationResumeResolvedBuildsConfirmResultWithNonNullContent( "toolName", "echo", "toolInput", Map.of("message", "hello"), "toolContent", "{\"message\":\"hello\"}", - "replyId", "reply-1")); + "replyId", "reply-1", + "agentscope.interruptKind", "permission_confirm")); RunAgentInput input = RunAgentInput.builder() .threadId("thread-1") @@ -664,11 +668,7 @@ void testConvertConfirmationResumeResolvedBuildsConfirmResultWithNonNullContent( Map.of("approved", true)))) .build(); - List msgs = - converter.toMsgList( - input, - Map.of("reply-1:tool-call-1", "tool-call-1"), - Map.of("reply-1:tool-call-1", interrupt)); + List msgs = converter.toMsgList(input, Map.of("reply-1:tool-call-1", interrupt)); assertEquals(1, msgs.size()); Msg confirmMsg = msgs.get(0); @@ -692,17 +692,105 @@ void testConvertConfirmationResumeResolvedBuildsConfirmResultWithNonNullContent( assertEquals("hello", toolCall.getInput().get("message")); } + @Test + void testConvertConfirmationResumeWithEditedArgsReplacesToolInputAndContent() { + AguiEvent.Interrupt interrupt = + new AguiEvent.Interrupt( + "reply-1:tool-call-1", + "tool_call", + "Tool 'echo' requires user confirmation before execution", + "tool-call-1", + null, + null, + Map.of( + "toolName", "echo", + "toolInput", Map.of("message", "hello", "drop", true), + "toolContent", "{\"message\":\"hello\",\"drop\":true}", + "replyId", "reply-1", + "agentscope.interruptKind", "permission_confirm")); + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-2") + .resume( + List.of( + new AguiResume( + "reply-1:tool-call-1", + AguiResume.STATUS_RESOLVED, + Map.of( + "approved", + true, + "editedArgs", + Map.of("message", "goodbye"))))) + .build(); + + List msgs = converter.toMsgList(input, Map.of("reply-1:tool-call-1", interrupt)); + + ConfirmResult cr = + (ConfirmResult) + ((List) msgs.get(0).getMetadata().get(Msg.METADATA_CONFIRM_RESULTS)) + .get(0); + ToolUseBlock toolCall = cr.getToolCall(); + assertTrue(cr.isConfirmed()); + assertEquals(Map.of("message", "goodbye"), toolCall.getInput()); + assertFalse(toolCall.getInput().containsKey("drop")); + assertTrue(toolCall.getContent().contains("goodbye")); + assertFalse(toolCall.getContent().contains("hello")); + } + + @Test + void testConvertConfirmationResumeRejectsInvalidEditedArgs() { + AguiEvent.Interrupt interrupt = + new AguiEvent.Interrupt( + "reply-1:tool-call-1", + "tool_call", + "confirm", + "tool-call-1", + null, + null, + Map.of( + "toolName", + "echo", + "toolContent", + "{\"message\":\"hi\"}", + "agentscope.interruptKind", + "permission_confirm")); + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-2") + .resume( + List.of( + new AguiResume( + "reply-1:tool-call-1", + AguiResume.STATUS_RESOLVED, + Map.of("approved", true, "editedArgs", "oops")))) + .build(); + + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> converter.toMsgList(input, Map.of("reply-1:tool-call-1", interrupt))); + assertTrue(error.getMessage().contains("editedArgs")); + } + @Test void testConvertConfirmationResumeCancelledBuildsDeniedConfirmResult() { AguiEvent.Interrupt interrupt = new AguiEvent.Interrupt( "reply-1:tool-call-1", - "tool_confirmation", + "tool_call", "confirm", "tool-call-1", null, null, - Map.of("toolName", "echo", "toolContent", "{\"message\":\"hi\"}")); + Map.of( + "toolName", + "echo", + "toolContent", + "{\"message\":\"hi\"}", + "agentscope.interruptKind", + "permission_confirm")); RunAgentInput input = RunAgentInput.builder() .threadId("thread-1") @@ -715,11 +803,7 @@ void testConvertConfirmationResumeCancelledBuildsDeniedConfirmResult() { null))) .build(); - List msgs = - converter.toMsgList( - input, - Map.of("reply-1:tool-call-1", "tool-call-1"), - Map.of("reply-1:tool-call-1", interrupt)); + List msgs = converter.toMsgList(input, Map.of("reply-1:tool-call-1", interrupt)); ConfirmResult cr = (ConfirmResult) @@ -734,12 +818,18 @@ void testConfirmationResumeRespectsExplicitApprovedFalsePayload() { AguiEvent.Interrupt interrupt = new AguiEvent.Interrupt( "reply-1:tool-call-1", - "tool_confirmation", + "tool_call", "confirm", "tool-call-1", null, null, - Map.of("toolName", "echo", "toolContent", "{\"message\":\"hi\"}")); + Map.of( + "toolName", + "echo", + "toolContent", + "{\"message\":\"hi\"}", + "agentscope.interruptKind", + "permission_confirm")); RunAgentInput input = RunAgentInput.builder() .threadId("thread-1") @@ -752,11 +842,83 @@ void testConfirmationResumeRespectsExplicitApprovedFalsePayload() { Map.of("approved", false)))) .build(); - List msgs = - converter.toMsgList( - input, - Map.of("reply-1:tool-call-1", "tool-call-1"), - Map.of("reply-1:tool-call-1", interrupt)); + List msgs = converter.toMsgList(input, Map.of("reply-1:tool-call-1", interrupt)); + + ConfirmResult cr = + (ConfirmResult) + ((List) msgs.get(0).getMetadata().get(Msg.METADATA_CONFIRM_RESULTS)) + .get(0); + assertFalse(cr.isConfirmed()); + } + + @Test + void testConfirmationResumeWithoutApprovedFieldIsDenied() { + AguiEvent.Interrupt interrupt = + new AguiEvent.Interrupt( + "reply-1:tool-call-1", + "tool_call", + "confirm", + "tool-call-1", + null, + null, + Map.of( + "toolName", + "echo", + "toolContent", + "{\"message\":\"hi\"}", + "agentscope.interruptKind", + "permission_confirm")); + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-2") + .resume( + List.of( + new AguiResume( + "reply-1:tool-call-1", + AguiResume.STATUS_RESOLVED, + Map.of("comment", "looks fine")))) + .build(); + + List msgs = converter.toMsgList(input, Map.of("reply-1:tool-call-1", interrupt)); + + ConfirmResult cr = + (ConfirmResult) + ((List) msgs.get(0).getMetadata().get(Msg.METADATA_CONFIRM_RESULTS)) + .get(0); + assertFalse(cr.isConfirmed()); + } + + @Test + void testConfirmationResumeWithNonBooleanApprovedFieldIsDenied() { + AguiEvent.Interrupt interrupt = + new AguiEvent.Interrupt( + "reply-1:tool-call-1", + "tool_call", + "confirm", + "tool-call-1", + null, + null, + Map.of( + "toolName", + "echo", + "toolContent", + "{\"message\":\"hi\"}", + "agentscope.interruptKind", + "permission_confirm")); + RunAgentInput input = + RunAgentInput.builder() + .threadId("thread-1") + .runId("run-2") + .resume( + List.of( + new AguiResume( + "reply-1:tool-call-1", + AguiResume.STATUS_RESOLVED, + Map.of("approved", "true")))) + .build(); + + List msgs = converter.toMsgList(input, Map.of("reply-1:tool-call-1", interrupt)); ConfirmResult cr = (ConfirmResult) @@ -789,10 +951,7 @@ void testResumeWithoutConfirmationInterruptStillUsesToolResultPath() { .build(); List msgs = - converter.toMsgList( - input, - Map.of("reply-1:tool-call-1", "tool-call-1"), - Map.of("reply-1:tool-call-1", suspendInterrupt)); + converter.toMsgList(input, Map.of("reply-1:tool-call-1", suspendInterrupt)); // Non-confirmation interrupt must still produce a TOOL-role ToolResultBlock message. assertEquals(MsgRole.TOOL, msgs.get(0).getRole()); diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiResumeCoordinatorTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiResumeCoordinatorTest.java index d31701c965..bdce90c753 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiResumeCoordinatorTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/processor/AguiResumeCoordinatorTest.java @@ -77,12 +77,13 @@ void validateRequiresResumeToCoverAllOpenInterrupts() { } @Test - void addResumeToolCallIdsAddsKnownToolMappingsToRuntimeContext() { + void addResumeInterruptsAddsKnownInterruptsToRuntimeContext() { AguiResumeCoordinator coordinator = new AguiResumeCoordinator(); - track(coordinator, "run-1", interruptedFinished("run-1", interrupt("interrupt-1")), false); + AguiEvent.Interrupt interrupt = interrupt("interrupt-1"); + track(coordinator, "run-1", interruptedFinished("run-1", interrupt), false); RuntimeContext context = - coordinator.addResumeToolCallIds( + coordinator.addResumeInterrupts( RunAgentInput.builder() .threadId("thread-1") .runId("run-2") @@ -97,17 +98,17 @@ void addResumeToolCallIdsAddsKnownToolMappingsToRuntimeContext() { assertEquals("tenant-a", context.get("tenant")); assertEquals( - Map.of("interrupt-1", "tool-call-1"), - context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY)); + Map.of("interrupt-1", interrupt), + context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY)); } @Test - void addResumeToolCallIdsAddsConfirmationInterruptToRuntimeContext() { + void addResumeInterruptsAddsConfirmationInterruptToRuntimeContext() { AguiResumeCoordinator coordinator = new AguiResumeCoordinator(); AguiEvent.Interrupt confirmation = new AguiEvent.Interrupt( "interrupt-1", - "tool_confirmation", + "tool_call", "confirm echo", "tool-call-1", null, @@ -116,7 +117,7 @@ void addResumeToolCallIdsAddsConfirmationInterruptToRuntimeContext() { track(coordinator, "run-1", interruptedFinished("run-1", confirmation), false); RuntimeContext context = - coordinator.addResumeToolCallIds( + coordinator.addResumeInterrupts( RunAgentInput.builder() .threadId("thread-1") .runId("run-2") @@ -129,15 +130,12 @@ void addResumeToolCallIdsAddsConfirmationInterruptToRuntimeContext() { .build(), null); - assertEquals( - Map.of("interrupt-1", "tool-call-1"), - context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY)); Object interrupts = context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY); assertEquals(Map.of("interrupt-1", confirmation), interrupts); } @Test - void addResumeToolCallIdsIgnoresInterruptsWithoutToolCallId() { + void addResumeInterruptsIgnoresInterruptsWithoutToolCallId() { AguiResumeCoordinator coordinator = new AguiResumeCoordinator(); AguiEvent.Interrupt custom = new AguiEvent.Interrupt( @@ -145,7 +143,7 @@ void addResumeToolCallIdsIgnoresInterruptsWithoutToolCallId() { track(coordinator, "run-1", interruptedFinished("run-1", custom), false); RuntimeContext context = - coordinator.addResumeToolCallIds( + coordinator.addResumeInterrupts( RunAgentInput.builder() .threadId("thread-1") .runId("run-2") @@ -159,7 +157,6 @@ void addResumeToolCallIdsIgnoresInterruptsWithoutToolCallId() { RuntimeContext.builder().put("tenant", "tenant-a").build()); assertEquals("tenant-a", context.get("tenant")); - assertNull(context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY)); assertNull(context.get(AguiAgentAdapter.RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY)); } diff --git a/docs/v2/en/integration/protocol/agui.md b/docs/v2/en/integration/protocol/agui.md index 6fd044fe91..8150f22368 100644 --- a/docs/v2/en/integration/protocol/agui.md +++ b/docs/v2/en/integration/protocol/agui.md @@ -59,7 +59,7 @@ AguiAgentAdapter adapter = new AguiAgentAdapter(agent, config); Flux events = adapter.run(runAgentInput); ``` -The front end provides `RunAgentInput`, including `threadId`, `runId`, `messages`, `tools`, `state`等. The adapter converts AG-UI messages to AgentScope `Msg` objects, invokes v2 `streamEvents(...)`, and converts each `AgentEvent` to AG-UI events. +The front end provides `RunAgentInput`, including `threadId`, `runId`, `messages`, `tools`, `state`, and related fields. The adapter converts AG-UI messages to AgentScope `Msg` objects, invokes v2 `streamEvents(...)`, and converts each `AgentEvent` to AG-UI events. ## Event Mapping @@ -254,7 +254,12 @@ The default is `MERGE_FRONTEND_PRIORITY`. Injection is run scoped and does not p ## HITL Interrupts -When the model requests a tool and suspension is needed for user approval or external execution, the AG-UI adapter converts the suspended result into a `RUN_FINISHED` interrupt outcome: +When a run pauses for a tool decision, the AG-UI adapter emits the official interrupt outcome on `RUN_FINISHED`. AgentScope Java has two built-in tool-call interrupt paths: + +- **Tool suspension / external execution**: a suspended `ToolResultBlock` becomes a `tool_call` interrupt and resumes as a `ToolResultBlock`. +- **Permission confirmation**: `RequireUserConfirmEvent` becomes a `tool_call` interrupt with AgentScope metadata and resumes as a `ConfirmResult`. + +Both use the official AG-UI `reason: "tool_call"` because the interrupt is bound to a specific `toolCallId`. Do not use `reason: "confirmation"` for these tool-bound approvals. ```json { @@ -263,11 +268,27 @@ When the model requests a tool and suspension is needed for user approval or ext "type": "interrupt", "interrupts": [ { + "id": "reply-1:call-1", "reason": "tool_call", "toolCallId": "call-1", "message": "Need approval before running this tool", + "responseSchema": { + "type": "object", + "properties": { + "approved": { "type": "boolean" }, + "editedArgs": { + "type": "object", + "description": "Full replacement of the tool args. Not merged." + } + }, + "required": ["approved"] + }, "metadata": { - "toolName": "request_approval" + "agentscope.interruptKind": "permission_confirm", + "toolName": "request_approval", + "toolInput": { "path": "/tmp/report.txt" }, + "toolContent": "{\"path\":\"/tmp/report.txt\"}", + "replyId": "reply-1" } } ] @@ -287,7 +308,10 @@ The front end can show an approval or external-execution UI. After the user acts "interruptId": "reply-1:call-1", "status": "resolved", "payload": { - "approved": true + "approved": true, + "editedArgs": { + "path": "/tmp/reviewed-report.txt" + } } } ] @@ -296,9 +320,9 @@ The front end can show an approval or external-execution UI. After the user acts `status` supports the official `resolved` and `cancelled` values. For the common approval case where a user rejects a tool request, prefer `resolved` and express the business decision in `payload`, for example `{ "approved": false }`; use `cancelled` when the interrupt itself is cancelled. -AgentScope Java bridges tool-call `resume[]` entries to the `ToolResultBlock` messages required by core so the suspended tool call can continue. Through the Spring `AguiRequestProcessor` entry point, the processor records the latest `RUN_FINISHED.outcome.interrupts[]` and resolves the real `toolCallId` by `interruptId`. +For permission confirmations, `payload.approved` must be the boolean `true` to approve the tool. Any missing, non-boolean, or `false` value is treated as denial. `payload.editedArgs`, when present, must be a JSON object and is a **full replacement** of the original tool arguments, not a partial merge. AgentScope Java rebuilds both the `ToolUseBlock.input` and raw JSON `ToolUseBlock.content` from `editedArgs`, so the approved tool executes the edited arguments. -The built-in resume path currently covers tool-call interrupts generated by the AG-UI adapter. Custom interrupts with different semantics usually need a custom `AgentEventConverter` / `AguiEventEnricher` or request-processing layer to interpret their `payload`. +The front end does not need to echo `metadata` in `resume[]`; it only sends `interruptId`, `status`, and `payload`. Through the Spring `AguiRequestProcessor` entry point, AgentScope Java records the latest `RUN_FINISHED.outcome.interrupts[]` server-side, validates that the next `resume[]` covers all open interrupts, and passes the originating interrupts into the adapter for conversion. ## Example Project diff --git a/docs/v2/zh/integration/protocol/agui.md b/docs/v2/zh/integration/protocol/agui.md index e188b9649e..2a628cff6d 100644 --- a/docs/v2/zh/integration/protocol/agui.md +++ b/docs/v2/zh/integration/protocol/agui.md @@ -254,7 +254,12 @@ AG-UI 前端可以在 `RunAgentInput.tools` 中传入工具 schema。adapter 会 ## HITL Interrupt -当模型请求工具但需要用户审批或外部执行挂起时,AG-UI adapter 会把挂起结果转换为 `RUN_FINISHED` 的 interrupt outcome: +当一次 run 因工具决策暂停时,AG-UI adapter 会在 `RUN_FINISHED` 上输出官方 interrupt outcome。AgentScope Java 内置了两类 tool-call interrupt 路径: + +- **工具挂起 / 外部执行**:挂起的 `ToolResultBlock` 会转换成 `tool_call` interrupt,恢复时桥接回 `ToolResultBlock`。 +- **权限确认**:`RequireUserConfirmEvent` 会转换成带 AgentScope metadata 的 `tool_call` interrupt,恢复时桥接为 `ConfirmResult`。 + +这两类场景都使用官方 AG-UI `reason: "tool_call"`,因为 interrupt 绑定到具体 `toolCallId`。不要把这类工具审批写成 `reason: "confirmation"`。 ```json { @@ -263,11 +268,27 @@ AG-UI 前端可以在 `RunAgentInput.tools` 中传入工具 schema。adapter 会 "type": "interrupt", "interrupts": [ { + "id": "reply-1:call-1", "reason": "tool_call", "toolCallId": "call-1", "message": "Need approval before running this tool", + "responseSchema": { + "type": "object", + "properties": { + "approved": { "type": "boolean" }, + "editedArgs": { + "type": "object", + "description": "Full replacement of the tool args. Not merged." + } + }, + "required": ["approved"] + }, "metadata": { - "toolName": "request_approval" + "agentscope.interruptKind": "permission_confirm", + "toolName": "request_approval", + "toolInput": { "path": "/tmp/report.txt" }, + "toolContent": "{\"path\":\"/tmp/report.txt\"}", + "replyId": "reply-1" } } ] @@ -287,7 +308,10 @@ AG-UI 前端可以在 `RunAgentInput.tools` 中传入工具 schema。adapter 会 "interruptId": "reply-1:call-1", "status": "resolved", "payload": { - "approved": true + "approved": true, + "editedArgs": { + "path": "/tmp/reviewed-report.txt" + } } } ] @@ -296,9 +320,9 @@ AG-UI 前端可以在 `RunAgentInput.tools` 中传入工具 schema。adapter 会 `status` 支持官方的 `resolved` 和 `cancelled`。对于用户拒绝某个工具请求的常见审批场景,建议仍使用 `resolved`,并在 `payload` 中表达业务决策,例如 `{ "approved": false }`;`cancelled` 更适合表示该 interrupt 本身被取消。 -AgentScope Java 会把 tool-call interrupt 的 `resume[]` 桥接为 core 需要的 `ToolResultBlock`,从而恢复上一次挂起的工具调用。通过 Spring `AguiRequestProcessor` 入口时,processor 会记录最近一次 `RUN_FINISHED.outcome.interrupts[]`,并按 `interruptId` 解析真实 `toolCallId`。 +对于权限确认,只有 `payload.approved` 是布尔值 `true` 时才会批准工具;缺失、非布尔值或 `false` 都会视为拒绝。`payload.editedArgs` 如果存在,必须是 JSON object,并且是对原始工具参数的**完整替换**,不是局部 merge。AgentScope Java 会根据 `editedArgs` 同时重建 `ToolUseBlock.input` 和原始 JSON `ToolUseBlock.content`,因此被批准的工具会使用修改后的参数执行。 -当前内置恢复只覆盖 AG-UI adapter 生成的 tool-call interrupt。自定义 interrupt 如果不是 tool-call 语义,通常需要自定义 `AgentEventConverter` / `AguiEventEnricher` 或请求处理层来解释 `payload`。 +前端不需要在 `resume[]` 中回传 `metadata`;只需要发送 `interruptId`、`status` 和 `payload`。通过 Spring `AguiRequestProcessor` 入口时,AgentScope Java 会在服务端记录最近一次 `RUN_FINISHED.outcome.interrupts[]`,校验下一次 `resume[]` 是否覆盖所有 open interrupts,并把原始 interrupt 传给 adapter 做恢复转换。 ## 示例项目