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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 34 additions & 21 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import io.agentscope.core.agent.StructuredOutputCapableAgent;
import io.agentscope.core.agent.accumulator.ReasoningContext;
import io.agentscope.core.hook.ActingChunkEvent;
import io.agentscope.core.hook.Hook;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostActingEvent;
Expand Down Expand Up @@ -63,6 +64,7 @@
import io.agentscope.core.util.MessageUtils;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -138,13 +140,13 @@ public class ReActAgent extends StructuredOutputCapableAgent {

// ==================== Constructor ====================

private ReActAgent(Builder builder) {
private ReActAgent(Builder builder, Toolkit agentToolkit) {
super(
builder.name,
builder.description,
builder.checkRunning,
builder.hooks,
builder.toolkit,
new ArrayList<>(builder.hooks),
agentToolkit,
builder.structuredOutputReminder);

this.memory = builder.memory;
Expand Down Expand Up @@ -404,6 +406,9 @@ private Mono<Msg> acting(int iter) {
return executeIteration(iter + 1);
}

// Set chunk callback for streaming tool responses
toolkit.setChunkCallback((toolUse, chunk) -> notifyActingChunk(toolUse, chunk).subscribe());
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chunk callback uses .subscribe() which creates a fire-and-forget subscription. This means:

  1. Any errors in notifyActingChunk will be silently swallowed
  2. The subscription is not managed or cancelled, potentially leading to resource leaks
  3. There's no backpressure handling

Consider returning the Mono without subscribing and let the caller manage the subscription, or use .subscribeOn() with proper error handling. Alternatively, if fire-and-forget is intentional, add explicit error handling with .doOnError().

Suggested change
toolkit.setChunkCallback((toolUse, chunk) -> notifyActingChunk(toolUse, chunk).subscribe());
toolkit.setChunkCallback(
(toolUse, chunk) ->
notifyActingChunk(toolUse, chunk)
.doOnError(
error ->
LoggerFactory.getLogger(ReActAgent.class)
.error(
"Error while notifying acting chunk",
error))
.subscribe());

Copilot uses AI. Check for mistakes.

// Execute all tools (including external ones which will return pending results)
return notifyPreActingHooks(allToolCalls)
.flatMap(this::executeToolCalls)
Expand Down Expand Up @@ -678,6 +683,11 @@ private Mono<List<ToolUseBlock>> notifyPreActingHooks(List<ToolUseBlock> toolCal
.collectList();
}

private Mono<Void> notifyActingChunk(ToolUseBlock toolUse, ToolResultBlock chunk) {
ActingChunkEvent event = new ActingChunkEvent(this, toolkit, toolUse, chunk);
return Flux.fromIterable(getSortedHooks()).flatMap(hook -> hook.onEvent(event)).then();
}

private Mono<Void> notifyReasoningChunk(Msg chunkMsg, ReasoningContext context) {
ContentBlock content = chunkMsg.getFirstContentBlock();

Expand Down Expand Up @@ -775,7 +785,7 @@ public static class Builder {
private int maxIters = 10;
private ExecutionConfig modelExecutionConfig;
private ExecutionConfig toolExecutionConfig;
private final List<Hook> hooks = new ArrayList<>();
private final Set<Hook> hooks = new LinkedHashSet<>();
private boolean enableMetaTool = false;
private StructuredOutputReminder structuredOutputReminder =
StructuredOutputReminder.TOOL_CHOICE;
Expand All @@ -791,7 +801,7 @@ public static class Builder {
private StatePersistence statePersistence;

// RAG configuration
private final List<Knowledge> knowledgeBases = new ArrayList<>();
private final Set<Knowledge> knowledgeBases = new LinkedHashSet<>();
private RAGMode ragMode = RAGMode.GENERIC;
private RetrieveConfig retrieveConfig =
RetrieveConfig.builder().limit(5).scoreThreshold(0.5).build();
Expand Down Expand Up @@ -1160,31 +1170,34 @@ public Builder toolExecutionContext(ToolExecutionContext toolExecutionContext) {
* @throws IllegalArgumentException if required parameters are missing or invalid
*/
public ReActAgent build() {
// Deep copy toolkit to avoid state interference between agents
Toolkit agentToolkit = this.toolkit.copy();

if (enableMetaTool) {
toolkit.registerMetaTool();
agentToolkit.registerMetaTool();
}

// Configure long-term memory if provided
if (longTermMemory != null) {
configureLongTermMemory();
configureLongTermMemory(agentToolkit);
}

// Configure RAG if knowledge bases are provided
if (!knowledgeBases.isEmpty()) {
configureRAG();
configureRAG(agentToolkit);
}

// Configure PlanNotebook if provided
if (planNotebook != null) {
configurePlan();
configurePlan(agentToolkit);
}

// Configure SkillBox if provided
if (skillBox != null) {
configureSkillBox();
configureSkillBox(agentToolkit);
}

return new ReActAgent(this);
return new ReActAgent(this, agentToolkit);
}

/**
Expand All @@ -1197,11 +1210,11 @@ public ReActAgent build() {
* <li>BOTH: Combines both approaches (registers tools + hook)</li>
* </ul>
*/
private void configureLongTermMemory() {
private void configureLongTermMemory(Toolkit agentToolkit) {
// If agent control is enabled, register memory tools via adapter
if (longTermMemoryMode == LongTermMemoryMode.AGENT_CONTROL
|| longTermMemoryMode == LongTermMemoryMode.BOTH) {
toolkit.registerTool(new LongTermMemoryTools(longTermMemory));
agentToolkit.registerTool(new LongTermMemoryTools(longTermMemory));
}

// If static control is enabled, register the hook for automatic memory management
Expand All @@ -1223,11 +1236,11 @@ private void configureLongTermMemory() {
* <li>NONE: Does nothing</li>
* </ul>
*/
private void configureRAG() {
private void configureRAG(Toolkit agentToolkit) {
// Aggregate knowledge bases if multiple are provided
Knowledge aggregatedKnowledge;
if (knowledgeBases.size() == 1) {
aggregatedKnowledge = knowledgeBases.get(0);
aggregatedKnowledge = knowledgeBases.iterator().next();
} else {
aggregatedKnowledge = buildAggregatedKnowledge();
}
Expand All @@ -1245,7 +1258,7 @@ private void configureRAG() {
// Register knowledge retrieval tools
KnowledgeRetrievalTools tools =
new KnowledgeRetrievalTools(aggregatedKnowledge);
toolkit.registerTool(tools);
agentToolkit.registerTool(tools);
}
case NONE -> {
// Do nothing
Expand Down Expand Up @@ -1305,9 +1318,9 @@ private List<Document> mergeAndSortResults(List<List<Document>> allResults) {
* <li>Adds a hook to inject plan hints before each reasoning step
* </ul>
*/
private void configurePlan() {
private void configurePlan(Toolkit agentToolkit) {
// Register plan tools to toolkit
toolkit.registerTool(planNotebook);
agentToolkit.registerTool(planNotebook);

// Add plan hint hook
Hook planHintHook =
Expand Down Expand Up @@ -1344,10 +1357,10 @@ public <T extends HookEvent> Mono<T> onEvent(T event) {
* <li>Adds the skill hook to inject skill prompts and manage skill activation
* </ul>
*/
private void configureSkillBox() {
skillBox.bindToolkit(toolkit);
private void configureSkillBox(Toolkit agentToolkit) {
skillBox.bindToolkit(agentToolkit);
// Register skill loader tools to toolkit
toolkit.registerTool(skillBox);
agentToolkit.registerTool(skillBox);

hooks.add(new SkillHook(skillBox));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,30 @@ public void setActiveGroups(List<String> activeGroups) {
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) {
for (Map.Entry<String, ToolGroup> 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);
}

// Copy activeGroups list
target.activeGroups = new ArrayList<>(this.activeGroups);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,18 @@ void removeTool(String toolName) {
void removeTools(Set<String> toolNames) {
toolNames.forEach(this::removeTool);
}

/**
* Copy all tools from this registry to another registry.
*
* @param target The target registry to copy tools to
*/
void copyTo(ToolRegistry target) {
for (Map.Entry<String, AgentTool> entry : tools.entrySet()) {
String toolName = entry.getKey();
AgentTool tool = entry.getValue();
RegisteredToolFunction registered = registeredTools.get(toolName);
target.registerTool(toolName, tool, registered);
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The copyTo method performs a shallow copy by sharing the same AgentTool and RegisteredToolFunction instances between the source and target registries. This violates the deep copy intention stated in the PR.

RegisteredToolFunction has mutable state (the presetParameters field can be modified via updatePresetParameters method). When agents share the same RegisteredToolFunction instance, calling toolkit.updateToolPresetParameters on one agent's toolkit will affect other agents that share the same tool reference.

To properly isolate agent state, each RegisteredToolFunction should be deep copied with its own presetParameters map. Consider creating a copy constructor or copy method in RegisteredToolFunction that creates a new instance with copied state.

Suggested change
target.registerTool(toolName, tool, registered);
RegisteredToolFunction registeredCopy = registered == null ? null : registered.copy();
target.registerTool(toolName, tool, registeredCopy);

Copilot uses AI. Check for mistakes.
}
}
}
19 changes: 19 additions & 0 deletions agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,25 @@ public void updateToolPresetParameters(
logger.debug("Updated preset parameters for tool '{}'", toolName);
}

// ==================== Deep Copy ====================

/**
* Create a deep copy of this toolkit.
*
* @return A new Toolkit instance with copied state
*/
public Toolkit copy() {
Toolkit copy = new Toolkit(this.config);

// Copy all registered tools
this.toolRegistry.copyTo(copy.toolRegistry);

// Copy all tool groups and their states
this.groupManager.copyTo(copy.groupManager);

return copy;
}
Comment on lines +652 to +662
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new copy() method lacks test coverage. Given the critical nature of this functionality for agent state isolation and the comprehensive test suite for Toolkit (ToolkitTest.java has 740 lines of tests), this method should have corresponding tests.

Suggested test scenarios:

  1. Verify that modifying copied toolkit's tool groups doesn't affect the original
  2. Verify that updating preset parameters in one toolkit doesn't affect the copy
  3. Verify that all tools are copied correctly
  4. Verify that active groups state is properly isolated
  5. Verify that the config is shared (not copied) as intended

Copilot uses AI. Check for mistakes.

/**
* Fluent builder for registering tools with optional configuration.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,11 @@ void testToolkitLoadRestoresActiveGroups() {
agent2.loadFrom(session, sessionKey);

// Should have loaded the saved activeGroups
assertEquals(2, toolkit2.getActiveGroups().size());
assertTrue(toolkit2.getActiveGroups().contains("group1"));
assertTrue(toolkit2.getActiveGroups().contains("group2"));
// Note: Agent uses a copy of toolkit, so we need to check agent's internal toolkit
Toolkit agentToolkit = agent2.getToolkit();
assertEquals(2, agentToolkit.getActiveGroups().size());
assertTrue(agentToolkit.getActiveGroups().contains("group1"));
assertTrue(agentToolkit.getActiveGroups().contains("group2"));
}

@Test
Expand Down Expand Up @@ -514,8 +516,10 @@ void testJsonSessionPersistence() {
// Verify all state was loaded
assertEquals(2, newMemory.getMessages().size());
assertEquals("Hello from JsonSession", getTextContent(newMemory.getMessages().get(0)));
assertEquals(2, newToolkit.getActiveGroups().size());
assertTrue(newToolkit.getActiveGroups().contains("web"));
// Note: Agent uses a copy of toolkit, so we need to check agent's internal toolkit
Toolkit agentToolkit = newAgent.getToolkit();
assertEquals(2, agentToolkit.getActiveGroups().size());
assertTrue(agentToolkit.getActiveGroups().contains("web"));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

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.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
Expand All @@ -35,6 +36,7 @@
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.Arrays;
import java.util.HashMap;
Expand Down Expand Up @@ -621,7 +623,8 @@ void testGetters() {
assertEquals(mockModel, agent.getModel());

assertNotNull(agent.getToolkit(), "Toolkit should not be null");
assertEquals(mockToolkit, agent.getToolkit());
// Agent uses a copy of toolkit for state isolation, so we verify it's a Toolkit instance
assertInstanceOf(Toolkit.class, agent.getToolkit());

assertEquals(TestConstants.DEFAULT_MAX_ITERS, agent.getMaxIters());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ void testFirstPreCallEvent() {

// Verify ContextOffloadTool was registered
assertTrue(
toolkit.getToolNames().contains("context_reload"),
agentWithPlan.getToolkit().getToolNames().contains("context_reload"),
"ContextOffloadTool should be registered");

// Verify PlanNotebook was attached (we can't directly verify, but we can check
Expand All @@ -135,13 +135,13 @@ void testHookExecutesOnlyOnce() {
PreCallEvent event3 = new PreCallEvent(agentWithPlan, new ArrayList<>());

hook.onEvent(event1).block();
int toolCountAfterFirst = toolkit.getToolNames().size();
int toolCountAfterFirst = agentWithPlan.getToolkit().getToolNames().size();

hook.onEvent(event2).block();
hook.onEvent(event3).block();

// Tool count should not increase after first call
assertEquals(toolCountAfterFirst, toolkit.getToolNames().size());
assertEquals(toolCountAfterFirst, agentWithPlan.getToolkit().getToolNames().size());
}

@Test
Expand Down Expand Up @@ -174,7 +174,7 @@ void testSkipAgentWithoutAutoContextMemory() {

// Verify no ContextOffloadTool was registered
assertFalse(
toolkit.getToolNames().contains("context_reload"),
agentWithOtherMemory.getToolkit().getToolNames().contains("context_reload"),
"ContextOffloadTool should not be registered for non-AutoContextMemory");
}

Expand All @@ -198,7 +198,7 @@ void testAgentWithoutPlanNotebook() {
assertNotNull(event);
// ContextOffloadTool should still be registered
assertTrue(
toolkit.getToolNames().contains("context_reload"),
agentWithoutPlan.getToolkit().getToolNames().contains("context_reload"),
"ContextOffloadTool should be registered even without PlanNotebook");
}

Expand All @@ -223,7 +223,7 @@ void testAgentWithoutToolkit() {
assertNotNull(event);
// ContextOffloadTool should be registered
assertTrue(
emptyToolkit.getToolNames().contains("context_reload"),
agentWithEmptyToolkit.getToolkit().getToolNames().contains("context_reload"),
"ContextOffloadTool should be registered");
}

Expand Down
Loading