diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 2de490fa0..eff6e1276 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -64,6 +64,8 @@ import io.agentscope.core.tool.ToolExecutionContext; import io.agentscope.core.tool.ToolResultMessageBuilder; import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.tool.subagent.SubAgentContext; +import io.agentscope.core.tool.subagent.SubAgentHook; import io.agentscope.core.util.MessageUtils; import java.util.ArrayList; import java.util.Comparator; @@ -71,6 +73,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -91,6 +94,7 @@ *
  • 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: false) + * @return This builder instance for method chaining + * @see SubAgentHook + */ + public Builder enableSubAgentHITL(boolean enableSubAgentHITL) { + this.enableSubAgentHITL = enableSubAgentHITL; + return this; + } + /** * Builds and returns a new ReActAgent instance with the configured settings. * @@ -1379,9 +1532,32 @@ public ReActAgent build() { configureSkillBox(agentToolkit); } + // Configure SubAgent HITL support if enabled + if (enableSubAgentHITL) { + configureSubAgentHitl(); + } + return new ReActAgent(this, agentToolkit); } + /** + * Configures SubAgent HITL (Human-in-the-Loop) support. + * + *

    This method automatically: + *

    + */ + private void configureSubAgentHitl() { + if (this.subAgentContext == null) { + this.subAgentContext = new SubAgentContext(); + } + + // Add SubAgentHook with the context + hooks.add(new SubAgentHook(this.subAgentContext)); + } + /** * Configures long-term memory based on the selected mode. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java b/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java index 52f18b23e..24196229c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java +++ b/agentscope-core/src/main/java/io/agentscope/core/state/StatePersistence.java @@ -63,6 +63,7 @@ * @param memoryManaged whether to manage Memory component state * @param toolkitManaged whether to manage Toolkit activeGroups state * @param planNotebookManaged whether to manage PlanNotebook state + * @param subAgentContextManaged whether to manage SubAgentContext state * @param statefulToolsManaged whether to manage stateful Tool states * @see StateModule * @see io.agentscope.core.ReActAgent @@ -71,21 +72,22 @@ public record StatePersistence( boolean memoryManaged, boolean toolkitManaged, boolean planNotebookManaged, - boolean statefulToolsManaged) { + boolean statefulToolsManaged, + boolean subAgentContextManaged) { /** Default configuration: manage all components. */ public static StatePersistence all() { - return new StatePersistence(true, true, true, true); + return new StatePersistence(true, true, true, true, true); } /** Don't manage any components (user fully controls). */ public static StatePersistence none() { - return new StatePersistence(false, false, false, false); + return new StatePersistence(false, false, false, false, false); } /** Only manage Memory component. */ public static StatePersistence memoryOnly() { - return new StatePersistence(true, false, false, false); + return new StatePersistence(true, false, false, false, false); } /** @@ -103,6 +105,7 @@ public static class Builder { private boolean memoryManaged = true; private boolean toolkitManaged = true; private boolean planNotebookManaged = true; + private boolean subAgentContextManaged = true; private boolean statefulToolsManaged = true; /** @@ -138,6 +141,17 @@ public Builder planNotebookManaged(boolean managed) { return this; } + /** + * Sets whether to manage SubAgentContext state. + * + * @param managed true to manage SubAgentContext state, false to let user manage + * @return This builder for method chaining + */ + public Builder subAgentContextManaged(boolean managed) { + this.subAgentContextManaged = managed; + return this; + } + /** * Sets whether to manage stateful Tool states. * @@ -156,7 +170,11 @@ public Builder statefulToolsManaged(boolean managed) { */ public StatePersistence build() { return new StatePersistence( - memoryManaged, toolkitManaged, planNotebookManaged, statefulToolsManaged); + memoryManaged, + toolkitManaged, + planNotebookManaged, + statefulToolsManaged, + subAgentContextManaged); } } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java index 66b3792fa..7738aaf3c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentConfig.java @@ -65,6 +65,7 @@ public class SubAgentConfig { private final boolean forwardEvents; private final StreamOptions streamOptions; private final Session session; + private final boolean enableHITL; private SubAgentConfig(Builder builder) { this.toolName = builder.toolName; @@ -72,6 +73,7 @@ private SubAgentConfig(Builder builder) { this.forwardEvents = builder.forwardEvents; this.streamOptions = builder.streamOptions; this.session = builder.session != null ? builder.session : new InMemorySession(); + this.enableHITL = builder.enableHITL; } /** @@ -148,6 +150,22 @@ public Session getSession() { return session; } + /** + * Gets whether HITL (Human-in-the-Loop) support is enabled. + * + *

    When enabled, the sub-agent tool will: + *

    + * + * @return true if HITL support is enabled (default: false) + */ + public boolean isEnableHITL() { + return enableHITL; + } + /** Builder for SubAgentConfig. */ public static class Builder { private String toolName; @@ -155,6 +173,7 @@ public static class Builder { private boolean forwardEvents = true; private StreamOptions streamOptions; private Session session; + private boolean enableHITL = false; private Builder() {} @@ -229,6 +248,23 @@ public Builder session(Session session) { return this; } + /** + * Sets whether HITL (Human-in-the-Loop) support is enabled. + * + *

    When enabled, the sub-agent tool will detect when the sub-agent is suspended + * waiting for user input and return a suspended result that can be resumed later. + * + *

    This is useful when the sub-agent uses tools that require human confirmation + * or external execution. + * + * @param enableHITL true to enable HITL support + * @return This builder + */ + public Builder enableHITL(boolean enableHITL) { + this.enableHITL = enableHITL; + return this; + } + /** * Builds the configuration. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentContext.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentContext.java new file mode 100644 index 000000000..a57130ac9 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentContext.java @@ -0,0 +1,293 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.subagent; + +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.session.Session; +import io.agentscope.core.state.SessionKey; +import io.agentscope.core.state.StateModule; +import java.util.List; +import java.util.Optional; + +/** + * Sub-agent context for managing tool call results during HITL (Human-in-the-Loop) interactions. + * + *

    This class provides business logic for handling sub-agent results, including: + * + *

    + * + *

    The actual storage and persistence of pending states is handled by {@link SubAgentPendingStore}, + * which delegates to {@link SubAgentPendingStore} that implements {@link io.agentscope.core.state.State} for direct serialization support. This class follows a sessionId-first + * constraint: a session ID must be registered before any results can be added for that tool. + * + *

    Usage Pattern:

    + *
    {@code
    + * SubAgentContext context = new SubAgentContext();
    + *
    + * // 1. Store session ID first (required before adding results)
    + * context.setSessionId("tool-123", "session-abc");
    + *
    + * // 2. Submit sub-agent result (can only be added after session ID is set)
    + * context.submitSubAgentResult("tool-123", result);
    + *
    + * // 3. Check if result is from sub-agent
    + * if (SubAgentContext.isSubAgentResult(result)) {
    + *     Optional sessionId = SubAgentContext.extractSessionId(result);
    + * }
    + *
    + * // 4. Consume pending results
    + * Optional pending = context.consumePendingResult("tool-123");
    + *
    + * // 5. Save state to session
    + * context.saveTo(session, sessionKey);
    + *
    + * // 6. Later, restore state
    + * context.loadFrom(session, sessionKey);
    + * }
    + * + *

    Thread Safety:

    + * This class is thread-safe and delegates all state management to the thread-safe + * {@link SubAgentPendingStore}. + */ +public class SubAgentContext implements StateModule { + + /** Metadata key for sub-agent session ID in ToolResultBlock. */ + public static final String METADATA_SUBAGENT_SESSION_ID = "subagent_session_id"; + + /** Metadata key for sub-agent suspend type in ToolResultBlock. */ + public static final String METADATA_GENERATE_REASON = "subagent_generate_reason"; + + /** The pending store manager. */ + private SubAgentPendingStore pendingStore; + + /** + * Creates a new SubAgentContext with an empty pending state. + */ + public SubAgentContext() { + this.pendingStore = new SubAgentPendingStore(); + } + + /** + * Gets the pending state manager. + * + *

    This provides access to the underlying state management for advanced use cases. + * Use with caution as direct modifications may bypass business logic validation. + * + * @return The pending state manager + */ + public SubAgentPendingStore getPendingStore() { + return pendingStore; + } + + /** + * Stores the session ID for a tool. + * + *

    This method must be called before any results can be added for the tool. + * This enforces the sessionId-first constraint to ensure proper lifecycle management. + * + * @param toolId The tool ID + * @param sessionId The session ID + */ + public void setSessionId(String toolId, String sessionId) { + if (toolId == null) { + throw new IllegalArgumentException("toolId cannot be null"); + } + String existingSessionId = pendingStore.getSessionId(toolId); + if (existingSessionId != null && existingSessionId.equals(sessionId)) { + return; + } + pendingStore.setSessionId(toolId, sessionId); + } + + /** + * Gets pending tool results for a tool. + * + * @param toolId The sub-agent tool ID + * @return An Optional containing the pending results, or empty if none exist + */ + public Optional> getPendingResult(String toolId) { + List results = pendingStore.getPendingResults(toolId); + return Optional.ofNullable(results.isEmpty() ? null : results); + } + + /** + * Gets the session ID for a tool. + * + * @param toolId The tool ID + * @return An Optional containing the session ID, or empty if none exist + */ + public Optional getSessionId(String toolId) { + if (toolId == null) { + throw new IllegalArgumentException("toolId cannot be null"); + } + return Optional.ofNullable(pendingStore.getSessionId(toolId)); + } + + /** + * Consumes and removes the pending context for a tool. + * + *

    This method atomically retrieves and removes all pending state for the tool, + * including both the session ID and pending results. This is typically called when + * resuming a suspended sub-agent execution. + * + * @param toolId The tool ID + * @return An Optional containing the pending context, or empty if none exist + */ + public Optional consumePendingResult(String toolId) { + if (toolId == null) { + return Optional.empty(); + } + return Optional.ofNullable(pendingStore.remove(toolId)); + } + + /** + * Checks if a tool has any pending results. + * + * @param toolId The tool ID + * @return true if the tool has pending results, false otherwise + */ + public boolean hasPendingResult(String toolId) { + return pendingStore.hasPendingResults(toolId); + } + + /** + * Clears all pending data for all tools. + * + *

    This removes all session IDs and pending results from the context. + */ + public void clear() { + pendingStore.clearAll(); + } + + /** + * Submit sub-agent tool call results and store them. + * + *

    This method should be called when users provide confirmation or results for suspended sub-agents. + * This is a convenience method that wraps a single result into a list and delegates to the batch submission method. + * + * @param subAgentToolId The sub-agent tool ID + * @param pendingResult The sub-agent tool result + * @throws IllegalArgumentException if the result is null + */ + public void submitSubAgentResult(String subAgentToolId, ToolResultBlock pendingResult) { + if (pendingResult == null) { + throw new IllegalArgumentException("result cannot be null"); + } + submitSubAgentResults(subAgentToolId, List.of(pendingResult)); + } + + /** + * Submit multiple sub-agent tool call results and store them. + * + *

    This is the core method for submitting sub-agent results. It validates and stores multiple + * results for suspended sub-agents. Tools must have registered session IDs before adding results + * (enforced through sessionId-first constraint). + * + * @param subAgentToolId The sub-agent tool ID + * @param pendingResults A list of sub-agent tool results + * @throws IllegalArgumentException If the results list is null or empty, or if the tool does not exist in pending state + */ + public void submitSubAgentResults(String subAgentToolId, List pendingResults) { + if (pendingResults == null || pendingResults.isEmpty()) { + throw new IllegalArgumentException("pendingResults cannot be null or empty"); + } + + if (!pendingStore.contains(subAgentToolId)) { + throw new IllegalArgumentException("No pending result for tool: " + subAgentToolId); + } + + pendingStore.addResults(subAgentToolId, pendingResults); + } + + /** + * Extracts the sub-agent session ID from a tool result block. + * + *

    This is a static utility method that can be called without a SubAgentContext instance. + * + * @param result The tool result block + * @return An Optional containing the session ID if present, otherwise empty + */ + public static Optional extractSessionId(ToolResultBlock result) { + if (result == null || result.getMetadata() == null) { + return Optional.empty(); + } + Object sessionId = result.getMetadata().get(METADATA_SUBAGENT_SESSION_ID); + return sessionId instanceof String ? Optional.of((String) sessionId) : Optional.empty(); + } + + /** + * Gets the generation reason for a sub-agent. + * + *

    This method retrieves the generation reason from the tool result and returns the corresponding enum value. + * If no valid generation reason is found, it defaults to MODEL_STOP. + * + * @param toolResult The tool execution result + * @return Returns the generation reason, or MODEL_STOP if no valid reason is found + */ + public static GenerateReason getSubAgentGenerateReason(ToolResultBlock toolResult) { + Object reason = toolResult.getMetadata().get(SubAgentContext.METADATA_GENERATE_REASON); + if (reason instanceof GenerateReason) { + return (GenerateReason) reason; + } + return GenerateReason.MODEL_STOP; + } + + /** + * Checks if the tool result originates from a sub-agent. + * + * @param result The tool result block + * @return True if the result comes from a sub-agent, false otherwise + */ + public static boolean isSubAgentResult(ToolResultBlock result) { + return extractSessionId(result).isPresent(); + } + + // ==================== StateModule Implementation ==================== + + /** + * Saves the context state to a session. + * + *

    Delegates to the pending state manager for actual persistence. + * The pending state is serialized directly as it implements {@link io.agentscope.core.state.State}. + * + * @param session The session object + * @param sessionKey The session identifier + */ + @Override + public void saveTo(Session session, SessionKey sessionKey) { + session.save(sessionKey, "subagent_context", pendingStore); + } + + /** + * Restores the context state from a session. + * + *

    Delegates to the pending state manager for actual restoration. + * The existing pending state is replaced with the loaded state. + * + * @param session The session object + * @param sessionKey The session identifier + */ + @Override + public void loadFrom(Session session, SessionKey sessionKey) { + session.get(sessionKey, "subagent_context", SubAgentPendingStore.class) + .ifPresent(state -> this.pendingStore = state); + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentHook.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentHook.java new file mode 100644 index 000000000..5ec6268dd --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentHook.java @@ -0,0 +1,161 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.subagent; + +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PreActingEvent; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Hook for injecting pending sub-agent tool results during sub-agent execution. + * + *

    This hook works in conjunction with {@link SubAgentContext} to enable resuming + * suspended sub-agent executions. When a sub-agent is suspended and then resumed, + * this hook intercepts the tool call and injects the previously pending results. + * + *

    The hook operates during the PreActingEvent phase: + *

    + * + *

    Result injection mechanism: + *

    + * + *

    Example usage: + *

    {@code
    + * SubAgentContext context = new SubAgentContext();
    + * SubAgentHook hook = new SubAgentHook(context);
    + *
    + * ReActAgent agent = ReActAgent.builder()
    + *     .name("MainAgent")
    + *     .hook(hook)
    + *     .build();
    + * }
    + */ +public class SubAgentHook implements Hook { + + private static final Logger logger = LoggerFactory.getLogger(SubAgentHook.class); + + private final SubAgentContext context; + + public static final String PREVIOUS_TOOL_RESULT = "previous_tool_result"; + + /** + * Creates a new SubAgentHook with the given context. + * + * @param context The SubAgentContext for managing pending results + */ + public SubAgentHook(SubAgentContext context) { + this.context = context; + } + + /** + * Gets the SubAgentContext associated with this hook. + * + * @return The SubAgentContext + */ + public SubAgentContext getContext() { + return context; + } + + @Override + public Mono onEvent(T event) { + if (event instanceof PreActingEvent preActingEvent) { + return handlePreActing(preActingEvent).map(e -> (T) e); + } + return Mono.just(event); + } + + /** + * Handles PreActingEvent to inject pending sub-agent results + * + *

    This method checks whether the called tool has any pending results in the context. + * If pending results exist, it injects them into the tool use block's metadata + * and updates the tool input with the session_id from the pending context. + * + *

    Injection process: + *

      + *
    • Retrieves pending context by tool ID from {@link SubAgentContext}
    • + *
    • Stores pending results in metadata under {@link #PREVIOUS_TOOL_RESULT}
    • + *
    • Updates tool input with session_id for context tracking
    • + *
    • Clears the pending result from context after injection
    • + *
    + * + * @param event PreActingEvent + * @return A Mono containing the possibly modified event + */ + private Mono handlePreActing(PreActingEvent event) { + ToolUseBlock toolUse = event.getToolUse(); + if (toolUse == null) { + return Mono.just(event); + } + + // Extract session_id from tool input + Map input = toolUse.getInput(); + if (input == null) { + return Mono.just(event); + } + + // Check if there's a pending result for this session + Optional pendingContext = + context.consumePendingResult(toolUse.getId()); + Optional> pendingResult = + pendingContext.map(SubAgentPendingContext::pendingResults); + if (pendingResult.isEmpty()) { + return Mono.just(event); + } + + // Inject the result into tool use input (create new mutable maps) + Map metadata = new HashMap<>(toolUse.getMetadata()); + metadata.put(PREVIOUS_TOOL_RESULT, pendingResult.get()); + Map newInput = new HashMap<>(toolUse.getInput()); + newInput.put("session_id", pendingContext.get().sessionId()); + + ToolUseBlock modifiedToolUse = + ToolUseBlock.builder() + .id(toolUse.getId()) + .name(toolUse.getName()) + .input(newInput) + .content(toolUse.getContent()) + .metadata(metadata) + .build(); + + event.setToolUse(modifiedToolUse); + + return Mono.just(event); + } + + @Override + public int priority() { + // High priority to ensure result injection happens before tool execution + return 10; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingContext.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingContext.java new file mode 100644 index 000000000..246d1f3ab --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingContext.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.subagent; + +import io.agentscope.core.message.ToolResultBlock; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents the pending state of a single sub-agent tool during HITL (Human-in-the-Loop) interactions. + * + *

    This immutable record encapsulates the complete state of a sub-agent tool that has been suspended, + * containing all information needed to resume execution: + * + *

      + *
    • The tool ID that identifies the specific sub-agent tool
    • + *
    • The session ID of the sub-agent's execution context
    • + *
    • The list of pending tool results that need to be injected when resuming
    • + *
    + * + *

    Immutability:

    + * This record is immutable. The pendingResults list is defensively copied during construction + * to prevent external modifications. This ensures thread-safety and predictable behavior. + * + *

    Usage:

    + * This class is typically created by {@link SubAgentPendingStore} when consuming pending state + * and is used to pass complete context information between components. + * + * @param toolId The tool ID that identifies the sub-agent tool + * @param sessionId The session ID of the sub-agent's execution context + * @param pendingResults The list of pending tool results that need to be injected when resuming + */ +public record SubAgentPendingContext( + String toolId, String sessionId, List pendingResults) { + + /** + * Creates a new SubAgentPendingContext with defensive copying of the results list. + * + *

    The compact constructor ensures that the pendingResults list is defensively copied + * to prevent external modifications after construction. + * + * @param toolId The tool ID + * @param sessionId The session ID + * @param pendingResults The list of pending results (will be copied) + */ + public SubAgentPendingContext { + if (pendingResults == null) { + pendingResults = new ArrayList<>(); + } else { + pendingResults = new ArrayList<>(pendingResults); + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingStore.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingStore.java new file mode 100644 index 000000000..022d93233 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentPendingStore.java @@ -0,0 +1,254 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.subagent; + +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.state.State; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages the pending state of multiple sub-agent tools during HITL (Human-in-the-Loop) interactions. + * + *

    This class is responsible for storing and managing pending states for sub-agent tools. + * It enforces a sessionId-first constraint: a session ID must be registered before any results + * can be added for that tool. This ensures proper lifecycle management and prevents orphaned + * results without associated sessions. + * + *

    Storage Structure:

    + * The storage is organized by tool ID: {@code Map}. + * Each tool ID maps to a complete context containing the tool ID, session ID, and pending results. + * This single-source-of-truth design ensures data consistency. + * + *

    Key Features:

    + *
      + *
    • Thread-safe storage using ConcurrentHashMap
    • + *
    • SessionId-first constraint: results can only be added after session ID is registered
    • + *
    • Defensive copying to prevent external modifications
    • + *
    • Simple CRUD operations for managing pending states
    • + *
    • Implements {@link State} for direct serialization support
    • + *
    • Single data source ensures consistency
    • + *
    + * + *

    Usage Pattern:

    + *
    {@code
    + * // 1. Register session ID first (required) - creates a pending context
    + * state.setSessionId("tool-123", "session-abc");
    + *
    + * // 2. Add results to the registered session
    + * state.addResult("tool-123", result1);
    + * state.addResult("tool-123", result2);
    + *
    + * // 3. Retrieve data
    + * String sessionId = state.getSessionId("tool-123");
    + * List results = state.getPendingResults("tool-123");
    + *
    + * // 4. Clean up when done
    + * state.remove("tool-123");
    + * }
    + * + *

    Thread Safety:

    + * This class is thread-safe and can be used concurrently from multiple threads. + * All operations are atomic at the method level. + */ +public class SubAgentPendingStore implements State { + + /** + * Map of tool IDs to their pending contexts. + * This is the single source of truth for all pending state data. + * Each tool ID maps to a complete context containing the tool ID, session ID, and pending results. + */ + private final Map toolIdToContext = new ConcurrentHashMap<>(); + + /** + * Gets the session ID for a tool. + * + * @param toolId The tool ID + * @return The session ID, or null if not found + */ + public String getSessionId(String toolId) { + SubAgentPendingContext context = toolIdToContext.get(toolId); + return context == null ? null : context.sessionId(); + } + + /** + * Sets or updates the session ID for a tool. + * + *

    This method must be called before any results can be added for the tool. + * It creates a new SubAgentPendingContext containing the tool ID and session ID. + * If the tool already has a context, it will be replaced. + * + * @param toolId The tool ID + * @param sessionId The session ID + * @throws IllegalArgumentException if toolId or sessionId is null + */ + public void setSessionId(String toolId, String sessionId) { + if (toolId == null) { + throw new IllegalArgumentException("toolId cannot be null"); + } + if (sessionId == null) { + throw new IllegalArgumentException("sessionId cannot be null"); + } + + // Create or update the context for this tool + SubAgentPendingContext context = + new SubAgentPendingContext(toolId, sessionId, new ArrayList<>()); + toolIdToContext.put(toolId, context); + } + + /** + * Gets the pending tool results for a tool. + * + *

    Returns a defensive copy of the results list to prevent external modifications. + * + * @param toolId The tool ID + * @return A defensive copy of the pending results list, or empty list if not found + */ + public List getPendingResults(String toolId) { + SubAgentPendingContext context = toolIdToContext.get(toolId); + if (context == null) { + return new ArrayList<>(); + } + + List results = context.pendingResults(); + return results == null ? new ArrayList<>() : new ArrayList<>(results); + } + + /** + * Adds a single result to the pending results for a tool. + * + *

    The tool must have a registered session ID before results can be added. + * + * @param toolId The tool ID + * @param result The result to add + * @throws IllegalStateException if the tool does not have a registered session ID + * @throws IllegalArgumentException if toolId or result is null + */ + public void addResult(String toolId, ToolResultBlock result) { + if (toolId == null) { + throw new IllegalArgumentException("toolId cannot be null"); + } + if (result == null) { + throw new IllegalArgumentException("result cannot be null"); + } + SubAgentPendingContext existingContext = toolIdToContext.get(toolId); + if (existingContext == null) { + throw new IllegalStateException( + "Cannot add result for tool '" + + toolId + + "' without a registered session ID. " + + "Call setSessionId() first."); + } + + addResults(toolId, List.of(result)); + } + + /** + * Adds multiple results to the pending results for a tool. + * + *

    The tool must have a registered session ID before results can be added. + * + * @param toolId The tool ID + * @param result The results to add + * @throws IllegalStateException if the tool does not have a registered session ID + * @throws IllegalArgumentException if toolId or result is null + */ + public void addResults(String toolId, List result) { + if (toolId == null) { + throw new IllegalArgumentException("toolId cannot be null"); + } + if (result == null) { + throw new IllegalArgumentException("result cannot be null"); + } + SubAgentPendingContext existingContext = toolIdToContext.get(toolId); + if (existingContext == null) { + throw new IllegalStateException( + "Cannot add result for tool '" + + toolId + + "' without a registered session ID. " + + "Call setSessionId() first."); + } + + // Create new list with the added result + List newResults = new ArrayList<>(existingContext.pendingResults()); + newResults.addAll(result); + + // Create new context with updated results + SubAgentPendingContext newContext = + new SubAgentPendingContext( + existingContext.toolId(), existingContext.sessionId(), newResults); + toolIdToContext.put(toolId, newContext); + } + + /** + * Removes all pending data for a tool (both session ID and results). + * + * @param toolId The tool ID + */ + public SubAgentPendingContext remove(String toolId) { + if (toolId != null) { + return toolIdToContext.remove(toolId); + } + return null; + } + + /** + * Checks if there are any pending results. + * + * @return true if there are no pending results, false otherwise + */ + public boolean isEmpty() { + return toolIdToContext.isEmpty(); + } + + /** + * Checks if a tool has a registered session ID. + * + *

    This is the primary check for whether a tool is in the system. + * A tool must have a session ID before it can have results. + * + * @param toolId The tool ID + * @return true if the tool has a registered session ID, false otherwise + */ + public boolean contains(String toolId) { + return toolId != null && toolIdToContext.containsKey(toolId); + } + + /** + * Checks if a tool has any pending results. + * + * @param toolId The tool ID + * @return true if the tool has pending results, false otherwise + */ + public boolean hasPendingResults(String toolId) { + if (toolId == null) { + return false; + } + SubAgentPendingContext context = toolIdToContext.get(toolId); + return context != null && !context.pendingResults().isEmpty(); + } + + /** + * Clears all pending data for all tools. + * + *

    This removes all session IDs and all pending results from the state. + */ + public void clearAll() { + toolIdToContext.clear(); + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java index d0cd8d0f6..1d9cdaf68 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentTool.java @@ -15,23 +15,29 @@ */ package io.agentscope.core.tool.subagent; +import io.agentscope.core.ReActAgent; import io.agentscope.core.agent.Agent; import io.agentscope.core.agent.Event; import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.message.GenerateReason; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.session.Session; import io.agentscope.core.state.StateModule; import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.ToolCallParam; import io.agentscope.core.tool.ToolEmitter; import io.agentscope.core.util.JsonUtils; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; @@ -52,6 +58,13 @@ * existing one. *

  • {@code message} - Required. The message to send to the agent. * + * + *

    HITL Support (Human-in-the-Loop): + *

    */ 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: + *

    * * @param param The tool call parameters containing input and emitter @@ -126,14 +183,25 @@ private Mono executeConversation(ToolCallParam param) { (ctxView) -> { try { Map input = param.getInput(); + ToolUseBlock toolUseBlock = param.getToolUseBlock(); // Get or create session ID String sessionId = (String) input.get(PARAM_SESSION_ID); - boolean isNewSession = sessionId == null; + boolean isNewSession = sessionId == null || sessionId.trim().isEmpty(); if (isNewSession) { sessionId = UUID.randomUUID().toString(); } + // Check if there's an injected result from SubAgentHook + if (config.isEnableHITL() + && toolUseBlock + .getMetadata() + .containsKey(SubAgentHook.PREVIOUS_TOOL_RESULT)) { + Optional> toolResults = + extractToolResults(toolUseBlock); + return resume(sessionId, toolResults.orElse(null), param); + } + // Get message String message = (String) input.get(PARAM_MESSAGE); if (message == null || message.isEmpty()) { @@ -165,12 +233,16 @@ private Mono executeConversation(ToolCallParam param) { // Get emitter for event forwarding ToolEmitter emitter = param.getEmitter(); - // Execute and save state after completion + // Execute and handle potential suspension Mono result; if (config.isForwardEvents()) { - result = executeWithStreaming(agent, userMsg, finalSessionId, emitter); + result = + executeWithStreaming( + agent, List.of(userMsg), finalSessionId, emitter); } else { - result = executeWithoutStreaming(agent, userMsg, finalSessionId); + result = + executeWithoutStreaming( + agent, List.of(userMsg), finalSessionId); } // Save state after execution @@ -188,6 +260,92 @@ private Mono executeConversation(ToolCallParam param) { }); } + /** + * Extracts injected result from tool use block input. + * + *

    This is used when SubAgentHook has injected a pending result for resumption. + * + * @param toolUseBlock The tool use block + * @return Optional containing the injected result + */ + @SuppressWarnings("unchecked") + private Optional> extractToolResults(ToolUseBlock toolUseBlock) { + if (toolUseBlock == null || toolUseBlock.getInput() == null) { + return Optional.empty(); + } + + Object toolResult = toolUseBlock.getMetadata().get(SubAgentHook.PREVIOUS_TOOL_RESULT); + + if (toolResult instanceof List) { + List list = (List) toolResult; + + List resultList = + list.stream() + .filter(ToolResultBlock.class::isInstance) + .map(ToolResultBlock.class::cast) + .collect(Collectors.toList()); + + return resultList.isEmpty() ? Optional.empty() : Optional.of(resultList); + } + + return Optional.empty(); + } + + /** + * Resume execution using injected tool results. + * + *

    This method is called when a sub-agent was previously paused and the user provides tool results. + * It loads the agent state and continues execution. + * + *

    For hook-triggered pauses, if toolResults is null or empty, it continues execution with an empty message list. + * For tool suspensions, tool results must be provided. + * + * @param sessionId Session ID + * @param toolResults Injected tool results from the user + * @param param Original tool call parameter + * @return Mono emitting tool result blocks + */ + private Mono resume( + String sessionId, List toolResults, ToolCallParam param) { + logger.debug( + "Resuming sub-agent session {} with tool result, HITL enabled: {}", + sessionId, + config.isEnableHITL()); + + Agent agent = agentProvider.provide(); + + // Load existing state + if (agent instanceof StateModule) { + loadAgentState(sessionId, (StateModule) agent); + } + + ToolEmitter emitter = param.getEmitter(); + + // Build messages from tool results, each ToolResultBlock becomes a separate Msg + List messages = List.of(); + if (toolResults != null && !toolResults.isEmpty()) { + messages = + toolResults.stream() + .map(result -> Msg.builder().role(MsgRole.TOOL).content(result).build()) + .collect(Collectors.toList()); + } + + // Continue execution + Mono result; + if (config.isForwardEvents()) { + result = executeWithStreaming(agent, messages, sessionId, emitter); + } else { + result = executeWithoutStreaming(agent, messages, sessionId); + } + + return result.doOnSuccess( + r -> { + if (agent instanceof StateModule) { + saveAgentState(sessionId, (StateModule) agent); + } + }); + } + /** * Loads agent state from the session storage. * @@ -227,28 +385,29 @@ private void saveAgentState(String sessionId, StateModule agent) { } /** - * Executes agent call with streaming, forwarding events to the emitter. + * Executes agent call using streaming and with HITL support. * *

    Uses the agent's streaming API and forwards each event to the provided emitter as JSON. * The final response is extracted from the last event. * * @param agent The agent to execute - * @param userMsg The user message to send + * @param userMsgs The user messages to send * @param sessionId The session ID for result building * @param emitter The emitter to forward events to * @return A Mono emitting the tool result block */ private Mono executeWithStreaming( - Agent agent, Msg userMsg, String sessionId, ToolEmitter emitter) { + Agent agent, List userMsgs, String sessionId, ToolEmitter emitter) { StreamOptions streamOptions = config.getStreamOptions() != null ? config.getStreamOptions() : StreamOptions.defaults(); + List msgs = userMsgs != null && !userMsgs.isEmpty() ? userMsgs : List.of(); return Mono.deferContextual( ctxView -> - agent.stream(List.of(userMsg), streamOptions) + agent.stream(msgs, streamOptions) .doOnNext(event -> forwardEvent(event, emitter, agent, sessionId)) .filter(Event::isLast) .last() @@ -271,21 +430,23 @@ private Mono executeWithStreaming( } /** - * Executes agent call without streaming. + * Execute agent call without streaming but with HITL support. * - *

    Uses the agent's standard call API. No events are forwarded to the emitter. + *

    Uses the standard calling API of the agent. If the sub-agent returns a pause status, + * this method constructs a suspended result containing the session ID and internal tool information. * - * @param agent The agent to execute - * @param userMsg The user message to send - * @param sessionId The session ID for result building - * @return A Mono emitting the tool result block + * @param agent The agent to be executed + * @param userMsgs The input messages to send; if null or empty, calls agent.call() without parameters + * @param sessionId The session ID used to construct the result + * @return A Mono emitting a tool result block */ private Mono executeWithoutStreaming( - Agent agent, Msg userMsg, String sessionId) { + Agent agent, List userMsgs, String sessionId) { + List messages = userMsgs != null && !userMsgs.isEmpty() ? userMsgs : List.of(); return Mono.deferContextual( ctxView -> - agent.call(List.of(userMsg)) + agent.call(messages) .map(response -> buildResult(response, sessionId)) .onErrorResume( e -> { @@ -298,6 +459,35 @@ private Mono executeWithoutStreaming( .contextWrite(context -> context.putAll(ctxView))); } + /** + * Build a suspended tool result from a paused sub-agent response. + * + *

    Extracts internal tool usage blocks from the response and creates a suspended result, + * which the main agent can use to request user input. + * + * @param response The paused response from the sub-agent + * @param sessionId Session ID + * @return A suspended tool result block + */ + private ToolResultBlock buildSuspendedResult(Msg response, String sessionId) { + // Extract inner tool use blocks and text blocks from the response + List toolUses = response.getContentBlocks(ToolUseBlock.class); + List textBlocks = response.getContentBlocks(TextBlock.class); + + // Combine text blocks and tool use blocks as content + List contentBlocks = new ArrayList<>(); + contentBlocks.addAll(textBlocks); + contentBlocks.addAll(toolUses); + + // Create metadata for the suspended result + Map metadata = new HashMap<>(); + metadata.put(ToolResultBlock.METADATA_SUSPENDED, true); + metadata.put(SubAgentContext.METADATA_SUBAGENT_SESSION_ID, sessionId); + metadata.put(SubAgentContext.METADATA_GENERATE_REASON, response.getGenerateReason()); + + return new ToolResultBlock(null, null, contentBlocks, metadata); + } + /** * Forwards an event to the emitter as serialized JSON. * @@ -328,14 +518,28 @@ private void forwardEvent(Event event, ToolEmitter emitter, Agent agent, String /** * Builds the final tool result with session context. * - *

    Formats the response to include the session ID, allowing callers to continue the + *

    Formats the response to include the session ID in metadata, allowing callers to continue the * conversation by passing the session ID in subsequent calls. * + *

    If HITL is disabled, suspended states will be converted to normal text responses + * without special metadata. + * * @param response The agent's response message - * @param sessionId The session ID to include in the result + * @param sessionId The session ID to include in the result metadata * @return A tool result block containing the formatted response */ private ToolResultBlock buildResult(Msg response, String sessionId) { + // Check if sub-agent is suspended + GenerateReason reason = response.getGenerateReason(); + boolean isSuspended = + reason == GenerateReason.TOOL_SUSPENDED + || reason == GenerateReason.REASONING_STOP_REQUESTED + || reason == GenerateReason.ACTING_STOP_REQUESTED; + + if (config.isEnableHITL() && isSuspended) { + return buildSuspendedResult(response, sessionId); + } + String textContent = response.getTextContent(); // Return response with session context diff --git a/agentscope-core/src/test/java/io/agentscope/core/e2e/HITLBasicE2ETest.java b/agentscope-core/src/test/java/io/agentscope/core/e2e/HITLBasicE2ETest.java index e8a9cbbe2..cb059e7d4 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/e2e/HITLBasicE2ETest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/e2e/HITLBasicE2ETest.java @@ -27,10 +27,12 @@ import io.agentscope.core.hook.HookEvent; import io.agentscope.core.hook.PostReasoningEvent; import io.agentscope.core.message.Msg; +import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.ToolParam; import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.tool.subagent.SubAgentContext; import java.time.Duration; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -297,4 +299,107 @@ void testResumeAfterPause(ModelProvider provider) { System.out.println("✓ Resume after pause test completed for " + provider.getProviderName()); } + + @ParameterizedTest + @MethodSource("io.agentscope.core.e2e.ProviderFactory#getToolProviders") + @DisplayName("Should handle HITL when ReactAgent uses SubAgent as tool") + void testReactAgentWithSubAgentToolHITL(ModelProvider provider) { + assumeTrue( + provider.supportsToolCalling(), + "Skipping: " + provider.getProviderName() + " does not support tool calling"); + + System.out.println( + "\n=== Test: ReactAgent with SubAgent Tool HITL with " + + provider.getProviderName() + + " ==="); + + // Create a sub-agent that will use sensitive tools + Toolkit subAgentToolkit = new Toolkit(); + subAgentToolkit.registerTool(new SensitiveTools()); + + AtomicBoolean subAgentStopCalled = new AtomicBoolean(false); + AtomicBoolean subAgentShouldStop = new AtomicBoolean(true); + Hook confirmationHook = createConfirmationHook(subAgentStopCalled, subAgentShouldStop); + + // Create main agent toolkit with SubAgent registered as a tool + Toolkit mainToolkit = new Toolkit(); + + // Register SubAgent as a tool with HITL enabled + mainToolkit + .registration() + .subAgent( + () -> + provider.createAgentBuilder("HelperAgent", subAgentToolkit) + .enableSubAgentHITL(false) + .hook(confirmationHook) + .sysPrompt( + "You are a helper agent that can perform file" + + " operations.") + .build(), + io.agentscope.core.tool.subagent.SubAgentConfig.builder() + .toolName("call_helper") + .description("Call the helper agent to perform tasks") + .enableHITL(true) + .build()) + .apply(); + + // Create main agent + ReActAgent mainAgent = + provider.createAgentBuilder("MainAgent", mainToolkit) + .sysPrompt("You are a coordinator agent that delegates tasks to helpers.") + .enableSubAgentHITL(true) + .build(); + + // Test 1: Main agent calls sub-agent, sub-agent triggers HITL + System.out.println("\n--- Phase 1: Main agent delegates to sub-agent ---"); + Msg input = + TestUtils.createUserMessage( + "User", + "Please use the helper agent to delete the file 'important.txt'. Tell the" + + " helper to use the delete_file tool directly."); + + Msg response1 = mainAgent.call(input).block(TEST_TIMEOUT); + assertNotNull(response1, "Should receive response from main agent"); + + // Verify that sub-agent was called and triggered HITL + System.out.println("Main agent response reason: " + response1.getGenerateReason()); + + // Check if the response contains tool suspension + if (response1.getGenerateReason() + == io.agentscope.core.message.GenerateReason.TOOL_SUSPENDED + || subAgentStopCalled.get()) { + System.out.println("✓ Sub-agent HITL triggered successfully"); + + // Verify response contains pending tool calls + List toolResults = response1.getContentBlocks(ToolResultBlock.class); + assertFalse(toolResults.isEmpty(), "Should have tool results when suspended"); + + System.out.println("Pending tool results: " + toolResults.size()); + toolResults.forEach( + t -> + System.out.println( + " - Tool: " + + t.getName() + + ", Reason: " + + SubAgentContext.getSubAgentGenerateReason(t))); + + // Test 2: Resume after user confirmation + System.out.println("\n--- Phase 2: Resume after user confirmation ---"); + subAgentShouldStop.set(false); + + Msg response2 = mainAgent.call().block(TEST_TIMEOUT); + assertNotNull(response2, "Should receive resume response"); + + System.out.println("Resume response reason: " + response2.getGenerateReason()); + assertTrue( + ContentValidator.hasMeaningfulContent(response2), + "Resume response should have meaningful content"); + + System.out.println("Resume response: " + TestUtils.extractTextContent(response2)); + } else { + System.out.println( + "Sub-agent did not trigger HITL (model may have handled differently)"); + System.out.println("Resume response: " + TestUtils.extractTextContent(response1)); + } + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java b/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java index 6130f8088..d3d0d5069 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/state/StatePersistenceTest.java @@ -167,7 +167,7 @@ void testEquality() { @Test @DisplayName("Constructor should accept all boolean values") void testConstructor() { - StatePersistence persistence = new StatePersistence(true, false, true, false); + StatePersistence persistence = new StatePersistence(true, false, true, false, false); assertTrue(persistence.memoryManaged()); assertFalse(persistence.toolkitManaged()); assertTrue(persistence.planNotebookManaged()); diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java index 15b5ab7ab..9079ee11f 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentConfigTest.java @@ -16,6 +16,7 @@ package io.agentscope.core.tool.subagent; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -49,6 +50,7 @@ void testDefaults() { assertNull(config.getStreamOptions()); assertNotNull(config.getSession()); assertInstanceOf(InMemorySession.class, config.getSession()); + assertFalse(config.isEnableHITL()); } @Test @@ -64,6 +66,13 @@ void testDefaultSession() { SubAgentConfig config = SubAgentConfig.defaults(); assertInstanceOf(InMemorySession.class, config.getSession()); } + + @Test + @DisplayName("Should have hitlEnabled false by default") + void testHitlEnabledDefaultFalse() { + SubAgentConfig config = SubAgentConfig.defaults(); + assertFalse(config.isEnableHITL()); + } } @Nested @@ -121,6 +130,22 @@ void testCustomSession() { assertEquals(customSession, config.getSession()); } + @Test + @DisplayName("Should build config with hitlEnabled enabled") + void testHitlEnabled() { + SubAgentConfig config = SubAgentConfig.builder().enableHITL(true).build(); + + assertTrue(config.isEnableHITL()); + } + + @Test + @DisplayName("Should build config with hitlEnabled disabled") + void testHitlDisabled() { + SubAgentConfig config = SubAgentConfig.builder().enableHITL(false).build(); + + assertFalse(config.isEnableHITL()); + } + @Test @DisplayName("Should build config with all custom values") void testAllCustomValues() { @@ -134,6 +159,7 @@ void testAllCustomValues() { .forwardEvents(true) .streamOptions(streamOptions) .session(customSession) + .enableHITL(true) .build(); assertEquals("expert_agent", config.getToolName()); @@ -141,6 +167,7 @@ void testAllCustomValues() { assertTrue(config.isForwardEvents()); assertEquals(streamOptions, config.getStreamOptions()); assertEquals(customSession, config.getSession()); + assertTrue(config.isEnableHITL()); } @Test @@ -168,6 +195,7 @@ void testBuilderChaining() { .forwardEvents(true) .streamOptions(null) .session(new InMemorySession()) + .enableHITL(true) .build(); assertNotNull(config); @@ -208,6 +236,13 @@ void testGetSessionNeverNull() { SubAgentConfig configWithNullSession = SubAgentConfig.builder().session(null).build(); assertNotNull(configWithNullSession.getSession()); } + + @Test + @DisplayName("isHitlEnabled() should return false by default") + void testIsHitlEnabledDefault() { + SubAgentConfig config = SubAgentConfig.builder().build(); + assertFalse(config.isEnableHITL()); + } } @Nested diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentContextTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentContextTest.java new file mode 100644 index 000000000..4c16d1004 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentContextTest.java @@ -0,0 +1,747 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.subagent; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.session.InMemorySession; +import io.agentscope.core.session.Session; +import io.agentscope.core.state.SessionKey; +import io.agentscope.core.state.SimpleSessionKey; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Comprehensive tests for SubAgentContext functionality. + * + *

    Test coverage includes: + *

      + *
    • Basic functionality: storing and retrieving pending results, session IDs
    • + *
    • State consistency: correctness of multiple operations
    • + *
    • SessionId-first constraint: enforcement of lifecycle management
    • + *
    • Metadata extraction: session ID and GenerateReason from ToolResultBlock
    • + *
    • Sub-agent result detection: identifying sub-agent results via metadata
    • + *
    • State persistence: save and load context state to/from Session
    • + *
    • Null handling: robust behavior with null inputs
    • + *
    • Clear operations: state cleanup and reset functionality
    • + *
    + */ +@DisplayName("SubAgentContext Tests") +class SubAgentContextTest { + + private SubAgentContext context; + + @BeforeEach + void setUp() { + context = new SubAgentContext(); + } + + @Nested + @DisplayName("Pending Result Management Tests") + class PendingResultManagementTests { + + @Test + @DisplayName("Should store and retrieve pending result") + void testStoreAndRetrievePendingResult() { + String toolId = "tool-123"; + String sessionId = "session-abc"; + ToolResultBlock result = createToolResultBlock(toolId, "Test result"); + + context.setSessionId(toolId, sessionId); + // First param: sub-agent tool ID, Second param: pending result when sub-agent is + // suspended + context.submitSubAgentResult(toolId, result); + + assertTrue(context.hasPendingResult(toolId)); + Optional> retrieved = context.getPendingResult(toolId); + assertTrue(retrieved.isPresent()); + assertEquals(1, retrieved.get().size()); + assertEquals(toolId, retrieved.get().get(0).getId()); + } + + @Test + @DisplayName("Should consume and remove pending result") + void testConsumePendingResult() { + String toolId = "tool-123"; + String sessionId = "session-abc"; + ToolResultBlock result = createToolResultBlock(toolId, "Test result"); + + context.setSessionId(toolId, sessionId); + context.submitSubAgentResult(toolId, result); + + assertTrue(context.hasPendingResult(toolId)); + Optional consumed = context.consumePendingResult(toolId); + assertTrue(consumed.isPresent()); + assertEquals(toolId, consumed.get().toolId()); + assertEquals(sessionId, consumed.get().sessionId()); + assertEquals(1, consumed.get().pendingResults().size()); + + // Verify it has been consumed + assertFalse(context.hasPendingResult(toolId)); + assertTrue(context.consumePendingResult(toolId).isEmpty()); + } + + @Test + @DisplayName("Should handle multiple results for same tool ID") + void testMultipleResultsForSameToolId() { + String toolId = "tool-123"; + String sessionId = "session-abc"; + ToolResultBlock result1 = createToolResultBlock("inner-call-1", "Result 1"); + ToolResultBlock result2 = createToolResultBlock("inner-call-2", "Result 2"); + + context.setSessionId(toolId, sessionId); + context.submitSubAgentResult(toolId, result1); + context.submitSubAgentResult(toolId, result2); + + Optional> retrieved = context.getPendingResult(toolId); + assertTrue(retrieved.isPresent()); + assertEquals(2, retrieved.get().size()); + assertEquals( + "Result 1", ((TextBlock) retrieved.get().get(0).getOutput().get(0)).getText()); + assertEquals( + "Result 2", ((TextBlock) retrieved.get().get(1).getOutput().get(0)).getText()); + } + + @Test + @DisplayName("Should consume all results for same tool ID") + void testConsumeAllResultsForSameToolId() { + String toolId = "tool-123"; + String sessionId = "session-abc"; + ToolResultBlock result1 = createToolResultBlock(toolId, "Result 1"); + ToolResultBlock result2 = createToolResultBlock(toolId, "Result 2"); + + context.setSessionId(toolId, sessionId); + context.submitSubAgentResult(toolId, result1); + context.submitSubAgentResult(toolId, result2); + + Optional consumed = context.consumePendingResult(toolId); + assertTrue(consumed.isPresent()); + assertEquals(2, consumed.get().pendingResults().size()); + + // Verify all results have been consumed + assertFalse(context.hasPendingResult(toolId)); + } + + @Test + @DisplayName("Should return empty for non-existent tool ID") + void testNonExistentToolId() { + Optional> result = context.getPendingResult("non-existent"); + assertFalse(result.isPresent()); + + Optional consumed = + context.consumePendingResult("non-existent"); + assertFalse(consumed.isPresent()); + } + + @Test + @DisplayName("Should throw exception when adding result without session ID") + void testAddResultWithoutSessionId() { + String toolId = "tool-123"; + ToolResultBlock result = createToolResultBlock(toolId, "Test result"); + + // Should throw exception because session ID is not set + assertThrows( + IllegalArgumentException.class, + () -> context.submitSubAgentResult(toolId, result)); + } + } + + @Nested + @DisplayName("Session ID Management Tests") + class SessionIdManagementTests { + + @Test + @DisplayName("Should store and retrieve session ID") + void testStoreAndRetrieveSessionId() { + String toolId = "tool-456"; + String sessionId = "session-abc"; + + context.setSessionId(toolId, sessionId); + + Optional retrieved = context.getSessionId(toolId); + assertTrue(retrieved.isPresent()); + assertEquals(sessionId, retrieved.get()); + } + + @Test + @DisplayName("Should update session ID for existing tool ID") + void testUpdateSessionId() { + String toolId = "tool-789"; + String sessionId1 = "session-xyz"; + String sessionId2 = "session-updated"; + + context.setSessionId(toolId, sessionId1); + assertEquals(sessionId1, context.getSessionId(toolId).orElse(null)); + + context.setSessionId(toolId, sessionId2); + assertEquals(sessionId2, context.getSessionId(toolId).orElse(null)); + } + + @Test + @DisplayName("Should return empty for non-existent tool ID") + void testNonExistentSessionId() { + Optional sessionId = context.getSessionId("non-existent"); + assertFalse(sessionId.isPresent()); + } + + @Test + @DisplayName("Should handle multiple session IDs") + void testMultipleSessionIds() { + context.setSessionId("tool-1", "session-1"); + context.setSessionId("tool-2", "session-2"); + context.setSessionId("tool-3", "session-3"); + + assertEquals("session-1", context.getSessionId("tool-1").orElse(null)); + assertEquals("session-2", context.getSessionId("tool-2").orElse(null)); + assertEquals("session-3", context.getSessionId("tool-3").orElse(null)); + } + } + + @Nested + @DisplayName("Null Handling Tests") + class NullHandlingTests { + + @Test + @DisplayName("Should handle null tool result") + void testNullToolResult() { + assertThrows( + IllegalArgumentException.class, + () -> context.submitSubAgentResult("tool-123", null)); + } + + @Test + @DisplayName("Should handle null tool ID in setSessionId") + void testNullToolIdSetSessionId() { + assertThrows( + IllegalArgumentException.class, () -> context.setSessionId(null, "session-1")); + } + + @Test + @DisplayName("Should handle tool result with null ID") + void testToolResultWithNullId() { + ToolResultBlock result = createToolResultBlock(null, "Result"); + assertThrows( + IllegalArgumentException.class, + () -> context.submitSubAgentResult("tool-123", result)); + } + } + + @Nested + @DisplayName("Clear and State Management Tests") + class ClearAndStateManagementTests { + + @Test + @DisplayName("Should clear all pending data") + void testClear() { + context.setSessionId("tool-1", "session-1"); + context.submitSubAgentResult("tool-1", createToolResultBlock("tool-1", "Result 1")); + context.setSessionId("tool-2", "session-2"); + + context.clear(); + + assertFalse(context.hasPendingResult("tool-1")); + assertFalse(context.getSessionId("tool-2").isPresent()); + } + + @Test + @DisplayName("Should maintain state after multiple operations") + void testStateAfterMultipleOperations() { + // Add some data + context.setSessionId("tool-1", "session-1"); + context.submitSubAgentResult("tool-1", createToolResultBlock("tool-1", "Result 1")); + + // Verify + assertTrue(context.hasPendingResult("tool-1")); + assertEquals("session-1", context.getSessionId("tool-1").orElse(null)); + + // Add more data + context.setSessionId("tool-2", "session-2"); + context.submitSubAgentResult("tool-2", createToolResultBlock("tool-2", "Result 2")); + + // Verify all data + assertTrue(context.hasPendingResult("tool-1")); + assertTrue(context.hasPendingResult("tool-2")); + assertEquals("session-1", context.getSessionId("tool-1").orElse(null)); + assertEquals("session-2", context.getSessionId("tool-2").orElse(null)); + + context.consumePendingResult("tool-1"); + + // Verify state + assertFalse(context.hasPendingResult("tool-1")); + assertTrue(context.hasPendingResult("tool-2")); + assertNull(context.getSessionId("tool-1").orElse(null)); + assertEquals("session-2", context.getSessionId("tool-2").orElse(null)); + } + } + + @Nested + @DisplayName("Metadata Extraction Tests") + class MetadataExtractionTests { + + @Test + @DisplayName("Should extract session ID from metadata") + void testExtractSessionId() { + String sessionId = "session-xyz"; + Map metadata = new HashMap<>(); + metadata.put(SubAgentContext.METADATA_SUBAGENT_SESSION_ID, sessionId); + ToolResultBlock result = + new ToolResultBlock( + "tool-1", + "test-tool", + List.of(TextBlock.builder().text("Result").build()), + metadata); + + Optional extracted = SubAgentContext.extractSessionId(result); + assertTrue(extracted.isPresent()); + assertEquals(sessionId, extracted.get()); + } + + @Test + @DisplayName("Should return empty when session ID not in metadata") + void testExtractSessionIdEmpty() { + ToolResultBlock result = createToolResultBlock("tool-1", "Result"); + + Optional extracted = SubAgentContext.extractSessionId(result); + assertFalse(extracted.isPresent()); + + assertFalse(SubAgentContext.extractSessionId(null).isPresent()); + } + } + + @Nested + @DisplayName("State Consistency Tests") + class StateConsistencyTests { + + @Test + @DisplayName("Should maintain consistency after repeated operations") + void testRepeatedOperations() { + // Repeatedly add the same data + for (int i = 0; i < 10; i++) { + context.setSessionId("tool-1", "session-1"); + context.submitSubAgentResult( + "tool-1", createToolResultBlock("tool-1", "Result " + i)); + } + + // Verify there are 10 results + Optional> results = context.getPendingResult("tool-1"); + assertTrue(results.isPresent()); + assertEquals(10, results.get().size()); + + // Consume all results + Optional consumed = context.consumePendingResult("tool-1"); + assertTrue(consumed.isPresent()); + assertEquals(10, consumed.get().pendingResults().size()); + + // Verify it has been cleared + assertFalse(context.hasPendingResult("tool-1")); + } + + @Test + @DisplayName("Should preserve metadata through operations") + void testMetadataPreservation() { + Map metadata = new HashMap<>(); + metadata.put(SubAgentContext.METADATA_SUBAGENT_SESSION_ID, "session-123"); + metadata.put("custom_key", "custom_value"); + + ToolResultBlock result = + ToolResultBlock.builder() + .id("tool-1") + .output(TextBlock.builder().text("Result").build()) + .metadata(metadata) + .build(); + + context.setSessionId("tool-1", "session-123"); + context.submitSubAgentResult("tool-1", result); + + // Get result + Optional> retrieved = context.getPendingResult("tool-1"); + assertTrue(retrieved.isPresent()); + assertEquals(1, retrieved.get().size()); + + ToolResultBlock retrievedResult = retrieved.get().get(0); + assertEquals( + "session-123", + retrievedResult + .getMetadata() + .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID)); + assertEquals("custom_value", retrievedResult.getMetadata().get("custom_key")); + + // Consume result + Optional consumed = context.consumePendingResult("tool-1"); + assertTrue(consumed.isPresent()); + assertEquals(1, consumed.get().pendingResults().size()); + + ToolResultBlock consumedResult = consumed.get().pendingResults().get(0); + assertEquals( + "session-123", + consumedResult.getMetadata().get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID)); + assertEquals("custom_value", consumedResult.getMetadata().get("custom_key")); + } + } + + @Nested + @DisplayName("SessionId-First Constraint Tests") + class SessionIdFirstConstraintTests { + + @Test + @DisplayName("Should enforce sessionId-first constraint") + void testSessionIdFirstConstraint() { + String toolId = "tool-123"; + ToolResultBlock result = createToolResultBlock(toolId, "Test result"); + + // Try to add result without setting session ID + assertThrows( + IllegalArgumentException.class, + () -> + context.submitSubAgentResult( + toolId, createToolResultBlock(toolId, "Test result"))); + + // Result should not be added + assertFalse(context.hasPendingResult(toolId)); + + // Now set session ID and add result + context.setSessionId(toolId, "session-abc"); + context.submitSubAgentResult(toolId, result); + + // Result should be added + assertTrue(context.hasPendingResult(toolId)); + } + + @Test + @DisplayName("Should handle session ID replacement") + void testSessionIdReplacement() { + String toolId = "tool-123"; + ToolResultBlock result1 = createToolResultBlock(toolId, "Result 1"); + ToolResultBlock result2 = createToolResultBlock(toolId, "Result 2"); + + // Set first session ID and add result + context.setSessionId(toolId, "session-1"); + context.submitSubAgentResult(toolId, result1); + + // Replace session ID + context.setSessionId(toolId, "session-2"); + context.submitSubAgentResult(toolId, result2); + + // Verify only the second result exists + Optional> results = context.getPendingResult(toolId); + assertTrue(results.isPresent()); + assertEquals(1, results.get().size()); + assertEquals( + "Result 2", ((TextBlock) results.get().get(0).getOutput().get(0)).getText()); + } + } + + @Nested + @DisplayName("Generate Reason Tests") + class GenerateReasonTests { + + @Test + @DisplayName("Should extract GenerateReason from metadata") + void testGetSubAgentGenerateReason() { + Map metadata = new HashMap<>(); + metadata.put(SubAgentContext.METADATA_GENERATE_REASON, GenerateReason.TOOL_SUSPENDED); + ToolResultBlock result = + new ToolResultBlock( + "tool-1", + "test-tool", + List.of(TextBlock.builder().text("Result").build()), + metadata); + + GenerateReason reason = SubAgentContext.getSubAgentGenerateReason(result); + assertEquals(GenerateReason.TOOL_SUSPENDED, reason); + } + + @Test + @DisplayName("Should return MODEL_STOP when GenerateReason not in metadata") + void testGetSubAgentGenerateReasonDefault() { + ToolResultBlock result = createToolResultBlock("tool-1", "Result"); + + GenerateReason reason = SubAgentContext.getSubAgentGenerateReason(result); + assertEquals(GenerateReason.MODEL_STOP, reason); + } + + @Test + @DisplayName("Should return MODEL_STOP when metadata value is not GenerateReason") + void testGetSubAgentGenerateReasonInvalidType() { + Map metadata = new HashMap<>(); + metadata.put(SubAgentContext.METADATA_GENERATE_REASON, "invalid_value"); + ToolResultBlock result = + new ToolResultBlock( + "tool-1", + "test-tool", + List.of(TextBlock.builder().text("Result").build()), + metadata); + + GenerateReason reason = SubAgentContext.getSubAgentGenerateReason(result); + assertEquals(GenerateReason.MODEL_STOP, reason); + } + + @Test + @DisplayName("Should handle all GenerateReason values") + void testAllGenerateReasonValues() { + for (GenerateReason expectedReason : GenerateReason.values()) { + Map metadata = new HashMap<>(); + metadata.put(SubAgentContext.METADATA_GENERATE_REASON, expectedReason); + ToolResultBlock result = + new ToolResultBlock( + "tool-1", + "test-tool", + List.of(TextBlock.builder().text("Result").build()), + metadata); + + GenerateReason actualReason = SubAgentContext.getSubAgentGenerateReason(result); + assertEquals(expectedReason, actualReason); + } + } + } + + @Nested + @DisplayName("SubAgent Result Detection Tests") + class SubAgentResultDetectionTests { + + @Test + @DisplayName("Should detect sub-agent result with session ID") + void testIsSubAgentResult() { + Map metadata = new HashMap<>(); + metadata.put(SubAgentContext.METADATA_SUBAGENT_SESSION_ID, "session-123"); + ToolResultBlock result = + new ToolResultBlock( + "tool-1", + "test-tool", + List.of(TextBlock.builder().text("Result").build()), + metadata); + + assertTrue(SubAgentContext.isSubAgentResult(result)); + } + + @Test + @DisplayName("Should return false for non-sub-agent result") + void testIsNotSubAgentResult() { + ToolResultBlock result = createToolResultBlock("tool-1", "Result"); + + assertFalse(SubAgentContext.isSubAgentResult(result)); + } + + @Test + @DisplayName("Should return false for null result") + void testIsSubAgentResultNull() { + assertFalse(SubAgentContext.isSubAgentResult(null)); + } + + @Test + @DisplayName("Should return false when metadata is null") + void testIsSubAgentResultNullMetadata() { + ToolResultBlock result = + new ToolResultBlock( + "tool-1", + "test-tool", + List.of(TextBlock.builder().text("Result").build()), + null); + + assertFalse(SubAgentContext.isSubAgentResult(result)); + } + } + + @Nested + @DisplayName("State Persistence Tests") + class StatePersistenceTests { + + private Session session; + private SessionKey sessionKey; + + @BeforeEach + void setUp() { + session = new InMemorySession(); + sessionKey = SimpleSessionKey.of("test-session"); + } + + @Test + @DisplayName("Should save and load context state") + void testSaveAndLoad() { + String toolId = "tool-123"; + String sessionId = "session-abc"; + ToolResultBlock result = createToolResultBlock(toolId, "Test result"); + + // Setup context + context.setSessionId(toolId, sessionId); + context.submitSubAgentResult(toolId, result); + + // Save to session + context.saveTo(session, sessionKey); + + // Create new context and load + SubAgentContext newContext = new SubAgentContext(); + newContext.loadFrom(session, sessionKey); + + // Verify state was restored + assertTrue(newContext.hasPendingResult(toolId)); + assertEquals(sessionId, newContext.getSessionId(toolId).orElse(null)); + + Optional> retrieved = newContext.getPendingResult(toolId); + assertTrue(retrieved.isPresent()); + assertEquals(1, retrieved.get().size()); + assertEquals(toolId, retrieved.get().get(0).getId()); + } + + @Test + @DisplayName("Should save and load multiple pending results") + void testSaveAndLoadMultipleResults() { + String toolId1 = "tool-1"; + String toolId2 = "tool-2"; + String sessionId1 = "session-1"; + String sessionId2 = "session-2"; + + context.setSessionId(toolId1, sessionId1); + context.submitSubAgentResult(toolId1, createToolResultBlock(toolId1, "Result 1")); + context.setSessionId(toolId2, sessionId2); + context.submitSubAgentResult(toolId2, createToolResultBlock(toolId2, "Result 2")); + + // Save to session + context.saveTo(session, sessionKey); + + // Create new context and load + SubAgentContext newContext = new SubAgentContext(); + newContext.loadFrom(session, sessionKey); + + // Verify both tools' states were restored + assertTrue(newContext.hasPendingResult(toolId1)); + assertTrue(newContext.hasPendingResult(toolId2)); + assertEquals(sessionId1, newContext.getSessionId(toolId1).orElse(null)); + assertEquals(sessionId2, newContext.getSessionId(toolId2).orElse(null)); + } + + @Test + @DisplayName("Should handle empty context save and load") + void testSaveAndLoadEmptyContext() { + // Save empty context + context.saveTo(session, sessionKey); + + // Create new context and load + SubAgentContext newContext = new SubAgentContext(); + newContext.loadFrom(session, sessionKey); + + // Verify context is empty + assertFalse(newContext.hasPendingResult("any-tool")); + } + + @Test + @DisplayName("Should overwrite previous saved state") + void testOverwritePreviousState() { + String toolId = "tool-123"; + + // Save first state + context.setSessionId(toolId, "session-1"); + context.submitSubAgentResult(toolId, createToolResultBlock(toolId, "Result 1")); + context.saveTo(session, sessionKey); + + // Modify and save again + context.clear(); + context.setSessionId(toolId, "session-2"); + context.submitSubAgentResult(toolId, createToolResultBlock(toolId, "Result 2")); + context.saveTo(session, sessionKey); + + // Load and verify latest state + SubAgentContext newContext = new SubAgentContext(); + newContext.loadFrom(session, sessionKey); + + assertEquals("session-2", newContext.getSessionId(toolId).orElse(null)); + Optional> results = newContext.getPendingResult(toolId); + assertTrue(results.isPresent()); + assertEquals(1, results.get().size()); + assertEquals( + "Result 2", ((TextBlock) results.get().get(0).getOutput().get(0)).getText()); + } + + @Test + @DisplayName("Should load from non-existent session without error") + void testLoadFromNonExistentSession() { + SessionKey nonExistentKey = SimpleSessionKey.of("non-existent"); + + // Should not throw exception + SubAgentContext newContext = new SubAgentContext(); + SubAgentPendingStore oldStore = newContext.getPendingStore(); + + newContext.loadFrom(session, nonExistentKey); + SubAgentPendingStore newStore = newContext.getPendingStore(); + // Context should remain empty + assertSame(oldStore, newStore); + } + + @Test + @DisplayName("Should preserve metadata through save and load") + void testPreserveMetadataThroughSaveLoad() { + String toolId = "tool-123"; + String sessionId = "session-abc"; + + Map metadata = new HashMap<>(); + metadata.put(SubAgentContext.METADATA_SUBAGENT_SESSION_ID, sessionId); + metadata.put(SubAgentContext.METADATA_GENERATE_REASON, GenerateReason.TOOL_SUSPENDED); + metadata.put("custom_key", "custom_value"); + + ToolResultBlock result = + new ToolResultBlock( + toolId, + "test-tool", + List.of(TextBlock.builder().text("Result").build()), + metadata); + + context.setSessionId(toolId, sessionId); + context.submitSubAgentResult(toolId, result); + + // Save and load + context.saveTo(session, sessionKey); + SubAgentContext newContext = new SubAgentContext(); + newContext.loadFrom(session, sessionKey); + + // Verify metadata preserved + Optional> retrieved = newContext.getPendingResult(toolId); + assertTrue(retrieved.isPresent()); + ToolResultBlock retrievedResult = retrieved.get().get(0); + + assertEquals( + sessionId, + retrievedResult + .getMetadata() + .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID)); + assertEquals( + GenerateReason.TOOL_SUSPENDED, + retrievedResult.getMetadata().get(SubAgentContext.METADATA_GENERATE_REASON)); + assertEquals("custom_value", retrievedResult.getMetadata().get("custom_key")); + } + } + + private ToolResultBlock createToolResultBlock(String id, String content) { + return new ToolResultBlock( + id, + "test-tool", + List.of(TextBlock.builder().text(content).build()), + new HashMap<>()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHITLTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHITLTest.java new file mode 100644 index 000000000..6cae3288d --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHITLTest.java @@ -0,0 +1,1108 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.subagent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.test.MockModel; +import io.agentscope.core.memory.InMemoryMemory; +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.tool.Toolkit; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** + * Integration tests for SubAgent HITL (Human-in-the-Loop) functionality with ReActAgent. + * + *

    Tests cover: + *

      + *
    • Main agent detecting sub-agent suspension
    • + *
    • User providing Result results to main agent
    • + *
    • Main agent resuming sub-agent execution
    • + *
    • Multi-turn HITL interactions
    • + *
    • SubAgentHook integration with ReActAgent
    • + *
    + */ +@DisplayName("SubAgent HITL Integration Tests") +class SubAgentHITLTest { + + private static final Duration TEST_TIMEOUT = Duration.ofSeconds(10); + private static final ChatUsage DEFAULT_USAGE = new ChatUsage(10, 20, 30); + private static final String DEFAULT_SYS_PROMPT = "You are a helpful assistant."; + private static final String MAIN_AGENT_NAME = "MainAgent"; + + private InMemoryMemory mainAgentMemory; + private Toolkit mainAgentToolkit; + + @BeforeEach + void setUp() { + mainAgentMemory = new InMemoryMemory(); + mainAgentToolkit = new Toolkit(); + } + + // ==================== Test Classes ==================== + + @Nested + @DisplayName("Main Agent Suspension Detection Tests") + class MainAgentSuspensionDetectionTests { + + @Test + @DisplayName("Should detect sub-agent suspension and return suspended result to user") + void testMainAgentDetectsSubAgentSuspension() { + Agent mockSubAgent = + createSubAgent( + "SuspendableSubAgent", suspendedMessage("Calling external API...")); + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createToolThenTextModel( + "call-sub-1", "call_suspendablesubagent", "Task completed"); + ReActAgent mainAgent = createHitlAgent(mainModel); + + Msg response = mainAgent.call(userMessage("Execute task")).block(TEST_TIMEOUT); + + assertNotNull(response); + assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason()); + + List toolResults = response.getContentBlocks(ToolResultBlock.class); + assertFalse(toolResults.isEmpty(), "Should contain tool result blocks"); + + ToolResultBlock suspendedResult = toolResults.get(0); + assertTrue( + suspendedResult + .getMetadata() + .containsKey(SubAgentContext.METADATA_SUBAGENT_SESSION_ID)); + } + + @Test + @DisplayName("Should include session ID in suspended response metadata") + void testSuspendedResponseContainsSessionId() { + Agent mockSubAgent = + createSubAgent( + "SuspendableSubAgent", suspendedMessage("Calling external API...")); + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createAlwaysToolUseModel("call-sub-1", "call_suspendablesubagent"); + ReActAgent mainAgent = createHitlAgent(mainModel); + + Msg response = mainAgent.call(userMessage("Execute task")).block(TEST_TIMEOUT); + + assertNotNull(response); + assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason()); + + List toolResults = response.getContentBlocks(ToolResultBlock.class); + assertFalse(toolResults.isEmpty()); + + ToolResultBlock suspendedResult = toolResults.get(0); + assertNotNull(suspendedResult.getMetadata()); + assertTrue( + suspendedResult + .getMetadata() + .containsKey(SubAgentContext.METADATA_SUBAGENT_SESSION_ID)); + + String sessionId = + (String) + suspendedResult + .getMetadata() + .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID); + assertNotNull(sessionId); + assertFalse(sessionId.isEmpty()); + } + } + + @Nested + @DisplayName("User Result and Resume Tests") + class UserResultAndResumeTests { + + @Test + @DisplayName("Should resume sub-agent execution after user provides Result") + void testResumeAfterUserResult() { + SubAgentContext context = new SubAgentContext(); + Agent mockSubAgent = + createSubAgent( + "ResumableSubAgent", suspendedMessage("Calling external API...")); + + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createSequentialToolModel("call_resumablesubagent", 2, "All done!"); + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + // First call - should suspend + Msg response1 = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT); + assertNotNull(response1); + assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason()); + + List toolResults = response1.getContentBlocks(ToolResultBlock.class); + assertFalse(toolResults.isEmpty()); + + ToolResultBlock suspendedResult = toolResults.get(0); + String toolId = suspendedResult.getId(); + String sessionId = + (String) + suspendedResult + .getMetadata() + .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID); + + context.setSessionId(toolId, sessionId); + + // Provide Result + ToolResultBlock Result = + ToolResultBlock.builder() + .id(toolId) + .name("external_api") + .output(TextBlock.builder().text("API response: success").build()) + .build(); + mainAgent.submitSubAgentResult(toolId, Result); + + // Second call - should complete + Msg response2 = mainAgent.call().block(TEST_TIMEOUT); + assertNotNull(response2); + assertEquals(GenerateReason.MODEL_STOP, response2.getGenerateReason()); + } + + @Test + @DisplayName("Should handle multiple sequential suspensions") + void testMultipleSequentialSuspensions() { + SubAgentContext context = new SubAgentContext(); + Agent mockSubAgent = createMultiStepSubAgent("MultiStepSubAgent", 2); + + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createSequentialToolModel("call_multistepsubagent", 3, "All done!"); + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + // First suspension + Msg response1 = + mainAgent.call(userMessage("Start multi-step task")).block(TEST_TIMEOUT); + assertNotNull(response1); + assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason()); + + // Second suspension + Msg response2 = mainAgent.call().block(TEST_TIMEOUT); + assertNotNull(response2); + assertEquals(GenerateReason.TOOL_SUSPENDED, response2.getGenerateReason()); + + // Final completion + Msg response3 = mainAgent.call().block(TEST_TIMEOUT); + assertNotNull(response3); + assertEquals(GenerateReason.MODEL_STOP, response3.getGenerateReason()); + } + } + + @Nested + @DisplayName("ConfirmSubAgent API Tests") + class ConfirmSubAgentAPITests { + + @Test + @DisplayName("Should store Result result via submitSubAgentResult API") + void testConfirmSubAgentAPI() { + SubAgentContext context = new SubAgentContext(); + Agent mockSubAgent = + createSubAgent( + "SuspendableSubAgent", suspendedMessage("Calling external API...")); + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createToolThenTextModel( + "call-confirm-1", "call_suspendablesubagent", "Completed"); + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + Msg suspendedResponse = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT); + + assertNotNull(suspendedResponse); + assertEquals(GenerateReason.TOOL_SUSPENDED, suspendedResponse.getGenerateReason()); + + List toolResults = + suspendedResponse.getContentBlocks(ToolResultBlock.class); + assertFalse(toolResults.isEmpty()); + + ToolResultBlock suspendedResult = toolResults.get(0); + String toolId = suspendedResult.getId(); + + // Submit the result for the internal tool call that caused the suspension + ToolResultBlock internalToolResult = + ToolResultBlock.builder() + .id("inner-api-call") // ID of the tool called inside the sub-agent + .name("external_api") + .output(TextBlock.builder().text("API response: success").build()) + .build(); + + // First parameter: the sub-agent tool ID (call-1) + // Second parameter: the result of the internal tool that suspended the sub-agent + context.submitSubAgentResult(toolId, internalToolResult); + + assertTrue(context.hasPendingResult(toolId), "Context should have pending result"); + } + } + + @Nested + @DisplayName("Paused State Handling Tests") + class PausedStateHandlingTests { + + @Test + @DisplayName("Should handle sub-agent paused state (REASONING_STOP_REQUESTED)") + void testSubAgentPausedState() { + Msg pausedResponse = + assistantMessage( + "Paused for reasoning", GenerateReason.REASONING_STOP_REQUESTED); + Agent mockSubAgent = + createSubAgent("PausedSubAgent", "Sub-agent that pauses", pausedResponse); + registerSubAgent(mockSubAgent); + + MockModel mainModel = createAlwaysToolUseModel("call-paused-1", "call_pausedsubagent"); + ReActAgent mainAgent = createHitlAgent(mainModel); + + Msg response = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT); + + assertNotNull(response); + assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason()); + } + + @Test + @DisplayName("Should resume from paused state after user continues") + void testResumeFromPausedState() { + SubAgentContext context = new SubAgentContext(); + Agent mockSubAgent = createSubAgent("PausableSubAgent", reasoningStopMessage("Paused")); + + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createSequentialToolModel("call_pausablesubagent", 1, "All done!"); + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + Msg response1 = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT); + assertNotNull(response1); + assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason()); + + Msg response2 = mainAgent.call().block(TEST_TIMEOUT); + assertNotNull(response2); + assertEquals(GenerateReason.MODEL_STOP, response2.getGenerateReason()); + } + } + + @Nested + @DisplayName("No Message Continue Tests") + class NoMessageContinueTests { + + @Test + @DisplayName("Should continue without user Result message") + void testContinueWithoutResult() { + SubAgentContext context = new SubAgentContext(); + + Agent mockSubAgent = + createSubAgent( + "AutoResumeSubAgent", suspendedMessage("Waiting for approval...")); + + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createSequentialToolModel("call_autoresumesubagent", 2, "Completed"); + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + Msg response1 = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT); + assertNotNull(response1); + assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason()); + + Msg response2 = mainAgent.call().block(TEST_TIMEOUT); + assertNotNull(response2); + assertEquals(GenerateReason.MODEL_STOP, response2.getGenerateReason()); + } + + @Test + @DisplayName("Should handle multiple suspensions without explicit results") + void testMultipleSuspensionsWithoutResults() { + SubAgentContext context = new SubAgentContext(); + + AtomicInteger subAgentCallCount = new AtomicInteger(0); + ReActAgent mockSubAgent = mock(ReActAgent.class); + when(mockSubAgent.getName()).thenReturn("MultiAutoResumeSubAgent"); + when(mockSubAgent.getDescription()).thenReturn("Sub-agent with multiple auto-resumes"); + when(mockSubAgent.call(any(List.class))) + .thenAnswer( + invocation -> { + int count = subAgentCallCount.incrementAndGet(); + if (count <= 2) { + return Mono.just( + suspendedMessage( + "Step " + count + " suspended", + externalApiToolUse("auto-step-" + count))); + } + return Mono.just(assistantMessage("All steps completed")); + }); + + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createSequentialToolModel("call_multiautoresumesubagent", 3, "Done"); + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + Msg response1 = mainAgent.call(userMessage("Start multi-step")).block(TEST_TIMEOUT); + assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason()); + + Msg response2 = mainAgent.call().block(TEST_TIMEOUT); + assertEquals(GenerateReason.TOOL_SUSPENDED, response2.getGenerateReason()); + + Msg response3 = mainAgent.call().block(TEST_TIMEOUT); + assertEquals(GenerateReason.MODEL_STOP, response3.getGenerateReason()); + } + } + + @Nested + @DisplayName("Complex Multi-turn Interaction Tests") + class ComplexMultiTurnTests { + + @Test + @DisplayName("Should handle multiple concurrent tool suspensions in sub-agent") + void testMultipleConcurrentSuspensions() { + ToolUseBlock tool1 = + ToolUseBlock.builder() + .id("concurrent-api-1") + .name("external_api_1") + .input(Map.of("url", "https://api1.example.com")) + .build(); + + ToolUseBlock tool2 = + ToolUseBlock.builder() + .id("concurrent-api-2") + .name("external_api_2") + .input(Map.of("url", "https://api2.example.com")) + .build(); + + Msg suspendedWithMultipleTools = + Msg.builder() + .role(MsgRole.ASSISTANT) + .content( + List.of( + TextBlock.builder() + .text("Calling multiple APIs...") + .build(), + tool1, + tool2)) + .generateReason(GenerateReason.TOOL_SUSPENDED) + .build(); + + Agent mockSubAgent = + createSubAgent( + "ConcurrentSubAgent", + "Sub-agent with concurrent tools", + suspendedWithMultipleTools); + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createAlwaysToolUseModel("call-concurrent-1", "call_concurrentsubagent"); + ReActAgent mainAgent = createHitlAgent(mainModel); + + Msg response = + mainAgent.call(userMessage("Execute concurrent tasks")).block(TEST_TIMEOUT); + + assertNotNull(response); + assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason()); + + List toolResults = response.getContentBlocks(ToolResultBlock.class); + assertFalse(toolResults.isEmpty()); + } + + @Test + @DisplayName("Should handle same session multiple suspend-resume cycles") + void testSameSessionMultipleCycles() { + SubAgentContext context = new SubAgentContext(); + + AtomicInteger subAgentCallCount = new AtomicInteger(0); + ReActAgent mockSubAgent = mock(ReActAgent.class); + when(mockSubAgent.getName()).thenReturn("CyclicSubAgent"); + when(mockSubAgent.getDescription()).thenReturn("Sub-agent with multiple cycles"); + when(mockSubAgent.call(any(List.class))) + .thenAnswer( + invocation -> { + int count = subAgentCallCount.incrementAndGet(); + if (count <= 3) { + return Mono.just( + suspendedMessage( + "Cycle " + count + " suspended", + externalApiToolUse("cycle-" + count))); + } + return Mono.just(assistantMessage("All cycles completed")); + }); + + registerSubAgent(mockSubAgent); + + MockModel mainModel = createSequentialToolModel("call_cyclicsubagent", 4, "Finished"); + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + for (int i = 1; i <= 3; i++) { + Msg response = + mainAgent + .call(i == 1 ? userMessage("Start cycles") : null) + .block(TEST_TIMEOUT); + assertNotNull(response); + assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason()); + } + + Msg finalResponse = mainAgent.call().block(TEST_TIMEOUT); + assertNotNull(finalResponse); + assertEquals(GenerateReason.MODEL_STOP, finalResponse.getGenerateReason()); + } + } + + @Nested + @DisplayName("Error Handling and Exception Tests") + class ErrorHandlingTests { + + @Test + @DisplayName("Should handle invalid tool ID") + void testInvalidToolId() { + SubAgentContext context = new SubAgentContext(); + Agent mockSubAgent = + createSubAgent( + "SuspendableSubAgent", suspendedMessage("Calling external API...")); + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createToolThenTextModel( + "call-invalid-1", "call_suspendablesubagent", "Completed"); + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + Msg suspendedResponse = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT); + assertNotNull(suspendedResponse); + + ToolResultBlock invalidResult = + ToolResultBlock.builder() + .id("inner-api-call") + .name("external_api") + .output(TextBlock.builder().text("Invalid Result").build()) + .build(); + // Should throw because "invalid-tool-id" is not a registered sub-agent tool + assertThrows( + IllegalArgumentException.class, + () -> context.submitSubAgentResult("invalid-tool-id", invalidResult)); + } + + @Test + @DisplayName("Should handle mismatched Result result") + void testMismatchedResult() { + SubAgentContext context = new SubAgentContext(); + Agent mockSubAgent = + createSubAgent( + "SuspendableSubAgent", suspendedMessage("Calling external API...")); + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createToolThenTextModel( + "call-mismatch-1", "call_suspendablesubagent", "Completed"); + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + Msg suspendedResponse = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT); + assertNotNull(suspendedResponse); + + List toolResults = + suspendedResponse.getContentBlocks(ToolResultBlock.class); + assertFalse(toolResults.isEmpty()); + + ToolResultBlock suspendedResult = toolResults.get(0); + String toolId = suspendedResult.getId(); + + // Submit result with internal tool ID from the suspended sub-agent + ToolResultBlock mismatchedResult = + ToolResultBlock.builder() + .id("inner-api-call") // Internal tool ID that caused suspension + .name("external_api") + .output(TextBlock.builder().text("Mismatched Result").build()) + .build(); + + // First parameter: sub-agent tool ID, second parameter: internal tool result + context.submitSubAgentResult(toolId, mismatchedResult); + + assertTrue(context.hasPendingResult(toolId)); + } + + @Test + @DisplayName("Should handle sub-agent exception during resume") + void testExceptionDuringResume() { + SubAgentContext context = new SubAgentContext(); + Agent mockSubAgent = + createSubAgent("FailingSubAgent", suspendedMessage("About to fail...")); + + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createSequentialToolModel("call_failingsubagent", 2, "Should not reach"); + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + Msg response1 = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT); + assertNotNull(response1); + assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason()); + + try { + mainAgent.call().block(TEST_TIMEOUT); + } catch (Exception e) { + assertTrue(e.getMessage().contains("Resume failed") || e.getCause() != null); + } + } + } + + @Nested + @DisplayName("SubAgentContext Management Tests") + class ContextManagementTests { + + @Test + @DisplayName("Should manage multiple concurrent sub-agent suspensions") + void testMultipleSubAgentSuspensions() { + SubAgentContext context = new SubAgentContext(); + + Agent mockSubAgent1 = + createSubAgent("SubAgent1", actingStopMessage("Calling external API...")); + Agent mockSubAgent2 = + createSubAgent("SubAgent2", suspendedMessage("Calling external API...")); + Agent mockSubAgent3 = + createSubAgent("SubAgent3", reasoningStopMessage("Calling external API...")); + registerSubAgent(mockSubAgent1); + registerSubAgent(mockSubAgent2); + registerSubAgent(mockSubAgent3); + + AtomicInteger callCount = new AtomicInteger(0); + MockModel mainModel = + createMockModel( + messages -> { + int count = callCount.incrementAndGet(); + if (count == 1) { + return List.of( + toolUseResponse( + "call-1", + "call_subagent1", + "{\"message\": \"test\"}"), + toolUseResponse( + "call-2", + "call_subagent2", + "{\"message\": \"value\"}"), + toolUseResponse( + "call-3", + "call_subagent3", + "{\"message\": \"value\"}")); + } + return List.of(textResponse("Both completed")); + }); + + ReActAgent mainAgent = createHitlAgent(mainModel, context); + + Msg response1 = mainAgent.call(userMessage("Start both agents")).block(TEST_TIMEOUT); + assertEquals(GenerateReason.TOOL_SUSPENDED, response1.getGenerateReason()); + + List results = response1.getContentBlocks(ToolResultBlock.class); + + ToolResultBlock firstResult = null; + ToolResultBlock secondResult = null; + ToolResultBlock thirdResult = null; + for (ToolResultBlock resultBlock : results) { + switch (resultBlock.getName()) { + case "call_subagent1" -> firstResult = resultBlock; + case "call_subagent2" -> secondResult = resultBlock; + case "call_subagent3" -> thirdResult = resultBlock; + } + } + assertNotNull(firstResult); + assertNotNull(secondResult); + assertNotNull(thirdResult); + assertEquals( + GenerateReason.ACTING_STOP_REQUESTED, + SubAgentContext.getSubAgentGenerateReason(firstResult)); + assertEquals( + GenerateReason.TOOL_SUSPENDED, + SubAgentContext.getSubAgentGenerateReason(secondResult)); + assertEquals( + GenerateReason.REASONING_STOP_REQUESTED, + SubAgentContext.getSubAgentGenerateReason(thirdResult)); + + // Submit result for the second sub-agent's internal tool call + ToolResultBlock internalResult = + ToolResultBlock.builder() + .id("inner-api-call") + .name("external_api") + .output(TextBlock.builder().text("API response: success").build()) + .build(); + context.submitSubAgentResult(secondResult.getId(), internalResult); + + Msg response2 = mainAgent.call().block(TEST_TIMEOUT); + assertNotNull(response2); + assertEquals(GenerateReason.MODEL_STOP, response2.getGenerateReason()); + } + + @Test + @DisplayName("Should ensure session ID uniqueness across multiple suspensions") + void testSessionIdUniqueness() { + Agent mockSubAgent = createMultiStepSubAgent("SuspendableSubAgent", 2); + registerSubAgent(mockSubAgent); + + AtomicInteger callCount = new AtomicInteger(0); + MockModel mainModel = + createMockModel( + messages -> { + int count = callCount.incrementAndGet(); + if (count <= 2) { + return List.of( + toolUseResponse( + "call-" + count, + "call_suspendablesubagent", + "{\"message\": \"call\"}")); + } + return List.of(textResponse("Done")); + }); + + ReActAgent mainAgent = createHitlAgent(mainModel); + + Msg response1 = mainAgent.call(userMessage("First call")).block(TEST_TIMEOUT); + List results1 = response1.getContentBlocks(ToolResultBlock.class); + Optional sessionId1 = SubAgentContext.extractSessionId(results1.get(0)); + assertTrue(sessionId1.isPresent()); + + Msg response2 = mainAgent.call().block(TEST_TIMEOUT); + List results2 = response2.getContentBlocks(ToolResultBlock.class); + Optional sessionId2 = SubAgentContext.extractSessionId(results2.get(0)); + assertTrue(sessionId2.isPresent()); + + assertTrue(sessionId1.equals(sessionId2), "Session IDs should be same"); + } + } + + @Nested + @DisplayName("HITL Configuration Mismatch Tests") + class HITLConfigurationMismatchTests { + + @Test + @DisplayName("Should handle main agent HITL enabled but sub-agent HITL disabled") + void testMainAgentHITLEnabledSubAgentDisabled() { + Agent mockSubAgent = + createSubAgent("SubAgentNoHITL", suspendedMessage("Processing...")); + + // Register sub-agent with HITL disabled + SubAgentConfig disabledConfig = + SubAgentConfig.builder().forwardEvents(false).enableHITL(false).build(); + mainAgentToolkit.registration().subAgent(() -> mockSubAgent, disabledConfig).apply(); + + MockModel mainModel = + createToolThenTextModel( + "call-subagent-1", "call_subagentnohitl", "Task completed"); + ReActAgent mainAgent = createHitlAgent(mainModel); + + Msg response = mainAgent.call(userMessage("Start task")).block(TEST_TIMEOUT); + + assertNotNull(response); + // Sub-agent suspension should not propagate to main agent + assertEquals( + GenerateReason.MODEL_STOP, + response.getGenerateReason(), + "Main agent should complete normally when sub-agent HITL is disabled"); + } + + @Test + @DisplayName("Should handle main agent HITL disabled but sub-agent HITL enabled") + void testMainAgentHITLDisabledSubAgentEnabled() { + Agent mockSubAgent = + createSubAgent("SubAgentWithHITL", suspendedMessage("Waiting for input...")); + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createToolThenTextModel("call-subagent-2", "call_subagentwithhitl", "All done"); + ReActAgent mainAgent = createNonHitlAgent(mainModel); + + Msg response = mainAgent.call(userMessage("Execute")).block(TEST_TIMEOUT); + + assertNotNull(response); + // Main agent should not suspend even if sub-agent is suspended + assertEquals( + GenerateReason.TOOL_SUSPENDED, + response.getGenerateReason(), + "Main agent should complete normally when main HITL is disabled"); + } + + @Test + @DisplayName("Should handle both main and sub-agent HITL disabled") + void testBothHITLDisabled() { + Agent mockSubAgent = + createSubAgent("SubAgentNoHITL2", suspendedMessage("Processing data...")); + + SubAgentConfig disabledConfig = + SubAgentConfig.builder().forwardEvents(false).enableHITL(false).build(); + mainAgentToolkit.registration().subAgent(() -> mockSubAgent, disabledConfig).apply(); + + MockModel mainModel = + createToolThenTextModel("call-subagent-3", "call_subagentnohitl2", "Finished"); + ReActAgent mainAgent = createNonHitlAgent(mainModel); + + Msg response = mainAgent.call(userMessage("Run")).block(TEST_TIMEOUT); + + assertNotNull(response); + assertEquals( + GenerateReason.MODEL_STOP, + response.getGenerateReason(), + "Both agents should complete normally when HITL is disabled"); + } + } + + @Nested + @DisplayName("Edge Cases and Error Handling Tests") + class EdgeCasesAndErrorHandlingTests { + + @Test + @DisplayName("Should handle sub-agent returning normal response (not suspended)") + void testNormalSubAgentResponse() { + Agent mockSubAgent = createNormalSubAgent("NormalSubAgent", "Normal response"); + registerSubAgent(mockSubAgent); + + MockModel mainModel = + createToolThenTextModel("call-normal-1", "call_normalsubagent", "Task done"); + ReActAgent mainAgent = createHitlAgent(mainModel); + + Msg response = mainAgent.call(userMessage("Say hello")).block(TEST_TIMEOUT); + + assertNotNull(response); + assertEquals( + GenerateReason.MODEL_STOP, + response.getGenerateReason(), + "Should complete normally without suspension"); + } + + @Test + @DisplayName("Should handle empty tool results gracefully") + void testEmptyToolResults() { + Msg pausedResponse = + assistantMessage("Paused", GenerateReason.REASONING_STOP_REQUESTED); + Agent mockSubAgent = + createSubAgent("EmptyResultAgent", "Agent with empty result", pausedResponse); + registerSubAgent(mockSubAgent); + + MockModel mainModel = createAlwaysToolUseModel("call-empty-1", "call_emptyresultagent"); + ReActAgent mainAgent = createHitlAgent(mainModel); + + Msg response = mainAgent.call(userMessage("Test")).block(TEST_TIMEOUT); + + assertNotNull(response); + assertEquals(GenerateReason.TOOL_SUSPENDED, response.getGenerateReason()); + } + } + + // ==================== Message Factory Methods ==================== + + /** + * Creates a user message with the given text. + */ + private Msg userMessage(String text) { + return Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + /** + * Creates an assistant message with the given text. + */ + private Msg assistantMessage(String text) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private Msg actingStopMessage(String text) { + ToolUseBlock innerToolUse = externalApiToolUse("inner-api-call"); + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text(text).build(), innerToolUse)) + .generateReason(GenerateReason.ACTING_STOP_REQUESTED) + .build(); + } + + private Msg suspendedMessage(String text) { + ToolUseBlock innerToolUse = externalApiToolUse("inner-api-call"); + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text(text).build(), innerToolUse)) + .generateReason(GenerateReason.TOOL_SUSPENDED) + .build(); + } + + private Msg reasoningStopMessage(String text) { + ToolUseBlock innerToolUse = externalApiToolUse("inner-api-call"); + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text(text).build(), innerToolUse)) + .generateReason(GenerateReason.REASONING_STOP_REQUESTED) + .build(); + } + + /** + * Creates a suspended assistant message with tool use block. + */ + private Msg suspendedMessage(String text, ToolUseBlock toolUse) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(List.of(TextBlock.builder().text(text).build(), toolUse)) + .generateReason(GenerateReason.TOOL_SUSPENDED) + .build(); + } + + /** + * Creates an assistant message with custom generate reason. + */ + private Msg assistantMessage(String text, GenerateReason reason) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text(text).build()) + .generateReason(reason) + .build(); + } + + // ==================== Response Factory Methods ==================== + + /** + * Creates a ChatResponse with a single text block. + */ + private ChatResponse textResponse(String text) { + return ChatResponse.builder() + .content(List.of(TextBlock.builder().text(text).build())) + .usage(DEFAULT_USAGE) + .build(); + } + + /** + * Creates a ChatResponse with a tool use block. + */ + private ChatResponse toolUseResponse(String toolId, String toolName, String content) { + return ChatResponse.builder() + .content( + List.of( + ToolUseBlock.builder() + .id(toolId) + .name(toolName) + .content(content) + .build())) + .usage(DEFAULT_USAGE) + .build(); + } + + /** + * Creates a ToolUseBlock for external API calls. + */ + private ToolUseBlock externalApiToolUse(String id) { + return ToolUseBlock.builder() + .id(id) + .name("external_api") + .input(Map.of("url", "https://api.example.com")) + .build(); + } + + // ==================== Config Factory Methods ==================== + + /** + * Creates a default SubAgentConfig with HITL enabled. + */ + private SubAgentConfig hitlEnabledConfig() { + return SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build(); + } + + // ==================== Model Factory Methods ==================== + + /** + * Creates a MockModel with custom response handler. + */ + private MockModel createMockModel(Function, List> handler) { + return new MockModel(handler); + } + + /** + * Creates a MockModel that returns tool use on first call, then text response. + */ + private MockModel createToolThenTextModel(String toolId, String toolName, String finalText) { + AtomicInteger callCount = new AtomicInteger(0); + return createMockModel( + messages -> { + if (callCount.incrementAndGet() == 1) { + return List.of( + toolUseResponse(toolId, toolName, "{\"message\": \"value\"}")); + } + return List.of(textResponse(finalText)); + }); + } + + /** + * Creates a MockModel that always returns tool use response. + */ + private MockModel createAlwaysToolUseModel(String toolId, String toolName) { + return createMockModel( + messages -> List.of(toolUseResponse(toolId, toolName, "{\"message\": \"value\"}"))); + } + + /** + * Creates a MockModel with sequential tool calls then final text. + */ + private MockModel createSequentialToolModel( + String toolNamePrefix, int toolCallCount, String finalText) { + AtomicInteger callCount = new AtomicInteger(0); + return createMockModel( + messages -> { + int count = callCount.incrementAndGet(); + if (count <= toolCallCount) { + return List.of( + toolUseResponse( + "call-" + count, + toolNamePrefix, + "{\"message\": \"Execute step " + count + "\"}")); + } + return List.of(textResponse(finalText)); + }); + } + + // ==================== Agent Factory Methods ==================== + + /** + * Registers a sub-agent with HITL config to the toolkit. + */ + private void registerSubAgent(Agent subAgent) { + mainAgentToolkit.registration().subAgent(() -> subAgent, hitlEnabledConfig()).apply(); + } + + /** + * Creates a ReActAgent with HITL enabled. + */ + private ReActAgent createHitlAgent(MockModel model) { + return ReActAgent.builder() + .name(MAIN_AGENT_NAME) + .sysPrompt(DEFAULT_SYS_PROMPT) + .model(model) + .toolkit(mainAgentToolkit) + .memory(mainAgentMemory) + .enableSubAgentHITL(true) + .build(); + } + + /** + * Creates a ReActAgent with HITL enabled and custom context. + */ + private ReActAgent createHitlAgent(MockModel model, SubAgentContext context) { + return ReActAgent.builder() + .name(MAIN_AGENT_NAME) + .sysPrompt(DEFAULT_SYS_PROMPT) + .model(model) + .toolkit(mainAgentToolkit) + .memory(mainAgentMemory) + .subAgentContext(context) + .enableSubAgentHITL(true) + .build(); + } + + /** + * Creates a ReActAgent with HITL disabled. + */ + private ReActAgent createNonHitlAgent(MockModel model) { + return ReActAgent.builder() + .name(MAIN_AGENT_NAME) + .sysPrompt(DEFAULT_SYS_PROMPT) + .model(model) + .toolkit(mainAgentToolkit) + .memory(mainAgentMemory) + .enableSubAgentHITL(false) + .build(); + } + + /** + * Creates a mock sub-agent that returns response. + */ + private Agent createSubAgent(String name, Msg response) { + AtomicInteger count = new AtomicInteger(0); + ReActAgent mockSubAgent = mock(ReActAgent.class); + when(mockSubAgent.getName()).thenReturn(name); + when(mockSubAgent.getDescription()).thenReturn("Sub-agent that suspends"); + when(mockSubAgent.call(any(List.class))) + .thenAnswer( + invocation -> { + int c = count.incrementAndGet(); + if (c == 1) { + return Mono.just(response); + } + return Mono.just(assistantMessage("Task completed successfully")); + }); + return mockSubAgent; + } + + private Agent createMultiStepSubAgent(String name, int step) { + AtomicInteger subAgentCallCount = new AtomicInteger(0); + ReActAgent mockSubAgent = mock(ReActAgent.class); + when(mockSubAgent.getName()).thenReturn(name); + when(mockSubAgent.getDescription()).thenReturn("Sub-agent with multiple steps"); + when(mockSubAgent.call(any(List.class))) + .thenAnswer( + invocation -> { + int count = subAgentCallCount.incrementAndGet(); + if (count <= step) { + return Mono.just( + suspendedMessage( + "Step " + count + " - calling API...", + externalApiToolUse("step-" + count + "-api"))); + } + return Mono.just(assistantMessage("All steps completed")); + }); + return mockSubAgent; + } + + /** + * Creates a mock sub-agent that returns normal response. + */ + private Agent createNormalSubAgent(String name, String response) { + ReActAgent mockSubAgent = mock(ReActAgent.class); + when(mockSubAgent.getName()).thenReturn(name); + when(mockSubAgent.getDescription()).thenReturn("Normal sub-agent"); + when(mockSubAgent.call(any(List.class))).thenReturn(Mono.just(assistantMessage(response))); + return mockSubAgent; + } + + /** + * Creates a mock sub-agent with custom response. + */ + private Agent createSubAgent(String name, String description, Msg response) { + ReActAgent mockSubAgent = mock(ReActAgent.class); + when(mockSubAgent.getName()).thenReturn(name); + when(mockSubAgent.getDescription()).thenReturn(description); + when(mockSubAgent.call(any(List.class))).thenReturn(Mono.just(response)); + return mockSubAgent; + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHookTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHookTest.java new file mode 100644 index 000000000..21b699a72 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentHookTest.java @@ -0,0 +1,531 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.subagent; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.hook.PreActingEvent; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.tool.Toolkit; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * SubAgentHook basic functionality tests + * + *

    Coverage includes: + *

      + *
    • Basic functionalities: context return, priority, result injection
    • + *
    • Null handling: null tool use, null input, null metadata
    • + *
    • Boundary conditions: empty string tool ID
    • + *
    • State consistency: multiple executions, metadata preservation, interleaved operations
    • + *
    • Exception handling: empty pending result list
    • + *
    • Integration: SubAgentContext state management
    • + *
    + * + */ +@DisplayName("SubAgentHook Basic Tests") +class SubAgentHookTest { + + private static final Duration TEST_TIMEOUT = Duration.ofSeconds(10); + + private SubAgentContext context; + private SubAgentHook hook; + + @BeforeEach + void setUp() { + context = new SubAgentContext(); + hook = new SubAgentHook(context); + } + + @Nested + @DisplayName("Basic Functionality Tests") + class BasicFunctionalityTests { + + @Test + @DisplayName("Should return correct context") + void testGetContext() { + assertSame(context, hook.getContext()); + } + + @Test + @DisplayName("Should have correct priority") + void testPriority() { + assertEquals(10, hook.priority()); + } + + @Test + @DisplayName("Should inject pending result into tool use") + void testInjectPendingResult() { + // Sub-agent tool ID (the tool that invokes the sub-agent) + String subAgentToolId = "tool-123"; + String sessionId = "session-abc"; + + // Internal tool ID used by the sub-agent when it calls tools + String internalToolId = "internal-tool-456"; + ToolResultBlock pendingResult = createToolResultBlock(internalToolId, "Pending result"); + context.setSessionId(subAgentToolId, sessionId); + context.submitSubAgentResult(subAgentToolId, pendingResult); + + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id(subAgentToolId) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT); + + assertNotNull(result); + ToolUseBlock modifiedToolUse = result.getToolUse(); + assertNotNull(modifiedToolUse); + + assertTrue( + modifiedToolUse.getMetadata().containsKey(SubAgentHook.PREVIOUS_TOOL_RESULT)); + assertEquals(sessionId, modifiedToolUse.getInput().get("session_id")); + } + + @Test + @DisplayName("Should not modify tool use when no pending result") + void testNoPendingResult() { + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id("tool-456") + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT); + + assertNotNull(result); + ToolUseBlock resultToolUse = result.getToolUse(); + assertEquals(toolUse.getId(), resultToolUse.getId()); + assertFalse(resultToolUse.getMetadata().containsKey(SubAgentHook.PREVIOUS_TOOL_RESULT)); + } + + @Test + @DisplayName("Should inject multiple pending results") + void testInjectMultiplePendingResults() { + // Sub-agent tool ID + String subAgentToolId = "tool-multi"; + String sessionId = "session-multi"; + + // Internal tool IDs used by the sub-agent + String internalToolId1 = "internal-tool-1"; + String internalToolId2 = "internal-tool-2"; + ToolResultBlock result1 = createToolResultBlock(internalToolId1, "Result 1"); + ToolResultBlock result2 = createToolResultBlock(internalToolId2, "Result 2"); + + context.setSessionId(subAgentToolId, sessionId); + context.submitSubAgentResults(subAgentToolId, List.of(result1, result2)); + + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id(subAgentToolId) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT); + + assertNotNull(result); + ToolUseBlock modifiedToolUse = result.getToolUse(); + + @SuppressWarnings("unchecked") + List injectedResults = + (List) + modifiedToolUse.getMetadata().get(SubAgentHook.PREVIOUS_TOOL_RESULT); + assertNotNull(injectedResults); + assertEquals(2, injectedResults.size()); + } + + @Test + @DisplayName("Should consume pending results after injection") + void testConsumePendingResultsAfterInjection() { + // Sub-agent tool ID + String subAgentToolId = "tool-consume"; + String sessionId = "session-consume"; + + // Internal tool ID used by the sub-agent + String internalToolId = "internal-tool-consume"; + ToolResultBlock pendingResult = createToolResultBlock(internalToolId, "Pending result"); + context.setSessionId(subAgentToolId, sessionId); + context.submitSubAgentResult(subAgentToolId, pendingResult); + + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id(subAgentToolId) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + hook.onEvent(event).block(TEST_TIMEOUT); + + // Verify pending result was consumed + assertFalse(context.hasPendingResult(subAgentToolId)); + } + } + + @Nested + @DisplayName("Null Handling Tests") + class NullHandlingTests { + + @Test + @DisplayName("Should handle null tool use") + void testNullToolUse() { + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, null); + + PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT); + assertNotNull(result); + } + + @Test + @DisplayName("Should handle null input in tool use") + void testNullInput() { + ToolUseBlock toolUse = + ToolUseBlock.builder().id("tool-789").name("test_tool").input(null).build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT); + assertNotNull(result); + } + + @Test + @DisplayName("Should handle null metadata in tool use") + void testNullMetadata() { + // Sub-agent tool ID + String subAgentToolId = "tool-null-meta"; + // Internal tool ID used by the sub-agent + String internalToolId = "internal-tool-null"; + ToolResultBlock pendingResult = createToolResultBlock(internalToolId, "Result"); + context.setSessionId(subAgentToolId, "session-null"); + context.submitSubAgentResult(subAgentToolId, pendingResult); + + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id(subAgentToolId) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT); + assertNotNull(result); + ToolUseBlock modifiedToolUse = result.getToolUse(); + assertNotNull(modifiedToolUse.getMetadata()); + } + } + + @Nested + @DisplayName("Boundary Condition Tests") + class BoundaryConditionTests { + @Test + @DisplayName("Should handle empty string tool ID") + void testEmptyStringToolId() { + // Sub-agent tool ID (empty string for boundary test) + String subAgentToolId = ""; + // Internal tool ID used by the sub-agent + String internalToolId = "internal-tool-empty"; + ToolResultBlock pendingResult = createToolResultBlock(internalToolId, "Pending result"); + context.setSessionId(subAgentToolId, "session-empty"); + context.submitSubAgentResult(subAgentToolId, pendingResult); + + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id(subAgentToolId) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT); + assertNotNull(result); + } + } + + @Nested + @DisplayName("State Consistency Tests") + class StateConsistencyTests { + + @Test + @DisplayName("Should maintain state after multiple hook executions") + void testStateAfterMultipleExecutions() { + // Sub-agent tool ID + String subAgentToolId = "tool-repeat"; + + // First execution - internal tool ID used by the sub-agent + String internalToolId1 = "internal-tool-repeat-1"; + ToolResultBlock result1 = createToolResultBlock(internalToolId1, "Result 1"); + context.setSessionId(subAgentToolId, "session-1"); + context.submitSubAgentResult(subAgentToolId, result1); + + ToolUseBlock toolUse1 = + ToolUseBlock.builder() + .id(subAgentToolId) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event1 = new PreActingEvent(mockAgent, mockToolkit, toolUse1); + hook.onEvent(event1).block(TEST_TIMEOUT); + + assertFalse(context.hasPendingResult(subAgentToolId)); + + // Second execution with new result - different internal tool ID + String internalToolId2 = "internal-tool-repeat-2"; + ToolResultBlock result2 = createToolResultBlock(internalToolId2, "Result 2"); + context.setSessionId(subAgentToolId, "session-2"); + context.submitSubAgentResult(subAgentToolId, result2); + + ToolUseBlock toolUse2 = + ToolUseBlock.builder() + .id(subAgentToolId) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + PreActingEvent event2 = new PreActingEvent(mockAgent, mockToolkit, toolUse2); + PreActingEvent result2Event = hook.onEvent(event2).block(TEST_TIMEOUT); + + assertNotNull(result2Event); + assertEquals("session-2", result2Event.getToolUse().getInput().get("session_id")); + } + + @Test + @DisplayName("Should preserve metadata through hook execution") + void testMetadataPreservation() { + // Sub-agent tool ID + String subAgentToolId = "tool-meta"; + String sessionId = "session-meta"; + + Map originalMetadata = new HashMap<>(); + originalMetadata.put("custom_key", "custom_value"); + + // Internal tool ID used by the sub-agent + String internalToolId = "internal-tool-meta"; + ToolResultBlock pendingResult = createToolResultBlock(internalToolId, "Pending result"); + context.setSessionId(subAgentToolId, sessionId); + context.submitSubAgentResult(subAgentToolId, pendingResult); + + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id(subAgentToolId) + .name("test_tool") + .input(Map.of("key", "value")) + .metadata(originalMetadata) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT); + + assertNotNull(result); + ToolUseBlock modifiedToolUse = result.getToolUse(); + // Original metadata should be preserved + assertEquals("custom_value", modifiedToolUse.getMetadata().get("custom_key")); + // New metadata key should be added + assertTrue( + modifiedToolUse.getMetadata().containsKey(SubAgentHook.PREVIOUS_TOOL_RESULT)); + } + + @Test + @DisplayName("Should handle interleaved operations correctly") + void testInterleavedOperations() { + // Sub-agent tool IDs + String subAgentToolId1 = "tool-1"; + String subAgentToolId2 = "tool-2"; + + // Add result for tool 1 - internal tool ID used by sub-agent 1 + String internalToolId1 = "internal-tool-1"; + ToolResultBlock result1 = createToolResultBlock(internalToolId1, "Result 1"); + context.setSessionId(subAgentToolId1, "session-1"); + context.submitSubAgentResult(subAgentToolId1, result1); + + // Execute hook for tool 1 + ToolUseBlock toolUse1 = + ToolUseBlock.builder() + .id(subAgentToolId1) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event1 = new PreActingEvent(mockAgent, mockToolkit, toolUse1); + hook.onEvent(event1).block(TEST_TIMEOUT); + + // Add result for tool 2 - internal tool ID used by sub-agent 2 + String internalToolId2 = "internal-tool-2"; + ToolResultBlock result2 = createToolResultBlock(internalToolId2, "Result 2"); + context.setSessionId(subAgentToolId2, "session-2"); + context.submitSubAgentResult(subAgentToolId2, result2); + + // Execute hook for tool 2 + ToolUseBlock toolUse2 = + ToolUseBlock.builder() + .id(subAgentToolId2) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + PreActingEvent event2 = new PreActingEvent(mockAgent, mockToolkit, toolUse2); + PreActingEvent result2Event = hook.onEvent(event2).block(TEST_TIMEOUT); + + assertNotNull(result2Event); + assertEquals("session-2", result2Event.getToolUse().getInput().get("session_id")); + + // Verify final state + assertFalse(context.hasPendingResult(subAgentToolId1)); + assertFalse(context.hasPendingResult(subAgentToolId2)); + } + } + + @Nested + @DisplayName("Exception Handling Tests") + class ExceptionHandlingTests { + + @Test + @DisplayName("Should handle empty pending result list") + void testEmptyPendingResultList() { + // Sub-agent tool ID + String subAgentToolId = "tool-empty-list"; + + // Store session ID but no pending results + context.setSessionId(subAgentToolId, "session-empty"); + + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id(subAgentToolId) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + PreActingEvent result = hook.onEvent(event).block(TEST_TIMEOUT); + + assertNotNull(result); + assertTrue( + result.getToolUse() + .getMetadata() + .containsKey(SubAgentHook.PREVIOUS_TOOL_RESULT)); + } + } + + @Nested + @DisplayName("Integration with SubAgentContext Tests") + class SubAgentContextIntegrationTests { + + @Test + @DisplayName("Should work with SubAgentContext state management") + void testContextStateManagement() { + // Sub-agent tool ID + String subAgentToolId = "tool-state"; + String sessionId = "session-state"; + + // Internal tool ID used by the sub-agent + String internalToolId = "internal-tool-state"; + ToolResultBlock result = createToolResultBlock(internalToolId, "State result"); + context.setSessionId(subAgentToolId, sessionId); + context.submitSubAgentResult(subAgentToolId, result); + + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id(subAgentToolId) + .name("test_tool") + .input(Map.of("key", "value")) + .build(); + + Agent mockAgent = mock(Agent.class); + Toolkit mockToolkit = mock(Toolkit.class); + + PreActingEvent event = new PreActingEvent(mockAgent, mockToolkit, toolUse); + + hook.onEvent(event).block(TEST_TIMEOUT); + + // Verify context state after hook execution + assertFalse(context.hasPendingResult(subAgentToolId)); + assertFalse(context.getSessionId(subAgentToolId).isPresent()); + } + } + + private ToolResultBlock createToolResultBlock(String id, String content) { + return new ToolResultBlock( + id, + "test-tool", + List.of(TextBlock.builder().text(content).build()), + new HashMap<>()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentPendingStoreTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentPendingStoreTest.java new file mode 100644 index 000000000..bee934a2f --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentPendingStoreTest.java @@ -0,0 +1,482 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.subagent; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Comprehensive tests for SubAgentPendingStore functionality. + * + *

    Test coverage includes: + *

      + *
    • Basic functionality: storing and retrieving pending results, session IDs
    • + *
    • SessionId-first constraint: enforcement of lifecycle management
    • + *
    • Boundary conditions: null values, empty strings, special characters
    • + *
    • Store consistency: correctness of multiple operations
    • + *
    • Defensive copying: prevention of external modifications
    • + *
    • Context management: new context creation on sessionId updates and result additions
    • + *
    + */ +@DisplayName("SubAgentPendingStore Tests") +class SubAgentPendingStoreTest { + + private SubAgentPendingStore store; + + @BeforeEach + void setUp() { + store = new SubAgentPendingStore(); + } + + @Nested + @DisplayName("Session ID Management Tests") + class SessionIdManagementTests { + + @Test + @DisplayName("Should store and retrieve session ID") + void testStoreAndRetrieveSessionId() { + String toolId = "tool-456"; + String sessionId = "session-abc"; + + store.setSessionId(toolId, sessionId); + + String retrieved = store.getSessionId(toolId); + assertEquals(sessionId, retrieved); + } + + @Test + @DisplayName("Should update session ID for existing tool ID") + void testUpdateSessionId() { + String toolId = "tool-789"; + String sessionId1 = "session-xyz"; + String sessionId2 = "session-updated"; + + store.setSessionId(toolId, sessionId1); + assertEquals(sessionId1, store.getSessionId(toolId)); + + store.setSessionId(toolId, sessionId2); + assertEquals(sessionId2, store.getSessionId(toolId)); + } + + @Test + @DisplayName("Should return null for non-existent tool ID") + void testNonExistentSessionId() { + String sessionId = store.getSessionId("non-existent"); + assertNull(sessionId); + } + + @Test + @DisplayName("Should handle multiple session IDs") + void testMultipleSessionIds() { + store.setSessionId("tool-1", "session-1"); + store.setSessionId("tool-2", "session-2"); + store.setSessionId("tool-3", "session-3"); + + assertEquals("session-1", store.getSessionId("tool-1")); + assertEquals("session-2", store.getSessionId("tool-2")); + assertEquals("session-3", store.getSessionId("tool-3")); + } + + @Test + @DisplayName("Should check if tool has registered session ID") + void testContains() { + assertFalse(store.contains("tool-1")); + + store.setSessionId("tool-1", "session-1"); + + assertTrue(store.contains("tool-1")); + assertFalse(store.contains("tool-2")); + } + } + + @Nested + @DisplayName("Pending Result Management Tests") + class PendingResultManagementTests { + + @Test + @DisplayName("Should add and retrieve pending result") + void testAddAndRetrievePendingResult() { + String toolId = "tool-123"; + String sessionId = "session-abc"; + ToolResultBlock result = createToolResultBlock(toolId, "Test result"); + + // Must set session ID first + store.setSessionId(toolId, sessionId); + store.addResult(toolId, result); + + List retrieved = store.getPendingResults(toolId); + assertEquals(1, retrieved.size()); + assertEquals(toolId, retrieved.get(0).getId()); + } + + @Test + @DisplayName("Should handle multiple results for same tool ID") + void testMultipleResultsForSameToolId() { + String toolId = "tool-1"; + String sessionId = "session-1"; + ToolResultBlock result1 = createToolResultBlock(toolId, "First result"); + ToolResultBlock result2 = createToolResultBlock(toolId, "Second result"); + + store.setSessionId(toolId, sessionId); + store.addResult(toolId, result1); + store.addResult(toolId, result2); + + List retrieved = store.getPendingResults(toolId); + assertEquals(2, retrieved.size()); + assertEquals( + "First result", ((TextBlock) retrieved.get(0).getOutput().get(0)).getText()); + assertEquals( + "Second result", ((TextBlock) retrieved.get(1).getOutput().get(0)).getText()); + } + + @Test + @DisplayName("Should return empty list for non-existent tool ID") + void testNonExistentToolId() { + List results = store.getPendingResults("non-existent"); + assertNotNull(results); + assertTrue(results.isEmpty()); + } + + @Test + @DisplayName("Should check if tool has pending results") + void testHasPendingResults() { + String toolId = "tool-1"; + String sessionId = "session-1"; + + store.setSessionId(toolId, sessionId); + + assertFalse(store.hasPendingResults(toolId)); + + store.addResult(toolId, createToolResultBlock(toolId, "Result")); + + assertTrue(store.hasPendingResults(toolId)); + } + + @Test + @DisplayName("Should return false for non-existent tool ID in hasPendingResults") + void testHasPendingResultsNonExistent() { + assertFalse(store.hasPendingResults("non-existent")); + } + + @Test + @DisplayName("Should preserve result order") + void testPreserveResultOrder() { + String toolId = "tool-1"; + String sessionId = "session-1"; + + store.setSessionId(toolId, sessionId); + + for (int i = 0; i < 5; i++) { + store.addResult(toolId, createToolResultBlock(toolId, "Result " + i)); + } + + List results = store.getPendingResults(toolId); + assertEquals(5, results.size()); + + for (int i = 0; i < 5; i++) { + assertEquals( + "Result " + i, ((TextBlock) results.get(i).getOutput().get(0)).getText()); + } + } + + @Test + @DisplayName("Should return defensive copy of results") + void testDefensiveCopy() { + String toolId = "tool-1"; + String sessionId = "session-1"; + + store.setSessionId(toolId, sessionId); + store.addResult(toolId, createToolResultBlock(toolId, "Original")); + + List results1 = store.getPendingResults(toolId); + List results2 = store.getPendingResults(toolId); + + // Should be different list instances + assertNotSame(results1, results2); + + // Should have same content + assertEquals(results1, results2); + + // Modifying one should not affect the other or the Store + results1.clear(); + + List results3 = store.getPendingResults(toolId); + assertEquals(1, results3.size()); + assertEquals("Original", ((TextBlock) results3.get(0).getOutput().get(0)).getText()); + } + } + + @Nested + @DisplayName("SessionId-First Constraint Tests") + class SessionIdFirstConstraintTests { + + @Test + @DisplayName("Should enforce sessionId-first constraint") + void testSessionIdFirstConstraint() { + String toolId = "tool-123"; + ToolResultBlock result = createToolResultBlock(toolId, "Test result"); + + // Try to add result without setting session ID + IllegalStateException exception = + assertThrows( + IllegalStateException.class, () -> store.addResult(toolId, result)); + + assertTrue(exception.getMessage().contains("Cannot add result")); + assertTrue(exception.getMessage().contains("without a registered session ID")); + assertTrue(exception.getMessage().contains("Call setSessionId() first")); + } + + @Test + @DisplayName("Should allow adding result after session ID is set") + void testAddResultAfterSessionId() { + String toolId = "tool-123"; + ToolResultBlock result = createToolResultBlock(toolId, "Test result"); + + // Set session ID first + store.setSessionId(toolId, "session-abc"); + + // Now adding result should succeed + assertDoesNotThrow(() -> store.addResult(toolId, result)); + + List results = store.getPendingResults(toolId); + assertEquals(1, results.size()); + } + } + + @Nested + @DisplayName("Null Handling Tests") + class NullHandlingTests { + + @Test + @DisplayName("Should throw IllegalArgumentException for null tool ID in setSessionId") + void testNullToolIdInSetSessionId() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> store.setSessionId(null, "session-1")); + assertTrue(exception.getMessage().contains("toolId cannot be null")); + } + + @Test + @DisplayName("Should throw IllegalArgumentException for null session ID in setSessionId") + void testNullSessionIdInSetSessionId() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> store.setSessionId("tool-1", null)); + assertTrue(exception.getMessage().contains("sessionId cannot be null")); + } + + @Test + @DisplayName("Should throw IllegalArgumentException for null tool ID in addResult") + void testNullToolIdInAddResult() { + ToolResultBlock result = createToolResultBlock("tool-1", "Result"); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, () -> store.addResult(null, result)); + assertTrue(exception.getMessage().contains("toolId cannot be null")); + } + + @Test + @DisplayName("Should throw IllegalArgumentException for null result in addResult") + void testNullResultInAddResult() { + store.setSessionId("tool-1", "session-1"); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, () -> store.addResult("tool-1", null)); + assertTrue(exception.getMessage().contains("result cannot be null")); + } + } + + @Nested + @DisplayName("Clear and Remove Tests") + class ClearAndRemoveTests { + + @Test + @DisplayName("Should remove tool data") + void testRemove() { + store.setSessionId("tool-1", "session-1"); + store.addResult("tool-1", createToolResultBlock("tool-1", "Result")); + + assertTrue(store.contains("tool-1")); + assertTrue(store.hasPendingResults("tool-1")); + + store.remove("tool-1"); + + assertFalse(store.contains("tool-1")); + assertFalse(store.hasPendingResults("tool-1")); + assertNull(store.getSessionId("tool-1")); + } + + @Test + @DisplayName("Should clear all data") + void testClearAll() { + store.setSessionId("tool-1", "session-1"); + store.addResult("tool-1", createToolResultBlock("tool-1", "Result 1")); + store.setSessionId("tool-2", "session-2"); + store.addResult("tool-2", createToolResultBlock("tool-2", "Result 2")); + + assertFalse(store.isEmpty()); + + store.clearAll(); + + assertTrue(store.isEmpty()); + assertFalse(store.contains("tool-1")); + assertFalse(store.contains("tool-2")); + } + + @Test + @DisplayName("Should check if Store is empty") + void testIsEmpty() { + assertTrue(store.isEmpty()); + + store.setSessionId("tool-1", "session-1"); + + assertFalse(store.isEmpty()); + + store.clearAll(); + + assertTrue(store.isEmpty()); + } + } + + @Nested + @DisplayName("Store Consistency Tests") + class StoreConsistencyTests { + + @Test + @DisplayName("Should handle interleaved operations correctly") + void testInterleavedOperations() { + // Add result 1 + store.setSessionId("tool-1", "session-1"); + store.addResult("tool-1", createToolResultBlock("tool-1", "Result 1")); + // Add session ID 1 + store.setSessionId("tool-2", "session-2"); + // Add result 2 + store.addResult("tool-2", createToolResultBlock("tool-2", "Result 2")); + // Remove result 1 + store.remove("tool-1"); + // Add result 3 + store.setSessionId("tool-3", "session-3"); + store.addResult("tool-3", createToolResultBlock("tool-3", "Result 3")); + + // Verify final Store + assertFalse(store.contains("tool-1")); + assertTrue(store.contains("tool-2")); + assertTrue(store.contains("tool-3")); + assertEquals("session-2", store.getSessionId("tool-2")); + assertEquals("session-3", store.getSessionId("tool-3")); + } + + @Test + @DisplayName("Should preserve metadata through operations") + void testMetadataPreservation() { + Map metadata = new HashMap<>(); + metadata.put("custom_key", "custom_value"); + + ToolResultBlock result = + ToolResultBlock.builder() + .id("tool-1") + .output(TextBlock.builder().text("Result").build()) + .metadata(metadata) + .build(); + + store.setSessionId("tool-1", "session-123"); + store.addResult("tool-1", result); + + // Get result + List results = store.getPendingResults("tool-1"); + assertEquals(1, results.size()); + + ToolResultBlock retrievedResult = results.get(0); + assertEquals("custom_value", retrievedResult.getMetadata().get("custom_key")); + } + } + + @Nested + @DisplayName("Context Management Tests") + class ContextManagementTests { + + @Test + @DisplayName("Should create new context on sessionId update") + void testNewContextOnSessionIdUpdate() { + String toolId = "tool-1"; + String sessionId1 = "session-1"; + String sessionId2 = "session-2"; + + store.setSessionId(toolId, sessionId1); + store.addResult(toolId, createToolResultBlock(toolId, "Result 1")); + + List results1 = store.getPendingResults(toolId); + + store.setSessionId(toolId, sessionId2); + + List results2 = store.getPendingResults(toolId); + + // Results should be different instances + assertNotSame(results1, results2); + assertEquals(1, results1.size()); + assertEquals(0, results2.size()); + } + + @Test + @DisplayName("Should create new context on result addition") + void testNewContextOnResultAddition() { + String toolId = "tool-1"; + String sessionId = "session-1"; + + store.setSessionId(toolId, sessionId); + store.addResult(toolId, createToolResultBlock(toolId, "Result 1")); + + List results1 = store.getPendingResults(toolId); + + store.addResult(toolId, createToolResultBlock(toolId, "Result 2")); + + List results2 = store.getPendingResults(toolId); + + // Results should be different instances + assertNotSame(results1, results2); + assertEquals(1, results1.size()); + assertEquals(2, results2.size()); + } + } + + private ToolResultBlock createToolResultBlock(String id, String content) { + return new ToolResultBlock( + id, + "test-tool", + List.of(TextBlock.builder().text(content).build()), + new HashMap<>()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java index 909badfd8..e8c9be447 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/SubAgentToolTest.java @@ -25,10 +25,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import io.agentscope.core.ReActAgent; import io.agentscope.core.agent.Agent; import io.agentscope.core.agent.Event; import io.agentscope.core.agent.EventType; import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; @@ -42,7 +44,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -428,44 +432,498 @@ void testCustomStreamOptions() { verify(mockAgent).stream(any(List.class), any(StreamOptions.class)); } + /** + * HITL (Human-in-the-Loop) Tests for SubAgentTool. + * + *

    Tests cover: + *

      + *
    • Suspended state detection and result building
    • + *
    • Resume functionality with injected tool results
    • + *
    • HITL enabled/disabled behavior
    • + *
    • Multiple suspension types handling
    • + *
    + */ + @Nested + @DisplayName("HITL (Human-in-the-Loop) Tests") + class HITLTests { + + @Test + @DisplayName( + "Should return suspended result when sub-agent is suspended with TOOL_SUSPENDED") + void testSuspendedResultOnToolSuspended() { + ReActAgent mockAgent = mock(ReActAgent.class); + when(mockAgent.getName()).thenReturn("SuspendableAgent"); + when(mockAgent.getDescription()).thenReturn("Agent that can suspend"); + + ToolUseBlock innerToolUse = + createToolUseBlock( + "inner-tool-1", + "external_api", + Map.of("url", "https://api.example.com")); + + Msg suspendedResponse = + createMultiContentMsg( + List.of( + TextBlock.builder().text("Calling external API...").build(), + innerToolUse), + io.agentscope.core.message.GenerateReason.TOOL_SUSPENDED); + + when(mockAgent.call(any(List.class))).thenReturn(Mono.just(suspendedResponse)); + + SubAgentConfig config = + SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map input = Map.of("message", "Call the external API"); + ToolUseBlock toolUse = createToolUseBlock("tool-1", "call_suspendableagent", input); + + ToolResultBlock result = executeToolCall(tool, toolUse, input); + + assertNotNull(result); + assertTrue(result.isSuspended(), "Result should be marked as suspended"); + assertNotNull( + result.getMetadata().get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID), + "Should contain session ID in metadata"); + assertEquals( + io.agentscope.core.message.GenerateReason.TOOL_SUSPENDED, + result.getMetadata().get(SubAgentContext.METADATA_GENERATE_REASON), + "Should contain generate reason in metadata"); + } + + @Test + @DisplayName( + "Should return suspended result when sub-agent is suspended with" + + " REASONING_STOP_REQUESTED") + void testSuspendedResultOnReasoningStopRequested() { + ReActAgent mockAgent = mock(ReActAgent.class); + when(mockAgent.getName()).thenReturn("HookStopAgent"); + when(mockAgent.getDescription()).thenReturn("Agent stopped by hook"); + + Msg suspendedResponse = + createTextMsgWithReason( + "Stopped for review", + io.agentscope.core.message.GenerateReason.REASONING_STOP_REQUESTED); + + when(mockAgent.call(any(List.class))).thenReturn(Mono.just(suspendedResponse)); + + SubAgentConfig config = + SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map input = Map.of("message", "Process this request"); + ToolUseBlock toolUse = createToolUseBlock("tool-2", "call_hookstopagent", input); + + ToolResultBlock result = executeToolCall(tool, toolUse, input); + + assertNotNull(result); + assertTrue(result.isSuspended(), "Result should be marked as suspended"); + assertEquals( + io.agentscope.core.message.GenerateReason.REASONING_STOP_REQUESTED, + result.getMetadata().get(SubAgentContext.METADATA_GENERATE_REASON)); + } + + @Test + @DisplayName( + "Should return suspended result when sub-agent is suspended with" + + " ACTING_STOP_REQUESTED") + void testSuspendedResultOnActingStopRequested() { + ReActAgent mockAgent = mock(ReActAgent.class); + when(mockAgent.getName()).thenReturn("ActingStopAgent"); + when(mockAgent.getDescription()).thenReturn("Agent stopped during acting"); + + Msg suspendedResponse = + createTextMsgWithReason( + "Stopped during tool execution", + io.agentscope.core.message.GenerateReason.ACTING_STOP_REQUESTED); + + when(mockAgent.call(any(List.class))).thenReturn(Mono.just(suspendedResponse)); + + SubAgentConfig config = + SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map input = Map.of("message", "Execute action"); + ToolUseBlock toolUse = createToolUseBlock("tool-3", "call_actingstopagent", input); + + ToolResultBlock result = executeToolCall(tool, toolUse, input); + + assertNotNull(result); + assertTrue(result.isSuspended(), "Result should be marked as suspended"); + assertEquals( + io.agentscope.core.message.GenerateReason.ACTING_STOP_REQUESTED, + result.getMetadata().get(SubAgentContext.METADATA_GENERATE_REASON)); + } + + @Test + @DisplayName("Should convert suspended state to text when HITL is disabled") + void testSuspendedStateConvertedToTextWhenHitlDisabled() { + ReActAgent mockAgent = mock(ReActAgent.class); + when(mockAgent.getName()).thenReturn("NoHitlAgent"); + when(mockAgent.getDescription()).thenReturn("Agent without HITL"); + + Msg suspendedResponse = + createTextMsgWithReason( + "Suspended content", + io.agentscope.core.message.GenerateReason.TOOL_SUSPENDED); + + when(mockAgent.call(any(List.class))).thenReturn(Mono.just(suspendedResponse)); + + SubAgentConfig config = + SubAgentConfig.builder().forwardEvents(false).enableHITL(false).build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map input = Map.of("message", "Test message"); + ToolUseBlock toolUse = createToolUseBlock("tool-4", "call_nohitlagent", input); + + ToolResultBlock result = executeToolCall(tool, toolUse, input); + + assertNotNull(result); + assertFalse( + result.isSuspended(), + "Result should NOT be marked as suspended when HITL disabled"); + String text = extractText(result); + assertTrue(text.contains("session_id:"), "Should still contain session_id"); + } + + @Test + @DisplayName("Should resume execution with submit tool results") + void testResumeWithSubmitToolResults() { + AtomicInteger callCount = new AtomicInteger(0); + + ReActAgent mockAgent = mock(ReActAgent.class); + when(mockAgent.getName()).thenReturn("ResumableAgent"); + when(mockAgent.getDescription()).thenReturn("Agent that can resume"); + + when(mockAgent.call(any(List.class))) + .thenAnswer( + invocation -> { + int count = callCount.incrementAndGet(); + if (count == 1) { + ToolUseBlock innerToolUse = + createToolUseBlock( + "inner-tool-resume", + "database_query", + Map.of("sql", "SELECT * FROM users")); + + return Mono.just( + createMultiContentMsg( + List.of( + TextBlock.builder() + .text("Querying database...") + .build(), + innerToolUse), + io.agentscope.core.message.GenerateReason + .TOOL_SUSPENDED)); + } else { + return Mono.just( + createTextMsg("Query completed: 5 users found")); + } + }); + + SubAgentConfig config = + SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map input1 = Map.of("message", "Query the database"); + ToolUseBlock toolUse1 = + createToolUseBlock("tool-resume", "call_resumableagent", input1); + + ToolResultBlock suspendedResult = executeToolCall(tool, toolUse1, input1); + + assertNotNull(suspendedResult); + assertTrue(suspendedResult.isSuspended()); + + String sessionId = + (String) + suspendedResult + .getMetadata() + .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID); + assertNotNull(sessionId); + + ToolResultBlock userProvidedResult = + ToolResultBlock.builder() + .id("tool-resume") + .name("call_resumableagent") + .output( + TextBlock.builder() + .text("[{id: 1, name: 'Alice'}, ...]") + .build()) + .build(); + + Map input2 = createResumeInput("Continue", sessionId); + ToolUseBlock toolUse2 = + createResumeToolUse( + "tool-resume-", + "call_resumableagent", + input2, + List.of(userProvidedResult)); + + ToolResultBlock resumedResult = executeToolCall(tool, toolUse2, input2); + + assertNotNull(resumedResult); + assertFalse(resumedResult.isSuspended(), "Resumed result should not be suspended"); + String text = extractText(resumedResult); + assertTrue(text.contains("5 users found"), "Should contain resumed response"); + } + + @Test + @DisplayName("Should resume with empty results for hook stop") + void testResumeWithEmptyResultsForHookStop() { + AtomicInteger callCount = new AtomicInteger(0); + + ReActAgent mockAgent = mock(ReActAgent.class); + when(mockAgent.getName()).thenReturn("HookResumeAgent"); + when(mockAgent.getDescription()).thenReturn("Agent resuming from hook stop"); + + when(mockAgent.call(any(List.class))) + .thenAnswer( + invocation -> { + int count = callCount.incrementAndGet(); + if (count == 1) { + return Mono.just( + createTextMsgWithReason( + "Paused for review", + io.agentscope.core.message.GenerateReason + .REASONING_STOP_REQUESTED)); + } else { + return Mono.just(createTextMsg("Continued after review")); + } + }); + + SubAgentConfig config = + SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map input1 = Map.of("message", "Start task"); + ToolUseBlock toolUse1 = createToolUseBlock("tool-hook", "call_hookresumeagent", input1); + + ToolResultBlock suspendedResult = executeToolCall(tool, toolUse1, input1); + + assertTrue(suspendedResult.isSuspended()); + + String sessionId = + (String) + suspendedResult + .getMetadata() + .get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID); + + Map input2 = createResumeInput("Continue", sessionId); + ToolUseBlock toolUse2 = + createResumeToolUse("tool-hook", "call_hookresumeagent", input2, List.of()); + + ToolResultBlock resumedResult = executeToolCall(tool, toolUse2, input2); + + assertNotNull(resumedResult); + assertFalse(resumedResult.isSuspended()); + String text = extractText(resumedResult); + assertTrue(text.contains("Continued after review")); + } + + @Test + @DisplayName("Should include inner tool use blocks in suspended result") + void testSuspendedResultContainsInnerToolUseBlocks() { + ReActAgent mockAgent = mock(ReActAgent.class); + when(mockAgent.getName()).thenReturn("MultiToolAgent"); + when(mockAgent.getDescription()).thenReturn("Agent with multiple tools"); + + ToolUseBlock innerTool1 = + createToolUseBlock("inner-1", "api_call", Map.of("endpoint", "/users")); + + ToolUseBlock innerTool2 = + createToolUseBlock("inner-2", "file_write", Map.of("path", "/tmp/result.json")); + + Msg suspendedResponse = + createMultiContentMsg( + List.of( + TextBlock.builder() + .text("Executing multiple operations...") + .build(), + innerTool1, + innerTool2), + io.agentscope.core.message.GenerateReason.TOOL_SUSPENDED); + + when(mockAgent.call(any(List.class))).thenReturn(Mono.just(suspendedResponse)); + + SubAgentConfig config = + SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map input = Map.of("message", "Execute operations"); + ToolUseBlock toolUse = createToolUseBlock("tool-multi", "call_multitoolagent", input); + + ToolResultBlock result = executeToolCall(tool, toolUse, input); + + assertNotNull(result); + assertTrue(result.isSuspended()); + + List toolUseBlocks = + result.getOutput().stream() + .filter(block -> block instanceof ToolUseBlock) + .map(block -> (ToolUseBlock) block) + .toList(); + + assertEquals(2, toolUseBlocks.size(), "Should contain 2 inner tool use blocks"); + assertEquals("api_call", toolUseBlocks.get(0).getName()); + assertEquals("file_write", toolUseBlocks.get(1).getName()); + } + + @Test + @DisplayName("Should preserve session state across suspension and resumption") + void testSessionStatePreservedAcrossSuspension() { + AtomicInteger callCount = new AtomicInteger(0); + + ReActAgent mockAgent = mock(ReActAgent.class); + when(mockAgent.getName()).thenReturn("StatefulAgent"); + when(mockAgent.getDescription()).thenReturn("Agent with state"); + + when(mockAgent.call(any(List.class))) + .thenAnswer( + invocation -> { + int count = callCount.incrementAndGet(); + if (count == 1) { + return Mono.just( + createTextMsgWithReason( + "Step 1 complete, waiting...", + io.agentscope.core.message.GenerateReason + .TOOL_SUSPENDED)); + } else if (count == 2) { + return Mono.just( + createTextMsgWithReason( + "Step 2 complete, waiting...", + io.agentscope.core.message.GenerateReason + .TOOL_SUSPENDED)); + } else { + return Mono.just(createTextMsg("All steps completed!")); + } + }); + + SubAgentConfig config = + SubAgentConfig.builder().forwardEvents(false).enableHITL(true).build(); + SubAgentTool tool = new SubAgentTool(() -> mockAgent, config); + + Map input1 = Map.of("message", "Start multi-step task"); + ToolUseBlock toolUse1 = + createToolUseBlock("tool-state-1", "call_statefulagent", input1); + + ToolResultBlock result1 = executeToolCall(tool, toolUse1, input1); + + assertTrue(result1.isSuspended()); + String sessionId = + (String) + result1.getMetadata().get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID); + + Map input2 = createResumeInput("Continue step 2", sessionId); + ToolUseBlock toolUse2 = + createResumeToolUse("tool-state-2", "call_statefulagent", input2, List.of()); + + ToolResultBlock result2 = executeToolCall(tool, toolUse2, input2); + + assertTrue(result2.isSuspended()); + String sessionId2 = + (String) + result2.getMetadata().get(SubAgentContext.METADATA_SUBAGENT_SESSION_ID); + assertEquals(sessionId, sessionId2, "Session ID should be preserved"); + } + } + // Helper methods private Agent createMockAgent(String name, String description) { - Agent agent = mock(Agent.class); - when(agent.getName()).thenReturn(name); - when(agent.getDescription()).thenReturn(description); - when(agent.call(any(List.class))) - .thenReturn( - Mono.just( - Msg.builder() - .role(MsgRole.ASSISTANT) - .content(TextBlock.builder().text("Response").build()) - .build())); - return agent; + Agent mockAgent = mock(Agent.class); + when(mockAgent.getName()).thenReturn(name); + when(mockAgent.getDescription()).thenReturn(description); + return mockAgent; } private String extractText(ToolResultBlock result) { - if (result.getOutput() == null || result.getOutput().isEmpty()) { - return ""; - } return result.getOutput().stream() .filter(block -> block instanceof TextBlock) .map(block -> ((TextBlock) block).getText()) - .findFirst() - .orElse(""); + .collect(Collectors.joining("\n")); } private String extractSessionId(ToolResultBlock result) { String text = extractText(result); - if (text.startsWith("session_id: ")) { - int endIndex = text.indexOf("\n"); - if (endIndex > 0) { - return text.substring("session_id: ".length(), endIndex); - } else { - // Handle case where no newline exists (session_id is the entire text) - return text.substring("session_id: ".length()); - } + String prefix = "session_id:"; + int start = text.indexOf(prefix); + if (start == -1) { + return null; } - return null; + start += prefix.length(); + int end = text.indexOf('\n', start); + return end == -1 ? text.substring(start).trim() : text.substring(start, end).trim(); + } + + /** + * Create simple text message + */ + private Msg createTextMsg(String text) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + /** + * Creates a text message with GenerateReason + */ + private Msg createTextMsgWithReason( + String text, io.agentscope.core.message.GenerateReason reason) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(TextBlock.builder().text(text).build()) + .generateReason(reason) + .build(); + } + + /** + * Creates a message with multiple ContentBlocks + */ + private Msg createMultiContentMsg( + List contents, io.agentscope.core.message.GenerateReason reason) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(contents) + .generateReason(reason) + .build(); + } + + /** + * Creates a simple ToolUseBlock + */ + private ToolUseBlock createToolUseBlock(String id, String name, Map input) { + return ToolUseBlock.builder().id(id).name(name).input(input).build(); + } + + /** + * Helper method to execute tool call + */ + private ToolResultBlock executeToolCall( + SubAgentTool tool, ToolUseBlock toolUse, Map input) { + return tool.callAsync(ToolCallParam.builder().toolUseBlock(toolUse).input(input).build()) + .block(); + } + + /** + * Creates input for resuming with session_id + */ + private Map createResumeInput(String message, String sessionId) { + Map input = new HashMap<>(); + input.put("message", message); + input.put("session_id", sessionId); + return input; + } + + /** + * Creates a ToolUseBlock with previous_tool_result + */ + private ToolUseBlock createResumeToolUse( + String id, + String name, + Map input, + List previousResults) { + Map metadata = new HashMap<>(); + metadata.put(SubAgentHook.PREVIOUS_TOOL_RESULT, previousResults); + return ToolUseBlock.builder().id(id).name(name).input(input).metadata(metadata).build(); } } diff --git a/docs/en/multi-agent/agent-as-tool.md b/docs/en/multi-agent/agent-as-tool.md index dea6f5a43..0d3cb13a5 100644 --- a/docs/en/multi-agent/agent-as-tool.md +++ b/docs/en/multi-agent/agent-as-tool.md @@ -45,9 +45,9 @@ DashScopeChatModel model = DashScopeChatModel.builder() .modelName("qwen-plus") .build(); -// Create sub-agent Provider (factory) + // Create sub-agent Provider (factory) // Note: Must use lambda to ensure new instance is created for each call -Toolkit toolkit = new Toolkit(); + Toolkit toolkit = new Toolkit(); toolkit.registration() .subAgent(() -> ReActAgent.builder() .name("Expert") @@ -56,16 +56,16 @@ toolkit.registration() .build()) .apply(); -// Create main agent with toolkit -ReActAgent mainAgent = ReActAgent.builder() - .name("Coordinator") - .sysPrompt("You are a coordinator. When facing professional questions, call the call_expert tool to consult the expert.") - .model(model) - .toolkit(toolkit) - .build(); + // Create main agent with toolkit + ReActAgent mainAgent = ReActAgent.builder() + .name("Coordinator") + .sysPrompt("You are a coordinator. When facing professional questions, call the call_expert tool to consult the expert.") + .model(model) + .toolkit(toolkit) + .build(); -// Main agent will automatically call expert agent when needed -Msg response = mainAgent.call(userMsg).block(); + // Main agent will automatically call expert agent when needed + Msg response = mainAgent.call(userMsg).block(); ``` ## Configuration Options @@ -151,3 +151,91 @@ toolkit.registration() .group("experts") .apply(); ``` + +## Human-in-the-Loop (HITL) Support + +Sub-agents support Human-in-the-Loop (HITL), allowing sub-agents to pass suspend state to the parent agent and user when encountering operations requiring human confirmation, and resume execution after user confirmation. Currently, only ReactAgent is supported as a sub-Agent. + +### Enabling HITL + +```java +import io.agentscope.core.tool.subagent.SubAgentConfig; + +// Configure sub-agent tool with HITL enabled +toolkit.registration() + .subAgent(() -> ReActAgent.builder() + .name("DataAnalyst") + .sysPrompt("You are a data analysis expert.") + .model(model) + .build()) + .config(SubAgentConfig.builder() + .enableHITL(true) // Enable human-in-the-loop + .build()) + .apply(); + +// Create main agent with HITL support enabled +ReActAgent mainAgent = ReActAgent.builder() + .name("Coordinator") + .sysPrompt("You are a coordinator responsible for calling the data analyst.") + .model(model) + .toolkit(toolkit) + .enableSubAgentHITL(true) // Main agent also needs HITL support enabled + .build(); +``` + +### Handling Suspend and Resume + +When a sub-agent is suspended, the returned message contains the pending tool information. Display it to the user and decide next steps based on their choice: + +```java +import io.agentscope.core.tool.subagent.SubAgentContext; + +Msg response = mainAgent.call(userMsg).block(); + +// Check if sub-agent is suspended +while (response.getGenerateReason() == GenerateReason.TOOL_SUSPENDED) { + List toolResults = response.getContentBlocks(ToolResultBlock.class); + + for (ToolResultBlock resultBlock : toolResults) { + if (!SubAgentContext.isSubAgentResult(resultBlock)) { + continue; + } + + // Get the blocked tool calls from sub-agent + List pendingTools = resultBlock.getOutput().stream() + .filter(ToolUseBlock.class::isInstance) + .map(ToolUseBlock.class::cast) + .toList(); + + if (!userConfirms(pendingTools)) { + // User declined, submit cancellation results + List cancelResults = pendingTools.stream() + .map(t -> ToolResultBlock.of(t.getId(), t.getName(), + TextBlock.builder().text("Operation cancelled").build())) + .toList(); + mainAgent.submitSubAgentResults(resultBlock.getId(), cancelResults); + } + response = mainAgent.call().block(); + } +} + +// Final response +System.out.println(response.getTextContent()); +``` + +## Quick Reference + +**Configuration methods**: +- `SubAgentConfig.enableHITL(boolean)` — Enable/disable sub-agent HITL support +- `ReActAgent.enableSubAgentHITL(boolean)` — Enable/disable main agent HITL support +- `ReActAgent.isEnableSubAgentHITL()` — Whether the main agent supports sub-agent HITL + +**Detection methods**: +- `SubAgentContext.isSubAgentResult(ToolResultBlock)` — Check if result is from sub-agent +- `SubAgentContext.getSubAgentGenerateReason(ToolResultBlock)` — Get generate reason of sub-agent +- `SubAgentContext.extractSessionId(ToolResultBlock)` — Extract session ID + +**Resume methods**: +- `mainAgent.call()` — Continue executing pending tools +- `mainAgent.submitSubAgentResult(String, ToolResultBlock)` — Submit single tool result +- `mainAgent.submitSubAgentResults(String, List)` — Submit multiple tool results \ No newline at end of file diff --git a/docs/zh/multi-agent/agent-as-tool.md b/docs/zh/multi-agent/agent-as-tool.md index 6ea8362a4..ebd8b742e 100644 --- a/docs/zh/multi-agent/agent-as-tool.md +++ b/docs/zh/multi-agent/agent-as-tool.md @@ -151,3 +151,90 @@ toolkit.registration() .group("experts") .apply(); ``` + +## 人机交互支持(HITL) + +子智能体支持人机交互(Human-in-the-Loop),允许子智能体在执行过程中遇到需要人工确认的操作时,将挂起状态传递给主智能体和用户,并在用户确认后恢复执行。目前只支持使用ReactAgent作为子Agent。 + +### 启用 HITL + +```java +import io.agentscope.core.tool.subagent.SubAgentConfig; + +// 配置子智能体工具并启用 HITL +toolkit.registration() + .subAgent(() -> ReActAgent.builder() + .name("DataAnalyst") + .sysPrompt("你是一个数据分析专家。") + .model(model) + .build()) + .config(SubAgentConfig.builder() + .enableHITL(true) // 启用人机交互 + .build()) + .apply(); + +// 创建主智能体,启用 HITL 支持 +ReActAgent mainAgent = ReActAgent.builder() + .name("Coordinator") + .sysPrompt("你是一个协调员,负责调用数据分析专家。") + .model(model) + .toolkit(toolkit) + .enableSubAgentHITL(true) // 主智能体也需要启用 HITL 支持 + .build(); +``` + +### 处理挂起和恢复 + +当子智能体挂起时,返回的消息会包含待执行的工具信息。你需要展示给用户,并根据用户选择决定下一步: + +```java +import io.agentscope.core.tool.subagent.SubAgentContext; + +Msg response = mainAgent.call(userMsg).block(); + +// 检查是否有子智能体挂起 +while (response.getGenerateReason() == GenerateReason.TOOL_SUSPENDED) { + List toolResults = response.getContentBlocks(ToolResultBlock.class); + + for (ToolResultBlock resultBlock : toolResults) { + if (!SubAgentContext.isSubAgentResult(resultBlock)) { + continue; + } + + // 获取子智能体被阻塞的工具调用 + List pendingTools = resultBlock.getOutput().stream() + .filter(ToolUseBlock.class::isInstance) + .map(ToolUseBlock.class::cast) + .toList(); + + if (!userConfirms(pendingTools)) { + // 用户拒绝,提交取消结果 + List cancelResults = pendingTools.stream() + .map(t -> ToolResultBlock.of(t.getId(), t.getName(), + TextBlock.builder().text("操作已取消").build())) + .toList(); + mainAgent.submitSubAgentResults(resultBlock.getId(), cancelResults); + } + response = mainAgent.call().block(); + } +} + +// 最终响应 +System.out.println(response.getTextContent()); +``` + +## API 速查 + +**配置方法**: +- `SubAgentConfig.enableHITL(boolean)` — 启用/禁用子智能体 HITL 支持 +- `ReActAgent.enableSubAgentHITL(boolean)` — 启用/禁用主智能体 HITL 支持 +- `ReActAgent.isEnableSubAgentHITL()` — 主智能体是/否支持 HITL + +**检测方法**: +- `SubAgentContext.isSubAgentResult(ToolResultBlock)` — 判断是否是子智能体结果 +- `SubAgentContext.getSubAgentGenerateReason(ToolResultBlock)` — 获取子智能体生成原因 +- `SubAgentContext.extractSessionId(ToolResultBlock)` — 提取会话 ID + +**恢复方法**: +- `ReActAgent.submitSubAgentResult(String, ToolResultBlock)` — 提交单个工具结果 +- `ReActAgent.submitSubAgentResults(String, List)` — 批量提交工具结果 \ No newline at end of file