Skip to content

Commit 3004201

Browse files
authored
fix(agui): emit AG-UI interrupt for permission-type HITL tool confirmation (#2437) (#2495)
Fixes #2437. The AG-UI adapter only surfaced **suspend-type** HITL (tools that throw `ToolSuspendException` / frontend tools, i.e. `GenerateReason.TOOL_SUSPENDED`). The **permission-type** HITL path was dropped: - Under `DEFAULT` permission mode, a non-readonly tool makes `ReActAgent` emit `RequireUserConfirmEvent` + `RequestStopEvent(PERMISSION_ASKING)`. - `RequireUserConfirmEvent` had no converter, so it fell through to the `RAW` fallback and **no `Interrupt` reached `RUN_FINISHED`**. - The frontend therefore had nothing to confirm/deny, and resume was impossible. This builds directly on the converter-registry architecture introduced in #2306, extending it to the confirmation path rather than changing its design.
1 parent 3fb4d0e commit 3004201

13 files changed

Lines changed: 1005 additions & 63 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.agentscope.core.agui;
17+
18+
/** Constants for AG-UI interrupt reasons and AgentScope interrupt metadata. */
19+
public final class AguiInterruptConstants {
20+
21+
private AguiInterruptConstants() {}
22+
23+
/** AG-UI interrupt reason for tool-bound decisions. */
24+
public static final String TOOL_CALL_INTERRUPT_REASON = "tool_call";
25+
26+
/** AG-UI interrupt reason for structured input requests. */
27+
public static final String INPUT_REQUIRED_INTERRUPT_REASON = "input_required";
28+
29+
/** Interrupt metadata key: AgentScope-specific interrupt kind. */
30+
public static final String METADATA_AGENTSCOPE_INTERRUPT_KIND = "agentscope.interruptKind";
31+
32+
/** Interrupt metadata value for permission-mode tool confirmations. */
33+
public static final String INTERRUPT_KIND_PERMISSION_CONFIRM = "permission_confirm";
34+
35+
/** Interrupt metadata key: the tool name. */
36+
public static final String METADATA_TOOL_NAME = "toolName";
37+
38+
/** Interrupt metadata key: the parsed tool arguments. */
39+
public static final String METADATA_TOOL_INPUT = "toolInput";
40+
41+
/** Interrupt metadata key: the tool arguments serialized as a JSON-object string. */
42+
public static final String METADATA_TOOL_CONTENT = "toolContent";
43+
44+
/** Interrupt metadata key: the originating reply id. */
45+
public static final String METADATA_REPLY_ID = "replyId";
46+
}

agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/AguiAgentAdapter.java

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public class AguiAgentAdapter {
8585
public static final String RUNTIME_CONTEXT_STATE_KEY = "agui.state";
8686
public static final String RUNTIME_CONTEXT_FORWARDED_PROPS_KEY = "agui.forwardedProps";
8787
public static final String RUNTIME_CONTEXT_RESUME_KEY = "agui.resume";
88-
public static final String RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY = "agui.resume.toolCallIds";
88+
public static final String RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY = "agui.resume.interrupts";
8989

9090
private final Agent agent;
9191
private final AguiAdapterConfig config;
@@ -147,7 +147,7 @@ public Flux<AguiEvent> run(RunAgentInput input, RuntimeContext runtimeContext) {
147147
// Convert AG-UI messages and official resume entries to AgentScope messages.
148148
List<Msg> msgs =
149149
messageConverter.toMsgList(
150-
input, resumeToolCallIds(effectiveRuntimeContext));
150+
input, resumeInterrupts(effectiveRuntimeContext));
151151

152152
// Create stream options - use incremental mode for true streaming
153153
StreamOptions options =
@@ -312,21 +312,22 @@ protected RuntimeContext buildRuntimeContext(
312312
}
313313

314314
@SuppressWarnings("unchecked")
315-
private Map<String, String> resumeToolCallIds(RuntimeContext runtimeContext) {
315+
private Map<String, AguiEvent.Interrupt> resumeInterrupts(RuntimeContext runtimeContext) {
316316
if (runtimeContext == null) {
317317
return Map.of();
318318
}
319-
Object value = runtimeContext.get(RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY);
319+
Object value = runtimeContext.get(RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY);
320320
if (!(value instanceof Map<?, ?> map)) {
321321
return Map.of();
322322
}
323-
Map<String, String> toolCallIds = new LinkedHashMap<>();
323+
Map<String, AguiEvent.Interrupt> interrupts = new LinkedHashMap<>();
324324
for (Map.Entry<?, ?> entry : map.entrySet()) {
325-
if (entry.getKey() instanceof String key && entry.getValue() instanceof String id) {
326-
toolCallIds.put(key, id);
325+
if (entry.getKey() instanceof String key
326+
&& entry.getValue() instanceof AguiEvent.Interrupt interrupt) {
327+
interrupts.put(key, interrupt);
327328
}
328329
}
329-
return Map.copyOf(toolCallIds);
330+
return Map.copyOf(interrupts);
330331
}
331332

332333
private ToolInjection injectFrontendTools(RunAgentInput input) {

agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentEventConverterRegistry.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public AgentEventConverterRegistry(
6868
boolean emitSubagentEventsAsNative) {
6969
Map<Class<? extends AgentEvent>, AgentEventConverter> map = new LinkedHashMap<>();
7070
register(map, new AgentLifecycleEventConverter());
71+
register(map, new PermissionConfirmEventConverter());
7172
register(map, new TextBlockEventConverter());
7273
register(map, new ThinkingBlockEventConverter());
7374
register(map, new ToolCallEventConverter());

agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/adapter/strategy/AgentLifecycleEventConverter.java

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
*/
1616
package io.agentscope.core.agui.adapter.strategy;
1717

18+
import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_REPLY_ID;
19+
import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_CONTENT;
20+
import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_INPUT;
21+
import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_NAME;
22+
import static io.agentscope.core.agui.AguiInterruptConstants.TOOL_CALL_INTERRUPT_REASON;
23+
1824
import io.agentscope.core.agui.event.AguiEvent;
1925
import io.agentscope.core.event.AgentEndEvent;
2026
import io.agentscope.core.event.AgentEvent;
@@ -27,6 +33,7 @@
2733
import io.agentscope.core.message.TextBlock;
2834
import io.agentscope.core.message.ToolResultBlock;
2935
import io.agentscope.core.message.ToolUseBlock;
36+
import io.agentscope.core.util.JsonUtils;
3037
import java.util.LinkedHashMap;
3138
import java.util.List;
3239
import java.util.Map;
@@ -98,11 +105,14 @@ private static void collectSuspendedToolInterrupts(
98105
}
99106

100107
for (ContentBlock block : result.getContent()) {
101-
if (!(block instanceof ToolResultBlock toolResult)
102-
|| !toolResult.isSuspended()
103-
|| isBlank(toolResult.getId())) {
108+
if (!(block instanceof ToolResultBlock toolResult) || !toolResult.isSuspended()) {
104109
continue;
105110
}
111+
if (isBlank(toolResult.getId())) {
112+
throw new IllegalStateException(
113+
"TOOL_SUSPENDED result contains a suspended tool result without a stable"
114+
+ " id");
115+
}
106116
context.addInterrupt(
107117
buildToolCallInterrupt(result, toolUses.get(toolResult.getId()), toolResult));
108118
}
@@ -112,28 +122,25 @@ private static AguiEvent.Interrupt buildToolCallInterrupt(
112122
Msg result, ToolUseBlock toolUse, ToolResultBlock toolResult) {
113123
String toolCallId = toolResult.getId();
114124
Map<String, Object> metadata = new LinkedHashMap<>();
115-
String toolName =
116-
toolUse != null && !isBlank(toolUse.getName())
117-
? toolUse.getName()
118-
: toolResult.getName();
119-
if (!isBlank(toolName)) {
120-
metadata.put("toolName", toolName);
125+
if (!isBlank(toolUse.getName())) {
126+
metadata.put(METADATA_TOOL_NAME, toolUse.getName());
121127
}
122128
if (toolUse != null && toolUse.getInput() != null && !toolUse.getInput().isEmpty()) {
123-
metadata.put("toolInput", toolUse.getInput());
129+
metadata.put(METADATA_TOOL_INPUT, toolUse.getInput());
124130
}
131+
metadata.put(METADATA_TOOL_CONTENT, JsonUtils.resolveToolCallArgsJson(toolUse));
125132
if (!isBlank(result.getId())) {
126-
metadata.put("replyId", result.getId());
133+
metadata.put(METADATA_REPLY_ID, result.getId());
127134
}
128135

129136
return new AguiEvent.Interrupt(
130137
interruptId(result, toolCallId),
131-
"tool_call",
138+
TOOL_CALL_INTERRUPT_REASON,
132139
extractText(toolResult.getOutput()),
133140
toolCallId,
134141
null,
135142
null,
136-
metadata.isEmpty() ? null : Map.copyOf(metadata));
143+
Map.copyOf(metadata));
137144
}
138145

139146
private static String interruptId(Msg result, String toolCallId) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.agentscope.core.agui.adapter.strategy;
17+
18+
import static io.agentscope.core.agui.AguiInterruptConstants.INTERRUPT_KIND_PERMISSION_CONFIRM;
19+
import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_AGENTSCOPE_INTERRUPT_KIND;
20+
import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_REPLY_ID;
21+
import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_CONTENT;
22+
import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_INPUT;
23+
import static io.agentscope.core.agui.AguiInterruptConstants.METADATA_TOOL_NAME;
24+
import static io.agentscope.core.agui.AguiInterruptConstants.TOOL_CALL_INTERRUPT_REASON;
25+
26+
import io.agentscope.core.agui.event.AguiEvent;
27+
import io.agentscope.core.event.AgentEvent;
28+
import io.agentscope.core.event.RequireUserConfirmEvent;
29+
import io.agentscope.core.message.ToolUseBlock;
30+
import io.agentscope.core.util.JsonUtils;
31+
import java.util.LinkedHashMap;
32+
import java.util.List;
33+
import java.util.Map;
34+
import java.util.Set;
35+
36+
/**
37+
* Converts permission-mode HITL {@link RequireUserConfirmEvent}s into AG-UI interrupt outcomes.
38+
*
39+
* <p>When an agent runs under {@code PermissionMode.DEFAULT}, the permission engine returns {@code
40+
* ASK} for non-readonly tools and {@code ReActAgent} emits a {@link RequireUserConfirmEvent} (paired
41+
* with a {@code RequestStopEvent(PERMISSION_ASKING)}) instead of executing the tool. This is a
42+
* distinct path from the tool-suspension flow handled by {@code AgentLifecycleEventConverter} (which
43+
* is gated on {@code GenerateReason.TOOL_SUSPENDED}).
44+
*
45+
* <p>Each pending {@link ToolUseBlock} is surfaced as one {@link AguiEvent.Interrupt} added to the
46+
* stream context; {@code AgentLifecycleEventConverter} drains them into the {@code RUN_FINISHED}
47+
* interrupt outcome on {@code AgentEndEvent}. The interrupt id reuses the {@code replyId:toolCallId}
48+
* format so the resume path can recover the reply/tool-call identity.
49+
*
50+
* <p>The interrupt metadata carries {@code toolContent} — a valid JSON-object string of the tool
51+
* arguments — so the resume path can rebuild a {@link ToolUseBlock} whose {@code content} is
52+
* non-null. This matters because {@code ReActAgent.applyConfirmResults} fully replaces the stored
53+
* {@code ToolUseBlock}, and tool-input validation reads {@code content} directly with no fallback to
54+
* {@code input}; a null content would fail the resume with {@code argument "content" is null}.
55+
*/
56+
final class PermissionConfirmEventConverter implements AgentEventConverter {
57+
58+
private static final Map<String, Object> CONFIRM_RESPONSE_SCHEMA =
59+
Map.of(
60+
"type",
61+
"object",
62+
"properties",
63+
Map.of(
64+
"approved",
65+
Map.of("type", "boolean"),
66+
"editedArgs",
67+
Map.of(
68+
"type",
69+
"object",
70+
"description",
71+
"Full replacement of the tool args. Not merged.")),
72+
"required",
73+
List.of("approved"));
74+
75+
@Override
76+
public Set<Class<? extends AgentEvent>> eventTypes() {
77+
return Set.of(RequireUserConfirmEvent.class);
78+
}
79+
80+
@Override
81+
public void convert(AgentEvent event, AguiStreamContext context) {
82+
RequireUserConfirmEvent confirmEvent = (RequireUserConfirmEvent) event;
83+
String replyId = confirmEvent.getReplyId();
84+
85+
List<ToolUseBlock> toolCalls = confirmEvent.getToolCalls();
86+
for (ToolUseBlock toolUse : toolCalls) {
87+
// toolUse will not be null, see RequireUserConfirmEvent constructor
88+
if (isBlank(toolUse.getId())) {
89+
throw new IllegalStateException(
90+
"RequireUserConfirmEvent contains a tool call without a stable id");
91+
}
92+
context.addInterrupt(buildInterrupt(replyId, toolUse));
93+
}
94+
}
95+
96+
private static AguiEvent.Interrupt buildInterrupt(String replyId, ToolUseBlock toolUse) {
97+
String toolCallId = toolUse.getId();
98+
Map<String, Object> metadata = new LinkedHashMap<>();
99+
if (!isBlank(toolUse.getName())) {
100+
metadata.put(METADATA_TOOL_NAME, toolUse.getName());
101+
}
102+
if (toolUse.getInput() != null && !toolUse.getInput().isEmpty()) {
103+
metadata.put(METADATA_TOOL_INPUT, toolUse.getInput());
104+
}
105+
metadata.put(METADATA_TOOL_CONTENT, JsonUtils.resolveToolCallArgsJson(toolUse));
106+
metadata.put(METADATA_AGENTSCOPE_INTERRUPT_KIND, INTERRUPT_KIND_PERMISSION_CONFIRM);
107+
if (!isBlank(replyId)) {
108+
metadata.put(METADATA_REPLY_ID, replyId);
109+
}
110+
return new AguiEvent.Interrupt(
111+
interruptId(replyId, toolCallId),
112+
TOOL_CALL_INTERRUPT_REASON,
113+
confirmMessage(toolUse),
114+
toolCallId,
115+
CONFIRM_RESPONSE_SCHEMA,
116+
null,
117+
Map.copyOf(metadata));
118+
}
119+
120+
private static String confirmMessage(ToolUseBlock toolUse) {
121+
String name = isBlank(toolUse.getName()) ? "tool" : toolUse.getName();
122+
return "Tool '" + name + "' requires user confirmation before execution";
123+
}
124+
125+
private static String interruptId(String replyId, String toolCallId) {
126+
if (!isBlank(replyId)) {
127+
return replyId + ":" + toolCallId;
128+
}
129+
return toolCallId;
130+
}
131+
132+
private static boolean isBlank(String value) {
133+
return value == null || value.isBlank();
134+
}
135+
}

0 commit comments

Comments
 (0)