diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
index 2de490fa0..eff6e1276 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
@@ -64,6 +64,8 @@
import io.agentscope.core.tool.ToolExecutionContext;
import io.agentscope.core.tool.ToolResultMessageBuilder;
import io.agentscope.core.tool.Toolkit;
+import io.agentscope.core.tool.subagent.SubAgentContext;
+import io.agentscope.core.tool.subagent.SubAgentHook;
import io.agentscope.core.util.MessageUtils;
import java.util.ArrayList;
import java.util.Comparator;
@@ -71,6 +73,7 @@
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -91,6 +94,7 @@
*
SubAgentContext state (if subAgentContextManaged is true)
*
*
* @param session the session to save state to
@@ -210,6 +217,11 @@ public void saveTo(Session session, SessionKey sessionKey) {
if (statePersistence.planNotebookManaged() && planNotebook != null) {
planNotebook.saveTo(session, sessionKey);
}
+
+ // Save SubAgentContext if managed
+ if (statePersistence.subAgentContextManaged() && subAgentContext != null) {
+ subAgentContext.saveTo(session, sessionKey);
+ }
}
/**
@@ -238,6 +250,75 @@ public void loadFrom(Session session, SessionKey sessionKey) {
if (statePersistence.planNotebookManaged() && planNotebook != null) {
planNotebook.loadFrom(session, sessionKey);
}
+
+ // Load SubAgentContext if managed
+ if (statePersistence.subAgentContextManaged() && subAgentContext != null) {
+ subAgentContext.loadFrom(session, sessionKey);
+ }
+ }
+
+ // ==================== Sub-Agent API ====================
+
+ /**
+ * Check if SubAgent HITL (Human-in-the-Loop) is enabled.
+ *
+ * This method checks whether the sub-agent human-in-the-loop functionality is available
+ * based on the presence of a sub-agent context.
+ *
+ * @return true if SubAgent HITL is enabled, false otherwise
+ */
+ public boolean isEnableSubAgentHITL() {
+ return subAgentContext != null;
+ }
+
+ /**
+ * Submit the execution result of a single sub-agent's tool.
+ *
+ *
This interface is used to submit confirmation information when a sub-agent requires user
+ * approval.
+ *
+ * @param subAgentToolId The ID of the sub-agent tool
+ * @param pendingResult The execution result of the sub-agent tool
+ * @throws IllegalStateException If SubAgent HITL is not enabled
+ * @throws IllegalArgumentException If the tool result is null
+ */
+ public void submitSubAgentResult(String subAgentToolId, ToolResultBlock pendingResult) {
+ if (!isEnableSubAgentHITL()) {
+ throw new IllegalStateException(
+ "SubAgent HITL is not enabled. Please enable it via"
+ + " builder.enableSubAgentHITL(true)");
+ }
+
+ if (pendingResult == null) {
+ throw new IllegalArgumentException("Tool result cannot be null");
+ }
+
+ subAgentContext.submitSubAgentResult(subAgentToolId, pendingResult);
+ }
+
+ /**
+ * Submit multiple tool execution results for a single sub-agent at once.
+ *
+ *
This method should be called when users provide multiple confirmations or results
+ * for already suspended sub-agents.
+ *
+ * @param subAgentToolId The ID of the sub-agent tool
+ * @param pendingResults A list of tool execution results from the sub-agent
+ * @throws IllegalStateException If SubAgent HITL is not enabled
+ * @throws IllegalArgumentException If the results list is null or empty
+ */
+ public void submitSubAgentResults(String subAgentToolId, List pendingResults) {
+ if (!isEnableSubAgentHITL()) {
+ throw new IllegalStateException(
+ "SubAgent HITL is not enabled. Please enable it via"
+ + " builder.enableSubAgentHITL(true)");
+ }
+
+ if (pendingResults == null || pendingResults.isEmpty()) {
+ throw new IllegalArgumentException("pendingResults cannot be null or empty");
+ }
+
+ subAgentContext.submitSubAgentResults(subAgentToolId, pendingResults);
}
// ==================== Protected API ====================
@@ -567,19 +648,29 @@ private Mono acting(int iter) {
}
/**
- * Build a message containing suspended tool calls for user execution.
- *
- * The message contains both the ToolUseBlocks and corresponding pending ToolResultBlocks
+ * Build a suspended message containing the tool calls and their pending results. This is used
* for the suspended tools.
*
+ *
This method also registers SubAgentTool sessionIds in SubAgentContext so that
+ * when users provide tool results, the framework can automatically inject the sessionId
+ * without requiring users to be aware of it.
+ *
* @param pendingPairs List of (ToolUseBlock, pending ToolResultBlock) pairs
* @return Msg with GenerateReason.TOOL_SUSPENDED
*/
private Msg buildSuspendedMsg(List> pendingPairs) {
List content = new ArrayList<>();
for (Map.Entry pair : pendingPairs) {
- content.add(pair.getKey());
- content.add(pair.getValue());
+ ToolUseBlock toolUse = pair.getKey();
+ ToolResultBlock result = pair.getValue();
+
+ content.add(toolUse);
+ ToolResultBlock resultWithIdAndName =
+ result.withIdAndName(toolUse.getId(), toolUse.getName());
+ content.add(resultWithIdAndName);
+
+ // Register SubAgentTool sessionId in SubAgentContext if this is a sub-agent suspension
+ registerSubAgentSessionIfNeeded(toolUse, result);
}
return Msg.builder()
.name(getName())
@@ -589,6 +680,32 @@ private Msg buildSuspendedMsg(List> pen
.build();
}
+ /**
+ * Registers SubAgentTool sessionId in SubAgentContext if the suspended tool is a SubAgentTool.
+ *
+ * This allows the framework to automatically inject sessionId when users provide
+ * tool results, making the sessionId transparent to external users.
+ *
+ * @param toolUse The tool use block
+ * @param result The suspended tool result block
+ */
+ private void registerSubAgentSessionIfNeeded(ToolUseBlock toolUse, ToolResultBlock result) {
+ if (subAgentContext == null || result == null || result.getMetadata() == null) {
+ return;
+ }
+
+ // Check if this is a SubAgentTool suspension by looking for the session ID in metadata
+ Optional sessionIdOpt = SubAgentContext.extractSessionId(result);
+ if (sessionIdOpt.isPresent()) {
+ String sessionId = sessionIdOpt.get();
+ subAgentContext.setSessionId(toolUse.getId(), sessionId);
+ log.debug(
+ "Registered SubAgentTool sessionId {} for tool {}",
+ sessionId,
+ toolUse.getName());
+ }
+ }
+
/**
* Execute tool calls and return paired results.
*
@@ -1000,6 +1117,10 @@ public static class Builder {
private RetrieveConfig retrieveConfig =
RetrieveConfig.builder().limit(5).scoreThreshold(0.5).build();
+ // SubAgent HITL configuration
+ private SubAgentContext subAgentContext;
+ private boolean enableSubAgentHITL = false;
+
private Builder() {}
/**
@@ -1345,6 +1466,38 @@ public Builder toolExecutionContext(ToolExecutionContext toolExecutionContext) {
return this;
}
+ /**
+ * Sets the SubAgentContext for managing sub-agent HITL interactions.
+ *
+ * The SubAgentContext is used to store and retrieve pending tool results
+ * when a sub-agent is suspended waiting for user input. If not set, a new
+ * context will be created automatically when sub-agent HITL is enabled.
+ *
+ * @param subAgentContext The SubAgentContext instance
+ * @return This builder instance for method chaining
+ * @see SubAgentContext
+ */
+ public Builder subAgentContext(SubAgentContext subAgentContext) {
+ this.subAgentContext = subAgentContext;
+ return this;
+ }
+
+ /**
+ * Enables or disables sub-agent HITL (Human-in-the-Loop) support.
+ *
+ *
When enabled (default), the agent will automatically register a SubAgentHook
+ * to handle sub-agent suspension and resumption. This allows sub-agents to be
+ * suspended when they need user input and resumed when the user provides results.
+ *
+ * @param enableSubAgentHITL true to enable sub-agent HITL support (default: false)
+ * @return This builder instance for method chaining
+ * @see SubAgentHook
+ */
+ public Builder enableSubAgentHITL(boolean enableSubAgentHITL) {
+ this.enableSubAgentHITL = enableSubAgentHITL;
+ return this;
+ }
+
/**
* Builds and returns a new ReActAgent instance with the configured settings.
*
@@ -1379,9 +1532,32 @@ public ReActAgent build() {
configureSkillBox(agentToolkit);
}
+ // Configure SubAgent HITL support if enabled
+ if (enableSubAgentHITL) {
+ configureSubAgentHitl();
+ }
+
return new ReActAgent(this, agentToolkit);
}
+ /**
+ * Configures SubAgent HITL (Human-in-the-Loop) support.
+ *
+ *
This method automatically:
+ *
+ * - Creates a SubAgentContext if not provided
+ * - Registers a SubAgentHook to handle sub-agent suspension and resumption
+ *
+ */
+ private void configureSubAgentHitl() {
+ if (this.subAgentContext == null) {
+ this.subAgentContext = new SubAgentContext();
+ }
+
+ // Add SubAgentHook with the context
+ hooks.add(new SubAgentHook(this.subAgentContext));
+ }
+
/**
* Configures long-term memory based on the selected mode.
*
diff --git a/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java b/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java
index 52f18b23e..24196229c 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java
@@ -63,6 +63,7 @@
* @param memoryManaged whether to manage Memory component state
* @param toolkitManaged whether to manage Toolkit activeGroups state
* @param planNotebookManaged whether to manage PlanNotebook state
+ * @param subAgentContextManaged whether to manage SubAgentContext state
* @param statefulToolsManaged whether to manage stateful Tool states
* @see StateModule
* @see io.agentscope.core.ReActAgent
@@ -71,21 +72,22 @@ public record StatePersistence(
boolean memoryManaged,
boolean toolkitManaged,
boolean planNotebookManaged,
- boolean statefulToolsManaged) {
+ boolean statefulToolsManaged,
+ boolean subAgentContextManaged) {
/** Default configuration: manage all components. */
public static StatePersistence all() {
- return new StatePersistence(true, true, true, true);
+ return new StatePersistence(true, true, true, true, true);
}
/** Don't manage any components (user fully controls). */
public static StatePersistence none() {
- return new StatePersistence(false, false, false, false);
+ return new StatePersistence(false, false, false, false, false);
}
/** Only manage Memory component. */
public static StatePersistence memoryOnly() {
- return new StatePersistence(true, false, false, false);
+ return new StatePersistence(true, false, false, false, false);
}
/**
@@ -103,6 +105,7 @@ public static class Builder {
private boolean memoryManaged = true;
private boolean toolkitManaged = true;
private boolean planNotebookManaged = true;
+ private boolean subAgentContextManaged = true;
private boolean statefulToolsManaged = true;
/**
@@ -138,6 +141,17 @@ public Builder planNotebookManaged(boolean managed) {
return this;
}
+ /**
+ * Sets whether to manage SubAgentContext state.
+ *
+ * @param managed true to manage SubAgentContext state, false to let user manage
+ * @return This builder for method chaining
+ */
+ public Builder subAgentContextManaged(boolean managed) {
+ this.subAgentContextManaged = managed;
+ return this;
+ }
+
/**
* Sets whether to manage stateful Tool states.
*
@@ -156,7 +170,11 @@ public Builder statefulToolsManaged(boolean managed) {
*/
public StatePersistence build() {
return new StatePersistence(
- memoryManaged, toolkitManaged, planNotebookManaged, statefulToolsManaged);
+ memoryManaged,
+ toolkitManaged,
+ planNotebookManaged,
+ statefulToolsManaged,
+ subAgentContextManaged);
}
}
}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java
index 66b3792fa..7738aaf3c 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java
@@ -65,6 +65,7 @@ public class SubAgentConfig {
private final boolean forwardEvents;
private final StreamOptions streamOptions;
private final Session session;
+ private final boolean enableHITL;
private SubAgentConfig(Builder builder) {
this.toolName = builder.toolName;
@@ -72,6 +73,7 @@ private SubAgentConfig(Builder builder) {
this.forwardEvents = builder.forwardEvents;
this.streamOptions = builder.streamOptions;
this.session = builder.session != null ? builder.session : new InMemorySession();
+ this.enableHITL = builder.enableHITL;
}
/**
@@ -148,6 +150,22 @@ public Session getSession() {
return session;
}
+ /**
+ * Gets whether HITL (Human-in-the-Loop) support is enabled.
+ *
+ * When enabled, the sub-agent tool will:
+ *
+ * - Detect when the sub-agent is suspended waiting for user input
+ * - Return a suspended result containing session ID and inner tool information
+ * - Support resumption with user-provided tool results
+ *
+ *
+ * @return true if HITL support is enabled (default: false)
+ */
+ public boolean isEnableHITL() {
+ return enableHITL;
+ }
+
/** Builder for SubAgentConfig. */
public static class Builder {
private String toolName;
@@ -155,6 +173,7 @@ public static class Builder {
private boolean forwardEvents = true;
private StreamOptions streamOptions;
private Session session;
+ private boolean enableHITL = false;
private Builder() {}
@@ -229,6 +248,23 @@ public Builder session(Session session) {
return this;
}
+ /**
+ * Sets whether HITL (Human-in-the-Loop) support is enabled.
+ *
+ * When enabled, the sub-agent tool will detect when the sub-agent is suspended
+ * waiting for user input and return a suspended result that can be resumed later.
+ *
+ *
This is useful when the sub-agent uses tools that require human confirmation
+ * or external execution.
+ *
+ * @param enableHITL true to enable HITL support
+ * @return This builder
+ */
+ public Builder enableHITL(boolean enableHITL) {
+ this.enableHITL = enableHITL;
+ return this;
+ }
+
/**
* Builds the configuration.
*
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentContext.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentContext.java
new file mode 100644
index 000000000..a57130ac9
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentContext.java
@@ -0,0 +1,293 @@
+/*
+ * 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.tool.subagent;
+
+import io.agentscope.core.message.GenerateReason;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.session.Session;
+import io.agentscope.core.state.SessionKey;
+import io.agentscope.core.state.StateModule;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Sub-agent context for managing tool call results during HITL (Human-in-the-Loop) interactions.
+ *
+ *
This class provides business logic for handling sub-agent results, including:
+ *
+ *
+ * - Detection of whether a ToolResultBlock is a sub-agent result confirmation
+ * - Extraction of sub-agent session IDs from tool results
+ * - Delegation of state management to {@link SubAgentPendingStore}
+ *
+ *
+ * The actual storage and persistence of pending states is handled by {@link SubAgentPendingStore},
+ * which delegates to {@link SubAgentPendingStore} that implements {@link io.agentscope.core.state.State} for direct serialization support. This class follows a sessionId-first
+ * constraint: a session ID must be registered before any results can be added for that tool.
+ *
+ *
Usage Pattern:
+ * {@code
+ * SubAgentContext context = new SubAgentContext();
+ *
+ * // 1. Store session ID first (required before adding results)
+ * context.setSessionId("tool-123", "session-abc");
+ *
+ * // 2. Submit sub-agent result (can only be added after session ID is set)
+ * context.submitSubAgentResult("tool-123", result);
+ *
+ * // 3. Check if result is from sub-agent
+ * if (SubAgentContext.isSubAgentResult(result)) {
+ * Optional sessionId = SubAgentContext.extractSessionId(result);
+ * }
+ *
+ * // 4. Consume pending results
+ * Optional pending = context.consumePendingResult("tool-123");
+ *
+ * // 5. Save state to session
+ * context.saveTo(session, sessionKey);
+ *
+ * // 6. Later, restore state
+ * context.loadFrom(session, sessionKey);
+ * }
+ *
+ * Thread Safety:
+ * This class is thread-safe and delegates all state management to the thread-safe
+ * {@link SubAgentPendingStore}.
+ */
+public class SubAgentContext implements StateModule {
+
+ /** Metadata key for sub-agent session ID in ToolResultBlock. */
+ public static final String METADATA_SUBAGENT_SESSION_ID = "subagent_session_id";
+
+ /** Metadata key for sub-agent suspend type in ToolResultBlock. */
+ public static final String METADATA_GENERATE_REASON = "subagent_generate_reason";
+
+ /** The pending store manager. */
+ private SubAgentPendingStore pendingStore;
+
+ /**
+ * Creates a new SubAgentContext with an empty pending state.
+ */
+ public SubAgentContext() {
+ this.pendingStore = new SubAgentPendingStore();
+ }
+
+ /**
+ * Gets the pending state manager.
+ *
+ * This provides access to the underlying state management for advanced use cases.
+ * Use with caution as direct modifications may bypass business logic validation.
+ *
+ * @return The pending state manager
+ */
+ public SubAgentPendingStore getPendingStore() {
+ return pendingStore;
+ }
+
+ /**
+ * Stores the session ID for a tool.
+ *
+ *
This method must be called before any results can be added for the tool.
+ * This enforces the sessionId-first constraint to ensure proper lifecycle management.
+ *
+ * @param toolId The tool ID
+ * @param sessionId The session ID
+ */
+ public void setSessionId(String toolId, String sessionId) {
+ if (toolId == null) {
+ throw new IllegalArgumentException("toolId cannot be null");
+ }
+ String existingSessionId = pendingStore.getSessionId(toolId);
+ if (existingSessionId != null && existingSessionId.equals(sessionId)) {
+ return;
+ }
+ pendingStore.setSessionId(toolId, sessionId);
+ }
+
+ /**
+ * Gets pending tool results for a tool.
+ *
+ * @param toolId The sub-agent tool ID
+ * @return An Optional containing the pending results, or empty if none exist
+ */
+ public Optional> getPendingResult(String toolId) {
+ List results = pendingStore.getPendingResults(toolId);
+ return Optional.ofNullable(results.isEmpty() ? null : results);
+ }
+
+ /**
+ * Gets the session ID for a tool.
+ *
+ * @param toolId The tool ID
+ * @return An Optional containing the session ID, or empty if none exist
+ */
+ public Optional getSessionId(String toolId) {
+ if (toolId == null) {
+ throw new IllegalArgumentException("toolId cannot be null");
+ }
+ return Optional.ofNullable(pendingStore.getSessionId(toolId));
+ }
+
+ /**
+ * Consumes and removes the pending context for a tool.
+ *
+ * This method atomically retrieves and removes all pending state for the tool,
+ * including both the session ID and pending results. This is typically called when
+ * resuming a suspended sub-agent execution.
+ *
+ * @param toolId The tool ID
+ * @return An Optional containing the pending context, or empty if none exist
+ */
+ public Optional consumePendingResult(String toolId) {
+ if (toolId == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(pendingStore.remove(toolId));
+ }
+
+ /**
+ * Checks if a tool has any pending results.
+ *
+ * @param toolId The tool ID
+ * @return true if the tool has pending results, false otherwise
+ */
+ public boolean hasPendingResult(String toolId) {
+ return pendingStore.hasPendingResults(toolId);
+ }
+
+ /**
+ * Clears all pending data for all tools.
+ *
+ * This removes all session IDs and pending results from the context.
+ */
+ public void clear() {
+ pendingStore.clearAll();
+ }
+
+ /**
+ * Submit sub-agent tool call results and store them.
+ *
+ *
This method should be called when users provide confirmation or results for suspended sub-agents.
+ * This is a convenience method that wraps a single result into a list and delegates to the batch submission method.
+ *
+ * @param subAgentToolId The sub-agent tool ID
+ * @param pendingResult The sub-agent tool result
+ * @throws IllegalArgumentException if the result is null
+ */
+ public void submitSubAgentResult(String subAgentToolId, ToolResultBlock pendingResult) {
+ if (pendingResult == null) {
+ throw new IllegalArgumentException("result cannot be null");
+ }
+ submitSubAgentResults(subAgentToolId, List.of(pendingResult));
+ }
+
+ /**
+ * Submit multiple sub-agent tool call results and store them.
+ *
+ *
This is the core method for submitting sub-agent results. It validates and stores multiple
+ * results for suspended sub-agents. Tools must have registered session IDs before adding results
+ * (enforced through sessionId-first constraint).
+ *
+ * @param subAgentToolId The sub-agent tool ID
+ * @param pendingResults A list of sub-agent tool results
+ * @throws IllegalArgumentException If the results list is null or empty, or if the tool does not exist in pending state
+ */
+ public void submitSubAgentResults(String subAgentToolId, List pendingResults) {
+ if (pendingResults == null || pendingResults.isEmpty()) {
+ throw new IllegalArgumentException("pendingResults cannot be null or empty");
+ }
+
+ if (!pendingStore.contains(subAgentToolId)) {
+ throw new IllegalArgumentException("No pending result for tool: " + subAgentToolId);
+ }
+
+ pendingStore.addResults(subAgentToolId, pendingResults);
+ }
+
+ /**
+ * Extracts the sub-agent session ID from a tool result block.
+ *
+ * This is a static utility method that can be called without a SubAgentContext instance.
+ *
+ * @param result The tool result block
+ * @return An Optional containing the session ID if present, otherwise empty
+ */
+ public static Optional extractSessionId(ToolResultBlock result) {
+ if (result == null || result.getMetadata() == null) {
+ return Optional.empty();
+ }
+ Object sessionId = result.getMetadata().get(METADATA_SUBAGENT_SESSION_ID);
+ return sessionId instanceof String ? Optional.of((String) sessionId) : Optional.empty();
+ }
+
+ /**
+ * Gets the generation reason for a sub-agent.
+ *
+ * This method retrieves the generation reason from the tool result and returns the corresponding enum value.
+ * If no valid generation reason is found, it defaults to MODEL_STOP.
+ *
+ * @param toolResult The tool execution result
+ * @return Returns the generation reason, or MODEL_STOP if no valid reason is found
+ */
+ public static GenerateReason getSubAgentGenerateReason(ToolResultBlock toolResult) {
+ Object reason = toolResult.getMetadata().get(SubAgentContext.METADATA_GENERATE_REASON);
+ if (reason instanceof GenerateReason) {
+ return (GenerateReason) reason;
+ }
+ return GenerateReason.MODEL_STOP;
+ }
+
+ /**
+ * Checks if the tool result originates from a sub-agent.
+ *
+ * @param result The tool result block
+ * @return True if the result comes from a sub-agent, false otherwise
+ */
+ public static boolean isSubAgentResult(ToolResultBlock result) {
+ return extractSessionId(result).isPresent();
+ }
+
+ // ==================== StateModule Implementation ====================
+
+ /**
+ * Saves the context state to a session.
+ *
+ *
Delegates to the pending state manager for actual persistence.
+ * The pending state is serialized directly as it implements {@link io.agentscope.core.state.State}.
+ *
+ * @param session The session object
+ * @param sessionKey The session identifier
+ */
+ @Override
+ public void saveTo(Session session, SessionKey sessionKey) {
+ session.save(sessionKey, "subagent_context", pendingStore);
+ }
+
+ /**
+ * Restores the context state from a session.
+ *
+ *
Delegates to the pending state manager for actual restoration.
+ * The existing pending state is replaced with the loaded state.
+ *
+ * @param session The session object
+ * @param sessionKey The session identifier
+ */
+ @Override
+ public void loadFrom(Session session, SessionKey sessionKey) {
+ session.get(sessionKey, "subagent_context", SubAgentPendingStore.class)
+ .ifPresent(state -> this.pendingStore = state);
+ }
+}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentHook.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentHook.java
new file mode 100644
index 000000000..5ec6268dd
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentHook.java
@@ -0,0 +1,161 @@
+/*
+ * 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.tool.subagent;
+
+import io.agentscope.core.hook.Hook;
+import io.agentscope.core.hook.HookEvent;
+import io.agentscope.core.hook.PreActingEvent;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+
+/**
+ * Hook for injecting pending sub-agent tool results during sub-agent execution.
+ *
+ *
This hook works in conjunction with {@link SubAgentContext} to enable resuming
+ * suspended sub-agent executions. When a sub-agent is suspended and then resumed,
+ * this hook intercepts the tool call and injects the previously pending results.
+ *
+ *
The hook operates during the PreActingEvent phase:
+ *
+ * - Checks if the called tool has pending results in the context
+ * - Injects pending results into the tool use block's metadata
+ * - Updates the tool input with the session_id from the pending context
+ *
+ *
+ * Result injection mechanism:
+ *
+ * - Pending results are stored in metadata under the key {@link #PREVIOUS_TOOL_RESULT}
+ * - The session_id is added to the tool input for proper context tracking
+ * - After injection, pending results are cleared from the context
+ *
+ *
+ * Example usage:
+ *
{@code
+ * SubAgentContext context = new SubAgentContext();
+ * SubAgentHook hook = new SubAgentHook(context);
+ *
+ * ReActAgent agent = ReActAgent.builder()
+ * .name("MainAgent")
+ * .hook(hook)
+ * .build();
+ * }
+ */
+public class SubAgentHook implements Hook {
+
+ private static final Logger logger = LoggerFactory.getLogger(SubAgentHook.class);
+
+ private final SubAgentContext context;
+
+ public static final String PREVIOUS_TOOL_RESULT = "previous_tool_result";
+
+ /**
+ * Creates a new SubAgentHook with the given context.
+ *
+ * @param context The SubAgentContext for managing pending results
+ */
+ public SubAgentHook(SubAgentContext context) {
+ this.context = context;
+ }
+
+ /**
+ * Gets the SubAgentContext associated with this hook.
+ *
+ * @return The SubAgentContext
+ */
+ public SubAgentContext getContext() {
+ return context;
+ }
+
+ @Override
+ public Mono onEvent(T event) {
+ if (event instanceof PreActingEvent preActingEvent) {
+ return handlePreActing(preActingEvent).map(e -> (T) e);
+ }
+ return Mono.just(event);
+ }
+
+ /**
+ * Handles PreActingEvent to inject pending sub-agent results
+ *
+ * This method checks whether the called tool has any pending results in the context.
+ * If pending results exist, it injects them into the tool use block's metadata
+ * and updates the tool input with the session_id from the pending context.
+ *
+ *
Injection process:
+ *
+ * - Retrieves pending context by tool ID from {@link SubAgentContext}
+ * - Stores pending results in metadata under {@link #PREVIOUS_TOOL_RESULT}
+ * - Updates tool input with session_id for context tracking
+ * - Clears the pending result from context after injection
+ *
+ *
+ * @param event PreActingEvent
+ * @return A Mono containing the possibly modified event
+ */
+ private Mono handlePreActing(PreActingEvent event) {
+ ToolUseBlock toolUse = event.getToolUse();
+ if (toolUse == null) {
+ return Mono.just(event);
+ }
+
+ // Extract session_id from tool input
+ Map input = toolUse.getInput();
+ if (input == null) {
+ return Mono.just(event);
+ }
+
+ // Check if there's a pending result for this session
+ Optional pendingContext =
+ context.consumePendingResult(toolUse.getId());
+ Optional> pendingResult =
+ pendingContext.map(SubAgentPendingContext::pendingResults);
+ if (pendingResult.isEmpty()) {
+ return Mono.just(event);
+ }
+
+ // Inject the result into tool use input (create new mutable maps)
+ Map metadata = new HashMap<>(toolUse.getMetadata());
+ metadata.put(PREVIOUS_TOOL_RESULT, pendingResult.get());
+ Map newInput = new HashMap<>(toolUse.getInput());
+ newInput.put("session_id", pendingContext.get().sessionId());
+
+ ToolUseBlock modifiedToolUse =
+ ToolUseBlock.builder()
+ .id(toolUse.getId())
+ .name(toolUse.getName())
+ .input(newInput)
+ .content(toolUse.getContent())
+ .metadata(metadata)
+ .build();
+
+ event.setToolUse(modifiedToolUse);
+
+ return Mono.just(event);
+ }
+
+ @Override
+ public int priority() {
+ // High priority to ensure result injection happens before tool execution
+ return 10;
+ }
+}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingContext.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingContext.java
new file mode 100644
index 000000000..246d1f3ab
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingContext.java
@@ -0,0 +1,66 @@
+/*
+ * 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.tool.subagent;
+
+import io.agentscope.core.message.ToolResultBlock;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents the pending state of a single sub-agent tool during HITL (Human-in-the-Loop) interactions.
+ *
+ * This immutable record encapsulates the complete state of a sub-agent tool that has been suspended,
+ * containing all information needed to resume execution:
+ *
+ *
+ * - The tool ID that identifies the specific sub-agent tool
+ * - The session ID of the sub-agent's execution context
+ * - The list of pending tool results that need to be injected when resuming
+ *
+ *
+ * Immutability:
+ * This record is immutable. The pendingResults list is defensively copied during construction
+ * to prevent external modifications. This ensures thread-safety and predictable behavior.
+ *
+ * Usage:
+ * This class is typically created by {@link SubAgentPendingStore} when consuming pending state
+ * and is used to pass complete context information between components.
+ *
+ * @param toolId The tool ID that identifies the sub-agent tool
+ * @param sessionId The session ID of the sub-agent's execution context
+ * @param pendingResults The list of pending tool results that need to be injected when resuming
+ */
+public record SubAgentPendingContext(
+ String toolId, String sessionId, List pendingResults) {
+
+ /**
+ * Creates a new SubAgentPendingContext with defensive copying of the results list.
+ *
+ * The compact constructor ensures that the pendingResults list is defensively copied
+ * to prevent external modifications after construction.
+ *
+ * @param toolId The tool ID
+ * @param sessionId The session ID
+ * @param pendingResults The list of pending results (will be copied)
+ */
+ public SubAgentPendingContext {
+ if (pendingResults == null) {
+ pendingResults = new ArrayList<>();
+ } else {
+ pendingResults = new ArrayList<>(pendingResults);
+ }
+ }
+}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingStore.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingStore.java
new file mode 100644
index 000000000..022d93233
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingStore.java
@@ -0,0 +1,254 @@
+/*
+ * 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.tool.subagent;
+
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.state.State;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Manages the pending state of multiple sub-agent tools during HITL (Human-in-the-Loop) interactions.
+ *
+ *
This class is responsible for storing and managing pending states for sub-agent tools.
+ * It enforces a sessionId-first constraint: a session ID must be registered before any results
+ * can be added for that tool. This ensures proper lifecycle management and prevents orphaned
+ * results without associated sessions.
+ *
+ *
Storage Structure:
+ * The storage is organized by tool ID: {@code Map}.
+ * Each tool ID maps to a complete context containing the tool ID, session ID, and pending results.
+ * This single-source-of-truth design ensures data consistency.
+ *
+ * Key Features:
+ *
+ * - Thread-safe storage using ConcurrentHashMap
+ * - SessionId-first constraint: results can only be added after session ID is registered
+ * - Defensive copying to prevent external modifications
+ * - Simple CRUD operations for managing pending states
+ * - Implements {@link State} for direct serialization support
+ * - Single data source ensures consistency
+ *
+ *
+ * Usage Pattern:
+ * {@code
+ * // 1. Register session ID first (required) - creates a pending context
+ * state.setSessionId("tool-123", "session-abc");
+ *
+ * // 2. Add results to the registered session
+ * state.addResult("tool-123", result1);
+ * state.addResult("tool-123", result2);
+ *
+ * // 3. Retrieve data
+ * String sessionId = state.getSessionId("tool-123");
+ * List results = state.getPendingResults("tool-123");
+ *
+ * // 4. Clean up when done
+ * state.remove("tool-123");
+ * }
+ *
+ * Thread Safety:
+ * This class is thread-safe and can be used concurrently from multiple threads.
+ * All operations are atomic at the method level.
+ */
+public class SubAgentPendingStore implements State {
+
+ /**
+ * Map of tool IDs to their pending contexts.
+ * This is the single source of truth for all pending state data.
+ * Each tool ID maps to a complete context containing the tool ID, session ID, and pending results.
+ */
+ private final Map toolIdToContext = new ConcurrentHashMap<>();
+
+ /**
+ * Gets the session ID for a tool.
+ *
+ * @param toolId The tool ID
+ * @return The session ID, or null if not found
+ */
+ public String getSessionId(String toolId) {
+ SubAgentPendingContext context = toolIdToContext.get(toolId);
+ return context == null ? null : context.sessionId();
+ }
+
+ /**
+ * Sets or updates the session ID for a tool.
+ *
+ * This method must be called before any results can be added for the tool.
+ * It creates a new SubAgentPendingContext containing the tool ID and session ID.
+ * If the tool already has a context, it will be replaced.
+ *
+ * @param toolId The tool ID
+ * @param sessionId The session ID
+ * @throws IllegalArgumentException if toolId or sessionId is null
+ */
+ public void setSessionId(String toolId, String sessionId) {
+ if (toolId == null) {
+ throw new IllegalArgumentException("toolId cannot be null");
+ }
+ if (sessionId == null) {
+ throw new IllegalArgumentException("sessionId cannot be null");
+ }
+
+ // Create or update the context for this tool
+ SubAgentPendingContext context =
+ new SubAgentPendingContext(toolId, sessionId, new ArrayList<>());
+ toolIdToContext.put(toolId, context);
+ }
+
+ /**
+ * Gets the pending tool results for a tool.
+ *
+ *
Returns a defensive copy of the results list to prevent external modifications.
+ *
+ * @param toolId The tool ID
+ * @return A defensive copy of the pending results list, or empty list if not found
+ */
+ public List getPendingResults(String toolId) {
+ SubAgentPendingContext context = toolIdToContext.get(toolId);
+ if (context == null) {
+ return new ArrayList<>();
+ }
+
+ List results = context.pendingResults();
+ return results == null ? new ArrayList<>() : new ArrayList<>(results);
+ }
+
+ /**
+ * Adds a single result to the pending results for a tool.
+ *
+ * The tool must have a registered session ID before results can be added.
+ *
+ * @param toolId The tool ID
+ * @param result The result to add
+ * @throws IllegalStateException if the tool does not have a registered session ID
+ * @throws IllegalArgumentException if toolId or result is null
+ */
+ public void addResult(String toolId, ToolResultBlock result) {
+ if (toolId == null) {
+ throw new IllegalArgumentException("toolId cannot be null");
+ }
+ if (result == null) {
+ throw new IllegalArgumentException("result cannot be null");
+ }
+ SubAgentPendingContext existingContext = toolIdToContext.get(toolId);
+ if (existingContext == null) {
+ throw new IllegalStateException(
+ "Cannot add result for tool '"
+ + toolId
+ + "' without a registered session ID. "
+ + "Call setSessionId() first.");
+ }
+
+ addResults(toolId, List.of(result));
+ }
+
+ /**
+ * Adds multiple results to the pending results for a tool.
+ *
+ *
The tool must have a registered session ID before results can be added.
+ *
+ * @param toolId The tool ID
+ * @param result The results to add
+ * @throws IllegalStateException if the tool does not have a registered session ID
+ * @throws IllegalArgumentException if toolId or result is null
+ */
+ public void addResults(String toolId, List result) {
+ if (toolId == null) {
+ throw new IllegalArgumentException("toolId cannot be null");
+ }
+ if (result == null) {
+ throw new IllegalArgumentException("result cannot be null");
+ }
+ SubAgentPendingContext existingContext = toolIdToContext.get(toolId);
+ if (existingContext == null) {
+ throw new IllegalStateException(
+ "Cannot add result for tool '"
+ + toolId
+ + "' without a registered session ID. "
+ + "Call setSessionId() first.");
+ }
+
+ // Create new list with the added result
+ List newResults = new ArrayList<>(existingContext.pendingResults());
+ newResults.addAll(result);
+
+ // Create new context with updated results
+ SubAgentPendingContext newContext =
+ new SubAgentPendingContext(
+ existingContext.toolId(), existingContext.sessionId(), newResults);
+ toolIdToContext.put(toolId, newContext);
+ }
+
+ /**
+ * Removes all pending data for a tool (both session ID and results).
+ *
+ * @param toolId The tool ID
+ */
+ public SubAgentPendingContext remove(String toolId) {
+ if (toolId != null) {
+ return toolIdToContext.remove(toolId);
+ }
+ return null;
+ }
+
+ /**
+ * Checks if there are any pending results.
+ *
+ * @return true if there are no pending results, false otherwise
+ */
+ public boolean isEmpty() {
+ return toolIdToContext.isEmpty();
+ }
+
+ /**
+ * Checks if a tool has a registered session ID.
+ *
+ * This is the primary check for whether a tool is in the system.
+ * A tool must have a session ID before it can have results.
+ *
+ * @param toolId The tool ID
+ * @return true if the tool has a registered session ID, false otherwise
+ */
+ public boolean contains(String toolId) {
+ return toolId != null && toolIdToContext.containsKey(toolId);
+ }
+
+ /**
+ * Checks if a tool has any pending results.
+ *
+ * @param toolId The tool ID
+ * @return true if the tool has pending results, false otherwise
+ */
+ public boolean hasPendingResults(String toolId) {
+ if (toolId == null) {
+ return false;
+ }
+ SubAgentPendingContext context = toolIdToContext.get(toolId);
+ return context != null && !context.pendingResults().isEmpty();
+ }
+
+ /**
+ * Clears all pending data for all tools.
+ *
+ *
This removes all session IDs and all pending results from the state.
+ */
+ public void clearAll() {
+ toolIdToContext.clear();
+ }
+}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java
index d0cd8d0f6..1d9cdaf68 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java
@@ -15,23 +15,29 @@
*/
package io.agentscope.core.tool.subagent;
+import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.Agent;
import io.agentscope.core.agent.Event;
import io.agentscope.core.agent.StreamOptions;
+import io.agentscope.core.message.GenerateReason;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.session.Session;
import io.agentscope.core.state.StateModule;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import io.agentscope.core.tool.ToolEmitter;
import io.agentscope.core.util.JsonUtils;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.UUID;
+import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
@@ -52,6 +58,13 @@
* existing one.
*
{@code message} - Required. The message to send to the agent.
*
+ *
+ * HITL Support (Human-in-the-Loop):
+ *
+ * - Enables sub-agent to pause and wait for user confirmation when internal tools require it.
+ *
- User can provide confirmation results to resume sub-agent execution.
+ *
- Supports multi-round human-computer interaction within the same session.
+ *
*/
public class SubAgentTool implements AgentTool {
@@ -83,9 +96,44 @@ public SubAgentTool(SubAgentProvider> agentProvider, SubAgentConfig config) {
this.name = resolveToolName(sampleAgent, this.config);
this.description = resolveDescription(sampleAgent, this.config);
+ // Check HITL compatibility if enabled
+ if (this.config.isEnableHITL()) {
+ checkHITLCompatibility(agentProvider);
+ checkParentAgentHITLSupport(agentProvider);
+ }
+
logger.debug("Created SubAgentTool: name={}, description={}", name, description);
}
+ /**
+ * Checks if the agent is compatible with HITL (Human-in-the-Loop) support.
+ *
+ * @param agentProvider The agent provider to check
+ * @throws IllegalArgumentException if the agent is not a ReActAgent instance
+ */
+ private void checkHITLCompatibility(SubAgentProvider> agentProvider) {
+ Agent agent = agentProvider.provide();
+ if (!(agent instanceof ReActAgent)) {
+ throw new IllegalArgumentException("HITL is only supported with ReActAgent");
+ }
+ }
+
+ private void checkParentAgentHITLSupport(SubAgentProvider> agentProvider) {
+ Agent agent = agentProvider.provide();
+ if (agent instanceof ReActAgent parentReActAgent) {
+ // Assuming ReActAgent has a method to check HITL support
+ if (!parentReActAgent.isEnableSubAgentHITL()) {
+ logger.warn(
+ "SubAgentTool '{}' has HITL enabled but parent agent '{}' has HITL"
+ + " disabled. If the subagent suspends, the parent agent cannot resume"
+ + " it. Consider enabling HITL on the parent agent or disabling it on"
+ + " the subagent.",
+ name,
+ parentReActAgent.getName());
+ }
+ }
+ }
+
@Override
public String getName() {
return name;
@@ -116,6 +164,15 @@ public Mono callAsync(ToolCallParam param) {
* Agent state loading for continued sessions
* Message execution (streaming or non-streaming based on config)
* Agent state persistence after execution
+ * HITL support: detecting suspended state and resuming with injected results
+ *
+ *
+ * HITL Behavior:
+ *
+ * - When HITL is enabled, suspended states are returned with special metadata
+ * for resumption with user-provided results
+ * - When HITL is disabled, suspended states are converted to normal text responses
+ * to ensure conversation continues without interruption
*
*
* @param param The tool call parameters containing input and emitter
@@ -126,14 +183,25 @@ private Mono executeConversation(ToolCallParam param) {
(ctxView) -> {
try {
Map input = param.getInput();
+ ToolUseBlock toolUseBlock = param.getToolUseBlock();
// Get or create session ID
String sessionId = (String) input.get(PARAM_SESSION_ID);
- boolean isNewSession = sessionId == null;
+ boolean isNewSession = sessionId == null || sessionId.trim().isEmpty();
if (isNewSession) {
sessionId = UUID.randomUUID().toString();
}
+ // Check if there's an injected result from SubAgentHook
+ if (config.isEnableHITL()
+ && toolUseBlock
+ .getMetadata()
+ .containsKey(SubAgentHook.PREVIOUS_TOOL_RESULT)) {
+ Optional> toolResults =
+ extractToolResults(toolUseBlock);
+ return resume(sessionId, toolResults.orElse(null), param);
+ }
+
// Get message
String message = (String) input.get(PARAM_MESSAGE);
if (message == null || message.isEmpty()) {
@@ -165,12 +233,16 @@ private Mono executeConversation(ToolCallParam param) {
// Get emitter for event forwarding
ToolEmitter emitter = param.getEmitter();
- // Execute and save state after completion
+ // Execute and handle potential suspension
Mono result;
if (config.isForwardEvents()) {
- result = executeWithStreaming(agent, userMsg, finalSessionId, emitter);
+ result =
+ executeWithStreaming(
+ agent, List.of(userMsg), finalSessionId, emitter);
} else {
- result = executeWithoutStreaming(agent, userMsg, finalSessionId);
+ result =
+ executeWithoutStreaming(
+ agent, List.of(userMsg), finalSessionId);
}
// Save state after execution
@@ -188,6 +260,92 @@ private Mono executeConversation(ToolCallParam param) {
});
}
+ /**
+ * Extracts injected result from tool use block input.
+ *
+ * This is used when SubAgentHook has injected a pending result for resumption.
+ *
+ * @param toolUseBlock The tool use block
+ * @return Optional containing the injected result
+ */
+ @SuppressWarnings("unchecked")
+ private Optional> extractToolResults(ToolUseBlock toolUseBlock) {
+ if (toolUseBlock == null || toolUseBlock.getInput() == null) {
+ return Optional.empty();
+ }
+
+ Object toolResult = toolUseBlock.getMetadata().get(SubAgentHook.PREVIOUS_TOOL_RESULT);
+
+ if (toolResult instanceof List) {
+ List> list = (List>) toolResult;
+
+ List resultList =
+ list.stream()
+ .filter(ToolResultBlock.class::isInstance)
+ .map(ToolResultBlock.class::cast)
+ .collect(Collectors.toList());
+
+ return resultList.isEmpty() ? Optional.empty() : Optional.of(resultList);
+ }
+
+ return Optional.empty();
+ }
+
+ /**
+ * Resume execution using injected tool results.
+ *
+ * This method is called when a sub-agent was previously paused and the user provides tool results.
+ * It loads the agent state and continues execution.
+ *
+ *
For hook-triggered pauses, if toolResults is null or empty, it continues execution with an empty message list.
+ * For tool suspensions, tool results must be provided.
+ *
+ * @param sessionId Session ID
+ * @param toolResults Injected tool results from the user
+ * @param param Original tool call parameter
+ * @return Mono emitting tool result blocks
+ */
+ private Mono resume(
+ String sessionId, List toolResults, ToolCallParam param) {
+ logger.debug(
+ "Resuming sub-agent session {} with tool result, HITL enabled: {}",
+ sessionId,
+ config.isEnableHITL());
+
+ Agent agent = agentProvider.provide();
+
+ // Load existing state
+ if (agent instanceof StateModule) {
+ loadAgentState(sessionId, (StateModule) agent);
+ }
+
+ ToolEmitter emitter = param.getEmitter();
+
+ // Build messages from tool results, each ToolResultBlock becomes a separate Msg
+ List messages = List.of();
+ if (toolResults != null && !toolResults.isEmpty()) {
+ messages =
+ toolResults.stream()
+ .map(result -> Msg.builder().role(MsgRole.TOOL).content(result).build())
+ .collect(Collectors.toList());
+ }
+
+ // Continue execution
+ Mono result;
+ if (config.isForwardEvents()) {
+ result = executeWithStreaming(agent, messages, sessionId, emitter);
+ } else {
+ result = executeWithoutStreaming(agent, messages, sessionId);
+ }
+
+ return result.doOnSuccess(
+ r -> {
+ if (agent instanceof StateModule) {
+ saveAgentState(sessionId, (StateModule) agent);
+ }
+ });
+ }
+
/**
* Loads agent state from the session storage.
*
@@ -227,28 +385,29 @@ private void saveAgentState(String sessionId, StateModule agent) {
}
/**
- * Executes agent call with streaming, forwarding events to the emitter.
+ * Executes agent call using streaming and with HITL support.
*
* Uses the agent's streaming API and forwards each event to the provided emitter as JSON.
* The final response is extracted from the last event.
*
* @param agent The agent to execute
- * @param userMsg The user message to send
+ * @param userMsgs The user messages to send
* @param sessionId The session ID for result building
* @param emitter The emitter to forward events to
* @return A Mono emitting the tool result block
*/
private Mono executeWithStreaming(
- Agent agent, Msg userMsg, String sessionId, ToolEmitter emitter) {
+ Agent agent, List userMsgs, String sessionId, ToolEmitter emitter) {
StreamOptions streamOptions =
config.getStreamOptions() != null
? config.getStreamOptions()
: StreamOptions.defaults();
+ List msgs = userMsgs != null && !userMsgs.isEmpty() ? userMsgs : List.of();
return Mono.deferContextual(
ctxView ->
- agent.stream(List.of(userMsg), streamOptions)
+ agent.stream(msgs, streamOptions)
.doOnNext(event -> forwardEvent(event, emitter, agent, sessionId))
.filter(Event::isLast)
.last()
@@ -271,21 +430,23 @@ private Mono executeWithStreaming(
}
/**
- * Executes agent call without streaming.
+ * Execute agent call without streaming but with HITL support.
*
- * Uses the agent's standard call API. No events are forwarded to the emitter.
+ *
Uses the standard calling API of the agent. If the sub-agent returns a pause status,
+ * this method constructs a suspended result containing the session ID and internal tool information.
*
- * @param agent The agent to execute
- * @param userMsg The user message to send
- * @param sessionId The session ID for result building
- * @return A Mono emitting the tool result block
+ * @param agent The agent to be executed
+ * @param userMsgs The input messages to send; if null or empty, calls agent.call() without parameters
+ * @param sessionId The session ID used to construct the result
+ * @return A Mono emitting a tool result block
*/
private Mono executeWithoutStreaming(
- Agent agent, Msg userMsg, String sessionId) {
+ Agent agent, List userMsgs, String sessionId) {
+ List messages = userMsgs != null && !userMsgs.isEmpty() ? userMsgs : List.of();
return Mono.deferContextual(
ctxView ->
- agent.call(List.of(userMsg))
+ agent.call(messages)
.map(response -> buildResult(response, sessionId))
.onErrorResume(
e -> {
@@ -298,6 +459,35 @@ private Mono executeWithoutStreaming(
.contextWrite(context -> context.putAll(ctxView)));
}
+ /**
+ * Build a suspended tool result from a paused sub-agent response.
+ *
+ * Extracts internal tool usage blocks from the response and creates a suspended result,
+ * which the main agent can use to request user input.
+ *
+ * @param response The paused response from the sub-agent
+ * @param sessionId Session ID
+ * @return A suspended tool result block
+ */
+ private ToolResultBlock buildSuspendedResult(Msg response, String sessionId) {
+ // Extract inner tool use blocks and text blocks from the response
+ List toolUses = response.getContentBlocks(ToolUseBlock.class);
+ List textBlocks = response.getContentBlocks(TextBlock.class);
+
+ // Combine text blocks and tool use blocks as content
+ List contentBlocks = new ArrayList<>();
+ contentBlocks.addAll(textBlocks);
+ contentBlocks.addAll(toolUses);
+
+ // Create metadata for the suspended result
+ Map metadata = new HashMap<>();
+ metadata.put(ToolResultBlock.METADATA_SUSPENDED, true);
+ metadata.put(SubAgentContext.METADATA_SUBAGENT_SESSION_ID, sessionId);
+ metadata.put(SubAgentContext.METADATA_GENERATE_REASON, response.getGenerateReason());
+
+ return new ToolResultBlock(null, null, contentBlocks, metadata);
+ }
+
/**
* Forwards an event to the emitter as serialized JSON.
*
@@ -328,14 +518,28 @@ private void forwardEvent(Event event, ToolEmitter emitter, Agent agent, String
/**
* Builds the final tool result with session context.
*
- * Formats the response to include the session ID, allowing callers to continue the
+ *
Formats the response to include the session ID in metadata, allowing callers to continue the
* conversation by passing the session ID in subsequent calls.
*
+ *
If HITL is disabled, suspended states will be converted to normal text responses
+ * without special metadata.
+ *
* @param response The agent's response message
- * @param sessionId The session ID to include in the result
+ * @param sessionId The session ID to include in the result metadata
* @return A tool result block containing the formatted response
*/
private ToolResultBlock buildResult(Msg response, String sessionId) {
+ // Check if sub-agent is suspended
+ GenerateReason reason = response.getGenerateReason();
+ boolean isSuspended =
+ reason == GenerateReason.TOOL_SUSPENDED
+ || reason == GenerateReason.REASONING_STOP_REQUESTED
+ || reason == GenerateReason.ACTING_STOP_REQUESTED;
+
+ if (config.isEnableHITL() && isSuspended) {
+ return buildSuspendedResult(response, sessionId);
+ }
+
String textContent = response.getTextContent();
// Return response with session context
diff --git a/agentscope-core/src/test/java/io/agentscope/core/e2e/HITLBasicE2ETest.java b/agentscope-core/src/test/java/io/agentscope/core/e2e/HITLBasicE2ETest.java
index e8a9cbbe2..cb059e7d4 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/e2e/HITLBasicE2ETest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/e2e/HITLBasicE2ETest.java
@@ -27,10 +27,12 @@
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostReasoningEvent;
import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import io.agentscope.core.tool.Toolkit;
+import io.agentscope.core.tool.subagent.SubAgentContext;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -297,4 +299,107 @@ void testResumeAfterPause(ModelProvider provider) {
System.out.println("✓ Resume after pause test completed for " + provider.getProviderName());
}
+
+ @ParameterizedTest
+ @MethodSource("io.agentscope.core.e2e.ProviderFactory#getToolProviders")
+ @DisplayName("Should handle HITL when ReactAgent uses SubAgent as tool")
+ void testReactAgentWithSubAgentToolHITL(ModelProvider provider) {
+ assumeTrue(
+ provider.supportsToolCalling(),
+ "Skipping: " + provider.getProviderName() + " does not support tool calling");
+
+ System.out.println(
+ "\n=== Test: ReactAgent with SubAgent Tool HITL with "
+ + provider.getProviderName()
+ + " ===");
+
+ // Create a sub-agent that will use sensitive tools
+ Toolkit subAgentToolkit = new Toolkit();
+ subAgentToolkit.registerTool(new SensitiveTools());
+
+ AtomicBoolean subAgentStopCalled = new AtomicBoolean(false);
+ AtomicBoolean subAgentShouldStop = new AtomicBoolean(true);
+ Hook confirmationHook = createConfirmationHook(subAgentStopCalled, subAgentShouldStop);
+
+ // Create main agent toolkit with SubAgent registered as a tool
+ Toolkit mainToolkit = new Toolkit();
+
+ // Register SubAgent as a tool with HITL enabled
+ mainToolkit
+ .registration()
+ .subAgent(
+ () ->
+ provider.createAgentBuilder("HelperAgent", subAgentToolkit)
+ .enableSubAgentHITL(false)
+ .hook(confirmationHook)
+ .sysPrompt(
+ "You are a helper agent that can perform file"
+ + " operations.")
+ .build(),
+ io.agentscope.core.tool.subagent.SubAgentConfig.builder()
+ .toolName("call_helper")
+ .description("Call the helper agent to perform tasks")
+ .enableHITL(true)
+ .build())
+ .apply();
+
+ // Create main agent
+ ReActAgent mainAgent =
+ provider.createAgentBuilder("MainAgent", mainToolkit)
+ .sysPrompt("You are a coordinator agent that delegates tasks to helpers.")
+ .enableSubAgentHITL(true)
+ .build();
+
+ // Test 1: Main agent calls sub-agent, sub-agent triggers HITL
+ System.out.println("\n--- Phase 1: Main agent delegates to sub-agent ---");
+ Msg input =
+ TestUtils.createUserMessage(
+ "User",
+ "Please use the helper agent to delete the file 'important.txt'. Tell the"
+ + " helper to use the delete_file tool directly.");
+
+ Msg response1 = mainAgent.call(input).block(TEST_TIMEOUT);
+ assertNotNull(response1, "Should receive response from main agent");
+
+ // Verify that sub-agent was called and triggered HITL
+ System.out.println("Main agent response reason: " + response1.getGenerateReason());
+
+ // Check if the response contains tool suspension
+ if (response1.getGenerateReason()
+ == io.agentscope.core.message.GenerateReason.TOOL_SUSPENDED
+ || subAgentStopCalled.get()) {
+ System.out.println("✓ Sub-agent HITL triggered successfully");
+
+ // Verify response contains pending tool calls
+ List toolResults = response1.getContentBlocks(ToolResultBlock.class);
+ assertFalse(toolResults.isEmpty(), "Should have tool results when suspended");
+
+ System.out.println("Pending tool results: " + toolResults.size());
+ toolResults.forEach(
+ t ->
+ System.out.println(
+ " - Tool: "
+ + t.getName()
+ + ", Reason: "
+ + SubAgentContext.getSubAgentGenerateReason(t)));
+
+ // Test 2: Resume after user confirmation
+ System.out.println("\n--- Phase 2: Resume after user confirmation ---");
+ subAgentShouldStop.set(false);
+
+ Msg response2 = mainAgent.call().block(TEST_TIMEOUT);
+ assertNotNull(response2, "Should receive resume response");
+
+ System.out.println("Resume response reason: " + response2.getGenerateReason());
+ assertTrue(
+ ContentValidator.hasMeaningfulContent(response2),
+ "Resume response should have meaningful content");
+
+ System.out.println("Resume response: " + TestUtils.extractTextContent(response2));
+ } else {
+ System.out.println(
+ "Sub-agent did not trigger HITL (model may have handled differently)");
+ System.out.println("Resume response: " + TestUtils.extractTextContent(response1));
+ }
+ }
}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java b/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java
index 6130f8088..d3d0d5069 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java
@@ -167,7 +167,7 @@ void testEquality() {
@Test
@DisplayName("Constructor should accept all boolean values")
void testConstructor() {
- StatePersistence persistence = new StatePersistence(true, false, true, false);
+ StatePersistence persistence = new StatePersistence(true, false, true, false, false);
assertTrue(persistence.memoryManaged());
assertFalse(persistence.toolkitManaged());
assertTrue(persistence.planNotebookManaged());
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java
index 15b5ab7ab..9079ee11f 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java
@@ -16,6 +16,7 @@
package io.agentscope.core.tool.subagent;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -49,6 +50,7 @@ void testDefaults() {
assertNull(config.getStreamOptions());
assertNotNull(config.getSession());
assertInstanceOf(InMemorySession.class, config.getSession());
+ assertFalse(config.isEnableHITL());
}
@Test
@@ -64,6 +66,13 @@ void testDefaultSession() {
SubAgentConfig config = SubAgentConfig.defaults();
assertInstanceOf(InMemorySession.class, config.getSession());
}
+
+ @Test
+ @DisplayName("Should have hitlEnabled false by default")
+ void testHitlEnabledDefaultFalse() {
+ SubAgentConfig config = SubAgentConfig.defaults();
+ assertFalse(config.isEnableHITL());
+ }
}
@Nested
@@ -121,6 +130,22 @@ void testCustomSession() {
assertEquals(customSession, config.getSession());
}
+ @Test
+ @DisplayName("Should build config with hitlEnabled enabled")
+ void testHitlEnabled() {
+ SubAgentConfig config = SubAgentConfig.builder().enableHITL(true).build();
+
+ assertTrue(config.isEnableHITL());
+ }
+
+ @Test
+ @DisplayName("Should build config with hitlEnabled disabled")
+ void testHitlDisabled() {
+ SubAgentConfig config = SubAgentConfig.builder().enableHITL(false).build();
+
+ assertFalse(config.isEnableHITL());
+ }
+
@Test
@DisplayName("Should build config with all custom values")
void testAllCustomValues() {
@@ -134,6 +159,7 @@ void testAllCustomValues() {
.forwardEvents(true)
.streamOptions(streamOptions)
.session(customSession)
+ .enableHITL(true)
.build();
assertEquals("expert_agent", config.getToolName());
@@ -141,6 +167,7 @@ void testAllCustomValues() {
assertTrue(config.isForwardEvents());
assertEquals(streamOptions, config.getStreamOptions());
assertEquals(customSession, config.getSession());
+ assertTrue(config.isEnableHITL());
}
@Test
@@ -168,6 +195,7 @@ void testBuilderChaining() {
.forwardEvents(true)
.streamOptions(null)
.session(new InMemorySession())
+ .enableHITL(true)
.build();
assertNotNull(config);
@@ -208,6 +236,13 @@ void testGetSessionNeverNull() {
SubAgentConfig configWithNullSession = SubAgentConfig.builder().session(null).build();
assertNotNull(configWithNullSession.getSession());
}
+
+ @Test
+ @DisplayName("isHitlEnabled() should return false by default")
+ void testIsHitlEnabledDefault() {
+ SubAgentConfig config = SubAgentConfig.builder().build();
+ assertFalse(config.isEnableHITL());
+ }
}
@Nested
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentContextTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentContextTest.java
new file mode 100644
index 000000000..4c16d1004
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentContextTest.java
@@ -0,0 +1,747 @@
+/*
+ * 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
+ *
+ * https://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.tool.subagent;
+
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.agentscope.core.message.GenerateReason;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.session.InMemorySession;
+import io.agentscope.core.session.Session;
+import io.agentscope.core.state.SessionKey;
+import io.agentscope.core.state.SimpleSessionKey;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Comprehensive tests for SubAgentContext functionality.
+ *
+ * Test coverage includes:
+ *
+ * - Basic functionality: storing and retrieving pending results, session IDs
+ * - State consistency: correctness of multiple operations
+ * - SessionId-first constraint: enforcement of lifecycle management
+ * - Metadata extraction: session ID and GenerateReason from ToolResultBlock
+ * - Sub-agent result detection: identifying sub-agent results via metadata
+ * - State persistence: save and load context state to/from Session
+ * - Null handling: robust behavior with null inputs
+ * - Clear operations: state cleanup and reset functionality
+ *
+ */
+@DisplayName("SubAgentContext Tests")
+class SubAgentContextTest {
+
+ private SubAgentContext context;
+
+ @BeforeEach
+ void setUp() {
+ context = new SubAgentContext();
+ }
+
+ @Nested
+ @DisplayName("Pending Result Management Tests")
+ class PendingResultManagementTests {
+
+ @Test
+ @DisplayName("Should store and retrieve pending result")
+ void testStoreAndRetrievePendingResult() {
+ String toolId = "tool-123";
+ String sessionId = "session-abc";
+ ToolResultBlock result = createToolResultBlock(toolId, "Test result");
+
+ context.setSessionId(toolId, sessionId);
+ // First param: sub-agent tool ID, Second param: pending result when sub-agent is
+ // suspended
+ context.submitSubAgentResult(toolId, result);
+
+ assertTrue(context.hasPendingResult(toolId));
+ Optional> retrieved = context.getPendingResult(toolId);
+ assertTrue(retrieved.isPresent());
+ assertEquals(1, retrieved.get().size());
+ assertEquals(toolId, retrieved.get().get(0).getId());
+ }
+
+ @Test
+ @DisplayName("Should consume and remove pending result")
+ void testConsumePendingResult() {
+ String toolId = "tool-123";
+ String sessionId = "session-abc";
+ ToolResultBlock result = createToolResultBlock(toolId, "Test result");
+
+ context.setSessionId(toolId, sessionId);
+ context.submitSubAgentResult(toolId, result);
+
+ assertTrue(context.hasPendingResult(toolId));
+ Optional consumed = context.consumePendingResult(toolId);
+ assertTrue(consumed.isPresent());
+ assertEquals(toolId, consumed.get().toolId());
+ assertEquals(sessionId, consumed.get().sessionId());
+ assertEquals(1, consumed.get().pendingResults().size());
+
+ // Verify it has been consumed
+ assertFalse(context.hasPendingResult(toolId));
+ assertTrue(context.consumePendingResult(toolId).isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should handle multiple results for same tool ID")
+ void testMultipleResultsForSameToolId() {
+ String toolId = "tool-123";
+ String sessionId = "session-abc";
+ ToolResultBlock result1 = createToolResultBlock("inner-call-1", "Result 1");
+ ToolResultBlock result2 = createToolResultBlock("inner-call-2", "Result 2");
+
+ context.setSessionId(toolId, sessionId);
+ context.submitSubAgentResult(toolId, result1);
+ context.submitSubAgentResult(toolId, result2);
+
+ Optional> retrieved = context.getPendingResult(toolId);
+ assertTrue(retrieved.isPresent());
+ assertEquals(2, retrieved.get().size());
+ assertEquals(
+ "Result 1", ((TextBlock) retrieved.get().get(0).getOutput().get(0)).getText());
+ assertEquals(
+ "Result 2", ((TextBlock) retrieved.get().get(1).getOutput().get(0)).getText());
+ }
+
+ @Test
+ @DisplayName("Should consume all results for same tool ID")
+ void testConsumeAllResultsForSameToolId() {
+ String toolId = "tool-123";
+ String sessionId = "session-abc";
+ ToolResultBlock result1 = createToolResultBlock(toolId, "Result 1");
+ ToolResultBlock result2 = createToolResultBlock(toolId, "Result 2");
+
+ context.setSessionId(toolId, sessionId);
+ context.submitSubAgentResult(toolId, result1);
+ context.submitSubAgentResult(toolId, result2);
+
+ Optional consumed = context.consumePendingResult(toolId);
+ assertTrue(consumed.isPresent());
+ assertEquals(2, consumed.get().pendingResults().size());
+
+ // Verify all results have been consumed
+ assertFalse(context.hasPendingResult(toolId));
+ }
+
+ @Test
+ @DisplayName("Should return empty for non-existent tool ID")
+ void testNonExistentToolId() {
+ Optional> result = context.getPendingResult("non-existent");
+ assertFalse(result.isPresent());
+
+ Optional consumed =
+ context.consumePendingResult("non-existent");
+ assertFalse(consumed.isPresent());
+ }
+
+ @Test
+ @DisplayName("Should throw exception when adding result without session ID")
+ void testAddResultWithoutSessionId() {
+ String toolId = "tool-123";
+ ToolResultBlock result = createToolResultBlock(toolId, "Test result");
+
+ // Should throw exception because session ID is not set
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> context.submitSubAgentResult(toolId, result));
+ }
+ }
+
+ @Nested
+ @DisplayName("Session ID Management Tests")
+ class SessionIdManagementTests {
+
+ @Test
+ @DisplayName("Should store and retrieve session ID")
+ void testStoreAndRetrieveSessionId() {
+ String toolId = "tool-456";
+ String sessionId = "session-abc";
+
+ context.setSessionId(toolId, sessionId);
+
+ Optional retrieved = context.getSessionId(toolId);
+ assertTrue(retrieved.isPresent());
+ assertEquals(sessionId, retrieved.get());
+ }
+
+ @Test
+ @DisplayName("Should update session ID for existing tool ID")
+ void testUpdateSessionId() {
+ String toolId = "tool-789";
+ String sessionId1 = "session-xyz";
+ String sessionId2 = "session-updated";
+
+ context.setSessionId(toolId, sessionId1);
+ assertEquals(sessionId1, context.getSessionId(toolId).orElse(null));
+
+ context.setSessionId(toolId, sessionId2);
+ assertEquals(sessionId2, context.getSessionId(toolId).orElse(null));
+ }
+
+ @Test
+ @DisplayName("Should return empty for non-existent tool ID")
+ void testNonExistentSessionId() {
+ Optional sessionId = context.getSessionId("non-existent");
+ assertFalse(sessionId.isPresent());
+ }
+
+ @Test
+ @DisplayName("Should handle multiple session IDs")
+ void testMultipleSessionIds() {
+ context.setSessionId("tool-1", "session-1");
+ context.setSessionId("tool-2", "session-2");
+ context.setSessionId("tool-3", "session-3");
+
+ assertEquals("session-1", context.getSessionId("tool-1").orElse(null));
+ assertEquals("session-2", context.getSessionId("tool-2").orElse(null));
+ assertEquals("session-3", context.getSessionId("tool-3").orElse(null));
+ }
+ }
+
+ @Nested
+ @DisplayName("Null Handling Tests")
+ class NullHandlingTests {
+
+ @Test
+ @DisplayName("Should handle null tool result")
+ void testNullToolResult() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> context.submitSubAgentResult("tool-123", null));
+ }
+
+ @Test
+ @DisplayName("Should handle null tool ID in setSessionId")
+ void testNullToolIdSetSessionId() {
+ assertThrows(
+ IllegalArgumentException.class, () -> context.setSessionId(null, "session-1"));
+ }
+
+ @Test
+ @DisplayName("Should handle tool result with null ID")
+ void testToolResultWithNullId() {
+ ToolResultBlock result = createToolResultBlock(null, "Result");
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> context.submitSubAgentResult("tool-123", result));
+ }
+ }
+
+ @Nested
+ @DisplayName("Clear and State Management Tests")
+ class ClearAndStateManagementTests {
+
+ @Test
+ @DisplayName("Should clear all pending data")
+ void testClear() {
+ context.setSessionId("tool-1", "session-1");
+ context.submitSubAgentResult("tool-1", createToolResultBlock("tool-1", "Result 1"));
+ context.setSessionId("tool-2", "session-2");
+
+ context.clear();
+
+ assertFalse(context.hasPendingResult("tool-1"));
+ assertFalse(context.getSessionId("tool-2").isPresent());
+ }
+
+ @Test
+ @DisplayName("Should maintain state after multiple operations")
+ void testStateAfterMultipleOperations() {
+ // Add some data
+ context.setSessionId("tool-1", "session-1");
+ context.submitSubAgentResult("tool-1", createToolResultBlock("tool-1", "Result 1"));
+
+ // Verify
+ assertTrue(context.hasPendingResult("tool-1"));
+ assertEquals("session-1", context.getSessionId("tool-1").orElse(null));
+
+ // Add more data
+ context.setSessionId("tool-2", "session-2");
+ context.submitSubAgentResult("tool-2", createToolResultBlock("tool-2", "Result 2"));
+
+ // Verify all data
+ assertTrue(context.hasPendingResult("tool-1"));
+ assertTrue(context.hasPendingResult("tool-2"));
+ assertEquals("session-1", context.getSessionId("tool-1").orElse(null));
+ assertEquals("session-2", context.getSessionId("tool-2").orElse(null));
+
+ context.consumePendingResult("tool-1");
+
+ // Verify state
+ assertFalse(context.hasPendingResult("tool-1"));
+ assertTrue(context.hasPendingResult("tool-2"));
+ assertNull(context.getSessionId("tool-1").orElse(null));
+ assertEquals("session-2", context.getSessionId("tool-2").orElse(null));
+ }
+ }
+
+ @Nested
+ @DisplayName("Metadata Extraction Tests")
+ class MetadataExtractionTests {
+
+ @Test
+ @DisplayName("Should extract session ID from metadata")
+ void testExtractSessionId() {
+ String sessionId = "session-xyz";
+ Map metadata = new HashMap<>();
+ metadata.put(SubAgentContext.METADATA_SUBAGENT_SESSION_ID, sessionId);
+ ToolResultBlock result =
+ new ToolResultBlock(
+ "tool-1",
+ "test-tool",
+ List.of(TextBlock.builder().text("Result").build()),
+ metadata);
+
+ Optional extracted = SubAgentContext.extractSessionId(result);
+ assertTrue(extracted.isPresent());
+ assertEquals(sessionId, extracted.get());
+ }
+
+ @Test
+ @DisplayName("Should return empty when session ID not in metadata")
+ void testExtractSessionIdEmpty() {
+ ToolResultBlock result = createToolResultBlock("tool-1", "Result");
+
+ Optional extracted = SubAgentContext.extractSessionId(result);
+ assertFalse(extracted.isPresent());
+
+ assertFalse(SubAgentContext.extractSessionId(null).isPresent());
+ }
+ }
+
+ @Nested
+ @DisplayName("State Consistency Tests")
+ class StateConsistencyTests {
+
+ @Test
+ @DisplayName("Should maintain consistency after repeated operations")
+ void testRepeatedOperations() {
+ // Repeatedly add the same data
+ for (int i = 0; i < 10; i++) {
+ context.setSessionId("tool-1", "session-1");
+ context.submitSubAgentResult(
+ "tool-1", createToolResultBlock("tool-1", "Result " + i));
+ }
+
+ // Verify there are 10 results
+ Optional> results = context.getPendingResult("tool-1");
+ assertTrue(results.isPresent());
+ assertEquals(10, results.get().size());
+
+ // Consume all results
+ Optional consumed = context.consumePendingResult("tool-1");
+ assertTrue(consumed.isPresent());
+ assertEquals(10, consumed.get().pendingResults().size());
+
+ // Verify it has been cleared
+ assertFalse(context.hasPendingResult("tool-1"));
+ }
+
+ @Test
+ @DisplayName("Should preserve metadata through operations")
+ void testMetadataPreservation() {
+ Map metadata = new HashMap<>();
+ metadata.put(SubAgentContext.METADATA_SUBAGENT_SESSION_ID, "session-123");
+ metadata.put("custom_key", "custom_value");
+
+ ToolResultBlock result =
+ ToolResultBlock.builder()
+ .id("tool-1")
+ .output(TextBlock.builder().text("Result").build())
+ .metadata(metadata)
+ .build();
+
+ context.setSessionId("tool-1", "session-123");
+ context.submitSubAgentResult("tool-1", result);
+
+ // Get result
+ Optional> retrieved = context.getPendingResult("tool-1");
+ assertTrue(retrieved.isPresent());
+ assertEquals(1, retrieved.get().size());
+
+ ToolResultBlock retrievedResult = retrieved.get().get(0);
+ assertEquals(
+ "session-123",
+ retrievedResult
+ .getMetadata()
+ .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID));
+ assertEquals("custom_value", retrievedResult.getMetadata().get("custom_key"));
+
+ // Consume result
+ Optional consumed = context.consumePendingResult("tool-1");
+ assertTrue(consumed.isPresent());
+ assertEquals(1, consumed.get().pendingResults().size());
+
+ ToolResultBlock consumedResult = consumed.get().pendingResults().get(0);
+ assertEquals(
+ "session-123",
+ consumedResult.getMetadata().get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID));
+ assertEquals("custom_value", consumedResult.getMetadata().get("custom_key"));
+ }
+ }
+
+ @Nested
+ @DisplayName("SessionId-First Constraint Tests")
+ class SessionIdFirstConstraintTests {
+
+ @Test
+ @DisplayName("Should enforce sessionId-first constraint")
+ void testSessionIdFirstConstraint() {
+ String toolId = "tool-123";
+ ToolResultBlock result = createToolResultBlock(toolId, "Test result");
+
+ // Try to add result without setting session ID
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ context.submitSubAgentResult(
+ toolId, createToolResultBlock(toolId, "Test result")));
+
+ // Result should not be added
+ assertFalse(context.hasPendingResult(toolId));
+
+ // Now set session ID and add result
+ context.setSessionId(toolId, "session-abc");
+ context.submitSubAgentResult(toolId, result);
+
+ // Result should be added
+ assertTrue(context.hasPendingResult(toolId));
+ }
+
+ @Test
+ @DisplayName("Should handle session ID replacement")
+ void testSessionIdReplacement() {
+ String toolId = "tool-123";
+ ToolResultBlock result1 = createToolResultBlock(toolId, "Result 1");
+ ToolResultBlock result2 = createToolResultBlock(toolId, "Result 2");
+
+ // Set first session ID and add result
+ context.setSessionId(toolId, "session-1");
+ context.submitSubAgentResult(toolId, result1);
+
+ // Replace session ID
+ context.setSessionId(toolId, "session-2");
+ context.submitSubAgentResult(toolId, result2);
+
+ // Verify only the second result exists
+ Optional> results = context.getPendingResult(toolId);
+ assertTrue(results.isPresent());
+ assertEquals(1, results.get().size());
+ assertEquals(
+ "Result 2", ((TextBlock) results.get().get(0).getOutput().get(0)).getText());
+ }
+ }
+
+ @Nested
+ @DisplayName("Generate Reason Tests")
+ class GenerateReasonTests {
+
+ @Test
+ @DisplayName("Should extract GenerateReason from metadata")
+ void testGetSubAgentGenerateReason() {
+ Map metadata = new HashMap<>();
+ metadata.put(SubAgentContext.METADATA_GENERATE_REASON, GenerateReason.TOOL_SUSPENDED);
+ ToolResultBlock result =
+ new ToolResultBlock(
+ "tool-1",
+ "test-tool",
+ List.of(TextBlock.builder().text("Result").build()),
+ metadata);
+
+ GenerateReason reason = SubAgentContext.getSubAgentGenerateReason(result);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, reason);
+ }
+
+ @Test
+ @DisplayName("Should return MODEL_STOP when GenerateReason not in metadata")
+ void testGetSubAgentGenerateReasonDefault() {
+ ToolResultBlock result = createToolResultBlock("tool-1", "Result");
+
+ GenerateReason reason = SubAgentContext.getSubAgentGenerateReason(result);
+ assertEquals(GenerateReason.MODEL_STOP, reason);
+ }
+
+ @Test
+ @DisplayName("Should return MODEL_STOP when metadata value is not GenerateReason")
+ void testGetSubAgentGenerateReasonInvalidType() {
+ Map metadata = new HashMap<>();
+ metadata.put(SubAgentContext.METADATA_GENERATE_REASON, "invalid_value");
+ ToolResultBlock result =
+ new ToolResultBlock(
+ "tool-1",
+ "test-tool",
+ List.of(TextBlock.builder().text("Result").build()),
+ metadata);
+
+ GenerateReason reason = SubAgentContext.getSubAgentGenerateReason(result);
+ assertEquals(GenerateReason.MODEL_STOP, reason);
+ }
+
+ @Test
+ @DisplayName("Should handle all GenerateReason values")
+ void testAllGenerateReasonValues() {
+ for (GenerateReason expectedReason : GenerateReason.values()) {
+ Map metadata = new HashMap<>();
+ metadata.put(SubAgentContext.METADATA_GENERATE_REASON, expectedReason);
+ ToolResultBlock result =
+ new ToolResultBlock(
+ "tool-1",
+ "test-tool",
+ List.of(TextBlock.builder().text("Result").build()),
+ metadata);
+
+ GenerateReason actualReason = SubAgentContext.getSubAgentGenerateReason(result);
+ assertEquals(expectedReason, actualReason);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("SubAgent Result Detection Tests")
+ class SubAgentResultDetectionTests {
+
+ @Test
+ @DisplayName("Should detect sub-agent result with session ID")
+ void testIsSubAgentResult() {
+ Map metadata = new HashMap<>();
+ metadata.put(SubAgentContext.METADATA_SUBAGENT_SESSION_ID, "session-123");
+ ToolResultBlock result =
+ new ToolResultBlock(
+ "tool-1",
+ "test-tool",
+ List.of(TextBlock.builder().text("Result").build()),
+ metadata);
+
+ assertTrue(SubAgentContext.isSubAgentResult(result));
+ }
+
+ @Test
+ @DisplayName("Should return false for non-sub-agent result")
+ void testIsNotSubAgentResult() {
+ ToolResultBlock result = createToolResultBlock("tool-1", "Result");
+
+ assertFalse(SubAgentContext.isSubAgentResult(result));
+ }
+
+ @Test
+ @DisplayName("Should return false for null result")
+ void testIsSubAgentResultNull() {
+ assertFalse(SubAgentContext.isSubAgentResult(null));
+ }
+
+ @Test
+ @DisplayName("Should return false when metadata is null")
+ void testIsSubAgentResultNullMetadata() {
+ ToolResultBlock result =
+ new ToolResultBlock(
+ "tool-1",
+ "test-tool",
+ List.of(TextBlock.builder().text("Result").build()),
+ null);
+
+ assertFalse(SubAgentContext.isSubAgentResult(result));
+ }
+ }
+
+ @Nested
+ @DisplayName("State Persistence Tests")
+ class StatePersistenceTests {
+
+ private Session session;
+ private SessionKey sessionKey;
+
+ @BeforeEach
+ void setUp() {
+ session = new InMemorySession();
+ sessionKey = SimpleSessionKey.of("test-session");
+ }
+
+ @Test
+ @DisplayName("Should save and load context state")
+ void testSaveAndLoad() {
+ String toolId = "tool-123";
+ String sessionId = "session-abc";
+ ToolResultBlock result = createToolResultBlock(toolId, "Test result");
+
+ // Setup context
+ context.setSessionId(toolId, sessionId);
+ context.submitSubAgentResult(toolId, result);
+
+ // Save to session
+ context.saveTo(session, sessionKey);
+
+ // Create new context and load
+ SubAgentContext newContext = new SubAgentContext();
+ newContext.loadFrom(session, sessionKey);
+
+ // Verify state was restored
+ assertTrue(newContext.hasPendingResult(toolId));
+ assertEquals(sessionId, newContext.getSessionId(toolId).orElse(null));
+
+ Optional> retrieved = newContext.getPendingResult(toolId);
+ assertTrue(retrieved.isPresent());
+ assertEquals(1, retrieved.get().size());
+ assertEquals(toolId, retrieved.get().get(0).getId());
+ }
+
+ @Test
+ @DisplayName("Should save and load multiple pending results")
+ void testSaveAndLoadMultipleResults() {
+ String toolId1 = "tool-1";
+ String toolId2 = "tool-2";
+ String sessionId1 = "session-1";
+ String sessionId2 = "session-2";
+
+ context.setSessionId(toolId1, sessionId1);
+ context.submitSubAgentResult(toolId1, createToolResultBlock(toolId1, "Result 1"));
+ context.setSessionId(toolId2, sessionId2);
+ context.submitSubAgentResult(toolId2, createToolResultBlock(toolId2, "Result 2"));
+
+ // Save to session
+ context.saveTo(session, sessionKey);
+
+ // Create new context and load
+ SubAgentContext newContext = new SubAgentContext();
+ newContext.loadFrom(session, sessionKey);
+
+ // Verify both tools' states were restored
+ assertTrue(newContext.hasPendingResult(toolId1));
+ assertTrue(newContext.hasPendingResult(toolId2));
+ assertEquals(sessionId1, newContext.getSessionId(toolId1).orElse(null));
+ assertEquals(sessionId2, newContext.getSessionId(toolId2).orElse(null));
+ }
+
+ @Test
+ @DisplayName("Should handle empty context save and load")
+ void testSaveAndLoadEmptyContext() {
+ // Save empty context
+ context.saveTo(session, sessionKey);
+
+ // Create new context and load
+ SubAgentContext newContext = new SubAgentContext();
+ newContext.loadFrom(session, sessionKey);
+
+ // Verify context is empty
+ assertFalse(newContext.hasPendingResult("any-tool"));
+ }
+
+ @Test
+ @DisplayName("Should overwrite previous saved state")
+ void testOverwritePreviousState() {
+ String toolId = "tool-123";
+
+ // Save first state
+ context.setSessionId(toolId, "session-1");
+ context.submitSubAgentResult(toolId, createToolResultBlock(toolId, "Result 1"));
+ context.saveTo(session, sessionKey);
+
+ // Modify and save again
+ context.clear();
+ context.setSessionId(toolId, "session-2");
+ context.submitSubAgentResult(toolId, createToolResultBlock(toolId, "Result 2"));
+ context.saveTo(session, sessionKey);
+
+ // Load and verify latest state
+ SubAgentContext newContext = new SubAgentContext();
+ newContext.loadFrom(session, sessionKey);
+
+ assertEquals("session-2", newContext.getSessionId(toolId).orElse(null));
+ Optional> results = newContext.getPendingResult(toolId);
+ assertTrue(results.isPresent());
+ assertEquals(1, results.get().size());
+ assertEquals(
+ "Result 2", ((TextBlock) results.get().get(0).getOutput().get(0)).getText());
+ }
+
+ @Test
+ @DisplayName("Should load from non-existent session without error")
+ void testLoadFromNonExistentSession() {
+ SessionKey nonExistentKey = SimpleSessionKey.of("non-existent");
+
+ // Should not throw exception
+ SubAgentContext newContext = new SubAgentContext();
+ SubAgentPendingStore oldStore = newContext.getPendingStore();
+
+ newContext.loadFrom(session, nonExistentKey);
+ SubAgentPendingStore newStore = newContext.getPendingStore();
+ // Context should remain empty
+ assertSame(oldStore, newStore);
+ }
+
+ @Test
+ @DisplayName("Should preserve metadata through save and load")
+ void testPreserveMetadataThroughSaveLoad() {
+ String toolId = "tool-123";
+ String sessionId = "session-abc";
+
+ Map metadata = new HashMap<>();
+ metadata.put(SubAgentContext.METADATA_SUBAGENT_SESSION_ID, sessionId);
+ metadata.put(SubAgentContext.METADATA_GENERATE_REASON, GenerateReason.TOOL_SUSPENDED);
+ metadata.put("custom_key", "custom_value");
+
+ ToolResultBlock result =
+ new ToolResultBlock(
+ toolId,
+ "test-tool",
+ List.of(TextBlock.builder().text("Result").build()),
+ metadata);
+
+ context.setSessionId(toolId, sessionId);
+ context.submitSubAgentResult(toolId, result);
+
+ // Save and load
+ context.saveTo(session, sessionKey);
+ SubAgentContext newContext = new SubAgentContext();
+ newContext.loadFrom(session, sessionKey);
+
+ // Verify metadata preserved
+ Optional> retrieved = newContext.getPendingResult(toolId);
+ assertTrue(retrieved.isPresent());
+ ToolResultBlock retrievedResult = retrieved.get().get(0);
+
+ assertEquals(
+ sessionId,
+ retrievedResult
+ .getMetadata()
+ .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID));
+ assertEquals(
+ GenerateReason.TOOL_SUSPENDED,
+ retrievedResult.getMetadata().get(SubAgentContext.METADATA_GENERATE_REASON));
+ assertEquals("custom_value", retrievedResult.getMetadata().get("custom_key"));
+ }
+ }
+
+ private ToolResultBlock createToolResultBlock(String id, String content) {
+ return new ToolResultBlock(
+ id,
+ "test-tool",
+ List.of(TextBlock.builder().text(content).build()),
+ new HashMap<>());
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHITLTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHITLTest.java
new file mode 100644
index 000000000..6cae3288d
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHITLTest.java
@@ -0,0 +1,1108 @@
+/*
+ * 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
+ *
+ * https://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.tool.subagent;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import io.agentscope.core.ReActAgent;
+import io.agentscope.core.agent.Agent;
+import io.agentscope.core.agent.test.MockModel;
+import io.agentscope.core.memory.InMemoryMemory;
+import io.agentscope.core.message.GenerateReason;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.model.ChatResponse;
+import io.agentscope.core.model.ChatUsage;
+import io.agentscope.core.tool.Toolkit;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+/**
+ * Integration tests for SubAgent HITL (Human-in-the-Loop) functionality with ReActAgent.
+ *
+ * Tests cover:
+ *
+ * - Main agent detecting sub-agent suspension
+ * - User providing Result results to main agent
+ * - Main agent resuming sub-agent execution
+ * - Multi-turn HITL interactions
+ * - SubAgentHook integration with ReActAgent
+ *
+ */
+@DisplayName("SubAgent HITL Integration Tests")
+class SubAgentHITLTest {
+
+ private static final Duration TEST_TIMEOUT = Duration.ofSeconds(10);
+ private static final ChatUsage DEFAULT_USAGE = new ChatUsage(10, 20, 30);
+ private static final String DEFAULT_SYS_PROMPT = "You are a helpful assistant.";
+ private static final String MAIN_AGENT_NAME = "MainAgent";
+
+ private InMemoryMemory mainAgentMemory;
+ private Toolkit mainAgentToolkit;
+
+ @BeforeEach
+ void setUp() {
+ mainAgentMemory = new InMemoryMemory();
+ mainAgentToolkit = new Toolkit();
+ }
+
+ // ==================== Test Classes ====================
+
+ @Nested
+ @DisplayName("Main Agent Suspension Detection Tests")
+ class MainAgentSuspensionDetectionTests {
+
+ @Test
+ @DisplayName("Should detect sub-agent suspension and return suspended result to user")
+ void testMainAgentDetectsSubAgentSuspension() {
+ Agent mockSubAgent =
+ createSubAgent(
+ "SuspendableSubAgent", suspendedMessage("Calling external API..."));
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createToolThenTextModel(
+ "call-sub-1", "call_suspendablesubagent", "Task completed");
+ ReActAgent mainAgent = createHitlAgent(mainModel);
+
+ Msg response = mainAgent.call(userMessage("Execute task")).block(TEST_TIMEOUT);
+
+ assertNotNull(response);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason());
+
+ List toolResults = response.getContentBlocks(ToolResultBlock.class);
+ assertFalse(toolResults.isEmpty(), "Should contain tool result blocks");
+
+ ToolResultBlock suspendedResult = toolResults.get(0);
+ assertTrue(
+ suspendedResult
+ .getMetadata()
+ .containsKey(SubAgentContext.METADATA_SUBAGENT_SESSION_ID));
+ }
+
+ @Test
+ @DisplayName("Should include session ID in suspended response metadata")
+ void testSuspendedResponseContainsSessionId() {
+ Agent mockSubAgent =
+ createSubAgent(
+ "SuspendableSubAgent", suspendedMessage("Calling external API..."));
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createAlwaysToolUseModel("call-sub-1", "call_suspendablesubagent");
+ ReActAgent mainAgent = createHitlAgent(mainModel);
+
+ Msg response = mainAgent.call(userMessage("Execute task")).block(TEST_TIMEOUT);
+
+ assertNotNull(response);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason());
+
+ List toolResults = response.getContentBlocks(ToolResultBlock.class);
+ assertFalse(toolResults.isEmpty());
+
+ ToolResultBlock suspendedResult = toolResults.get(0);
+ assertNotNull(suspendedResult.getMetadata());
+ assertTrue(
+ suspendedResult
+ .getMetadata()
+ .containsKey(SubAgentContext.METADATA_SUBAGENT_SESSION_ID));
+
+ String sessionId =
+ (String)
+ suspendedResult
+ .getMetadata()
+ .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID);
+ assertNotNull(sessionId);
+ assertFalse(sessionId.isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("User Result and Resume Tests")
+ class UserResultAndResumeTests {
+
+ @Test
+ @DisplayName("Should resume sub-agent execution after user provides Result")
+ void testResumeAfterUserResult() {
+ SubAgentContext context = new SubAgentContext();
+ Agent mockSubAgent =
+ createSubAgent(
+ "ResumableSubAgent", suspendedMessage("Calling external API..."));
+
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createSequentialToolModel("call_resumablesubagent", 2, "All done!");
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ // First call - should suspend
+ Msg response1 = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT);
+ assertNotNull(response1);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason());
+
+ List toolResults = response1.getContentBlocks(ToolResultBlock.class);
+ assertFalse(toolResults.isEmpty());
+
+ ToolResultBlock suspendedResult = toolResults.get(0);
+ String toolId = suspendedResult.getId();
+ String sessionId =
+ (String)
+ suspendedResult
+ .getMetadata()
+ .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID);
+
+ context.setSessionId(toolId, sessionId);
+
+ // Provide Result
+ ToolResultBlock Result =
+ ToolResultBlock.builder()
+ .id(toolId)
+ .name("external_api")
+ .output(TextBlock.builder().text("API response: success").build())
+ .build();
+ mainAgent.submitSubAgentResult(toolId, Result);
+
+ // Second call - should complete
+ Msg response2 = mainAgent.call().block(TEST_TIMEOUT);
+ assertNotNull(response2);
+ assertEquals(GenerateReason.MODEL_STOP, response2.getGenerateReason());
+ }
+
+ @Test
+ @DisplayName("Should handle multiple sequential suspensions")
+ void testMultipleSequentialSuspensions() {
+ SubAgentContext context = new SubAgentContext();
+ Agent mockSubAgent = createMultiStepSubAgent("MultiStepSubAgent", 2);
+
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createSequentialToolModel("call_multistepsubagent", 3, "All done!");
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ // First suspension
+ Msg response1 =
+ mainAgent.call(userMessage("Start multi-step task")).block(TEST_TIMEOUT);
+ assertNotNull(response1);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason());
+
+ // Second suspension
+ Msg response2 = mainAgent.call().block(TEST_TIMEOUT);
+ assertNotNull(response2);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response2.getGenerateReason());
+
+ // Final completion
+ Msg response3 = mainAgent.call().block(TEST_TIMEOUT);
+ assertNotNull(response3);
+ assertEquals(GenerateReason.MODEL_STOP, response3.getGenerateReason());
+ }
+ }
+
+ @Nested
+ @DisplayName("ConfirmSubAgent API Tests")
+ class ConfirmSubAgentAPITests {
+
+ @Test
+ @DisplayName("Should store Result result via submitSubAgentResult API")
+ void testConfirmSubAgentAPI() {
+ SubAgentContext context = new SubAgentContext();
+ Agent mockSubAgent =
+ createSubAgent(
+ "SuspendableSubAgent", suspendedMessage("Calling external API..."));
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createToolThenTextModel(
+ "call-confirm-1", "call_suspendablesubagent", "Completed");
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ Msg suspendedResponse = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT);
+
+ assertNotNull(suspendedResponse);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, suspendedResponse.getGenerateReason());
+
+ List toolResults =
+ suspendedResponse.getContentBlocks(ToolResultBlock.class);
+ assertFalse(toolResults.isEmpty());
+
+ ToolResultBlock suspendedResult = toolResults.get(0);
+ String toolId = suspendedResult.getId();
+
+ // Submit the result for the internal tool call that caused the suspension
+ ToolResultBlock internalToolResult =
+ ToolResultBlock.builder()
+ .id("inner-api-call") // ID of the tool called inside the sub-agent
+ .name("external_api")
+ .output(TextBlock.builder().text("API response: success").build())
+ .build();
+
+ // First parameter: the sub-agent tool ID (call-1)
+ // Second parameter: the result of the internal tool that suspended the sub-agent
+ context.submitSubAgentResult(toolId, internalToolResult);
+
+ assertTrue(context.hasPendingResult(toolId), "Context should have pending result");
+ }
+ }
+
+ @Nested
+ @DisplayName("Paused State Handling Tests")
+ class PausedStateHandlingTests {
+
+ @Test
+ @DisplayName("Should handle sub-agent paused state (REASONING_STOP_REQUESTED)")
+ void testSubAgentPausedState() {
+ Msg pausedResponse =
+ assistantMessage(
+ "Paused for reasoning", GenerateReason.REASONING_STOP_REQUESTED);
+ Agent mockSubAgent =
+ createSubAgent("PausedSubAgent", "Sub-agent that pauses", pausedResponse);
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel = createAlwaysToolUseModel("call-paused-1", "call_pausedsubagent");
+ ReActAgent mainAgent = createHitlAgent(mainModel);
+
+ Msg response = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT);
+
+ assertNotNull(response);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason());
+ }
+
+ @Test
+ @DisplayName("Should resume from paused state after user continues")
+ void testResumeFromPausedState() {
+ SubAgentContext context = new SubAgentContext();
+ Agent mockSubAgent = createSubAgent("PausableSubAgent", reasoningStopMessage("Paused"));
+
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createSequentialToolModel("call_pausablesubagent", 1, "All done!");
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ Msg response1 = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT);
+ assertNotNull(response1);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason());
+
+ Msg response2 = mainAgent.call().block(TEST_TIMEOUT);
+ assertNotNull(response2);
+ assertEquals(GenerateReason.MODEL_STOP, response2.getGenerateReason());
+ }
+ }
+
+ @Nested
+ @DisplayName("No Message Continue Tests")
+ class NoMessageContinueTests {
+
+ @Test
+ @DisplayName("Should continue without user Result message")
+ void testContinueWithoutResult() {
+ SubAgentContext context = new SubAgentContext();
+
+ Agent mockSubAgent =
+ createSubAgent(
+ "AutoResumeSubAgent", suspendedMessage("Waiting for approval..."));
+
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createSequentialToolModel("call_autoresumesubagent", 2, "Completed");
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ Msg response1 = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT);
+ assertNotNull(response1);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason());
+
+ Msg response2 = mainAgent.call().block(TEST_TIMEOUT);
+ assertNotNull(response2);
+ assertEquals(GenerateReason.MODEL_STOP, response2.getGenerateReason());
+ }
+
+ @Test
+ @DisplayName("Should handle multiple suspensions without explicit results")
+ void testMultipleSuspensionsWithoutResults() {
+ SubAgentContext context = new SubAgentContext();
+
+ AtomicInteger subAgentCallCount = new AtomicInteger(0);
+ ReActAgent mockSubAgent = mock(ReActAgent.class);
+ when(mockSubAgent.getName()).thenReturn("MultiAutoResumeSubAgent");
+ when(mockSubAgent.getDescription()).thenReturn("Sub-agent with multiple auto-resumes");
+ when(mockSubAgent.call(any(List.class)))
+ .thenAnswer(
+ invocation -> {
+ int count = subAgentCallCount.incrementAndGet();
+ if (count <= 2) {
+ return Mono.just(
+ suspendedMessage(
+ "Step " + count + " suspended",
+ externalApiToolUse("auto-step-" + count)));
+ }
+ return Mono.just(assistantMessage("All steps completed"));
+ });
+
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createSequentialToolModel("call_multiautoresumesubagent", 3, "Done");
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ Msg response1 = mainAgent.call(userMessage("Start multi-step")).block(TEST_TIMEOUT);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason());
+
+ Msg response2 = mainAgent.call().block(TEST_TIMEOUT);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response2.getGenerateReason());
+
+ Msg response3 = mainAgent.call().block(TEST_TIMEOUT);
+ assertEquals(GenerateReason.MODEL_STOP, response3.getGenerateReason());
+ }
+ }
+
+ @Nested
+ @DisplayName("Complex Multi-turn Interaction Tests")
+ class ComplexMultiTurnTests {
+
+ @Test
+ @DisplayName("Should handle multiple concurrent tool suspensions in sub-agent")
+ void testMultipleConcurrentSuspensions() {
+ ToolUseBlock tool1 =
+ ToolUseBlock.builder()
+ .id("concurrent-api-1")
+ .name("external_api_1")
+ .input(Map.of("url", "https://api1.example.com"))
+ .build();
+
+ ToolUseBlock tool2 =
+ ToolUseBlock.builder()
+ .id("concurrent-api-2")
+ .name("external_api_2")
+ .input(Map.of("url", "https://api2.example.com"))
+ .build();
+
+ Msg suspendedWithMultipleTools =
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(
+ List.of(
+ TextBlock.builder()
+ .text("Calling multiple APIs...")
+ .build(),
+ tool1,
+ tool2))
+ .generateReason(GenerateReason.TOOL_SUSPENDED)
+ .build();
+
+ Agent mockSubAgent =
+ createSubAgent(
+ "ConcurrentSubAgent",
+ "Sub-agent with concurrent tools",
+ suspendedWithMultipleTools);
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createAlwaysToolUseModel("call-concurrent-1", "call_concurrentsubagent");
+ ReActAgent mainAgent = createHitlAgent(mainModel);
+
+ Msg response =
+ mainAgent.call(userMessage("Execute concurrent tasks")).block(TEST_TIMEOUT);
+
+ assertNotNull(response);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason());
+
+ List toolResults = response.getContentBlocks(ToolResultBlock.class);
+ assertFalse(toolResults.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should handle same session multiple suspend-resume cycles")
+ void testSameSessionMultipleCycles() {
+ SubAgentContext context = new SubAgentContext();
+
+ AtomicInteger subAgentCallCount = new AtomicInteger(0);
+ ReActAgent mockSubAgent = mock(ReActAgent.class);
+ when(mockSubAgent.getName()).thenReturn("CyclicSubAgent");
+ when(mockSubAgent.getDescription()).thenReturn("Sub-agent with multiple cycles");
+ when(mockSubAgent.call(any(List.class)))
+ .thenAnswer(
+ invocation -> {
+ int count = subAgentCallCount.incrementAndGet();
+ if (count <= 3) {
+ return Mono.just(
+ suspendedMessage(
+ "Cycle " + count + " suspended",
+ externalApiToolUse("cycle-" + count)));
+ }
+ return Mono.just(assistantMessage("All cycles completed"));
+ });
+
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel = createSequentialToolModel("call_cyclicsubagent", 4, "Finished");
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ for (int i = 1; i <= 3; i++) {
+ Msg response =
+ mainAgent
+ .call(i == 1 ? userMessage("Start cycles") : null)
+ .block(TEST_TIMEOUT);
+ assertNotNull(response);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason());
+ }
+
+ Msg finalResponse = mainAgent.call().block(TEST_TIMEOUT);
+ assertNotNull(finalResponse);
+ assertEquals(GenerateReason.MODEL_STOP, finalResponse.getGenerateReason());
+ }
+ }
+
+ @Nested
+ @DisplayName("Error Handling and Exception Tests")
+ class ErrorHandlingTests {
+
+ @Test
+ @DisplayName("Should handle invalid tool ID")
+ void testInvalidToolId() {
+ SubAgentContext context = new SubAgentContext();
+ Agent mockSubAgent =
+ createSubAgent(
+ "SuspendableSubAgent", suspendedMessage("Calling external API..."));
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createToolThenTextModel(
+ "call-invalid-1", "call_suspendablesubagent", "Completed");
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ Msg suspendedResponse = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT);
+ assertNotNull(suspendedResponse);
+
+ ToolResultBlock invalidResult =
+ ToolResultBlock.builder()
+ .id("inner-api-call")
+ .name("external_api")
+ .output(TextBlock.builder().text("Invalid Result").build())
+ .build();
+ // Should throw because "invalid-tool-id" is not a registered sub-agent tool
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> context.submitSubAgentResult("invalid-tool-id", invalidResult));
+ }
+
+ @Test
+ @DisplayName("Should handle mismatched Result result")
+ void testMismatchedResult() {
+ SubAgentContext context = new SubAgentContext();
+ Agent mockSubAgent =
+ createSubAgent(
+ "SuspendableSubAgent", suspendedMessage("Calling external API..."));
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createToolThenTextModel(
+ "call-mismatch-1", "call_suspendablesubagent", "Completed");
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ Msg suspendedResponse = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT);
+ assertNotNull(suspendedResponse);
+
+ List toolResults =
+ suspendedResponse.getContentBlocks(ToolResultBlock.class);
+ assertFalse(toolResults.isEmpty());
+
+ ToolResultBlock suspendedResult = toolResults.get(0);
+ String toolId = suspendedResult.getId();
+
+ // Submit result with internal tool ID from the suspended sub-agent
+ ToolResultBlock mismatchedResult =
+ ToolResultBlock.builder()
+ .id("inner-api-call") // Internal tool ID that caused suspension
+ .name("external_api")
+ .output(TextBlock.builder().text("Mismatched Result").build())
+ .build();
+
+ // First parameter: sub-agent tool ID, second parameter: internal tool result
+ context.submitSubAgentResult(toolId, mismatchedResult);
+
+ assertTrue(context.hasPendingResult(toolId));
+ }
+
+ @Test
+ @DisplayName("Should handle sub-agent exception during resume")
+ void testExceptionDuringResume() {
+ SubAgentContext context = new SubAgentContext();
+ Agent mockSubAgent =
+ createSubAgent("FailingSubAgent", suspendedMessage("About to fail..."));
+
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createSequentialToolModel("call_failingsubagent", 2, "Should not reach");
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ Msg response1 = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT);
+ assertNotNull(response1);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason());
+
+ try {
+ mainAgent.call().block(TEST_TIMEOUT);
+ } catch (Exception e) {
+ assertTrue(e.getMessage().contains("Resume failed") || e.getCause() != null);
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("SubAgentContext Management Tests")
+ class ContextManagementTests {
+
+ @Test
+ @DisplayName("Should manage multiple concurrent sub-agent suspensions")
+ void testMultipleSubAgentSuspensions() {
+ SubAgentContext context = new SubAgentContext();
+
+ Agent mockSubAgent1 =
+ createSubAgent("SubAgent1", actingStopMessage("Calling external API..."));
+ Agent mockSubAgent2 =
+ createSubAgent("SubAgent2", suspendedMessage("Calling external API..."));
+ Agent mockSubAgent3 =
+ createSubAgent("SubAgent3", reasoningStopMessage("Calling external API..."));
+ registerSubAgent(mockSubAgent1);
+ registerSubAgent(mockSubAgent2);
+ registerSubAgent(mockSubAgent3);
+
+ AtomicInteger callCount = new AtomicInteger(0);
+ MockModel mainModel =
+ createMockModel(
+ messages -> {
+ int count = callCount.incrementAndGet();
+ if (count == 1) {
+ return List.of(
+ toolUseResponse(
+ "call-1",
+ "call_subagent1",
+ "{\"message\": \"test\"}"),
+ toolUseResponse(
+ "call-2",
+ "call_subagent2",
+ "{\"message\": \"value\"}"),
+ toolUseResponse(
+ "call-3",
+ "call_subagent3",
+ "{\"message\": \"value\"}"));
+ }
+ return List.of(textResponse("Both completed"));
+ });
+
+ ReActAgent mainAgent = createHitlAgent(mainModel, context);
+
+ Msg response1 = mainAgent.call(userMessage("Start both agents")).block(TEST_TIMEOUT);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason());
+
+ List results = response1.getContentBlocks(ToolResultBlock.class);
+
+ ToolResultBlock firstResult = null;
+ ToolResultBlock secondResult = null;
+ ToolResultBlock thirdResult = null;
+ for (ToolResultBlock resultBlock : results) {
+ switch (resultBlock.getName()) {
+ case "call_subagent1" -> firstResult = resultBlock;
+ case "call_subagent2" -> secondResult = resultBlock;
+ case "call_subagent3" -> thirdResult = resultBlock;
+ }
+ }
+ assertNotNull(firstResult);
+ assertNotNull(secondResult);
+ assertNotNull(thirdResult);
+ assertEquals(
+ GenerateReason.ACTING_STOP_REQUESTED,
+ SubAgentContext.getSubAgentGenerateReason(firstResult));
+ assertEquals(
+ GenerateReason.TOOL_SUSPENDED,
+ SubAgentContext.getSubAgentGenerateReason(secondResult));
+ assertEquals(
+ GenerateReason.REASONING_STOP_REQUESTED,
+ SubAgentContext.getSubAgentGenerateReason(thirdResult));
+
+ // Submit result for the second sub-agent's internal tool call
+ ToolResultBlock internalResult =
+ ToolResultBlock.builder()
+ .id("inner-api-call")
+ .name("external_api")
+ .output(TextBlock.builder().text("API response: success").build())
+ .build();
+ context.submitSubAgentResult(secondResult.getId(), internalResult);
+
+ Msg response2 = mainAgent.call().block(TEST_TIMEOUT);
+ assertNotNull(response2);
+ assertEquals(GenerateReason.MODEL_STOP, response2.getGenerateReason());
+ }
+
+ @Test
+ @DisplayName("Should ensure session ID uniqueness across multiple suspensions")
+ void testSessionIdUniqueness() {
+ Agent mockSubAgent = createMultiStepSubAgent("SuspendableSubAgent", 2);
+ registerSubAgent(mockSubAgent);
+
+ AtomicInteger callCount = new AtomicInteger(0);
+ MockModel mainModel =
+ createMockModel(
+ messages -> {
+ int count = callCount.incrementAndGet();
+ if (count <= 2) {
+ return List.of(
+ toolUseResponse(
+ "call-" + count,
+ "call_suspendablesubagent",
+ "{\"message\": \"call\"}"));
+ }
+ return List.of(textResponse("Done"));
+ });
+
+ ReActAgent mainAgent = createHitlAgent(mainModel);
+
+ Msg response1 = mainAgent.call(userMessage("First call")).block(TEST_TIMEOUT);
+ List results1 = response1.getContentBlocks(ToolResultBlock.class);
+ Optional sessionId1 = SubAgentContext.extractSessionId(results1.get(0));
+ assertTrue(sessionId1.isPresent());
+
+ Msg response2 = mainAgent.call().block(TEST_TIMEOUT);
+ List results2 = response2.getContentBlocks(ToolResultBlock.class);
+ Optional sessionId2 = SubAgentContext.extractSessionId(results2.get(0));
+ assertTrue(sessionId2.isPresent());
+
+ assertTrue(sessionId1.equals(sessionId2), "Session IDs should be same");
+ }
+ }
+
+ @Nested
+ @DisplayName("HITL Configuration Mismatch Tests")
+ class HITLConfigurationMismatchTests {
+
+ @Test
+ @DisplayName("Should handle main agent HITL enabled but sub-agent HITL disabled")
+ void testMainAgentHITLEnabledSubAgentDisabled() {
+ Agent mockSubAgent =
+ createSubAgent("SubAgentNoHITL", suspendedMessage("Processing..."));
+
+ // Register sub-agent with HITL disabled
+ SubAgentConfig disabledConfig =
+ SubAgentConfig.builder().forwardEvents(false).enableHITL(false).build();
+ mainAgentToolkit.registration().subAgent(() -> mockSubAgent, disabledConfig).apply();
+
+ MockModel mainModel =
+ createToolThenTextModel(
+ "call-subagent-1", "call_subagentnohitl", "Task completed");
+ ReActAgent mainAgent = createHitlAgent(mainModel);
+
+ Msg response = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT);
+
+ assertNotNull(response);
+ // Sub-agent suspension should not propagate to main agent
+ assertEquals(
+ GenerateReason.MODEL_STOP,
+ response.getGenerateReason(),
+ "Main agent should complete normally when sub-agent HITL is disabled");
+ }
+
+ @Test
+ @DisplayName("Should handle main agent HITL disabled but sub-agent HITL enabled")
+ void testMainAgentHITLDisabledSubAgentEnabled() {
+ Agent mockSubAgent =
+ createSubAgent("SubAgentWithHITL", suspendedMessage("Waiting for input..."));
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createToolThenTextModel("call-subagent-2", "call_subagentwithhitl", "All done");
+ ReActAgent mainAgent = createNonHitlAgent(mainModel);
+
+ Msg response = mainAgent.call(userMessage("Execute")).block(TEST_TIMEOUT);
+
+ assertNotNull(response);
+ // Main agent should not suspend even if sub-agent is suspended
+ assertEquals(
+ GenerateReason.TOOL_SUSPENDED,
+ response.getGenerateReason(),
+ "Main agent should complete normally when main HITL is disabled");
+ }
+
+ @Test
+ @DisplayName("Should handle both main and sub-agent HITL disabled")
+ void testBothHITLDisabled() {
+ Agent mockSubAgent =
+ createSubAgent("SubAgentNoHITL2", suspendedMessage("Processing data..."));
+
+ SubAgentConfig disabledConfig =
+ SubAgentConfig.builder().forwardEvents(false).enableHITL(false).build();
+ mainAgentToolkit.registration().subAgent(() -> mockSubAgent, disabledConfig).apply();
+
+ MockModel mainModel =
+ createToolThenTextModel("call-subagent-3", "call_subagentnohitl2", "Finished");
+ ReActAgent mainAgent = createNonHitlAgent(mainModel);
+
+ Msg response = mainAgent.call(userMessage("Run")).block(TEST_TIMEOUT);
+
+ assertNotNull(response);
+ assertEquals(
+ GenerateReason.MODEL_STOP,
+ response.getGenerateReason(),
+ "Both agents should complete normally when HITL is disabled");
+ }
+ }
+
+ @Nested
+ @DisplayName("Edge Cases and Error Handling Tests")
+ class EdgeCasesAndErrorHandlingTests {
+
+ @Test
+ @DisplayName("Should handle sub-agent returning normal response (not suspended)")
+ void testNormalSubAgentResponse() {
+ Agent mockSubAgent = createNormalSubAgent("NormalSubAgent", "Normal response");
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel =
+ createToolThenTextModel("call-normal-1", "call_normalsubagent", "Task done");
+ ReActAgent mainAgent = createHitlAgent(mainModel);
+
+ Msg response = mainAgent.call(userMessage("Say hello")).block(TEST_TIMEOUT);
+
+ assertNotNull(response);
+ assertEquals(
+ GenerateReason.MODEL_STOP,
+ response.getGenerateReason(),
+ "Should complete normally without suspension");
+ }
+
+ @Test
+ @DisplayName("Should handle empty tool results gracefully")
+ void testEmptyToolResults() {
+ Msg pausedResponse =
+ assistantMessage("Paused", GenerateReason.REASONING_STOP_REQUESTED);
+ Agent mockSubAgent =
+ createSubAgent("EmptyResultAgent", "Agent with empty result", pausedResponse);
+ registerSubAgent(mockSubAgent);
+
+ MockModel mainModel = createAlwaysToolUseModel("call-empty-1", "call_emptyresultagent");
+ ReActAgent mainAgent = createHitlAgent(mainModel);
+
+ Msg response = mainAgent.call(userMessage("Test")).block(TEST_TIMEOUT);
+
+ assertNotNull(response);
+ assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason());
+ }
+ }
+
+ // ==================== Message Factory Methods ====================
+
+ /**
+ * Creates a user message with the given text.
+ */
+ private Msg userMessage(String text) {
+ return Msg.builder()
+ .role(MsgRole.USER)
+ .content(TextBlock.builder().text(text).build())
+ .build();
+ }
+
+ /**
+ * Creates an assistant message with the given text.
+ */
+ private Msg assistantMessage(String text) {
+ return Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(TextBlock.builder().text(text).build())
+ .build();
+ }
+
+ private Msg actingStopMessage(String text) {
+ ToolUseBlock innerToolUse = externalApiToolUse("inner-api-call");
+ return Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(List.of(TextBlock.builder().text(text).build(), innerToolUse))
+ .generateReason(GenerateReason.ACTING_STOP_REQUESTED)
+ .build();
+ }
+
+ private Msg suspendedMessage(String text) {
+ ToolUseBlock innerToolUse = externalApiToolUse("inner-api-call");
+ return Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(List.of(TextBlock.builder().text(text).build(), innerToolUse))
+ .generateReason(GenerateReason.TOOL_SUSPENDED)
+ .build();
+ }
+
+ private Msg reasoningStopMessage(String text) {
+ ToolUseBlock innerToolUse = externalApiToolUse("inner-api-call");
+ return Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(List.of(TextBlock.builder().text(text).build(), innerToolUse))
+ .generateReason(GenerateReason.REASONING_STOP_REQUESTED)
+ .build();
+ }
+
+ /**
+ * Creates a suspended assistant message with tool use block.
+ */
+ private Msg suspendedMessage(String text, ToolUseBlock toolUse) {
+ return Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(List.of(TextBlock.builder().text(text).build(), toolUse))
+ .generateReason(GenerateReason.TOOL_SUSPENDED)
+ .build();
+ }
+
+ /**
+ * Creates an assistant message with custom generate reason.
+ */
+ private Msg assistantMessage(String text, GenerateReason reason) {
+ return Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(TextBlock.builder().text(text).build())
+ .generateReason(reason)
+ .build();
+ }
+
+ // ==================== Response Factory Methods ====================
+
+ /**
+ * Creates a ChatResponse with a single text block.
+ */
+ private ChatResponse textResponse(String text) {
+ return ChatResponse.builder()
+ .content(List.of(TextBlock.builder().text(text).build()))
+ .usage(DEFAULT_USAGE)
+ .build();
+ }
+
+ /**
+ * Creates a ChatResponse with a tool use block.
+ */
+ private ChatResponse toolUseResponse(String toolId, String toolName, String content) {
+ return ChatResponse.builder()
+ .content(
+ List.of(
+ ToolUseBlock.builder()
+ .id(toolId)
+ .name(toolName)
+ .content(content)
+ .build()))
+ .usage(DEFAULT_USAGE)
+ .build();
+ }
+
+ /**
+ * Creates a ToolUseBlock for external API calls.
+ */
+ private ToolUseBlock externalApiToolUse(String id) {
+ return ToolUseBlock.builder()
+ .id(id)
+ .name("external_api")
+ .input(Map.of("url", "https://api.example.com"))
+ .build();
+ }
+
+ // ==================== Config Factory Methods ====================
+
+ /**
+ * Creates a default SubAgentConfig with HITL enabled.
+ */
+ private SubAgentConfig hitlEnabledConfig() {
+ return SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build();
+ }
+
+ // ==================== Model Factory Methods ====================
+
+ /**
+ * Creates a MockModel with custom response handler.
+ */
+ private MockModel createMockModel(Function, List> handler) {
+ return new MockModel(handler);
+ }
+
+ /**
+ * Creates a MockModel that returns tool use on first call, then text response.
+ */
+ private MockModel createToolThenTextModel(String toolId, String toolName, String finalText) {
+ AtomicInteger callCount = new AtomicInteger(0);
+ return createMockModel(
+ messages -> {
+ if (callCount.incrementAndGet() == 1) {
+ return List.of(
+ toolUseResponse(toolId, toolName, "{\"message\": \"value\"}"));
+ }
+ return List.of(textResponse(finalText));
+ });
+ }
+
+ /**
+ * Creates a MockModel that always returns tool use response.
+ */
+ private MockModel createAlwaysToolUseModel(String toolId, String toolName) {
+ return createMockModel(
+ messages -> List.of(toolUseResponse(toolId, toolName, "{\"message\": \"value\"}")));
+ }
+
+ /**
+ * Creates a MockModel with sequential tool calls then final text.
+ */
+ private MockModel createSequentialToolModel(
+ String toolNamePrefix, int toolCallCount, String finalText) {
+ AtomicInteger callCount = new AtomicInteger(0);
+ return createMockModel(
+ messages -> {
+ int count = callCount.incrementAndGet();
+ if (count <= toolCallCount) {
+ return List.of(
+ toolUseResponse(
+ "call-" + count,
+ toolNamePrefix,
+ "{\"message\": \"Execute step " + count + "\"}"));
+ }
+ return List.of(textResponse(finalText));
+ });
+ }
+
+ // ==================== Agent Factory Methods ====================
+
+ /**
+ * Registers a sub-agent with HITL config to the toolkit.
+ */
+ private void registerSubAgent(Agent subAgent) {
+ mainAgentToolkit.registration().subAgent(() -> subAgent, hitlEnabledConfig()).apply();
+ }
+
+ /**
+ * Creates a ReActAgent with HITL enabled.
+ */
+ private ReActAgent createHitlAgent(MockModel model) {
+ return ReActAgent.builder()
+ .name(MAIN_AGENT_NAME)
+ .sysPrompt(DEFAULT_SYS_PROMPT)
+ .model(model)
+ .toolkit(mainAgentToolkit)
+ .memory(mainAgentMemory)
+ .enableSubAgentHITL(true)
+ .build();
+ }
+
+ /**
+ * Creates a ReActAgent with HITL enabled and custom context.
+ */
+ private ReActAgent createHitlAgent(MockModel model, SubAgentContext context) {
+ return ReActAgent.builder()
+ .name(MAIN_AGENT_NAME)
+ .sysPrompt(DEFAULT_SYS_PROMPT)
+ .model(model)
+ .toolkit(mainAgentToolkit)
+ .memory(mainAgentMemory)
+ .subAgentContext(context)
+ .enableSubAgentHITL(true)
+ .build();
+ }
+
+ /**
+ * Creates a ReActAgent with HITL disabled.
+ */
+ private ReActAgent createNonHitlAgent(MockModel model) {
+ return ReActAgent.builder()
+ .name(MAIN_AGENT_NAME)
+ .sysPrompt(DEFAULT_SYS_PROMPT)
+ .model(model)
+ .toolkit(mainAgentToolkit)
+ .memory(mainAgentMemory)
+ .enableSubAgentHITL(false)
+ .build();
+ }
+
+ /**
+ * Creates a mock sub-agent that returns response.
+ */
+ private Agent createSubAgent(String name, Msg response) {
+ AtomicInteger count = new AtomicInteger(0);
+ ReActAgent mockSubAgent = mock(ReActAgent.class);
+ when(mockSubAgent.getName()).thenReturn(name);
+ when(mockSubAgent.getDescription()).thenReturn("Sub-agent that suspends");
+ when(mockSubAgent.call(any(List.class)))
+ .thenAnswer(
+ invocation -> {
+ int c = count.incrementAndGet();
+ if (c == 1) {
+ return Mono.just(response);
+ }
+ return Mono.just(assistantMessage("Task completed successfully"));
+ });
+ return mockSubAgent;
+ }
+
+ private Agent createMultiStepSubAgent(String name, int step) {
+ AtomicInteger subAgentCallCount = new AtomicInteger(0);
+ ReActAgent mockSubAgent = mock(ReActAgent.class);
+ when(mockSubAgent.getName()).thenReturn(name);
+ when(mockSubAgent.getDescription()).thenReturn("Sub-agent with multiple steps");
+ when(mockSubAgent.call(any(List.class)))
+ .thenAnswer(
+ invocation -> {
+ int count = subAgentCallCount.incrementAndGet();
+ if (count <= step) {
+ return Mono.just(
+ suspendedMessage(
+ "Step " + count + " - calling API...",
+ externalApiToolUse("step-" + count + "-api")));
+ }
+ return Mono.just(assistantMessage("All steps completed"));
+ });
+ return mockSubAgent;
+ }
+
+ /**
+ * Creates a mock sub-agent that returns normal response.
+ */
+ private Agent createNormalSubAgent(String name, String response) {
+ ReActAgent mockSubAgent = mock(ReActAgent.class);
+ when(mockSubAgent.getName()).thenReturn(name);
+ when(mockSubAgent.getDescription()).thenReturn("Normal sub-agent");
+ when(mockSubAgent.call(any(List.class))).thenReturn(Mono.just(assistantMessage(response)));
+ return mockSubAgent;
+ }
+
+ /**
+ * Creates a mock sub-agent with custom response.
+ */
+ private Agent createSubAgent(String name, String description, Msg response) {
+ ReActAgent mockSubAgent = mock(ReActAgent.class);
+ when(mockSubAgent.getName()).thenReturn(name);
+ when(mockSubAgent.getDescription()).thenReturn(description);
+ when(mockSubAgent.call(any(List.class))).thenReturn(Mono.just(response));
+ return mockSubAgent;
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHookTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHookTest.java
new file mode 100644
index 000000000..21b699a72
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHookTest.java
@@ -0,0 +1,531 @@
+/*
+ * 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
+ *
+ * https://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.tool.subagent;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import io.agentscope.core.agent.Agent;
+import io.agentscope.core.hook.PreActingEvent;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.tool.Toolkit;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * SubAgentHook basic functionality tests
+ *
+ * Coverage includes:
+ *
+ * - Basic functionalities: context return, priority, result injection
+ * - Null handling: null tool use, null input, null metadata
+ * - Boundary conditions: empty string tool ID
+ * - State consistency: multiple executions, metadata preservation, interleaved operations
+ * - Exception handling: empty pending result list
+ * - Integration: SubAgentContext state management
+ *
+ *
+ */
+@DisplayName("SubAgentHook Basic Tests")
+class SubAgentHookTest {
+
+ private static final Duration TEST_TIMEOUT = Duration.ofSeconds(10);
+
+ private SubAgentContext context;
+ private SubAgentHook hook;
+
+ @BeforeEach
+ void setUp() {
+ context = new SubAgentContext();
+ hook = new SubAgentHook(context);
+ }
+
+ @Nested
+ @DisplayName("Basic Functionality Tests")
+ class BasicFunctionalityTests {
+
+ @Test
+ @DisplayName("Should return correct context")
+ void testGetContext() {
+ assertSame(context, hook.getContext());
+ }
+
+ @Test
+ @DisplayName("Should have correct priority")
+ void testPriority() {
+ assertEquals(10, hook.priority());
+ }
+
+ @Test
+ @DisplayName("Should inject pending result into tool use")
+ void testInjectPendingResult() {
+ // Sub-agent tool ID (the tool that invokes the sub-agent)
+ String subAgentToolId = "tool-123";
+ String sessionId = "session-abc";
+
+ // Internal tool ID used by the sub-agent when it calls tools
+ String internalToolId = "internal-tool-456";
+ ToolResultBlock pendingResult = createToolResultBlock(internalToolId, "Pending result");
+ context.setSessionId(subAgentToolId, sessionId);
+ context.submitSubAgentResult(subAgentToolId, pendingResult);
+
+ ToolUseBlock toolUse =
+ ToolUseBlock.builder()
+ .id(subAgentToolId)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse);
+
+ PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT);
+
+ assertNotNull(result);
+ ToolUseBlock modifiedToolUse = result.getToolUse();
+ assertNotNull(modifiedToolUse);
+
+ assertTrue(
+ modifiedToolUse.getMetadata().containsKey(SubAgentHook.PREVIOUS_TOOL_RESULT));
+ assertEquals(sessionId, modifiedToolUse.getInput().get("session_id"));
+ }
+
+ @Test
+ @DisplayName("Should not modify tool use when no pending result")
+ void testNoPendingResult() {
+ ToolUseBlock toolUse =
+ ToolUseBlock.builder()
+ .id("tool-456")
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse);
+
+ PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT);
+
+ assertNotNull(result);
+ ToolUseBlock resultToolUse = result.getToolUse();
+ assertEquals(toolUse.getId(), resultToolUse.getId());
+ assertFalse(resultToolUse.getMetadata().containsKey(SubAgentHook.PREVIOUS_TOOL_RESULT));
+ }
+
+ @Test
+ @DisplayName("Should inject multiple pending results")
+ void testInjectMultiplePendingResults() {
+ // Sub-agent tool ID
+ String subAgentToolId = "tool-multi";
+ String sessionId = "session-multi";
+
+ // Internal tool IDs used by the sub-agent
+ String internalToolId1 = "internal-tool-1";
+ String internalToolId2 = "internal-tool-2";
+ ToolResultBlock result1 = createToolResultBlock(internalToolId1, "Result 1");
+ ToolResultBlock result2 = createToolResultBlock(internalToolId2, "Result 2");
+
+ context.setSessionId(subAgentToolId, sessionId);
+ context.submitSubAgentResults(subAgentToolId, List.of(result1, result2));
+
+ ToolUseBlock toolUse =
+ ToolUseBlock.builder()
+ .id(subAgentToolId)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse);
+
+ PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT);
+
+ assertNotNull(result);
+ ToolUseBlock modifiedToolUse = result.getToolUse();
+
+ @SuppressWarnings("unchecked")
+ List injectedResults =
+ (List)
+ modifiedToolUse.getMetadata().get(SubAgentHook.PREVIOUS_TOOL_RESULT);
+ assertNotNull(injectedResults);
+ assertEquals(2, injectedResults.size());
+ }
+
+ @Test
+ @DisplayName("Should consume pending results after injection")
+ void testConsumePendingResultsAfterInjection() {
+ // Sub-agent tool ID
+ String subAgentToolId = "tool-consume";
+ String sessionId = "session-consume";
+
+ // Internal tool ID used by the sub-agent
+ String internalToolId = "internal-tool-consume";
+ ToolResultBlock pendingResult = createToolResultBlock(internalToolId, "Pending result");
+ context.setSessionId(subAgentToolId, sessionId);
+ context.submitSubAgentResult(subAgentToolId, pendingResult);
+
+ ToolUseBlock toolUse =
+ ToolUseBlock.builder()
+ .id(subAgentToolId)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse);
+
+ hook.onEvent(event).block(TEST_TIMEOUT);
+
+ // Verify pending result was consumed
+ assertFalse(context.hasPendingResult(subAgentToolId));
+ }
+ }
+
+ @Nested
+ @DisplayName("Null Handling Tests")
+ class NullHandlingTests {
+
+ @Test
+ @DisplayName("Should handle null tool use")
+ void testNullToolUse() {
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, null);
+
+ PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT);
+ assertNotNull(result);
+ }
+
+ @Test
+ @DisplayName("Should handle null input in tool use")
+ void testNullInput() {
+ ToolUseBlock toolUse =
+ ToolUseBlock.builder().id("tool-789").name("test_tool").input(null).build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse);
+
+ PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT);
+ assertNotNull(result);
+ }
+
+ @Test
+ @DisplayName("Should handle null metadata in tool use")
+ void testNullMetadata() {
+ // Sub-agent tool ID
+ String subAgentToolId = "tool-null-meta";
+ // Internal tool ID used by the sub-agent
+ String internalToolId = "internal-tool-null";
+ ToolResultBlock pendingResult = createToolResultBlock(internalToolId, "Result");
+ context.setSessionId(subAgentToolId, "session-null");
+ context.submitSubAgentResult(subAgentToolId, pendingResult);
+
+ ToolUseBlock toolUse =
+ ToolUseBlock.builder()
+ .id(subAgentToolId)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse);
+
+ PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT);
+ assertNotNull(result);
+ ToolUseBlock modifiedToolUse = result.getToolUse();
+ assertNotNull(modifiedToolUse.getMetadata());
+ }
+ }
+
+ @Nested
+ @DisplayName("Boundary Condition Tests")
+ class BoundaryConditionTests {
+ @Test
+ @DisplayName("Should handle empty string tool ID")
+ void testEmptyStringToolId() {
+ // Sub-agent tool ID (empty string for boundary test)
+ String subAgentToolId = "";
+ // Internal tool ID used by the sub-agent
+ String internalToolId = "internal-tool-empty";
+ ToolResultBlock pendingResult = createToolResultBlock(internalToolId, "Pending result");
+ context.setSessionId(subAgentToolId, "session-empty");
+ context.submitSubAgentResult(subAgentToolId, pendingResult);
+
+ ToolUseBlock toolUse =
+ ToolUseBlock.builder()
+ .id(subAgentToolId)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse);
+
+ PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT);
+ assertNotNull(result);
+ }
+ }
+
+ @Nested
+ @DisplayName("State Consistency Tests")
+ class StateConsistencyTests {
+
+ @Test
+ @DisplayName("Should maintain state after multiple hook executions")
+ void testStateAfterMultipleExecutions() {
+ // Sub-agent tool ID
+ String subAgentToolId = "tool-repeat";
+
+ // First execution - internal tool ID used by the sub-agent
+ String internalToolId1 = "internal-tool-repeat-1";
+ ToolResultBlock result1 = createToolResultBlock(internalToolId1, "Result 1");
+ context.setSessionId(subAgentToolId, "session-1");
+ context.submitSubAgentResult(subAgentToolId, result1);
+
+ ToolUseBlock toolUse1 =
+ ToolUseBlock.builder()
+ .id(subAgentToolId)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event1 = new PreActingEvent(mockAgent, mockToolkit, toolUse1);
+ hook.onEvent(event1).block(TEST_TIMEOUT);
+
+ assertFalse(context.hasPendingResult(subAgentToolId));
+
+ // Second execution with new result - different internal tool ID
+ String internalToolId2 = "internal-tool-repeat-2";
+ ToolResultBlock result2 = createToolResultBlock(internalToolId2, "Result 2");
+ context.setSessionId(subAgentToolId, "session-2");
+ context.submitSubAgentResult(subAgentToolId, result2);
+
+ ToolUseBlock toolUse2 =
+ ToolUseBlock.builder()
+ .id(subAgentToolId)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ PreActingEvent event2 = new PreActingEvent(mockAgent, mockToolkit, toolUse2);
+ PreActingEvent result2Event = hook.onEvent(event2).block(TEST_TIMEOUT);
+
+ assertNotNull(result2Event);
+ assertEquals("session-2", result2Event.getToolUse().getInput().get("session_id"));
+ }
+
+ @Test
+ @DisplayName("Should preserve metadata through hook execution")
+ void testMetadataPreservation() {
+ // Sub-agent tool ID
+ String subAgentToolId = "tool-meta";
+ String sessionId = "session-meta";
+
+ Map originalMetadata = new HashMap<>();
+ originalMetadata.put("custom_key", "custom_value");
+
+ // Internal tool ID used by the sub-agent
+ String internalToolId = "internal-tool-meta";
+ ToolResultBlock pendingResult = createToolResultBlock(internalToolId, "Pending result");
+ context.setSessionId(subAgentToolId, sessionId);
+ context.submitSubAgentResult(subAgentToolId, pendingResult);
+
+ ToolUseBlock toolUse =
+ ToolUseBlock.builder()
+ .id(subAgentToolId)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .metadata(originalMetadata)
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse);
+
+ PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT);
+
+ assertNotNull(result);
+ ToolUseBlock modifiedToolUse = result.getToolUse();
+ // Original metadata should be preserved
+ assertEquals("custom_value", modifiedToolUse.getMetadata().get("custom_key"));
+ // New metadata key should be added
+ assertTrue(
+ modifiedToolUse.getMetadata().containsKey(SubAgentHook.PREVIOUS_TOOL_RESULT));
+ }
+
+ @Test
+ @DisplayName("Should handle interleaved operations correctly")
+ void testInterleavedOperations() {
+ // Sub-agent tool IDs
+ String subAgentToolId1 = "tool-1";
+ String subAgentToolId2 = "tool-2";
+
+ // Add result for tool 1 - internal tool ID used by sub-agent 1
+ String internalToolId1 = "internal-tool-1";
+ ToolResultBlock result1 = createToolResultBlock(internalToolId1, "Result 1");
+ context.setSessionId(subAgentToolId1, "session-1");
+ context.submitSubAgentResult(subAgentToolId1, result1);
+
+ // Execute hook for tool 1
+ ToolUseBlock toolUse1 =
+ ToolUseBlock.builder()
+ .id(subAgentToolId1)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event1 = new PreActingEvent(mockAgent, mockToolkit, toolUse1);
+ hook.onEvent(event1).block(TEST_TIMEOUT);
+
+ // Add result for tool 2 - internal tool ID used by sub-agent 2
+ String internalToolId2 = "internal-tool-2";
+ ToolResultBlock result2 = createToolResultBlock(internalToolId2, "Result 2");
+ context.setSessionId(subAgentToolId2, "session-2");
+ context.submitSubAgentResult(subAgentToolId2, result2);
+
+ // Execute hook for tool 2
+ ToolUseBlock toolUse2 =
+ ToolUseBlock.builder()
+ .id(subAgentToolId2)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ PreActingEvent event2 = new PreActingEvent(mockAgent, mockToolkit, toolUse2);
+ PreActingEvent result2Event = hook.onEvent(event2).block(TEST_TIMEOUT);
+
+ assertNotNull(result2Event);
+ assertEquals("session-2", result2Event.getToolUse().getInput().get("session_id"));
+
+ // Verify final state
+ assertFalse(context.hasPendingResult(subAgentToolId1));
+ assertFalse(context.hasPendingResult(subAgentToolId2));
+ }
+ }
+
+ @Nested
+ @DisplayName("Exception Handling Tests")
+ class ExceptionHandlingTests {
+
+ @Test
+ @DisplayName("Should handle empty pending result list")
+ void testEmptyPendingResultList() {
+ // Sub-agent tool ID
+ String subAgentToolId = "tool-empty-list";
+
+ // Store session ID but no pending results
+ context.setSessionId(subAgentToolId, "session-empty");
+
+ ToolUseBlock toolUse =
+ ToolUseBlock.builder()
+ .id(subAgentToolId)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse);
+
+ PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT);
+
+ assertNotNull(result);
+ assertTrue(
+ result.getToolUse()
+ .getMetadata()
+ .containsKey(SubAgentHook.PREVIOUS_TOOL_RESULT));
+ }
+ }
+
+ @Nested
+ @DisplayName("Integration with SubAgentContext Tests")
+ class SubAgentContextIntegrationTests {
+
+ @Test
+ @DisplayName("Should work with SubAgentContext state management")
+ void testContextStateManagement() {
+ // Sub-agent tool ID
+ String subAgentToolId = "tool-state";
+ String sessionId = "session-state";
+
+ // Internal tool ID used by the sub-agent
+ String internalToolId = "internal-tool-state";
+ ToolResultBlock result = createToolResultBlock(internalToolId, "State result");
+ context.setSessionId(subAgentToolId, sessionId);
+ context.submitSubAgentResult(subAgentToolId, result);
+
+ ToolUseBlock toolUse =
+ ToolUseBlock.builder()
+ .id(subAgentToolId)
+ .name("test_tool")
+ .input(Map.of("key", "value"))
+ .build();
+
+ Agent mockAgent = mock(Agent.class);
+ Toolkit mockToolkit = mock(Toolkit.class);
+
+ PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse);
+
+ hook.onEvent(event).block(TEST_TIMEOUT);
+
+ // Verify context state after hook execution
+ assertFalse(context.hasPendingResult(subAgentToolId));
+ assertFalse(context.getSessionId(subAgentToolId).isPresent());
+ }
+ }
+
+ private ToolResultBlock createToolResultBlock(String id, String content) {
+ return new ToolResultBlock(
+ id,
+ "test-tool",
+ List.of(TextBlock.builder().text(content).build()),
+ new HashMap<>());
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentPendingStoreTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentPendingStoreTest.java
new file mode 100644
index 000000000..bee934a2f
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentPendingStoreTest.java
@@ -0,0 +1,482 @@
+/*
+ * 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
+ *
+ * https://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.tool.subagent;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+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.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Comprehensive tests for SubAgentPendingStore functionality.
+ *
+ * Test coverage includes:
+ *
+ * - Basic functionality: storing and retrieving pending results, session IDs
+ * - SessionId-first constraint: enforcement of lifecycle management
+ * - Boundary conditions: null values, empty strings, special characters
+ * - Store consistency: correctness of multiple operations
+ * - Defensive copying: prevention of external modifications
+ * - Context management: new context creation on sessionId updates and result additions
+ *
+ */
+@DisplayName("SubAgentPendingStore Tests")
+class SubAgentPendingStoreTest {
+
+ private SubAgentPendingStore store;
+
+ @BeforeEach
+ void setUp() {
+ store = new SubAgentPendingStore();
+ }
+
+ @Nested
+ @DisplayName("Session ID Management Tests")
+ class SessionIdManagementTests {
+
+ @Test
+ @DisplayName("Should store and retrieve session ID")
+ void testStoreAndRetrieveSessionId() {
+ String toolId = "tool-456";
+ String sessionId = "session-abc";
+
+ store.setSessionId(toolId, sessionId);
+
+ String retrieved = store.getSessionId(toolId);
+ assertEquals(sessionId, retrieved);
+ }
+
+ @Test
+ @DisplayName("Should update session ID for existing tool ID")
+ void testUpdateSessionId() {
+ String toolId = "tool-789";
+ String sessionId1 = "session-xyz";
+ String sessionId2 = "session-updated";
+
+ store.setSessionId(toolId, sessionId1);
+ assertEquals(sessionId1, store.getSessionId(toolId));
+
+ store.setSessionId(toolId, sessionId2);
+ assertEquals(sessionId2, store.getSessionId(toolId));
+ }
+
+ @Test
+ @DisplayName("Should return null for non-existent tool ID")
+ void testNonExistentSessionId() {
+ String sessionId = store.getSessionId("non-existent");
+ assertNull(sessionId);
+ }
+
+ @Test
+ @DisplayName("Should handle multiple session IDs")
+ void testMultipleSessionIds() {
+ store.setSessionId("tool-1", "session-1");
+ store.setSessionId("tool-2", "session-2");
+ store.setSessionId("tool-3", "session-3");
+
+ assertEquals("session-1", store.getSessionId("tool-1"));
+ assertEquals("session-2", store.getSessionId("tool-2"));
+ assertEquals("session-3", store.getSessionId("tool-3"));
+ }
+
+ @Test
+ @DisplayName("Should check if tool has registered session ID")
+ void testContains() {
+ assertFalse(store.contains("tool-1"));
+
+ store.setSessionId("tool-1", "session-1");
+
+ assertTrue(store.contains("tool-1"));
+ assertFalse(store.contains("tool-2"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Pending Result Management Tests")
+ class PendingResultManagementTests {
+
+ @Test
+ @DisplayName("Should add and retrieve pending result")
+ void testAddAndRetrievePendingResult() {
+ String toolId = "tool-123";
+ String sessionId = "session-abc";
+ ToolResultBlock result = createToolResultBlock(toolId, "Test result");
+
+ // Must set session ID first
+ store.setSessionId(toolId, sessionId);
+ store.addResult(toolId, result);
+
+ List retrieved = store.getPendingResults(toolId);
+ assertEquals(1, retrieved.size());
+ assertEquals(toolId, retrieved.get(0).getId());
+ }
+
+ @Test
+ @DisplayName("Should handle multiple results for same tool ID")
+ void testMultipleResultsForSameToolId() {
+ String toolId = "tool-1";
+ String sessionId = "session-1";
+ ToolResultBlock result1 = createToolResultBlock(toolId, "First result");
+ ToolResultBlock result2 = createToolResultBlock(toolId, "Second result");
+
+ store.setSessionId(toolId, sessionId);
+ store.addResult(toolId, result1);
+ store.addResult(toolId, result2);
+
+ List retrieved = store.getPendingResults(toolId);
+ assertEquals(2, retrieved.size());
+ assertEquals(
+ "First result", ((TextBlock) retrieved.get(0).getOutput().get(0)).getText());
+ assertEquals(
+ "Second result", ((TextBlock) retrieved.get(1).getOutput().get(0)).getText());
+ }
+
+ @Test
+ @DisplayName("Should return empty list for non-existent tool ID")
+ void testNonExistentToolId() {
+ List results = store.getPendingResults("non-existent");
+ assertNotNull(results);
+ assertTrue(results.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should check if tool has pending results")
+ void testHasPendingResults() {
+ String toolId = "tool-1";
+ String sessionId = "session-1";
+
+ store.setSessionId(toolId, sessionId);
+
+ assertFalse(store.hasPendingResults(toolId));
+
+ store.addResult(toolId, createToolResultBlock(toolId, "Result"));
+
+ assertTrue(store.hasPendingResults(toolId));
+ }
+
+ @Test
+ @DisplayName("Should return false for non-existent tool ID in hasPendingResults")
+ void testHasPendingResultsNonExistent() {
+ assertFalse(store.hasPendingResults("non-existent"));
+ }
+
+ @Test
+ @DisplayName("Should preserve result order")
+ void testPreserveResultOrder() {
+ String toolId = "tool-1";
+ String sessionId = "session-1";
+
+ store.setSessionId(toolId, sessionId);
+
+ for (int i = 0; i < 5; i++) {
+ store.addResult(toolId, createToolResultBlock(toolId, "Result " + i));
+ }
+
+ List results = store.getPendingResults(toolId);
+ assertEquals(5, results.size());
+
+ for (int i = 0; i < 5; i++) {
+ assertEquals(
+ "Result " + i, ((TextBlock) results.get(i).getOutput().get(0)).getText());
+ }
+ }
+
+ @Test
+ @DisplayName("Should return defensive copy of results")
+ void testDefensiveCopy() {
+ String toolId = "tool-1";
+ String sessionId = "session-1";
+
+ store.setSessionId(toolId, sessionId);
+ store.addResult(toolId, createToolResultBlock(toolId, "Original"));
+
+ List results1 = store.getPendingResults(toolId);
+ List results2 = store.getPendingResults(toolId);
+
+ // Should be different list instances
+ assertNotSame(results1, results2);
+
+ // Should have same content
+ assertEquals(results1, results2);
+
+ // Modifying one should not affect the other or the Store
+ results1.clear();
+
+ List results3 = store.getPendingResults(toolId);
+ assertEquals(1, results3.size());
+ assertEquals("Original", ((TextBlock) results3.get(0).getOutput().get(0)).getText());
+ }
+ }
+
+ @Nested
+ @DisplayName("SessionId-First Constraint Tests")
+ class SessionIdFirstConstraintTests {
+
+ @Test
+ @DisplayName("Should enforce sessionId-first constraint")
+ void testSessionIdFirstConstraint() {
+ String toolId = "tool-123";
+ ToolResultBlock result = createToolResultBlock(toolId, "Test result");
+
+ // Try to add result without setting session ID
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class, () -> store.addResult(toolId, result));
+
+ assertTrue(exception.getMessage().contains("Cannot add result"));
+ assertTrue(exception.getMessage().contains("without a registered session ID"));
+ assertTrue(exception.getMessage().contains("Call setSessionId() first"));
+ }
+
+ @Test
+ @DisplayName("Should allow adding result after session ID is set")
+ void testAddResultAfterSessionId() {
+ String toolId = "tool-123";
+ ToolResultBlock result = createToolResultBlock(toolId, "Test result");
+
+ // Set session ID first
+ store.setSessionId(toolId, "session-abc");
+
+ // Now adding result should succeed
+ assertDoesNotThrow(() -> store.addResult(toolId, result));
+
+ List results = store.getPendingResults(toolId);
+ assertEquals(1, results.size());
+ }
+ }
+
+ @Nested
+ @DisplayName("Null Handling Tests")
+ class NullHandlingTests {
+
+ @Test
+ @DisplayName("Should throw IllegalArgumentException for null tool ID in setSessionId")
+ void testNullToolIdInSetSessionId() {
+ IllegalArgumentException exception =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> store.setSessionId(null, "session-1"));
+ assertTrue(exception.getMessage().contains("toolId cannot be null"));
+ }
+
+ @Test
+ @DisplayName("Should throw IllegalArgumentException for null session ID in setSessionId")
+ void testNullSessionIdInSetSessionId() {
+ IllegalArgumentException exception =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> store.setSessionId("tool-1", null));
+ assertTrue(exception.getMessage().contains("sessionId cannot be null"));
+ }
+
+ @Test
+ @DisplayName("Should throw IllegalArgumentException for null tool ID in addResult")
+ void testNullToolIdInAddResult() {
+ ToolResultBlock result = createToolResultBlock("tool-1", "Result");
+
+ IllegalArgumentException exception =
+ assertThrows(
+ IllegalArgumentException.class, () -> store.addResult(null, result));
+ assertTrue(exception.getMessage().contains("toolId cannot be null"));
+ }
+
+ @Test
+ @DisplayName("Should throw IllegalArgumentException for null result in addResult")
+ void testNullResultInAddResult() {
+ store.setSessionId("tool-1", "session-1");
+
+ IllegalArgumentException exception =
+ assertThrows(
+ IllegalArgumentException.class, () -> store.addResult("tool-1", null));
+ assertTrue(exception.getMessage().contains("result cannot be null"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Clear and Remove Tests")
+ class ClearAndRemoveTests {
+
+ @Test
+ @DisplayName("Should remove tool data")
+ void testRemove() {
+ store.setSessionId("tool-1", "session-1");
+ store.addResult("tool-1", createToolResultBlock("tool-1", "Result"));
+
+ assertTrue(store.contains("tool-1"));
+ assertTrue(store.hasPendingResults("tool-1"));
+
+ store.remove("tool-1");
+
+ assertFalse(store.contains("tool-1"));
+ assertFalse(store.hasPendingResults("tool-1"));
+ assertNull(store.getSessionId("tool-1"));
+ }
+
+ @Test
+ @DisplayName("Should clear all data")
+ void testClearAll() {
+ store.setSessionId("tool-1", "session-1");
+ store.addResult("tool-1", createToolResultBlock("tool-1", "Result 1"));
+ store.setSessionId("tool-2", "session-2");
+ store.addResult("tool-2", createToolResultBlock("tool-2", "Result 2"));
+
+ assertFalse(store.isEmpty());
+
+ store.clearAll();
+
+ assertTrue(store.isEmpty());
+ assertFalse(store.contains("tool-1"));
+ assertFalse(store.contains("tool-2"));
+ }
+
+ @Test
+ @DisplayName("Should check if Store is empty")
+ void testIsEmpty() {
+ assertTrue(store.isEmpty());
+
+ store.setSessionId("tool-1", "session-1");
+
+ assertFalse(store.isEmpty());
+
+ store.clearAll();
+
+ assertTrue(store.isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("Store Consistency Tests")
+ class StoreConsistencyTests {
+
+ @Test
+ @DisplayName("Should handle interleaved operations correctly")
+ void testInterleavedOperations() {
+ // Add result 1
+ store.setSessionId("tool-1", "session-1");
+ store.addResult("tool-1", createToolResultBlock("tool-1", "Result 1"));
+ // Add session ID 1
+ store.setSessionId("tool-2", "session-2");
+ // Add result 2
+ store.addResult("tool-2", createToolResultBlock("tool-2", "Result 2"));
+ // Remove result 1
+ store.remove("tool-1");
+ // Add result 3
+ store.setSessionId("tool-3", "session-3");
+ store.addResult("tool-3", createToolResultBlock("tool-3", "Result 3"));
+
+ // Verify final Store
+ assertFalse(store.contains("tool-1"));
+ assertTrue(store.contains("tool-2"));
+ assertTrue(store.contains("tool-3"));
+ assertEquals("session-2", store.getSessionId("tool-2"));
+ assertEquals("session-3", store.getSessionId("tool-3"));
+ }
+
+ @Test
+ @DisplayName("Should preserve metadata through operations")
+ void testMetadataPreservation() {
+ Map metadata = new HashMap<>();
+ metadata.put("custom_key", "custom_value");
+
+ ToolResultBlock result =
+ ToolResultBlock.builder()
+ .id("tool-1")
+ .output(TextBlock.builder().text("Result").build())
+ .metadata(metadata)
+ .build();
+
+ store.setSessionId("tool-1", "session-123");
+ store.addResult("tool-1", result);
+
+ // Get result
+ List results = store.getPendingResults("tool-1");
+ assertEquals(1, results.size());
+
+ ToolResultBlock retrievedResult = results.get(0);
+ assertEquals("custom_value", retrievedResult.getMetadata().get("custom_key"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Context Management Tests")
+ class ContextManagementTests {
+
+ @Test
+ @DisplayName("Should create new context on sessionId update")
+ void testNewContextOnSessionIdUpdate() {
+ String toolId = "tool-1";
+ String sessionId1 = "session-1";
+ String sessionId2 = "session-2";
+
+ store.setSessionId(toolId, sessionId1);
+ store.addResult(toolId, createToolResultBlock(toolId, "Result 1"));
+
+ List results1 = store.getPendingResults(toolId);
+
+ store.setSessionId(toolId, sessionId2);
+
+ List results2 = store.getPendingResults(toolId);
+
+ // Results should be different instances
+ assertNotSame(results1, results2);
+ assertEquals(1, results1.size());
+ assertEquals(0, results2.size());
+ }
+
+ @Test
+ @DisplayName("Should create new context on result addition")
+ void testNewContextOnResultAddition() {
+ String toolId = "tool-1";
+ String sessionId = "session-1";
+
+ store.setSessionId(toolId, sessionId);
+ store.addResult(toolId, createToolResultBlock(toolId, "Result 1"));
+
+ List results1 = store.getPendingResults(toolId);
+
+ store.addResult(toolId, createToolResultBlock(toolId, "Result 2"));
+
+ List results2 = store.getPendingResults(toolId);
+
+ // Results should be different instances
+ assertNotSame(results1, results2);
+ assertEquals(1, results1.size());
+ assertEquals(2, results2.size());
+ }
+ }
+
+ private ToolResultBlock createToolResultBlock(String id, String content) {
+ return new ToolResultBlock(
+ id,
+ "test-tool",
+ List.of(TextBlock.builder().text(content).build()),
+ new HashMap<>());
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java
index 909badfd8..e8c9be447 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java
@@ -25,10 +25,12 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.Agent;
import io.agentscope.core.agent.Event;
import io.agentscope.core.agent.EventType;
import io.agentscope.core.agent.StreamOptions;
+import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.TextBlock;
@@ -42,7 +44,9 @@
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -428,44 +432,498 @@ void testCustomStreamOptions() {
verify(mockAgent).stream(any(List.class), any(StreamOptions.class));
}
+ /**
+ * HITL (Human-in-the-Loop) Tests for SubAgentTool.
+ *
+ * Tests cover:
+ *
+ * - Suspended state detection and result building
+ * - Resume functionality with injected tool results
+ * - HITL enabled/disabled behavior
+ * - Multiple suspension types handling
+ *
+ */
+ @Nested
+ @DisplayName("HITL (Human-in-the-Loop) Tests")
+ class HITLTests {
+
+ @Test
+ @DisplayName(
+ "Should return suspended result when sub-agent is suspended with TOOL_SUSPENDED")
+ void testSuspendedResultOnToolSuspended() {
+ ReActAgent mockAgent = mock(ReActAgent.class);
+ when(mockAgent.getName()).thenReturn("SuspendableAgent");
+ when(mockAgent.getDescription()).thenReturn("Agent that can suspend");
+
+ ToolUseBlock innerToolUse =
+ createToolUseBlock(
+ "inner-tool-1",
+ "external_api",
+ Map.of("url", "https://api.example.com"));
+
+ Msg suspendedResponse =
+ createMultiContentMsg(
+ List.of(
+ TextBlock.builder().text("Calling external API...").build(),
+ innerToolUse),
+ io.agentscope.core.message.GenerateReason.TOOL_SUSPENDED);
+
+ when(mockAgent.call(any(List.class))).thenReturn(Mono.just(suspendedResponse));
+
+ SubAgentConfig config =
+ SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build();
+ SubAgentTool tool = new SubAgentTool(() -> mockAgent, config);
+
+ Map input = Map.of("message", "Call the external API");
+ ToolUseBlock toolUse = createToolUseBlock("tool-1", "call_suspendableagent", input);
+
+ ToolResultBlock result = executeToolCall(tool, toolUse, input);
+
+ assertNotNull(result);
+ assertTrue(result.isSuspended(), "Result should be marked as suspended");
+ assertNotNull(
+ result.getMetadata().get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID),
+ "Should contain session ID in metadata");
+ assertEquals(
+ io.agentscope.core.message.GenerateReason.TOOL_SUSPENDED,
+ result.getMetadata().get(SubAgentContext.METADATA_GENERATE_REASON),
+ "Should contain generate reason in metadata");
+ }
+
+ @Test
+ @DisplayName(
+ "Should return suspended result when sub-agent is suspended with"
+ + " REASONING_STOP_REQUESTED")
+ void testSuspendedResultOnReasoningStopRequested() {
+ ReActAgent mockAgent = mock(ReActAgent.class);
+ when(mockAgent.getName()).thenReturn("HookStopAgent");
+ when(mockAgent.getDescription()).thenReturn("Agent stopped by hook");
+
+ Msg suspendedResponse =
+ createTextMsgWithReason(
+ "Stopped for review",
+ io.agentscope.core.message.GenerateReason.REASONING_STOP_REQUESTED);
+
+ when(mockAgent.call(any(List.class))).thenReturn(Mono.just(suspendedResponse));
+
+ SubAgentConfig config =
+ SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build();
+ SubAgentTool tool = new SubAgentTool(() -> mockAgent, config);
+
+ Map input = Map.of("message", "Process this request");
+ ToolUseBlock toolUse = createToolUseBlock("tool-2", "call_hookstopagent", input);
+
+ ToolResultBlock result = executeToolCall(tool, toolUse, input);
+
+ assertNotNull(result);
+ assertTrue(result.isSuspended(), "Result should be marked as suspended");
+ assertEquals(
+ io.agentscope.core.message.GenerateReason.REASONING_STOP_REQUESTED,
+ result.getMetadata().get(SubAgentContext.METADATA_GENERATE_REASON));
+ }
+
+ @Test
+ @DisplayName(
+ "Should return suspended result when sub-agent is suspended with"
+ + " ACTING_STOP_REQUESTED")
+ void testSuspendedResultOnActingStopRequested() {
+ ReActAgent mockAgent = mock(ReActAgent.class);
+ when(mockAgent.getName()).thenReturn("ActingStopAgent");
+ when(mockAgent.getDescription()).thenReturn("Agent stopped during acting");
+
+ Msg suspendedResponse =
+ createTextMsgWithReason(
+ "Stopped during tool execution",
+ io.agentscope.core.message.GenerateReason.ACTING_STOP_REQUESTED);
+
+ when(mockAgent.call(any(List.class))).thenReturn(Mono.just(suspendedResponse));
+
+ SubAgentConfig config =
+ SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build();
+ SubAgentTool tool = new SubAgentTool(() -> mockAgent, config);
+
+ Map input = Map.of("message", "Execute action");
+ ToolUseBlock toolUse = createToolUseBlock("tool-3", "call_actingstopagent", input);
+
+ ToolResultBlock result = executeToolCall(tool, toolUse, input);
+
+ assertNotNull(result);
+ assertTrue(result.isSuspended(), "Result should be marked as suspended");
+ assertEquals(
+ io.agentscope.core.message.GenerateReason.ACTING_STOP_REQUESTED,
+ result.getMetadata().get(SubAgentContext.METADATA_GENERATE_REASON));
+ }
+
+ @Test
+ @DisplayName("Should convert suspended state to text when HITL is disabled")
+ void testSuspendedStateConvertedToTextWhenHitlDisabled() {
+ ReActAgent mockAgent = mock(ReActAgent.class);
+ when(mockAgent.getName()).thenReturn("NoHitlAgent");
+ when(mockAgent.getDescription()).thenReturn("Agent without HITL");
+
+ Msg suspendedResponse =
+ createTextMsgWithReason(
+ "Suspended content",
+ io.agentscope.core.message.GenerateReason.TOOL_SUSPENDED);
+
+ when(mockAgent.call(any(List.class))).thenReturn(Mono.just(suspendedResponse));
+
+ SubAgentConfig config =
+ SubAgentConfig.builder().forwardEvents(false).enableHITL(false).build();
+ SubAgentTool tool = new SubAgentTool(() -> mockAgent, config);
+
+ Map input = Map.of("message", "Test message");
+ ToolUseBlock toolUse = createToolUseBlock("tool-4", "call_nohitlagent", input);
+
+ ToolResultBlock result = executeToolCall(tool, toolUse, input);
+
+ assertNotNull(result);
+ assertFalse(
+ result.isSuspended(),
+ "Result should NOT be marked as suspended when HITL disabled");
+ String text = extractText(result);
+ assertTrue(text.contains("session_id:"), "Should still contain session_id");
+ }
+
+ @Test
+ @DisplayName("Should resume execution with submit tool results")
+ void testResumeWithSubmitToolResults() {
+ AtomicInteger callCount = new AtomicInteger(0);
+
+ ReActAgent mockAgent = mock(ReActAgent.class);
+ when(mockAgent.getName()).thenReturn("ResumableAgent");
+ when(mockAgent.getDescription()).thenReturn("Agent that can resume");
+
+ when(mockAgent.call(any(List.class)))
+ .thenAnswer(
+ invocation -> {
+ int count = callCount.incrementAndGet();
+ if (count == 1) {
+ ToolUseBlock innerToolUse =
+ createToolUseBlock(
+ "inner-tool-resume",
+ "database_query",
+ Map.of("sql", "SELECT * FROM users"));
+
+ return Mono.just(
+ createMultiContentMsg(
+ List.of(
+ TextBlock.builder()
+ .text("Querying database...")
+ .build(),
+ innerToolUse),
+ io.agentscope.core.message.GenerateReason
+ .TOOL_SUSPENDED));
+ } else {
+ return Mono.just(
+ createTextMsg("Query completed: 5 users found"));
+ }
+ });
+
+ SubAgentConfig config =
+ SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build();
+ SubAgentTool tool = new SubAgentTool(() -> mockAgent, config);
+
+ Map input1 = Map.of("message", "Query the database");
+ ToolUseBlock toolUse1 =
+ createToolUseBlock("tool-resume", "call_resumableagent", input1);
+
+ ToolResultBlock suspendedResult = executeToolCall(tool, toolUse1, input1);
+
+ assertNotNull(suspendedResult);
+ assertTrue(suspendedResult.isSuspended());
+
+ String sessionId =
+ (String)
+ suspendedResult
+ .getMetadata()
+ .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID);
+ assertNotNull(sessionId);
+
+ ToolResultBlock userProvidedResult =
+ ToolResultBlock.builder()
+ .id("tool-resume")
+ .name("call_resumableagent")
+ .output(
+ TextBlock.builder()
+ .text("[{id: 1, name: 'Alice'}, ...]")
+ .build())
+ .build();
+
+ Map input2 = createResumeInput("Continue", sessionId);
+ ToolUseBlock toolUse2 =
+ createResumeToolUse(
+ "tool-resume-",
+ "call_resumableagent",
+ input2,
+ List.of(userProvidedResult));
+
+ ToolResultBlock resumedResult = executeToolCall(tool, toolUse2, input2);
+
+ assertNotNull(resumedResult);
+ assertFalse(resumedResult.isSuspended(), "Resumed result should not be suspended");
+ String text = extractText(resumedResult);
+ assertTrue(text.contains("5 users found"), "Should contain resumed response");
+ }
+
+ @Test
+ @DisplayName("Should resume with empty results for hook stop")
+ void testResumeWithEmptyResultsForHookStop() {
+ AtomicInteger callCount = new AtomicInteger(0);
+
+ ReActAgent mockAgent = mock(ReActAgent.class);
+ when(mockAgent.getName()).thenReturn("HookResumeAgent");
+ when(mockAgent.getDescription()).thenReturn("Agent resuming from hook stop");
+
+ when(mockAgent.call(any(List.class)))
+ .thenAnswer(
+ invocation -> {
+ int count = callCount.incrementAndGet();
+ if (count == 1) {
+ return Mono.just(
+ createTextMsgWithReason(
+ "Paused for review",
+ io.agentscope.core.message.GenerateReason
+ .REASONING_STOP_REQUESTED));
+ } else {
+ return Mono.just(createTextMsg("Continued after review"));
+ }
+ });
+
+ SubAgentConfig config =
+ SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build();
+ SubAgentTool tool = new SubAgentTool(() -> mockAgent, config);
+
+ Map input1 = Map.of("message", "Start task");
+ ToolUseBlock toolUse1 = createToolUseBlock("tool-hook", "call_hookresumeagent", input1);
+
+ ToolResultBlock suspendedResult = executeToolCall(tool, toolUse1, input1);
+
+ assertTrue(suspendedResult.isSuspended());
+
+ String sessionId =
+ (String)
+ suspendedResult
+ .getMetadata()
+ .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID);
+
+ Map input2 = createResumeInput("Continue", sessionId);
+ ToolUseBlock toolUse2 =
+ createResumeToolUse("tool-hook", "call_hookresumeagent", input2, List.of());
+
+ ToolResultBlock resumedResult = executeToolCall(tool, toolUse2, input2);
+
+ assertNotNull(resumedResult);
+ assertFalse(resumedResult.isSuspended());
+ String text = extractText(resumedResult);
+ assertTrue(text.contains("Continued after review"));
+ }
+
+ @Test
+ @DisplayName("Should include inner tool use blocks in suspended result")
+ void testSuspendedResultContainsInnerToolUseBlocks() {
+ ReActAgent mockAgent = mock(ReActAgent.class);
+ when(mockAgent.getName()).thenReturn("MultiToolAgent");
+ when(mockAgent.getDescription()).thenReturn("Agent with multiple tools");
+
+ ToolUseBlock innerTool1 =
+ createToolUseBlock("inner-1", "api_call", Map.of("endpoint", "/users"));
+
+ ToolUseBlock innerTool2 =
+ createToolUseBlock("inner-2", "file_write", Map.of("path", "/tmp/result.json"));
+
+ Msg suspendedResponse =
+ createMultiContentMsg(
+ List.of(
+ TextBlock.builder()
+ .text("Executing multiple operations...")
+ .build(),
+ innerTool1,
+ innerTool2),
+ io.agentscope.core.message.GenerateReason.TOOL_SUSPENDED);
+
+ when(mockAgent.call(any(List.class))).thenReturn(Mono.just(suspendedResponse));
+
+ SubAgentConfig config =
+ SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build();
+ SubAgentTool tool = new SubAgentTool(() -> mockAgent, config);
+
+ Map input = Map.of("message", "Execute operations");
+ ToolUseBlock toolUse = createToolUseBlock("tool-multi", "call_multitoolagent", input);
+
+ ToolResultBlock result = executeToolCall(tool, toolUse, input);
+
+ assertNotNull(result);
+ assertTrue(result.isSuspended());
+
+ List toolUseBlocks =
+ result.getOutput().stream()
+ .filter(block -> block instanceof ToolUseBlock)
+ .map(block -> (ToolUseBlock) block)
+ .toList();
+
+ assertEquals(2, toolUseBlocks.size(), "Should contain 2 inner tool use blocks");
+ assertEquals("api_call", toolUseBlocks.get(0).getName());
+ assertEquals("file_write", toolUseBlocks.get(1).getName());
+ }
+
+ @Test
+ @DisplayName("Should preserve session state across suspension and resumption")
+ void testSessionStatePreservedAcrossSuspension() {
+ AtomicInteger callCount = new AtomicInteger(0);
+
+ ReActAgent mockAgent = mock(ReActAgent.class);
+ when(mockAgent.getName()).thenReturn("StatefulAgent");
+ when(mockAgent.getDescription()).thenReturn("Agent with state");
+
+ when(mockAgent.call(any(List.class)))
+ .thenAnswer(
+ invocation -> {
+ int count = callCount.incrementAndGet();
+ if (count == 1) {
+ return Mono.just(
+ createTextMsgWithReason(
+ "Step 1 complete, waiting...",
+ io.agentscope.core.message.GenerateReason
+ .TOOL_SUSPENDED));
+ } else if (count == 2) {
+ return Mono.just(
+ createTextMsgWithReason(
+ "Step 2 complete, waiting...",
+ io.agentscope.core.message.GenerateReason
+ .TOOL_SUSPENDED));
+ } else {
+ return Mono.just(createTextMsg("All steps completed!"));
+ }
+ });
+
+ SubAgentConfig config =
+ SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build();
+ SubAgentTool tool = new SubAgentTool(() -> mockAgent, config);
+
+ Map input1 = Map.of("message", "Start multi-step task");
+ ToolUseBlock toolUse1 =
+ createToolUseBlock("tool-state-1", "call_statefulagent", input1);
+
+ ToolResultBlock result1 = executeToolCall(tool, toolUse1, input1);
+
+ assertTrue(result1.isSuspended());
+ String sessionId =
+ (String)
+ result1.getMetadata().get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID);
+
+ Map input2 = createResumeInput("Continue step 2", sessionId);
+ ToolUseBlock toolUse2 =
+ createResumeToolUse("tool-state-2", "call_statefulagent", input2, List.of());
+
+ ToolResultBlock result2 = executeToolCall(tool, toolUse2, input2);
+
+ assertTrue(result2.isSuspended());
+ String sessionId2 =
+ (String)
+ result2.getMetadata().get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID);
+ assertEquals(sessionId, sessionId2, "Session ID should be preserved");
+ }
+ }
+
// Helper methods
private Agent createMockAgent(String name, String description) {
- Agent agent = mock(Agent.class);
- when(agent.getName()).thenReturn(name);
- when(agent.getDescription()).thenReturn(description);
- when(agent.call(any(List.class)))
- .thenReturn(
- Mono.just(
- Msg.builder()
- .role(MsgRole.ASSISTANT)
- .content(TextBlock.builder().text("Response").build())
- .build()));
- return agent;
+ Agent mockAgent = mock(Agent.class);
+ when(mockAgent.getName()).thenReturn(name);
+ when(mockAgent.getDescription()).thenReturn(description);
+ return mockAgent;
}
private String extractText(ToolResultBlock result) {
- if (result.getOutput() == null || result.getOutput().isEmpty()) {
- return "";
- }
return result.getOutput().stream()
.filter(block -> block instanceof TextBlock)
.map(block -> ((TextBlock) block).getText())
- .findFirst()
- .orElse("");
+ .collect(Collectors.joining("\n"));
}
private String extractSessionId(ToolResultBlock result) {
String text = extractText(result);
- if (text.startsWith("session_id: ")) {
- int endIndex = text.indexOf("\n");
- if (endIndex > 0) {
- return text.substring("session_id: ".length(), endIndex);
- } else {
- // Handle case where no newline exists (session_id is the entire text)
- return text.substring("session_id: ".length());
- }
+ String prefix = "session_id:";
+ int start = text.indexOf(prefix);
+ if (start == -1) {
+ return null;
}
- return null;
+ start += prefix.length();
+ int end = text.indexOf('\n', start);
+ return end == -1 ? text.substring(start).trim() : text.substring(start, end).trim();
+ }
+
+ /**
+ * Create simple text message
+ */
+ private Msg createTextMsg(String text) {
+ return Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(TextBlock.builder().text(text).build())
+ .build();
+ }
+
+ /**
+ * Creates a text message with GenerateReason
+ */
+ private Msg createTextMsgWithReason(
+ String text, io.agentscope.core.message.GenerateReason reason) {
+ return Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(TextBlock.builder().text(text).build())
+ .generateReason(reason)
+ .build();
+ }
+
+ /**
+ * Creates a message with multiple ContentBlocks
+ */
+ private Msg createMultiContentMsg(
+ List contents, io.agentscope.core.message.GenerateReason reason) {
+ return Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .content(contents)
+ .generateReason(reason)
+ .build();
+ }
+
+ /**
+ * Creates a simple ToolUseBlock
+ */
+ private ToolUseBlock createToolUseBlock(String id, String name, Map input) {
+ return ToolUseBlock.builder().id(id).name(name).input(input).build();
+ }
+
+ /**
+ * Helper method to execute tool call
+ */
+ private ToolResultBlock executeToolCall(
+ SubAgentTool tool, ToolUseBlock toolUse, Map input) {
+ return tool.callAsync(ToolCallParam.builder().toolUseBlock(toolUse).input(input).build())
+ .block();
+ }
+
+ /**
+ * Creates input for resuming with session_id
+ */
+ private Map createResumeInput(String message, String sessionId) {
+ Map input = new HashMap<>();
+ input.put("message", message);
+ input.put("session_id", sessionId);
+ return input;
+ }
+
+ /**
+ * Creates a ToolUseBlock with previous_tool_result
+ */
+ private ToolUseBlock createResumeToolUse(
+ String id,
+ String name,
+ Map input,
+ List previousResults) {
+ Map metadata = new HashMap<>();
+ metadata.put(SubAgentHook.PREVIOUS_TOOL_RESULT, previousResults);
+ return ToolUseBlock.builder().id(id).name(name).input(input).metadata(metadata).build();
}
}
diff --git a/docs/en/multi-agent/agent-as-tool.md b/docs/en/multi-agent/agent-as-tool.md
index dea6f5a43..0d3cb13a5 100644
--- a/docs/en/multi-agent/agent-as-tool.md
+++ b/docs/en/multi-agent/agent-as-tool.md
@@ -45,9 +45,9 @@ DashScopeChatModel model = DashScopeChatModel.builder()
.modelName("qwen-plus")
.build();
-// Create sub-agent Provider (factory)
+ // Create sub-agent Provider (factory)
// Note: Must use lambda to ensure new instance is created for each call
-Toolkit toolkit = new Toolkit();
+ Toolkit toolkit = new Toolkit();
toolkit.registration()
.subAgent(() -> ReActAgent.builder()
.name("Expert")
@@ -56,16 +56,16 @@ toolkit.registration()
.build())
.apply();
-// Create main agent with toolkit
-ReActAgent mainAgent = ReActAgent.builder()
- .name("Coordinator")
- .sysPrompt("You are a coordinator. When facing professional questions, call the call_expert tool to consult the expert.")
- .model(model)
- .toolkit(toolkit)
- .build();
+ // Create main agent with toolkit
+ ReActAgent mainAgent = ReActAgent.builder()
+ .name("Coordinator")
+ .sysPrompt("You are a coordinator. When facing professional questions, call the call_expert tool to consult the expert.")
+ .model(model)
+ .toolkit(toolkit)
+ .build();
-// Main agent will automatically call expert agent when needed
-Msg response = mainAgent.call(userMsg).block();
+ // Main agent will automatically call expert agent when needed
+ Msg response = mainAgent.call(userMsg).block();
```
## Configuration Options
@@ -151,3 +151,91 @@ toolkit.registration()
.group("experts")
.apply();
```
+
+## Human-in-the-Loop (HITL) Support
+
+Sub-agents support Human-in-the-Loop (HITL), allowing sub-agents to pass suspend state to the parent agent and user when encountering operations requiring human confirmation, and resume execution after user confirmation. Currently, only ReactAgent is supported as a sub-Agent.
+
+### Enabling HITL
+
+```java
+import io.agentscope.core.tool.subagent.SubAgentConfig;
+
+// Configure sub-agent tool with HITL enabled
+toolkit.registration()
+ .subAgent(() -> ReActAgent.builder()
+ .name("DataAnalyst")
+ .sysPrompt("You are a data analysis expert.")
+ .model(model)
+ .build())
+ .config(SubAgentConfig.builder()
+ .enableHITL(true) // Enable human-in-the-loop
+ .build())
+ .apply();
+
+// Create main agent with HITL support enabled
+ReActAgent mainAgent = ReActAgent.builder()
+ .name("Coordinator")
+ .sysPrompt("You are a coordinator responsible for calling the data analyst.")
+ .model(model)
+ .toolkit(toolkit)
+ .enableSubAgentHITL(true) // Main agent also needs HITL support enabled
+ .build();
+```
+
+### Handling Suspend and Resume
+
+When a sub-agent is suspended, the returned message contains the pending tool information. Display it to the user and decide next steps based on their choice:
+
+```java
+import io.agentscope.core.tool.subagent.SubAgentContext;
+
+Msg response = mainAgent.call(userMsg).block();
+
+// Check if sub-agent is suspended
+while (response.getGenerateReason() == GenerateReason.TOOL_SUSPENDED) {
+ List toolResults = response.getContentBlocks(ToolResultBlock.class);
+
+ for (ToolResultBlock resultBlock : toolResults) {
+ if (!SubAgentContext.isSubAgentResult(resultBlock)) {
+ continue;
+ }
+
+ // Get the blocked tool calls from sub-agent
+ List pendingTools = resultBlock.getOutput().stream()
+ .filter(ToolUseBlock.class::isInstance)
+ .map(ToolUseBlock.class::cast)
+ .toList();
+
+ if (!userConfirms(pendingTools)) {
+ // User declined, submit cancellation results
+ List cancelResults = pendingTools.stream()
+ .map(t -> ToolResultBlock.of(t.getId(), t.getName(),
+ TextBlock.builder().text("Operation cancelled").build()))
+ .toList();
+ mainAgent.submitSubAgentResults(resultBlock.getId(), cancelResults);
+ }
+ response = mainAgent.call().block();
+ }
+}
+
+// Final response
+System.out.println(response.getTextContent());
+```
+
+## Quick Reference
+
+**Configuration methods**:
+- `SubAgentConfig.enableHITL(boolean)` — Enable/disable sub-agent HITL support
+- `ReActAgent.enableSubAgentHITL(boolean)` — Enable/disable main agent HITL support
+- `ReActAgent.isEnableSubAgentHITL()` — Whether the main agent supports sub-agent HITL
+
+**Detection methods**:
+- `SubAgentContext.isSubAgentResult(ToolResultBlock)` — Check if result is from sub-agent
+- `SubAgentContext.getSubAgentGenerateReason(ToolResultBlock)` — Get generate reason of sub-agent
+- `SubAgentContext.extractSessionId(ToolResultBlock)` — Extract session ID
+
+**Resume methods**:
+- `mainAgent.call()` — Continue executing pending tools
+- `mainAgent.submitSubAgentResult(String, ToolResultBlock)` — Submit single tool result
+- `mainAgent.submitSubAgentResults(String, List)` — Submit multiple tool results
\ No newline at end of file
diff --git a/docs/zh/multi-agent/agent-as-tool.md b/docs/zh/multi-agent/agent-as-tool.md
index 6ea8362a4..ebd8b742e 100644
--- a/docs/zh/multi-agent/agent-as-tool.md
+++ b/docs/zh/multi-agent/agent-as-tool.md
@@ -151,3 +151,90 @@ toolkit.registration()
.group("experts")
.apply();
```
+
+## 人机交互支持(HITL)
+
+子智能体支持人机交互(Human-in-the-Loop),允许子智能体在执行过程中遇到需要人工确认的操作时,将挂起状态传递给主智能体和用户,并在用户确认后恢复执行。目前只支持使用ReactAgent作为子Agent。
+
+### 启用 HITL
+
+```java
+import io.agentscope.core.tool.subagent.SubAgentConfig;
+
+// 配置子智能体工具并启用 HITL
+toolkit.registration()
+ .subAgent(() -> ReActAgent.builder()
+ .name("DataAnalyst")
+ .sysPrompt("你是一个数据分析专家。")
+ .model(model)
+ .build())
+ .config(SubAgentConfig.builder()
+ .enableHITL(true) // 启用人机交互
+ .build())
+ .apply();
+
+// 创建主智能体,启用 HITL 支持
+ReActAgent mainAgent = ReActAgent.builder()
+ .name("Coordinator")
+ .sysPrompt("你是一个协调员,负责调用数据分析专家。")
+ .model(model)
+ .toolkit(toolkit)
+ .enableSubAgentHITL(true) // 主智能体也需要启用 HITL 支持
+ .build();
+```
+
+### 处理挂起和恢复
+
+当子智能体挂起时,返回的消息会包含待执行的工具信息。你需要展示给用户,并根据用户选择决定下一步:
+
+```java
+import io.agentscope.core.tool.subagent.SubAgentContext;
+
+Msg response = mainAgent.call(userMsg).block();
+
+// 检查是否有子智能体挂起
+while (response.getGenerateReason() == GenerateReason.TOOL_SUSPENDED) {
+ List toolResults = response.getContentBlocks(ToolResultBlock.class);
+
+ for (ToolResultBlock resultBlock : toolResults) {
+ if (!SubAgentContext.isSubAgentResult(resultBlock)) {
+ continue;
+ }
+
+ // 获取子智能体被阻塞的工具调用
+ List pendingTools = resultBlock.getOutput().stream()
+ .filter(ToolUseBlock.class::isInstance)
+ .map(ToolUseBlock.class::cast)
+ .toList();
+
+ if (!userConfirms(pendingTools)) {
+ // 用户拒绝,提交取消结果
+ List cancelResults = pendingTools.stream()
+ .map(t -> ToolResultBlock.of(t.getId(), t.getName(),
+ TextBlock.builder().text("操作已取消").build()))
+ .toList();
+ mainAgent.submitSubAgentResults(resultBlock.getId(), cancelResults);
+ }
+ response = mainAgent.call().block();
+ }
+}
+
+// 最终响应
+System.out.println(response.getTextContent());
+```
+
+## API 速查
+
+**配置方法**:
+- `SubAgentConfig.enableHITL(boolean)` — 启用/禁用子智能体 HITL 支持
+- `ReActAgent.enableSubAgentHITL(boolean)` — 启用/禁用主智能体 HITL 支持
+- `ReActAgent.isEnableSubAgentHITL()` — 主智能体是/否支持 HITL
+
+**检测方法**:
+- `SubAgentContext.isSubAgentResult(ToolResultBlock)` — 判断是否是子智能体结果
+- `SubAgentContext.getSubAgentGenerateReason(ToolResultBlock)` — 获取子智能体生成原因
+- `SubAgentContext.extractSessionId(ToolResultBlock)` — 提取会话 ID
+
+**恢复方法**:
+- `ReActAgent.submitSubAgentResult(String, ToolResultBlock)` — 提交单个工具结果
+- `ReActAgent.submitSubAgentResults(String, List)` — 批量提交工具结果
\ No newline at end of file