From 347cc47eb5de7948df636ce3a22f8ff195a8b314 Mon Sep 17 00:00:00 2001 From: Alexxigang <37231458+Alexxigang@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:47:38 +0800 Subject: [PATCH] fix(tool): make active tool groups concurrency-safe --- .../io/agentscope/core/tool/ToolGroup.java | 400 ++++----- .../core/tool/ToolGroupManager.java | 785 +++++++++--------- .../tool/ToolGroupManagerConcurrencyTest.java | 203 +++++ 3 files changed, 796 insertions(+), 592 deletions(-) create mode 100644 agentscope-core/src/test/java/io/agentscope/core/tool/ToolGroupManagerConcurrencyTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java index 942204116..66597e947 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java @@ -1,200 +1,200 @@ -/* - * 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; - -import java.util.HashSet; -import java.util.Objects; -import java.util.Set; - -/** - * Represents a named group of tools with activation state. - * - *

Tool groups allow organizing tools into logical categories and controlling their availability - * dynamically. Only tools from active groups are made available to agents. - * - *

Usage Example: - * - *

{@code
- * ToolGroup adminGroup = ToolGroup.builder()
- *     .name("admin")
- *     .description("Administrative tools")
- *     .active(false) // Start inactive
- *     .build();
- *
- * adminGroup.addTool("delete_user");
- * adminGroup.addTool("modify_permissions");
- * adminGroup.setActive(true); // Activate when needed
- * }
- */ -public class ToolGroup { - - private final String name; - private final String description; - private boolean active; - private final Set tools; // Tool names in this group - - private ToolGroup(Builder builder) { - this.name = Objects.requireNonNull(builder.name, "name cannot be null"); - this.description = builder.description != null ? builder.description : ""; - this.active = builder.active; - this.tools = new HashSet<>(builder.tools); - } - - /** - * Gets the name of this tool group. - * - * @return The group name (never null) - */ - public String getName() { - return name; - } - - /** - * Gets the description of this tool group. - * - * @return The group description (empty string if not set) - */ - public String getDescription() { - return description; - } - - /** - * Checks if this tool group is currently active. - * - * @return true if active, false otherwise - */ - public boolean isActive() { - return active; - } - - /** - * Sets the activation state of this tool group. - * - * @param active true to activate, false to deactivate - */ - public void setActive(boolean active) { - this.active = active; - } - - /** - * Gets a defensive copy of the tools in this group. - * - * @return A new set containing the tool names - */ - public Set getTools() { - return new HashSet<>(tools); // Defensive copy - } - - /** - * Adds a tool to this group. - * - * @param toolName The name of the tool to add - */ - public void addTool(String toolName) { - tools.add(toolName); - } - - /** - * Removes a tool from this group. - * - * @param toolName The name of the tool to remove - */ - public void removeTool(String toolName) { - tools.remove(toolName); - } - - /** - * Checks if this group contains a specific tool. - * - * @param toolName The tool name to check - * @return true if the tool is in this group, false otherwise - */ - public boolean containsTool(String toolName) { - return tools.contains(toolName); - } - - /** - * Creates a new builder for constructing ToolGroup instances. - * - * @return A new builder instance - */ - public static Builder builder() { - return new Builder(); - } - - /** Builder for constructing ToolGroup instances. */ - public static class Builder { - - private String name; - private String description = ""; - private boolean active = true; - private Set tools = new HashSet<>(); - - /** - * Sets the name of the tool group. - * - * @param name The group name (required) - * @return This builder for method chaining - */ - public Builder name(String name) { - this.name = name; - return this; - } - - /** - * Sets the description of the tool group. - * - * @param description The group description - * @return This builder for method chaining - */ - public Builder description(String description) { - this.description = description; - return this; - } - - /** - * Sets the initial activation state. - * - * @param active true for active (default), false for inactive - * @return This builder for method chaining - */ - public Builder active(boolean active) { - this.active = active; - return this; - } - - /** - * Sets the initial set of tools in this group. - * - * @param tools The tool names (a defensive copy will be made) - * @return This builder for method chaining - */ - public Builder tools(Set tools) { - this.tools = new HashSet<>(tools); - return this; - } - - /** - * Builds a new ToolGroup with the configured settings. - * - * @return A new ToolGroup instance - * @throws NullPointerException if name is null - */ - public ToolGroup build() { - return new ToolGroup(this); - } - } -} +/* + * 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; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Represents a named group of tools with activation state. + * + *

Tool groups allow organizing tools into logical categories and controlling their availability + * dynamically. Only tools from active groups are made available to agents. + * + *

Usage Example: + * + *

{@code
+ * ToolGroup adminGroup = ToolGroup.builder()
+ *     .name("admin")
+ *     .description("Administrative tools")
+ *     .active(false) // Start inactive
+ *     .build();
+ *
+ * adminGroup.addTool("delete_user");
+ * adminGroup.addTool("modify_permissions");
+ * adminGroup.setActive(true); // Activate when needed
+ * }
+ */ +public class ToolGroup { + + private final String name; + private final String description; + private volatile boolean active; + private final Set tools; // Tool names in this group + + private ToolGroup(Builder builder) { + this.name = Objects.requireNonNull(builder.name, "name cannot be null"); + this.description = builder.description != null ? builder.description : ""; + this.active = builder.active; + this.tools = new HashSet<>(builder.tools); + } + + /** + * Gets the name of this tool group. + * + * @return The group name (never null) + */ + public String getName() { + return name; + } + + /** + * Gets the description of this tool group. + * + * @return The group description (empty string if not set) + */ + public String getDescription() { + return description; + } + + /** + * Checks if this tool group is currently active. + * + * @return true if active, false otherwise + */ + public boolean isActive() { + return active; + } + + /** + * Sets the activation state of this tool group. + * + * @param active true to activate, false to deactivate + */ + public void setActive(boolean active) { + this.active = active; + } + + /** + * Gets a defensive copy of the tools in this group. + * + * @return A new set containing the tool names + */ + public Set getTools() { + return new HashSet<>(tools); // Defensive copy + } + + /** + * Adds a tool to this group. + * + * @param toolName The name of the tool to add + */ + public void addTool(String toolName) { + tools.add(toolName); + } + + /** + * Removes a tool from this group. + * + * @param toolName The name of the tool to remove + */ + public void removeTool(String toolName) { + tools.remove(toolName); + } + + /** + * Checks if this group contains a specific tool. + * + * @param toolName The tool name to check + * @return true if the tool is in this group, false otherwise + */ + public boolean containsTool(String toolName) { + return tools.contains(toolName); + } + + /** + * Creates a new builder for constructing ToolGroup instances. + * + * @return A new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for constructing ToolGroup instances. */ + public static class Builder { + + private String name; + private String description = ""; + private boolean active = true; + private Set tools = new HashSet<>(); + + /** + * Sets the name of the tool group. + * + * @param name The group name (required) + * @return This builder for method chaining + */ + public Builder name(String name) { + this.name = name; + return this; + } + + /** + * Sets the description of the tool group. + * + * @param description The group description + * @return This builder for method chaining + */ + public Builder description(String description) { + this.description = description; + return this; + } + + /** + * Sets the initial activation state. + * + * @param active true for active (default), false for inactive + * @return This builder for method chaining + */ + public Builder active(boolean active) { + this.active = active; + return this; + } + + /** + * Sets the initial set of tools in this group. + * + * @param tools The tool names (a defensive copy will be made) + * @return This builder for method chaining + */ + public Builder tools(Set tools) { + this.tools = new HashSet<>(tools); + return this; + } + + /** + * Builds a new ToolGroup with the configured settings. + * + * @return A new ToolGroup instance + * @throws NullPointerException if name is null + */ + public ToolGroup build() { + return new ToolGroup(this); + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroupManager.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroupManager.java index c6d4c0fcf..a01a691de 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroupManager.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroupManager.java @@ -1,392 +1,393 @@ -/* - * 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; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Manages tool groups and their activation states. - */ -class ToolGroupManager { - - private static final Logger logger = LoggerFactory.getLogger(ToolGroupManager.class); - - private final Map toolGroups = new ConcurrentHashMap<>(); // group -> tools - private final Map> tools = new ConcurrentHashMap<>(); // tool -> groups - private List activeGroups = new ArrayList<>(); - - /** - * Create tool groups and record them in the manager. - * - * @param groupName Name of the tool group - * @param description Description of the tool group for the agent to understand - * @param active Whether the tool group is active by default - * @throws IllegalArgumentException if group already exists - */ - public void createToolGroup(String groupName, String description, boolean active) { - if (toolGroups.containsKey(groupName)) { - throw new IllegalArgumentException( - String.format("Tool group '%s' already exists", groupName)); - } - - ToolGroup group = - ToolGroup.builder().name(groupName).description(description).active(active).build(); - - toolGroups.put(groupName, group); - - if (active && !activeGroups.contains(groupName)) { - activeGroups.add(groupName); - } - - logger.info("Created tool group '{}': {}", groupName, description); - } - - /** - * Creates a tool group with default active status (true). - * - * @param groupName Name of the tool group - * @param description Description of the tool group for the agent to understand - * @throws IllegalArgumentException if group already exists - */ - public void createToolGroup(String groupName, String description) { - createToolGroup(groupName, description, true); - } - - /** - * Update the active status of tool groups. - * - * @param groupNames List of tool group names to update - * @param active Whether to activate or deactivate - * @throws IllegalArgumentException if any group doesn't exist - */ - public void updateToolGroups(List groupNames, boolean active) { - for (String groupName : groupNames) { - ToolGroup group = toolGroups.get(groupName); - if (group == null) { - throw new IllegalArgumentException( - String.format("Tool group '%s' does not exist", groupName)); - } - - group.setActive(active); - - if (active) { - if (!activeGroups.contains(groupName)) { - activeGroups.add(groupName); - } - } else { - activeGroups.remove(groupName); - } - - logger.info("Tool group '{}' active status set to: {}", groupName, active); - } - } - - /** - * Remove tool groups. - * Note: Caller is responsible for removing associated tools. - * - * @param groupNames List of tool group names to remove - * @return Set of tool names that were in the removed groups - */ - public Set removeToolGroups(List groupNames) { - Set toolsToRemove = new HashSet<>(); - - for (String groupName : groupNames) { - ToolGroup group = toolGroups.remove(groupName); - if (group == null) { - logger.warn("Tool group '{}' does not exist, skipping removal", groupName); - continue; - } - - // Collect tools from this group - Set groupTools = group.getTools(); - - // Remove group mapping from tool index - for (String toolName : groupTools) { - if (removeGroupFromToolIndex(toolName, groupName)) { - toolsToRemove.add(toolName); - } - } - - // Remove from active groups - activeGroups.remove(groupName); - - logger.info( - "Removed tool group '{}' with {} tools", groupName, group.getTools().size()); - } - - return toolsToRemove; - } - - /** - * Get notes about activated tool groups for display to user/agent. - * - * @return Formatted string describing active tool groups - */ - public String getActivatedNotes() { - if (activeGroups.isEmpty()) { - return "No tool groups are currently activated."; - } - - StringBuilder notes = new StringBuilder("Activated tool groups:\n"); - for (String groupName : activeGroups) { - ToolGroup group = toolGroups.get(groupName); - if (group != null) { - notes.append(String.format("- %s: %s\n", groupName, group.getDescription())); - } - } - return notes.toString(); - } - - /** - * Get notes about all tool groups for display to user/agent. - * - * @return Formatted string describing active tool groups - */ - public String getNotes() { - StringBuilder activatedNotes = new StringBuilder("Activated tool groups:\n"); - StringBuilder inactiveNotes = new StringBuilder("Inactive tool groups:\n"); - boolean hasActivatedGroup = false; - boolean hasInactiveGroup = false; - for (ToolGroup group : toolGroups.values()) { - if (group.isActive()) { - hasActivatedGroup = true; - activatedNotes.append( - String.format("- %s: %s\n", group.getName(), group.getDescription())); - } else { - hasInactiveGroup = true; - inactiveNotes.append( - String.format("- %s: %s\n", group.getName(), group.getDescription())); - } - } - - if (!hasActivatedGroup) { - activatedNotes.append("No tool groups are currently activated.\n"); - } - - if (!hasInactiveGroup) { - inactiveNotes.append("No tool groups are currently inactive.\n"); - } - - activatedNotes.append(inactiveNotes); - return activatedNotes.toString(); - } - - /** - * Validate that a group exists. - * - * @param groupName Group name to validate - * @throws IllegalArgumentException if group doesn't exist - */ - public void validateGroupExists(String groupName) { - if (!toolGroups.containsKey(groupName)) { - throw new IllegalArgumentException( - String.format("Tool group '%s' does not exist", groupName)); - } - } - - /** - * Check if a group is active. - * - * @param groupName Group name - * @return true if the group exists and is active - */ - public boolean isActiveGroup(String groupName) { - if (groupName == null) { - return false; - } - ToolGroup group = toolGroups.get(groupName); - return group != null && group.isActive(); - } - - /** - * Check if a tool is in any active group. - * - *

If the tool is not in any group, it is considered active by default. - * - * @param toolName Tool name - * @return true if ungrouped or in at least one active group - */ - public boolean isActiveTool(String toolName) { - if (toolName == null) { - return false; - } - Set groups = tools.get(toolName); - if (groups == null || groups.isEmpty()) { - return true; - } - for (String groupName : groups) { - if (isActiveGroup(groupName)) { - return true; - } - } - return false; - } - - /** - * Check whether a tool belongs to any group. - * - * @param toolName Tool name - * @return true if the tool is in at least one group - */ - public boolean isGroupedTool(String toolName) { - if (toolName == null) { - return false; - } - Set groups = tools.get(toolName); - return groups != null && !groups.isEmpty(); - } - - /** - * Get all tools that belong to active groups. - * - * @return Set of tool names that are in active groups - */ - public Set getActiveToolNames() { - Set activeTools = new HashSet<>(); - for (ToolGroup group : toolGroups.values()) { - if (group.isActive()) { - activeTools.addAll(group.getTools()); - } - } - return activeTools; - } - - /** - * Add a tool to a group. - * - * @param groupName Group name - * @param toolName Tool name - */ - public void addToolToGroup(String groupName, String toolName) { - ToolGroup group = toolGroups.get(groupName); - if (group != null) { - group.addTool(toolName); - tools.computeIfAbsent(toolName, key -> ConcurrentHashMap.newKeySet()).add(groupName); - } - } - - /** - * Remove a tool from a group. - * - * @param groupName Group name - * @param toolName Tool name - */ - public void removeToolFromGroup(String groupName, String toolName) { - ToolGroup group = toolGroups.get(groupName); - if (group != null) { - group.removeTool(toolName); - removeGroupFromToolIndex(toolName, groupName); - } - } - - /** - * Get all tool group names. - * - * @return Set of all tool group names - */ - public Set getToolGroupNames() { - return new HashSet<>(toolGroups.keySet()); - } - - /** - * Get active tool group names. - * - * @return List of active group names - */ - public List getActiveGroups() { - return new ArrayList<>(activeGroups); - } - - /** - * Set active groups (for state restoration). - * - * @param activeGroups List of group names to mark as active - */ - public void setActiveGroups(List activeGroups) { - this.activeGroups = new ArrayList<>(activeGroups); - - // Mark corresponding groups as active - for (String groupName : activeGroups) { - ToolGroup group = toolGroups.get(groupName); - if (group != null) { - group.setActive(true); - } - } - } - - /** - * Get a tool group by name. - * - * @param groupName Name of the tool group - * @return ToolGroup or null if not found - */ - public ToolGroup getToolGroup(String groupName) { - return toolGroups.get(groupName); - } - - /** - * Copy all tool groups from this manager to another manager. - * - * @param target The target manager to copy tool groups to - */ - void copyTo(ToolGroupManager target) { - target.tools.clear(); - target.toolGroups.clear(); - for (Map.Entry entry : toolGroups.entrySet()) { - String groupName = entry.getKey(); - ToolGroup sourceGroup = entry.getValue(); - - // Create a copy of the tool group - ToolGroup copiedGroup = - ToolGroup.builder() - .name(groupName) - .description(sourceGroup.getDescription()) - .active(sourceGroup.isActive()) - .tools(sourceGroup.getTools()) - .build(); - - target.toolGroups.put(groupName, copiedGroup); - } - - for (Map.Entry> entry : tools.entrySet()) { - target.tools.put(entry.getKey(), new HashSet<>(entry.getValue())); - } - - // Copy activeGroups list - target.activeGroups = new ArrayList<>(this.activeGroups); - } - - private boolean removeGroupFromToolIndex(String toolName, String groupName) { - Set groupNames = tools.get(toolName); - if (groupNames == null) { - return false; - } - groupNames.remove(groupName); - if (groupNames.isEmpty()) { - tools.remove(toolName); - return true; - } - return false; - } -} +/* + * 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; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages tool groups and their activation states. + */ +class ToolGroupManager { + + private static final Logger logger = LoggerFactory.getLogger(ToolGroupManager.class); + + private final Map toolGroups = new ConcurrentHashMap<>(); // group -> tools + private final Map> tools = new ConcurrentHashMap<>(); // tool -> groups + private final Set activeGroups = new CopyOnWriteArraySet<>(); + + /** + * Create tool groups and record them in the manager. + * + * @param groupName Name of the tool group + * @param description Description of the tool group for the agent to understand + * @param active Whether the tool group is active by default + * @throws IllegalArgumentException if group already exists + */ + public void createToolGroup(String groupName, String description, boolean active) { + if (toolGroups.containsKey(groupName)) { + throw new IllegalArgumentException( + String.format("Tool group '%s' already exists", groupName)); + } + + ToolGroup group = + ToolGroup.builder().name(groupName).description(description).active(active).build(); + + toolGroups.put(groupName, group); + + if (active) { + setGroupActiveState(groupName, group, true); + } + + logger.info("Created tool group '{}': {}", groupName, description); + } + + /** + * Creates a tool group with default active status (true). + * + * @param groupName Name of the tool group + * @param description Description of the tool group for the agent to understand + * @throws IllegalArgumentException if group already exists + */ + public void createToolGroup(String groupName, String description) { + createToolGroup(groupName, description, true); + } + + /** + * Update the active status of tool groups. + * + * @param groupNames List of tool group names to update + * @param active Whether to activate or deactivate + * @throws IllegalArgumentException if any group doesn't exist + */ + public void updateToolGroups(List groupNames, boolean active) { + for (String groupName : groupNames) { + ToolGroup group = toolGroups.get(groupName); + if (group == null) { + throw new IllegalArgumentException( + String.format("Tool group '%s' does not exist", groupName)); + } + setGroupActiveState(groupName, group, active); + + logger.info("Tool group '{}' active status set to: {}", groupName, active); + } + } + + /** + * Remove tool groups. + * Note: Caller is responsible for removing associated tools. + * + * @param groupNames List of tool group names to remove + * @return Set of tool names that were in the removed groups + */ + public Set removeToolGroups(List groupNames) { + Set toolsToRemove = new HashSet<>(); + + for (String groupName : groupNames) { + ToolGroup group = toolGroups.remove(groupName); + if (group == null) { + logger.warn("Tool group '{}' does not exist, skipping removal", groupName); + continue; + } + + // Collect tools from this group + Set groupTools = group.getTools(); + + // Remove group mapping from tool index + for (String toolName : groupTools) { + if (removeGroupFromToolIndex(toolName, groupName)) { + toolsToRemove.add(toolName); + } + } + + // Remove from active groups + activeGroups.remove(groupName); + + logger.info( + "Removed tool group '{}' with {} tools", groupName, group.getTools().size()); + } + + return toolsToRemove; + } + + /** + * Get notes about activated tool groups for display to user/agent. + * + * @return Formatted string describing active tool groups + */ + public String getActivatedNotes() { + List activeGroupSnapshot = getActiveGroups(); + if (activeGroupSnapshot.isEmpty()) { + return "No tool groups are currently activated."; + } + + StringBuilder notes = new StringBuilder("Activated tool groups:\n"); + for (String groupName : activeGroupSnapshot) { + ToolGroup group = toolGroups.get(groupName); + if (group != null) { + notes.append(String.format("- %s: %s\n", groupName, group.getDescription())); + } + } + return notes.toString(); + } + + /** + * Get notes about all tool groups for display to user/agent. + * + * @return Formatted string describing active tool groups + */ + public String getNotes() { + StringBuilder activatedNotes = new StringBuilder("Activated tool groups:\n"); + StringBuilder inactiveNotes = new StringBuilder("Inactive tool groups:\n"); + boolean hasActivatedGroup = false; + boolean hasInactiveGroup = false; + for (ToolGroup group : toolGroups.values()) { + if (group.isActive()) { + hasActivatedGroup = true; + activatedNotes.append( + String.format("- %s: %s\n", group.getName(), group.getDescription())); + } else { + hasInactiveGroup = true; + inactiveNotes.append( + String.format("- %s: %s\n", group.getName(), group.getDescription())); + } + } + + if (!hasActivatedGroup) { + activatedNotes.append("No tool groups are currently activated.\n"); + } + + if (!hasInactiveGroup) { + inactiveNotes.append("No tool groups are currently inactive.\n"); + } + + activatedNotes.append(inactiveNotes); + return activatedNotes.toString(); + } + + /** + * Validate that a group exists. + * + * @param groupName Group name to validate + * @throws IllegalArgumentException if group doesn't exist + */ + public void validateGroupExists(String groupName) { + if (!toolGroups.containsKey(groupName)) { + throw new IllegalArgumentException( + String.format("Tool group '%s' does not exist", groupName)); + } + } + + /** + * Check if a group is active. + * + * @param groupName Group name + * @return true if the group exists and is active + */ + public boolean isActiveGroup(String groupName) { + if (groupName == null) { + return false; + } + ToolGroup group = toolGroups.get(groupName); + return group != null && group.isActive(); + } + + /** + * Check if a tool is in any active group. + * + *

If the tool is not in any group, it is considered active by default. + * + * @param toolName Tool name + * @return true if ungrouped or in at least one active group + */ + public boolean isActiveTool(String toolName) { + if (toolName == null) { + return false; + } + Set groups = tools.get(toolName); + if (groups == null || groups.isEmpty()) { + return true; + } + for (String groupName : groups) { + if (isActiveGroup(groupName)) { + return true; + } + } + return false; + } + + /** + * Check whether a tool belongs to any group. + * + * @param toolName Tool name + * @return true if the tool is in at least one group + */ + public boolean isGroupedTool(String toolName) { + if (toolName == null) { + return false; + } + Set groups = tools.get(toolName); + return groups != null && !groups.isEmpty(); + } + + /** + * Get all tools that belong to active groups. + * + * @return Set of tool names that are in active groups + */ + public Set getActiveToolNames() { + Set activeTools = new HashSet<>(); + for (ToolGroup group : toolGroups.values()) { + if (group.isActive()) { + activeTools.addAll(group.getTools()); + } + } + return activeTools; + } + + /** + * Add a tool to a group. + * + * @param groupName Group name + * @param toolName Tool name + */ + public void addToolToGroup(String groupName, String toolName) { + ToolGroup group = toolGroups.get(groupName); + if (group != null) { + group.addTool(toolName); + tools.computeIfAbsent(toolName, key -> ConcurrentHashMap.newKeySet()).add(groupName); + } + } + + /** + * Remove a tool from a group. + * + * @param groupName Group name + * @param toolName Tool name + */ + public void removeToolFromGroup(String groupName, String toolName) { + ToolGroup group = toolGroups.get(groupName); + if (group != null) { + group.removeTool(toolName); + removeGroupFromToolIndex(toolName, groupName); + } + } + + /** + * Get all tool group names. + * + * @return Set of all tool group names + */ + public Set getToolGroupNames() { + return new HashSet<>(toolGroups.keySet()); + } + + /** + * Get active tool group names. + * + * @return List of active group names + */ + public List getActiveGroups() { + return new ArrayList<>(activeGroups); + } + + /** + * Set active groups (for state restoration). + * + * @param activeGroups List of group names to mark as active + */ + public void setActiveGroups(List activeGroups) { + Set desiredActiveGroups = new LinkedHashSet<>(activeGroups); + this.activeGroups.clear(); + this.activeGroups.addAll(desiredActiveGroups); + + for (ToolGroup group : toolGroups.values()) { + group.setActive(desiredActiveGroups.contains(group.getName())); + } + } + + /** + * Get a tool group by name. + * + * @param groupName Name of the tool group + * @return ToolGroup or null if not found + */ + public ToolGroup getToolGroup(String groupName) { + return toolGroups.get(groupName); + } + + /** + * Copy all tool groups from this manager to another manager. + * + * @param target The target manager to copy tool groups to + */ + void copyTo(ToolGroupManager target) { + target.tools.clear(); + target.toolGroups.clear(); + for (Map.Entry entry : toolGroups.entrySet()) { + String groupName = entry.getKey(); + ToolGroup sourceGroup = entry.getValue(); + + // Create a copy of the tool group + ToolGroup copiedGroup = + ToolGroup.builder() + .name(groupName) + .description(sourceGroup.getDescription()) + .active(sourceGroup.isActive()) + .tools(sourceGroup.getTools()) + .build(); + + target.toolGroups.put(groupName, copiedGroup); + } + + for (Map.Entry> entry : tools.entrySet()) { + target.tools.put(entry.getKey(), new HashSet<>(entry.getValue())); + } + + target.activeGroups.clear(); + target.activeGroups.addAll(this.activeGroups); + } + + private void setGroupActiveState(String groupName, ToolGroup group, boolean active) { + group.setActive(active); + if (active) { + activeGroups.add(groupName); + return; + } + activeGroups.remove(groupName); + } + + private boolean removeGroupFromToolIndex(String toolName, String groupName) { + Set groupNames = tools.get(toolName); + if (groupNames == null) { + return false; + } + groupNames.remove(groupName); + if (groupNames.isEmpty()) { + tools.remove(toolName); + return true; + } + return false; + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolGroupManagerConcurrencyTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolGroupManagerConcurrencyTest.java new file mode 100644 index 000000000..358d6b6b0 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolGroupManagerConcurrencyTest.java @@ -0,0 +1,203 @@ +/* + * 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; + +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 java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class ToolGroupManagerConcurrencyTest { + + private static final int GROUP_COUNT = 10; + private static final int WORKER_COUNT = 6; + private static final int ITERATIONS = 250; + + @Test + void concurrentReadersAndWritersShouldKeepActiveGroupSnapshotsConsistent() throws Exception { + ToolGroupManager manager = createManagerWithGroups(); + Queue failures = new ConcurrentLinkedQueue<>(); + ExecutorService executor = Executors.newFixedThreadPool(WORKER_COUNT); + CyclicBarrier startBarrier = new CyclicBarrier(WORKER_COUNT); + + try { + List> futures = new ArrayList<>(); + for (int worker = 0; worker < WORKER_COUNT / 2; worker++) { + final int workerIndex = worker; + futures.add( + executor.submit( + () -> { + await(startBarrier, failures); + for (int iteration = 0; + iteration < ITERATIONS && failures.isEmpty(); + iteration++) { + String groupName = + "group-" + + ((workerIndex + iteration) % GROUP_COUNT); + boolean active = (iteration + workerIndex) % 2 == 0; + try { + manager.updateToolGroups(List.of(groupName), active); + } catch (Throwable error) { + failures.add(error); + return; + } + } + })); + } + + for (int worker = WORKER_COUNT / 2; worker < WORKER_COUNT; worker++) { + futures.add( + executor.submit( + () -> { + await(startBarrier, failures); + for (int iteration = 0; + iteration < ITERATIONS && failures.isEmpty(); + iteration++) { + try { + List snapshot = manager.getActiveGroups(); + assertEquals( + new HashSet<>(snapshot).size(), + snapshot.size(), + "Concurrent snapshots should not contain" + + " duplicates"); + manager.getActivatedNotes(); + manager.getNotes(); + manager.getActiveToolNames(); + } catch (Throwable error) { + failures.add(error); + return; + } + } + })); + } + + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } finally { + executor.shutdownNow(); + } + + assertTrue(failures.isEmpty(), formatFailures(failures)); + assertManagerConsistency(manager); + } + + @Test + void setActiveGroupsShouldReplacePreviousActiveStateSnapshot() { + ToolGroupManager manager = createManagerWithGroups(); + + manager.setActiveGroups(List.of("group-1", "group-3", "missing-group")); + + assertEquals( + List.of("group-1", "group-3", "missing-group"), + manager.getActiveGroups(), + "setActiveGroups should publish a stable snapshot in the provided order"); + assertTrue(manager.getToolGroup("group-1").isActive()); + assertTrue(manager.getToolGroup("group-3").isActive()); + assertFalse(manager.getToolGroup("group-0").isActive()); + assertFalse(manager.getToolGroup("group-2").isActive()); + assertFalse(manager.getToolGroup("group-4").isActive()); + + String notes = manager.getActivatedNotes(); + assertTrue(notes.contains("group-1")); + assertTrue(notes.contains("group-3")); + assertFalse(notes.contains("missing-group")); + } + + @Test + void copyToShouldPreserveAnIndependentActiveGroupSnapshot() { + ToolGroupManager source = createManagerWithGroups(); + source.setActiveGroups(List.of("group-2", "group-4", "missing-group")); + + ToolGroupManager copy = new ToolGroupManager(); + source.copyTo(copy); + + assertEquals(source.getToolGroupNames(), copy.getToolGroupNames()); + assertEquals(source.getActiveGroups(), copy.getActiveGroups()); + assertManagerConsistency(copy); + + copy.updateToolGroups(List.of("group-2"), false); + copy.updateToolGroups(List.of("group-5"), true); + + assertTrue(source.getActiveGroups().contains("group-2")); + assertFalse(source.getActiveGroups().contains("group-5")); + assertFalse(copy.getActiveGroups().contains("group-2")); + assertTrue(copy.getActiveGroups().contains("group-5")); + assertManagerConsistency(source); + assertManagerConsistency(copy); + } + + private ToolGroupManager createManagerWithGroups() { + ToolGroupManager manager = new ToolGroupManager(); + for (int index = 0; index < GROUP_COUNT; index++) { + String groupName = "group-" + index; + manager.createToolGroup(groupName, "Description for " + groupName, index % 2 == 0); + manager.addToolToGroup(groupName, "tool-" + index); + } + return manager; + } + + private void assertManagerConsistency(ToolGroupManager manager) { + List activeGroups = manager.getActiveGroups(); + Set activeGroupSet = new HashSet<>(activeGroups); + assertEquals( + activeGroupSet.size(), + activeGroups.size(), + "activeGroups should stay deduplicated"); + + for (String groupName : manager.getToolGroupNames()) { + ToolGroup group = manager.getToolGroup(groupName); + assertEquals( + activeGroupSet.contains(groupName), + group.isActive(), + "ToolGroup active flag should stay aligned with activeGroups snapshots"); + } + } + + private void await(CyclicBarrier startBarrier, Queue failures) { + try { + startBarrier.await(10, TimeUnit.SECONDS); + } catch (Throwable error) { + failures.add(error); + } + } + + private String formatFailures(Queue failures) { + StringBuilder builder = new StringBuilder(); + for (Throwable failure : failures) { + if (builder.length() > 0) { + builder.append(" | "); + } + builder.append(failure.getClass().getSimpleName()) + .append(':') + .append(' ') + .append(failure.getMessage()); + } + return builder.toString(); + } +}