Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ 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;
private final AguiAdapterConfig config;
Expand Down Expand Up @@ -147,7 +147,7 @@ public Flux<AguiEvent> run(RunAgentInput input, RuntimeContext runtimeContext) {
// Convert AG-UI messages and official resume entries to AgentScope messages.
List<Msg> msgs =
messageConverter.toMsgList(
input, resumeToolCallIds(effectiveRuntimeContext));
input, resumeInterrupts(effectiveRuntimeContext));

// Create stream options - use incremental mode for true streaming
StreamOptions options =
Expand Down Expand Up @@ -312,21 +312,22 @@ protected RuntimeContext buildRuntimeContext(
}

@SuppressWarnings("unchecked")
private Map<String, String> resumeToolCallIds(RuntimeContext runtimeContext) {
private Map<String, AguiEvent.Interrupt> resumeInterrupts(RuntimeContext runtimeContext) {
if (runtimeContext == null) {
return Map.of();
}
Object value = runtimeContext.get(RUNTIME_CONTEXT_RESUME_TOOL_CALL_IDS_KEY);
Object value = runtimeContext.get(RUNTIME_CONTEXT_RESUME_INTERRUPTS_KEY);
if (!(value instanceof Map<?, ?> map)) {
return Map.of();
}
Map<String, String> toolCallIds = new LinkedHashMap<>();
Map<String, AguiEvent.Interrupt> interrupts = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (entry.getKey() instanceof String key && entry.getValue() instanceof String id) {
toolCallIds.put(key, id);
if (entry.getKey() instanceof String key
&& entry.getValue() instanceof AguiEvent.Interrupt interrupt) {
interrupts.put(key, interrupt);
}
}
return Map.copyOf(toolCallIds);
return Map.copyOf(interrupts);
}

private ToolInjection injectFrontendTools(RunAgentInput input) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public AgentEventConverterRegistry(
boolean emitSubagentEventsAsNative) {
Map<Class<? extends AgentEvent>, AgentEventConverter> map = new LinkedHashMap<>();
register(map, new AgentLifecycleEventConverter());
register(map, new PermissionConfirmEventConverter());
register(map, new TextBlockEventConverter());
register(map, new ThinkingBlockEventConverter());
register(map, new ToolCallEventConverter());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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));
}
Expand All @@ -112,28 +122,25 @@ private static AguiEvent.Interrupt buildToolCallInterrupt(
Msg result, ToolUseBlock toolUse, ToolResultBlock toolResult) {
String toolCallId = toolResult.getId();
Map<String, Object> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* 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 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;
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.
*
* <p>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}).
*
* <p>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.
*
* <p>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 {

private static final Map<String, Object> 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<Class<? extends AgentEvent>> eventTypes() {
return Set.of(RequireUserConfirmEvent.class);
}

@Override
public void convert(AgentEvent event, AguiStreamContext context) {
RequireUserConfirmEvent confirmEvent = (RequireUserConfirmEvent) event;
String replyId = confirmEvent.getReplyId();

List<ToolUseBlock> toolCalls = confirmEvent.getToolCalls();
for (ToolUseBlock toolUse : toolCalls) {
// 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));
}
}

private static AguiEvent.Interrupt buildInterrupt(String replyId, ToolUseBlock toolUse) {
String toolCallId = toolUse.getId();
Map<String, Object> 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));
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),
TOOL_CALL_INTERRUPT_REASON,
confirmMessage(toolUse),
toolCallId,
CONFIRM_RESPONSE_SCHEMA,
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();
}
}
Loading
Loading