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 void remove(String toolId) {
+ if (toolId != null) {
+ toolIdToContext.remove(toolId);
+ }
+ }
+
+ /**
+ * 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/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<>());
+ }
+}
From 31063cdf1035d83d7dd8690a6cee1522d7dfbb3d Mon Sep 17 00:00:00 2001
From: wuji1428 <2246065079@qq.com>
Date: Wed, 4 Feb 2026 19:54:48 +0800
Subject: [PATCH 2/9] feat(SubAgentContext): Adds SubAgentContext management
configuration item.
---
.../core/state/StatePersistence.java | 28 +-
.../core/tool/subagent/SubAgentContext.java | 311 +++++++
.../core/state/StatePersistenceTest.java | 2 +-
.../tool/subagent/SubAgentContextTest.java | 758 ++++++++++++++++++
4 files changed, 1093 insertions(+), 6 deletions(-)
create mode 100644 agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentContext.java
create mode 100644 agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentContextTest.java
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/SubAgentContext.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentContext.java
new file mode 100644
index 000000000..a936e407a
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentContext.java
@@ -0,0 +1,311 @@
+/*
+ * 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 (!pendingStore.contains(toolId)) {
+ return Optional.empty();
+ }
+ SubAgentPendingContext pending =
+ new SubAgentPendingContext(
+ toolId,
+ pendingStore.getSessionId(toolId),
+ pendingStore.getPendingResults(toolId));
+ clearToolResult(toolId);
+ return Optional.of(pending);
+ }
+
+ /**
+ * Clears the pending context for a tool.
+ *
+ * This removes both the session ID and all pending results for the tool.
+ *
+ * @param toolId The tool ID
+ */
+ public void clearToolResult(String toolId) {
+ 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) {
+ this.pendingStore = null;
+ session.get(sessionKey, "subagent_context", SubAgentPendingStore.class)
+ .ifPresent(state -> this.pendingStore = state);
+ }
+}
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/SubAgentContextTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentContextTest.java
new file mode 100644
index 000000000..f89ae3700
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentContextTest.java
@@ -0,0 +1,758 @@
+/*
+ * 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.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 clear only pending results when specified")
+ void testClearPendingResults() {
+ context.setSessionId("tool-1", "session-1");
+ context.submitSubAgentResult("tool-1", createToolResultBlock("tool-1", "Result 1"));
+ context.setSessionId("tool-2", "session-2");
+
+ // Clear all pending results
+ context.clearToolResult("tool-1");
+
+ assertFalse(context.hasPendingResult("tool-1"));
+ assertTrue(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();
+ newContext.loadFrom(session, nonExistentKey);
+
+ // Context should remain empty
+ assertNull(newContext.getPendingStore());
+ }
+
+ @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<>());
+ }
+}
From 1951aeb18579042b38ed64fd3710dbf8a4c9adc6 Mon Sep 17 00:00:00 2001
From: wuji1428 <2246065079@qq.com>
Date: Wed, 4 Feb 2026 19:55:14 +0800
Subject: [PATCH 3/9] feat(SubAgentHook): Adds sub-agent hook classes and test
cases.
---
.../core/tool/subagent/SubAgentHook.java | 162 ++++++
.../core/tool/subagent/SubAgentHookTest.java | 531 ++++++++++++++++++
2 files changed, 693 insertions(+)
create mode 100644 agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentHook.java
create mode 100644 agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHookTest.java
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..56309400a
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentHook.java
@@ -0,0 +1,162 @@
+/*
+ * 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());
+ context.clearToolResult(toolUse.getId());
+
+ 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/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<>());
+ }
+}
From f77d4eb0157a3bd5514a2b4b21f75df69d7ea544 Mon Sep 17 00:00:00 2001
From: wuji1428 <2246065079@qq.com>
Date: Tue, 10 Feb 2026 15:45:35 +0800
Subject: [PATCH 4/9] refactor(SubAgent): handle conflict with main branch
---
.../core/tool/subagent/SubAgentConfig.java | 36 +
.../core/tool/subagent/SubAgentTool.java | 240 +++-
.../tool/subagent/SubAgentConfigTest.java | 35 +
.../core/tool/subagent/SubAgentHITLTest.java | 1108 +++++++++++++++++
.../core/tool/subagent/SubAgentToolTest.java | 508 +++++++-
5 files changed, 1884 insertions(+), 43 deletions(-)
create mode 100644 agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHITLTest.java
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/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/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/SubAgentHITLTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHITLTest.java
new file mode 100644
index 000000000..acffc6567
--- /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", 2, "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/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();
}
}
From 724e85baa61b47f38974a5ebc0c8c82fcf87c7a7 Mon Sep 17 00:00:00 2001
From: wuji1428 <2246065079@qq.com>
Date: Wed, 4 Feb 2026 19:57:06 +0800
Subject: [PATCH 5/9] feat(ReActAgent): Updates ReActAgent to support
SubAgentHITL
---
.../java/io/agentscope/core/ReActAgent.java | 186 +++++++++++++++++-
1 file changed, 181 insertions(+), 5 deletions(-)
diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
index 2de490fa0..5040c24cf 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 @@
* Reactive Streaming: Uses Project Reactor for non-blocking execution
* Hook System: Extensible hooks for monitoring and intercepting agent execution
* HITL Support: Human-in-the-loop via stopAgent() in PostReasoningEvent/PostActingEvent
+ * SubAgent HITL: Supports human-in-the-loop interactions for sub-agents via SubAgentTool
* Structured Output: StructuredOutputCapableAgent provides type-safe output generation
*
*
@@ -141,6 +145,7 @@ public class ReActAgent extends StructuredOutputCapableAgent {
private final PlanNotebook planNotebook;
private final ToolExecutionContext toolExecutionContext;
private final StatePersistence statePersistence;
+ private final SubAgentContext subAgentContext;
// ==================== Constructor ====================
@@ -165,6 +170,7 @@ private ReActAgent(Builder builder, Toolkit agentToolkit) {
builder.statePersistence != null
? builder.statePersistence
: StatePersistence.all();
+ this.subAgentContext = builder.subAgentContext;
}
// ==================== New StateModule API ====================
@@ -180,6 +186,7 @@ private ReActAgent(Builder builder, Toolkit agentToolkit) {
* Memory messages (if memoryManaged is true)
* Toolkit activeGroups (if toolkitManaged is true)
* PlanNotebook state (if planNotebookManaged is true)
+ * 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: true)
+ * @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.
*
From 16c7aed19a6642e69a68dc4ec9cdb386e3ec1ab9 Mon Sep 17 00:00:00 2001
From: wuji1428 <2246065079@qq.com>
Date: Wed, 4 Feb 2026 19:57:27 +0800
Subject: [PATCH 6/9] test(HITLBasicE2ETest): Updates test cases to adapt to
new API changes.
---
.../agentscope/core/e2e/HITLBasicE2ETest.java | 105 ++++++++++++++++++
1 file changed, 105 insertions(+)
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));
+ }
+ }
}
From 5807ee10cb60ab1927161ffd58fee790e4a03e90 Mon Sep 17 00:00:00 2001
From: wuji1428 <2246065079@qq.com>
Date: Wed, 4 Feb 2026 19:58:22 +0800
Subject: [PATCH 7/9] docs (agent-as-tool): Add supporting documentation for
Hi-Total Interaction (HITL) with sub-agents.
---
docs/en/multi-agent/agent-as-tool.md | 110 ++++++++++++++++++++++++---
docs/zh/multi-agent/agent-as-tool.md | 87 +++++++++++++++++++++
2 files changed, 186 insertions(+), 11 deletions(-)
diff --git a/docs/en/multi-agent/agent-as-tool.md b/docs/en/multi-agent/agent-as-tool.md
index dea6f5a43..28782eb3f 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.submitSubAgentResult(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.submitSubAgentResult(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..e0705e1d3 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.submitSubAgentResult(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.submitSubAgentResult(String, List)` — 批量提交工具结果
\ No newline at end of file
From e0c4b3555d204fb7ff078c79ddb02c81cd9fb9bc Mon Sep 17 00:00:00 2001
From: wuji1428 <2246065079@qq.com>
Date: Thu, 5 Feb 2026 00:11:31 +0800
Subject: [PATCH 8/9] refactor(SubAgent): Optimization 1. The `remove` method
in the `SubAgentPendingStore` class now returns the removed object instead of
void type. 2. Correspondingly, the places where this method is called in the
`SubAgentContext` class have been updated, directly using the returned result
for subsequent processing. 3. At the same time, some unnecessary method
definitions, such as `clearToolResult`, have been cleaned up. 4. The relevant
documentation and test cases have been updated to match the new API changes.
These changes have made the code more concise and easier to maintain.
---
.../java/io/agentscope/core/ReActAgent.java | 6 ++---
.../core/tool/subagent/SubAgentContext.java | 22 ++-----------------
.../core/tool/subagent/SubAgentHook.java | 1 -
.../tool/subagent/SubAgentPendingStore.java | 5 +++--
.../tool/subagent/SubAgentContextTest.java | 21 +++++-------------
.../core/tool/subagent/SubAgentHITLTest.java | 2 +-
docs/en/multi-agent/agent-as-tool.md | 4 ++--
docs/zh/multi-agent/agent-as-tool.md | 4 ++--
8 files changed, 18 insertions(+), 47 deletions(-)
diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
index 5040c24cf..eff6e1276 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
@@ -286,7 +286,7 @@ public void submitSubAgentResult(String subAgentToolId, ToolResultBlock pendingR
if (!isEnableSubAgentHITL()) {
throw new IllegalStateException(
"SubAgent HITL is not enabled. Please enable it via"
- + " builder.enableSubAgentHitl(true)");
+ + " builder.enableSubAgentHITL(true)");
}
if (pendingResult == null) {
@@ -311,7 +311,7 @@ public void submitSubAgentResults(String subAgentToolId, List p
if (!isEnableSubAgentHITL()) {
throw new IllegalStateException(
"SubAgent HITL is not enabled. Please enable it via"
- + " builder.enableSubAgentHitl(true)");
+ + " builder.enableSubAgentHITL(true)");
}
if (pendingResults == null || pendingResults.isEmpty()) {
@@ -1489,7 +1489,7 @@ public Builder subAgentContext(SubAgentContext subAgentContext) {
* 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: true)
+ * @param enableSubAgentHITL true to enable sub-agent HITL support (default: false)
* @return This builder instance for method chaining
* @see SubAgentHook
*/
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
index a936e407a..d4918b87c 100644
--- 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
@@ -152,27 +152,10 @@ public Optional getSessionId(String toolId) {
* @return An Optional containing the pending context, or empty if none exist
*/
public Optional consumePendingResult(String toolId) {
- if (!pendingStore.contains(toolId)) {
+ if (toolId == null) {
return Optional.empty();
}
- SubAgentPendingContext pending =
- new SubAgentPendingContext(
- toolId,
- pendingStore.getSessionId(toolId),
- pendingStore.getPendingResults(toolId));
- clearToolResult(toolId);
- return Optional.of(pending);
- }
-
- /**
- * Clears the pending context for a tool.
- *
- * This removes both the session ID and all pending results for the tool.
- *
- * @param toolId The tool ID
- */
- public void clearToolResult(String toolId) {
- pendingStore.remove(toolId);
+ return Optional.ofNullable(pendingStore.remove(toolId));
}
/**
@@ -304,7 +287,6 @@ public void saveTo(Session session, SessionKey sessionKey) {
*/
@Override
public void loadFrom(Session session, SessionKey sessionKey) {
- this.pendingStore = null;
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
index 56309400a..5ec6268dd 100644
--- 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
@@ -138,7 +138,6 @@ private Mono handlePreActing(PreActingEvent event) {
metadata.put(PREVIOUS_TOOL_RESULT, pendingResult.get());
Map newInput = new HashMap<>(toolUse.getInput());
newInput.put("session_id", pendingContext.get().sessionId());
- context.clearToolResult(toolUse.getId());
ToolUseBlock modifiedToolUse =
ToolUseBlock.builder()
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
index eea6a16f7..9229206c4 100644
--- 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
@@ -200,10 +200,11 @@ public void addResults(String toolId, List result) {
*
* @param toolId The tool ID
*/
- public void remove(String toolId) {
+ public SubAgentPendingContext remove(String toolId) {
if (toolId != null) {
- toolIdToContext.remove(toolId);
+ return toolIdToContext.remove(toolId);
}
+ return null;
}
/**
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
index f89ae3700..4c16d1004 100644
--- 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
@@ -16,6 +16,7 @@
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;
@@ -269,20 +270,6 @@ void testClear() {
assertFalse(context.getSessionId("tool-2").isPresent());
}
- @Test
- @DisplayName("Should clear only pending results when specified")
- void testClearPendingResults() {
- context.setSessionId("tool-1", "session-1");
- context.submitSubAgentResult("tool-1", createToolResultBlock("tool-1", "Result 1"));
- context.setSessionId("tool-2", "session-2");
-
- // Clear all pending results
- context.clearToolResult("tool-1");
-
- assertFalse(context.hasPendingResult("tool-1"));
- assertTrue(context.getSessionId("tool-2").isPresent());
- }
-
@Test
@DisplayName("Should maintain state after multiple operations")
void testStateAfterMultipleOperations() {
@@ -699,10 +686,12 @@ void testLoadFromNonExistentSession() {
// Should not throw exception
SubAgentContext newContext = new SubAgentContext();
- newContext.loadFrom(session, nonExistentKey);
+ SubAgentPendingStore oldStore = newContext.getPendingStore();
+ newContext.loadFrom(session, nonExistentKey);
+ SubAgentPendingStore newStore = newContext.getPendingStore();
// Context should remain empty
- assertNull(newContext.getPendingStore());
+ assertSame(oldStore, newStore);
}
@Test
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
index acffc6567..6cae3288d 100644
--- 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
@@ -308,7 +308,7 @@ void testResumeFromPausedState() {
registerSubAgent(mockSubAgent);
MockModel mainModel =
- createSequentialToolModel("call_pausablesubagent", 2, "All done!");
+ createSequentialToolModel("call_pausablesubagent", 1, "All done!");
ReActAgent mainAgent = createHitlAgent(mainModel, context);
Msg response1 = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT);
diff --git a/docs/en/multi-agent/agent-as-tool.md b/docs/en/multi-agent/agent-as-tool.md
index 28782eb3f..0d3cb13a5 100644
--- a/docs/en/multi-agent/agent-as-tool.md
+++ b/docs/en/multi-agent/agent-as-tool.md
@@ -213,7 +213,7 @@ while (response.getGenerateReason() == GenerateReason.TOOL_SUSPENDED) {
.map(t -> ToolResultBlock.of(t.getId(), t.getName(),
TextBlock.builder().text("Operation cancelled").build()))
.toList();
- mainAgent.submitSubAgentResult(resultBlock.getId(), cancelResults);
+ mainAgent.submitSubAgentResults(resultBlock.getId(), cancelResults);
}
response = mainAgent.call().block();
}
@@ -238,4 +238,4 @@ System.out.println(response.getTextContent());
**Resume methods**:
- `mainAgent.call()` — Continue executing pending tools
- `mainAgent.submitSubAgentResult(String, ToolResultBlock)` — Submit single tool result
-- `mainAgent.submitSubAgentResult(String, List)` — Submit multiple tool results
\ No newline at end of file
+- `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 e0705e1d3..ebd8b742e 100644
--- a/docs/zh/multi-agent/agent-as-tool.md
+++ b/docs/zh/multi-agent/agent-as-tool.md
@@ -213,7 +213,7 @@ while (response.getGenerateReason() == GenerateReason.TOOL_SUSPENDED) {
.map(t -> ToolResultBlock.of(t.getId(), t.getName(),
TextBlock.builder().text("操作已取消").build()))
.toList();
- mainAgent.submitSubAgentResult(resultBlock.getId(), cancelResults);
+ mainAgent.submitSubAgentResults(resultBlock.getId(), cancelResults);
}
response = mainAgent.call().block();
}
@@ -237,4 +237,4 @@ System.out.println(response.getTextContent());
**恢复方法**:
- `ReActAgent.submitSubAgentResult(String, ToolResultBlock)` — 提交单个工具结果
-- `ReActAgent.submitSubAgentResult(String, List)` — 批量提交工具结果
\ No newline at end of file
+- `ReActAgent.submitSubAgentResults(String, List)` — 批量提交工具结果
\ No newline at end of file
From 2fcc30e8484e6892513a9224d2f3ab8c3e0385d6 Mon Sep 17 00:00:00 2001
From: wuji1428 <2246065079@qq.com>
Date: Tue, 10 Feb 2026 16:36:44 +0800
Subject: [PATCH 9/9] refactor(AgentBase): fix javadoc error
---
.../io/agentscope/core/tool/subagent/SubAgentContext.java | 4 ++--
.../core/tool/subagent/SubAgentPendingContext.java | 4 ++--
.../core/tool/subagent/SubAgentPendingStore.java | 8 ++++----
3 files changed, 8 insertions(+), 8 deletions(-)
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
index d4918b87c..a57130ac9 100644
--- 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
@@ -38,7 +38,7 @@
* 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:
+ * Usage Pattern:
* {@code
* SubAgentContext context = new SubAgentContext();
*
@@ -63,7 +63,7 @@
* context.loadFrom(session, sessionKey);
* }
*
- * Thread Safety:
+ * Thread Safety:
* This class is thread-safe and delegates all state management to the thread-safe
* {@link SubAgentPendingStore}.
*/
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
index 1219f1857..246d1f3ab 100644
--- 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
@@ -31,11 +31,11 @@
* The list of pending tool results that need to be injected when resuming
*
*
- * Immutability:
+ * 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:
+ * Usage:
* This class is typically created by {@link SubAgentPendingStore} when consuming pending state
* and is used to pass complete context information between components.
*
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
index 9229206c4..022d93233 100644
--- 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
@@ -30,12 +30,12 @@
* can be added for that tool. This ensures proper lifecycle management and prevents orphaned
* results without associated sessions.
*
- * Storage Structure:
+ * 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:
+ * Key Features:
*
* - Thread-safe storage using ConcurrentHashMap
* - SessionId-first constraint: results can only be added after session ID is registered
@@ -45,7 +45,7 @@
* - Single data source ensures consistency
*
*
- * Usage Pattern:
+ * Usage Pattern:
* {@code
* // 1. Register session ID first (required) - creates a pending context
* state.setSessionId("tool-123", "session-abc");
@@ -62,7 +62,7 @@
* state.remove("tool-123");
* }
*
- * Thread Safety:
+ * Thread Safety:
* This class is thread-safe and can be used concurrently from multiple threads.
* All operations are atomic at the method level.
*/