diff --git a/agentscope-core/src/main/java/io/agentscope/core/memory/InMemoryMemory.java b/agentscope-core/src/main/java/io/agentscope/core/memory/InMemoryMemory.java index 869828408..982ca4773 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/memory/InMemoryMemory.java +++ b/agentscope-core/src/main/java/io/agentscope/core/memory/InMemoryMemory.java @@ -124,4 +124,23 @@ public void deleteMessage(int index) { public void clear() { messages.clear(); } + + /** + * Creates a fork (copy) of this memory. + * + *
The fork contains a copy of all messages at the time of invocation. Changes to the fork + * do not affect the original memory, and vice versa. + * + * @return A new InMemoryMemory instance containing copies of all messages + */ + @Override + public Memory fork() { + InMemoryMemory forked = new InMemoryMemory(); + for (Msg msg : messages) { + if (msg != null) { + forked.messages.add(msg); + } + } + return forked; + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/memory/Memory.java b/agentscope-core/src/main/java/io/agentscope/core/memory/Memory.java index 4b223fedf..62974c92f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/memory/Memory.java +++ b/agentscope-core/src/main/java/io/agentscope/core/memory/Memory.java @@ -59,4 +59,17 @@ public interface Memory extends StateModule { * is typically irreversible unless state has been persisted. */ void clear(); + + /** + * Creates a fork (copy) of this memory. + * + *
The fork contains a copy of all messages at the time of invocation. Changes to the fork + * do not affect the original memory, and vice versa. + * + *
This is useful when you want to provide context to a sub-agent without allowing it to
+ * modify the parent's memory.
+ *
+ * @return A new Memory instance containing copies of all messages
+ */
+ Memory fork();
}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkill.java b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkill.java
index ee3027b34..02a82e584 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkill.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkill.java
@@ -67,6 +67,8 @@ public class AgentSkill {
private final String skillContent;
private final Map Use this constructor when you want to create a skill directly without parsing
+ * markdown. The source parameter indicates where the skill originated from.
+ * The model parameter specifies which AI model should be used when this skill is active.
+ *
+ * @param name Skill name (must not be null or empty)
+ * @param description Skill description (must not be null or empty)
+ * @param skillContent The skill implementation or instructions (must not be null or empty)
+ * @param resources Supporting resources referenced by the skill (can be null)
+ * @param source Source identifier for the skill (null defaults to "custom")
+ * @param model Model reference for the skill (can be null)
+ * @throws IllegalArgumentException if name, description, or skillContent is null or empty
+ */
+ public AgentSkill(
+ String name,
+ String description,
+ String skillContent,
+ Map Use this constructor when you want to create a skill directly without parsing markdown.
+ * The source parameter indicates where the skill originated from. The model parameter
+ * specifies which AI model should be used when this skill is active. The context parameter
+ * specifies how memory should be shared with the sub-agent.
+ *
+ * @param name Skill name (must not be null or empty)
+ * @param description Skill description (must not be null or empty)
+ * @param skillContent The skill implementation or instructions (must not be null or empty)
+ * @param resources Supporting resources referenced by the skill (can be null)
+ * @param source Source identifier for the skill (null defaults to "custom")
+ * @param model Model reference for the skill (can be null)
+ * @param context Context sharing mode: "shared" (default), "fork", or "new" (can be null)
+ * @throws IllegalArgumentException if name, description, or skillContent is null or empty
+ */
+ public AgentSkill(
+ String name,
+ String description,
+ String skillContent,
+ Map The model specifies which AI model should be used when this skill is active. For example:
+ * "haiku", "sonnet", "openai:gpt-4o".
+ *
+ * @return The model reference, or null if no specific model is required
+ */
+ public String getModel() {
+ return model;
+ }
+
+ /**
+ * Gets the context sharing mode for this skill.
+ *
+ * The context specifies how memory should be shared between the parent agent and the
+ * sub-agent:
+ *
+ * The context specifies how memory should be shared:
+ *
+ * This provider supports multiple model reference formats:
+ *
+ * Example usage:
+ *
+ * When set, this takes priority over the skill's model field. This allows dynamic model
+ * assignment at registration time.
+ *
+ * Priority: runtimeModel > skill.getModel()
+ *
+ * @param runtimeModel The model reference (e.g., "haiku", "sonnet", "openai:gpt-4o")
+ * @return This builder for chaining
+ */
+ public SkillRegistration runtimeModel(String runtimeModel) {
+ this.runtimeModel = runtimeModel;
+ return this;
+ }
+
/**
* Apply the registration with all configured options.
*
@@ -548,7 +608,184 @@ public void apply() {
.subAgent(subAgentProvider, subAgentConfig)
.apply();
}
+
+ // Create SubAgentTool if skill has model
+ createSubAgentIfHasModel();
+ }
+
+ /**
+ * Resolve the effective model reference.
+ *
+ * Priority: runtimeModel > skill.getModel()
+ *
+ * @return The effective model reference, or null if neither is set
+ */
+ private String resolveEffectiveModel() {
+ if (runtimeModel != null && !runtimeModel.isBlank()) {
+ return runtimeModel;
+ }
+ return skill.getModel();
}
+
+ /**
+ * Create a SubAgentTool if the skill has a model configured.
+ *
+ * This method automatically creates a sub-agent tool that can execute the skill using the
+ * configured model. The tool name follows the pattern "call_{skillName}".
+ *
+ * If no model is configured, the model provider is missing, or the model is not found,
+ * this method does nothing (graceful degradation).
+ */
+ private void createSubAgentIfHasModel() {
+ String effectiveModel = resolveEffectiveModel();
+ Toolkit effectiveToolkit = toolkit != null ? toolkit : skillBox.toolkit;
+ skillBox.createSubAgentToolForSkill(skill, effectiveToolkit, effectiveModel);
+ }
+ }
+
+ // ==================== SubAgent Creation Helper ====================
+
+ /**
+ * Creates a SubAgentTool for a skill with the specified model.
+ *
+ * This is a shared helper method used by both {@link SkillRegistration} and {@link
+ * SkillToolFactory} to create sub-agent tools for skills with model configuration.
+ *
+ * The method handles:
+ *
+ * This method creates a context-aware provider that handles memory sharing based on the
+ * context sharing mode.
+ *
+ * @param skill The skill to create provider for
+ * @param resolvedModel The resolved model to use
+ * @param toolkitCopy A copy of the toolkit for the sub-agent
+ * @param systemPrompt The system prompt for NEW mode
+ * @return A SubAgentProvider that creates ReActAgent instances
+ */
+ private SubAgentProvider Supports multiple model reference formats:
+ *
+ * This class creates comprehensive system prompts that include:
+ *
+ * This ensures sub-agents have clear boundaries and understand their role, preventing
+ * behavior drift and ensuring focused, accurate responses.
+ *
+ * Example usage:
+ *
+ * This method also creates a sub-agent tool if the skill has a model configured
+ * and the sub-agent tool hasn't been created yet.
+ *
* @param skillId The unique identifier of the skill
* @return The skill instance
* @throws IllegalArgumentException if skill doesn't exist
@@ -280,6 +308,40 @@ private AgentSkill validatedActiveSkill(String skillId) {
toolkit.getToolGroup(toolsGroupName).getTools());
}
+ // Create sub-agent tool if skill has model and not already created
+ createSubAgentIfHasModel(skill, skillId);
+
return skill;
}
+
+ /**
+ * Create a SubAgentTool if the skill has a model configured and not already created.
+ *
+ * This method is called when a skill is dynamically loaded via load_skill_through_path.
+ * It ensures that skills with models get their sub-agent tools created on-demand.
+ *
+ * @param skill The skill to check for model configuration
+ * @param skillId The skill ID for tracking creation status
+ */
+ private void createSubAgentIfHasModel(AgentSkill skill, String skillId) {
+ // Check if sub-agent tool already created for this skill
+ if (skillsWithSubAgentCreated.contains(skillId)) {
+ logger.debug("Sub-agent tool already exists for skill '{}'", skillId);
+ return;
+ }
+
+ String modelRef = skill.getModel();
+
+ // Use shared method from SkillBox to create the sub-agent
+ boolean created = skillBox.createSubAgentToolForSkill(skill, toolkit, modelRef);
+
+ if (created) {
+ // Mark as created to prevent duplicates
+ skillsWithSubAgentCreated.add(skillId);
+
+ // Activate the tool group
+ String skillToolGroup = skillId + "_skill_tools";
+ toolkit.updateToolGroups(List.of(skillToolGroup), true);
+ }
+ }
}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/util/SkillUtil.java b/agentscope-core/src/main/java/io/agentscope/core/skill/util/SkillUtil.java
index 46b29e0c8..8aa0688ad 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/skill/util/SkillUtil.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/skill/util/SkillUtil.java
@@ -91,6 +91,8 @@ public static AgentSkill createFrom(
String name = metadata.get("name");
String description = metadata.get("description");
+ String model = metadata.get("model");
+ String context = metadata.get("context");
String skillContent = parsed.getContent();
if (name == null || name.isEmpty() || description == null || description.isEmpty()) {
@@ -103,7 +105,7 @@ public static AgentSkill createFrom(
"The SKILL.md must have content except for the YAML Front Matter.");
}
- return new AgentSkill(name, description, skillContent, resources, source);
+ return new AgentSkill(name, description, skillContent, resources, source, model, context);
}
/**
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/ContextSharingMode.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/ContextSharingMode.java
new file mode 100644
index 000000000..69ce8e94a
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/ContextSharingMode.java
@@ -0,0 +1,113 @@
+/*
+ * 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 org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Mode for sharing memory context between parent agent and sub-agent.
+ *
+ * This enum defines how memory is shared between the parent agent and sub-agent:
+ *
+ * Implementation Note: Both SHARED and FORK modes use forked memory copies because the
+ * parent's memory contains the pending tool_use block that invoked the sub-agent. Directly sharing
+ * this memory would cause validation errors when the sub-agent tries to add new messages. The
+ * pending tool calls are removed from the forked copy to ensure proper message sequence.
+ *
+ * Aligned with skill specification: https://code.claude.com/docs/zh-CN/skills
+ */
+public enum ContextSharingMode {
+ /**
+ * Shared memory mode (default).
+ *
+ * SubAgent receives a forked copy of parent's memory at invocation time, with pending tool
+ * calls removed. This provides the sub-agent with full conversation context visibility while
+ * ensuring isolation - changes made by the sub-agent don't affect the parent's memory.
+ *
+ * Note: Despite the name "shared", this mode uses a forked copy for technical
+ * reasons. The parent's memory cannot be directly shared because it contains the pending
+ * tool_use block that invoked this sub-agent, which would cause validation errors.
+ *
+ * This is the default mode when context is not specified in skill.md.
+ */
+ SHARED,
+
+ /**
+ * Fork memory mode.
+ *
+ * SubAgent gets a copy (fork) of parent's memory at invocation time, with pending tool calls
+ * removed. Changes made by SubAgent don't affect parent's memory. SubAgent uses parent's system
+ * prompt context.
+ *
+ * Use this when you need context awareness but want isolation from parent's memory.
+ */
+ FORK,
+
+ /**
+ * New independent memory mode.
+ *
+ * SubAgent has completely independent memory with its own system prompt. No context from
+ * parent is available.
+ *
+ * Use this for isolated tasks that don't need parent context.
+ */
+ NEW;
+
+ private static final Logger logger = LoggerFactory.getLogger(ContextSharingMode.class);
+
+ /**
+ * Parses the context sharing mode from a string value.
+ *
+ * Supported values:
+ *
+ * Sub-agents operate in conversation mode, supporting multi-turn dialogue with session
- * management. The tool exposes two parameters:
+ * management. The tool exposes the following parameters:
*
* Default Behavior:
@@ -42,12 +42,24 @@
* Context Sharing Modes:
+ *
+ * Example usage:
*
* Controls how memory is shared between the parent agent and sub-agent:
+ *
+ * Controls how memory is shared between the parent agent and sub-agent:
+ *
+ * This context allows the provider to make informed decisions about agent
+ * configuration based on the parent agent's state, particularly for memory
+ * sharing modes.
+ *
+ * Usage example:
+ * This is used for backward compatibility when no context is available.
+ *
+ * @return An empty SubAgentContext
+ */
+ public static SubAgentContext empty() {
+ return new SubAgentContext(null, ContextSharingMode.NEW, null);
+ }
+
+ /**
+ * Gets the parent agent that is invoking the sub-agent.
+ *
+ * @return The parent agent, or null if not available
+ */
+ public Agent getParentAgent() {
+ return parentAgent;
+ }
+
+ /**
+ * Gets the context sharing mode.
+ *
+ * @return The context sharing mode
+ */
+ public ContextSharingMode getContextSharingMode() {
+ return contextSharingMode;
+ }
+
+ /**
+ * Gets the memory to use for the sub-agent.
+ *
+ * This is pre-computed based on the context sharing mode:
+ * Since ReActAgent is not thread-safe, this provider pattern ensures that each tool call gets a
* fresh agent instance. This is similar to Spring's ObjectProvider pattern.
*
- * Example usage:
+ * The provider supports context-aware agent creation for memory sharing features:
+ * Example usage (context-aware for memory sharing):
*
* This method is called for each tool invocation to ensure thread safety.
+ * The context provides information about the parent agent and the memory to use.
+ *
+ * Implementations should:
+ * This method is called for each tool invocation to ensure thread safety. Implementations
- * should create a new agent instance each time this method is called.
+ * This is a convenience method for backward compatibility. It calls
+ * {@link #provideWithContext(SubAgentContext)} with an empty context.
*
* @return A new agent instance
*/
- T provide();
+ default T provide() {
+ return provideWithContext(SubAgentContext.empty());
+ }
}
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..8e26dc4cc 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,9 +15,12 @@
*/
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.memory.Memory;
+import io.agentscope.core.message.ImageBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.TextBlock;
@@ -27,7 +30,9 @@
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import io.agentscope.core.tool.ToolEmitter;
+import io.agentscope.core.tool.ToolExecutionContext;
import io.agentscope.core.util.JsonUtils;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -45,13 +50,30 @@
* Thread safety is ensured by using {@link SubAgentProvider} to create a fresh agent instance
* for each new session.
*
- * The tool exposes two parameters:
+ * The tool exposes the following parameters:
*
* Context Sharing Modes:
+ *
+ * Note: Both SHARED and FORK modes fork the parent's memory because the parent's memory
+ * contains the pending tool_use block that invoked the sub-agent, which would cause validation
+ * errors if shared directly.
*/
public class SubAgentTool implements AgentTool {
@@ -63,6 +85,17 @@ public class SubAgentTool implements AgentTool {
/** Parameter name for message. */
private static final String PARAM_MESSAGE = "message";
+ /**
+ * Context key for parent session ID.
+ *
+ * Applications can register the parent session ID in ToolExecutionContext to enable session
+ * inheritance for SHARED/FORK modes. The sub-agent will derive its session ID from the parent
+ * session.
+ *
+ * Example: context.register(CONTEXT_KEY_PARENT_SESSION_ID, "parent_session_123", String.class)
+ */
+ public static final String CONTEXT_KEY_PARENT_SESSION_ID = "parentSessionId";
+
private final String name;
private final String description;
private final SubAgentProvider> agentProvider;
@@ -114,6 +147,7 @@ public Mono
+ *
+ * This is necessary because when a sub-agent is invoked as a tool, the parent's memory
+ * contains the tool_use block that called the sub-agent. If we share this memory directly,
+ * the sub-agent will fail validation when trying to add new messages because of the pending
+ * tool call.
+ *
+ * This method removes:
+ * The parent session ID should be registered in the context with the key {@link
+ * #CONTEXT_KEY_PARENT_SESSION_ID}.
+ *
+ * @param context The tool execution context, may be null
+ * @return The parent session ID, or null if not available
+ */
+ private String getParentSessionId(ToolExecutionContext context) {
+ if (context == null) {
+ return null;
+ }
+ return context.get(CONTEXT_KEY_PARENT_SESSION_ID, String.class);
+ }
+
/**
* Loads agent state from the session storage.
*
@@ -345,10 +569,42 @@ private ToolResultBlock buildResult(Msg response, String sessionId) {
sessionId, textContent != null ? textContent : "(No response)"));
}
+ /**
+ * Builds a user message with text content.
+ *
+ * @param message The text message
+ * @return The constructed message
+ */
+ private Msg buildUserMessage(String message, Memory memory) {
+ // Try to extract images from the most recent user message in memory
+ List Creates a schema with two properties:
+ * Creates a schema with properties:
*
* These tests verify that skills with models automatically create SubAgentTools.
+ */
+@Tag("unit")
+class SkillBoxRegistrationTest {
+
+ private Toolkit toolkit;
+ private SkillBox skillBox;
+ private Model qwenTurboModel;
+ private Model qwenPlusModel;
+
+ @BeforeEach
+ void setUp() {
+ toolkit = new Toolkit();
+ qwenTurboModel = mock(Model.class);
+ when(qwenTurboModel.getModelName()).thenReturn("qwen-turbo");
+
+ qwenPlusModel = mock(Model.class);
+ when(qwenPlusModel.getModelName()).thenReturn("qwen-plus");
+
+ SkillModelProvider provider =
+ MapBasedSkillModelProvider.builder()
+ .register("qwen-turbo", qwenTurboModel)
+ .register("qwen-plus", qwenPlusModel)
+ .build();
+
+ skillBox = new SkillBox(toolkit, null, null, provider);
+ }
+
+ @Nested
+ @DisplayName("Sub-Agent Tool Creation Tests")
+ class SubAgentToolCreationTests {
+
+ @Test
+ @DisplayName("Should create sub-agent tool when skill has model")
+ void shouldCreateSubAgentToolWhenSkillHasModel() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("code_review")
+ .description("Code review skill")
+ .skillContent("Review code")
+ .model("qwen-turbo")
+ .build();
+
+ skillBox.registration().skill(skill).apply();
+
+ // Verify sub-agent tool was created
+ assertNotNull(toolkit.getTool("call_code_review"));
+ }
+
+ @Test
+ @DisplayName("Should not create sub-agent tool when skill has no model")
+ void shouldNotCreateSubAgentToolWhenSkillHasNoModel() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("simple_skill")
+ .description("Simple skill")
+ .skillContent("Do something")
+ .build();
+
+ skillBox.registration().skill(skill).apply();
+
+ // No sub-agent tool should be created
+ assertNull(toolkit.getTool("call_simple_skill"));
+ }
+
+ @Test
+ @DisplayName("Should use runtime model over skill model")
+ void shouldUseRuntimeModelOverSkillModel() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("code_review")
+ .description("Code review skill")
+ .skillContent("Review code")
+ .model("qwen-turbo")
+ .build();
+
+ skillBox.registration().skill(skill).runtimeModel("qwen-plus").apply();
+
+ // Tool should still be created
+ assertNotNull(toolkit.getTool("call_code_review"));
+ }
+
+ @Test
+ @DisplayName("Should not create sub-agent when model not found")
+ void shouldNotCreateSubAgentWhenModelNotFound() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("unknown_model_skill")
+ .description("Skill with unknown model")
+ .skillContent("Content")
+ .model("unknown_model")
+ .build();
+
+ skillBox.registration().skill(skill).apply();
+
+ // Should not crash, just log warning
+ assertNull(toolkit.getTool("call_unknown_model_skill"));
+ }
+
+ @Test
+ @DisplayName("Should not create sub-agent when no model provider")
+ void shouldNotCreateSubAgentWhenNoModelProvider() {
+ SkillBox boxWithoutProvider = new SkillBox(toolkit);
+
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("code_review")
+ .description("Code review skill")
+ .skillContent("Review code")
+ .model("qwen-turbo")
+ .build();
+
+ boxWithoutProvider.registration().skill(skill).apply();
+
+ // Should not crash, just log warning
+ assertNull(toolkit.getTool("call_code_review"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Runtime Model Priority Tests")
+ class RuntimeModelPriorityTests {
+
+ @Test
+ @DisplayName("Should create sub-agent with runtime model even when skill has no model")
+ void shouldCreateSubAgentWithRuntimeModelEvenWhenSkillHasNoModel() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("dynamic_skill")
+ .description("Dynamic skill")
+ .skillContent("Do something")
+ .build();
+
+ skillBox.registration().skill(skill).runtimeModel("qwen-turbo").apply();
+
+ // Tool should be created with runtime model
+ assertNotNull(toolkit.getTool("call_dynamic_skill"));
+ }
+
+ @Test
+ @DisplayName("Should not create sub-agent when runtime model is blank")
+ void shouldNotCreateSubAgentWhenRuntimeModelIsBlank() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("blank_runtime_skill")
+ .description("Blank runtime skill")
+ .skillContent("Content")
+ .build();
+
+ skillBox.registration().skill(skill).runtimeModel(" ").apply();
+
+ // No tool should be created
+ assertNull(toolkit.getTool("call_blank_runtime_skill"));
+ }
+ }
+
+ @Nested
+ @DisplayName("createSubAgentToolForSkill Tests")
+ class CreateSubAgentToolForSkillTests {
+
+ @Test
+ @DisplayName("Should return false when modelRef is null")
+ void shouldReturnFalseWhenModelRefIsNull() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("test")
+ .description("Test")
+ .skillContent("Content")
+ .build();
+
+ boolean result = skillBox.createSubAgentToolForSkill(skill, toolkit, null);
+
+ assertNull(toolkit.getTool("call_test"));
+ }
+
+ @Test
+ @DisplayName("Should return false when modelRef is blank")
+ void shouldReturnFalseWhenModelRefIsBlank() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("test")
+ .description("Test")
+ .skillContent("Content")
+ .build();
+
+ boolean result = skillBox.createSubAgentToolForSkill(skill, toolkit, " ");
+
+ assertNull(toolkit.getTool("call_test"));
+ }
+
+ @Test
+ @DisplayName("Should return false when toolkit is null")
+ void shouldReturnFalseWhenToolkitIsNull() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("test")
+ .description("Test")
+ .skillContent("Content")
+ .model("test-model")
+ .build();
+
+ boolean result = skillBox.createSubAgentToolForSkill(skill, null, "test-model");
+
+ assertNull(toolkit.getTool("call_test"));
+ }
+
+ @Test
+ @DisplayName("Should return false when no model provider configured")
+ void shouldReturnFalseWhenNoModelProvider() {
+ SkillBox boxWithoutProvider = new SkillBox(toolkit);
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("test")
+ .description("Test")
+ .skillContent("Content")
+ .model("test-model")
+ .build();
+
+ boolean result =
+ boxWithoutProvider.createSubAgentToolForSkill(skill, toolkit, "test-model");
+
+ assertNull(toolkit.getTool("call_test"));
+ }
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxTest.java
index e3f5a13db..a3480a2d8 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxTest.java
@@ -20,6 +20,7 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
@@ -28,6 +29,7 @@
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.model.Model;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolCallParam;
@@ -762,6 +764,43 @@ void testPreserveCustomValidator() {
}
}
+ @Nested
+ @DisplayName("ModelProvider Tests")
+ class ModelProviderTests {
+ @Test
+ @DisplayName("Should store model provider")
+ void shouldStoreModelProvider() {
+ Model defaultModel = mock(Model.class);
+ SkillModelProvider provider =
+ MapBasedSkillModelProvider.builder().defaultModel(defaultModel).build();
+
+ SkillBox skillBoxWithProvider = new SkillBox(toolkit, null, null, provider);
+
+ assertSame(provider, skillBoxWithProvider.getModelProvider());
+ }
+
+ @Test
+ @DisplayName("Should allow setting model provider")
+ void shouldAllowSettingModelProvider() {
+ SkillBox skillBoxNoProvider = new SkillBox(toolkit);
+ Model defaultModel = mock(Model.class);
+ SkillModelProvider provider =
+ MapBasedSkillModelProvider.builder().defaultModel(defaultModel).build();
+
+ skillBoxNoProvider.setModelProvider(provider);
+
+ assertSame(provider, skillBoxNoProvider.getModelProvider());
+ }
+
+ @Test
+ @DisplayName("Should return null model provider when not set")
+ void shouldReturnNullModelProviderWhenNotSet() {
+ SkillBox skillBoxNoProvider = new SkillBox(toolkit);
+
+ assertNull(skillBoxNoProvider.getModelProvider());
+ }
+ }
+
@Test
@DisplayName("Should bind toolkit and propagate to SkillToolFactory")
void testBindToolkitUpdatesSkillToolFactory() {
diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillModelProviderTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillModelProviderTest.java
new file mode 100644
index 000000000..8a3fd749e
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillModelProviderTest.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.skill;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import io.agentscope.core.model.Model;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("SkillModelProvider Tests")
+class SkillModelProviderTest {
+
+ @Test
+ @DisplayName("Should return model from provider")
+ void shouldReturnModelFromProvider() {
+ Model qwenTurboModel = mock(Model.class);
+ when(qwenTurboModel.getModelName()).thenReturn("qwen-turbo");
+
+ SkillModelProvider provider =
+ modelRef -> "qwen-turbo".equals(modelRef) ? qwenTurboModel : null;
+
+ Model result = provider.getModel("qwen-turbo");
+ assertNotNull(result);
+ assertEquals("qwen-turbo", result.getModelName());
+ }
+
+ @Test
+ @DisplayName("Should return null when model not found")
+ void shouldReturnNullWhenModelNotFound() {
+ SkillModelProvider provider = modelRef -> null;
+
+ Model result = provider.getModel("unknown");
+ assertNull(result);
+ }
+
+ @Test
+ @DisplayName("Should check availability")
+ void shouldCheckAvailability() {
+ Model qwenTurboModel = mock(Model.class);
+ SkillModelProvider provider =
+ modelRef -> "qwen-turbo".equals(modelRef) ? qwenTurboModel : null;
+
+ assertTrue(provider.isAvailable("qwen-turbo"));
+ assertFalse(provider.isAvailable("unknown"));
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillSubagentPromptBuilderTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillSubagentPromptBuilderTest.java
new file mode 100644
index 000000000..cf7716e81
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillSubagentPromptBuilderTest.java
@@ -0,0 +1,258 @@
+/*
+ * 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.skill;
+
+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 org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link SkillSubagentPromptBuilder}.
+ */
+@Tag("unit")
+class SkillSubagentPromptBuilderTest {
+
+ @Nested
+ @DisplayName("Builder Tests")
+ class BuilderTests {
+
+ @Test
+ @DisplayName("Should build prompt with all skill information")
+ void shouldBuildPromptWithAllSkillInformation() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("code_review")
+ .description("Review code for quality and best practices")
+ .skillContent("# Code Review\nReview the code carefully...")
+ .model("qwen-turbo")
+ .build();
+
+ String prompt =
+ SkillSubagentPromptBuilder.builder()
+ .skill(skill)
+ .modelName("qwen-turbo")
+ .build();
+
+ assertNotNull(prompt);
+ assertTrue(prompt.contains("code_review"));
+ assertTrue(prompt.contains("Review code for quality and best practices"));
+ assertTrue(prompt.contains("# Code Review\nReview the code carefully..."));
+ assertTrue(prompt.contains("qwen-turbo"));
+ }
+
+ @Test
+ @DisplayName("Should include role definition")
+ void shouldIncludeRoleDefinition() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("data_analysis")
+ .description("Analyze data")
+ .skillContent("Content")
+ .build();
+
+ String prompt =
+ SkillSubagentPromptBuilder.builder()
+ .skill(skill)
+ .modelName("qwen-plus")
+ .build();
+
+ assertTrue(prompt.contains("You are a specialized agent"));
+ assertTrue(prompt.contains("data_analysis"));
+ }
+
+ @Test
+ @DisplayName("Should include guidelines section")
+ void shouldIncludeGuidelinesSection() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("test_skill")
+ .description("Test")
+ .skillContent("Content")
+ .build();
+
+ String prompt =
+ SkillSubagentPromptBuilder.builder().skill(skill).modelName("default").build();
+
+ assertTrue(prompt.contains("## Guidelines"));
+ assertTrue(prompt.contains("Focus ONLY on tasks related to this skill"));
+ }
+
+ @Test
+ @DisplayName("Should include tool usage section")
+ void shouldIncludeToolUsageSection() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("test_skill")
+ .description("Test")
+ .skillContent("Content")
+ .build();
+
+ String prompt =
+ SkillSubagentPromptBuilder.builder().skill(skill).modelName("default").build();
+
+ assertTrue(prompt.contains("## Tool Usage"));
+ assertTrue(prompt.contains("toolkit"));
+ }
+
+ @Test
+ @DisplayName("Should include constraints section")
+ void shouldIncludeConstraintsSection() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("test_skill")
+ .description("Test")
+ .skillContent("Content")
+ .build();
+
+ String prompt =
+ SkillSubagentPromptBuilder.builder().skill(skill).modelName("default").build();
+
+ assertTrue(prompt.contains("## Important Constraints"));
+ assertTrue(prompt.contains("Do not perform actions unrelated to the skill's purpose"));
+ }
+
+ @Test
+ @DisplayName("Should throw exception when skill is not set")
+ void shouldThrowExceptionWhenSkillNotSet() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> SkillSubagentPromptBuilder.builder().modelName("qwen-turbo").build());
+ }
+ }
+
+ @Nested
+ @DisplayName("Null Handling Tests")
+ class NullHandlingTests {
+
+ @Test
+ @DisplayName("Should handle null skill name")
+ void shouldHandleNullSkillName() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("temp_skill")
+ .description("Test")
+ .skillContent("Content")
+ .build();
+
+ // Create skill and manually set name to null for testing
+ String prompt =
+ SkillSubagentPromptBuilder.builder()
+ .skill(skill)
+ .modelName("qwen-turbo")
+ .build();
+
+ assertTrue(prompt.contains("temp_skill"));
+ }
+
+ @Test
+ @DisplayName("Should handle null model name")
+ void shouldHandleNullModelName() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("test_skill")
+ .description("Test description")
+ .skillContent("Test content")
+ .build();
+
+ String prompt =
+ SkillSubagentPromptBuilder.builder().skill(skill).modelName(null).build();
+
+ assertTrue(prompt.contains("default"));
+ }
+
+ @Test
+ @DisplayName("Should handle empty model name")
+ void shouldHandleEmptyModelName() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("test_skill")
+ .description("Test description")
+ .skillContent("Test content")
+ .build();
+
+ String prompt = SkillSubagentPromptBuilder.builder().skill(skill).modelName("").build();
+
+ // Empty string should be preserved, but displayed as empty
+ assertNotNull(prompt);
+ }
+ }
+
+ @Nested
+ @DisplayName("Template Structure Tests")
+ class TemplateStructureTests {
+
+ @Test
+ @DisplayName("Should have all expected sections")
+ void shouldHaveAllExpectedSections() {
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("complete_skill")
+ .description("A complete skill for testing")
+ .skillContent("# Instructions\nDo something useful.")
+ .build();
+
+ String prompt =
+ SkillSubagentPromptBuilder.builder().skill(skill).modelName("qwen-max").build();
+
+ assertTrue(prompt.contains("## Your Purpose"), "Should have Purpose section");
+ assertTrue(prompt.contains("## Your Instructions"), "Should have Instructions section");
+ assertTrue(prompt.contains("## Guidelines"), "Should have Guidelines section");
+ assertTrue(prompt.contains("## Tool Usage"), "Should have Tool Usage section");
+ assertTrue(
+ prompt.contains("## Important Constraints"), "Should have Constraints section");
+ }
+
+ @Test
+ @DisplayName("Should properly format multiline skill content")
+ void shouldProperlyFormatMultilineSkillContent() {
+ String multilineContent =
+ """
+ # Code Review Guidelines
+
+ ## Key Areas to Review
+ 1. Code quality
+ 2. Performance
+ 3. Security
+
+ ## Output Format
+ Return findings in JSON format.
+ """;
+
+ AgentSkill skill =
+ AgentSkill.builder()
+ .name("code_review")
+ .description("Review code")
+ .skillContent(multilineContent)
+ .build();
+
+ String prompt =
+ SkillSubagentPromptBuilder.builder()
+ .skill(skill)
+ .modelName("qwen-turbo")
+ .build();
+
+ assertTrue(prompt.contains("# Code Review Guidelines"));
+ assertTrue(prompt.contains("## Key Areas to Review"));
+ assertTrue(prompt.contains("1. Code quality"));
+ assertTrue(prompt.contains("Return findings in JSON format"));
+ }
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryTest.java
new file mode 100644
index 000000000..73f18933d
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.skill;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.tool.AgentTool;
+import io.agentscope.core.tool.ToolCallParam;
+import io.agentscope.core.tool.Toolkit;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class SkillToolFactoryTest {
+
+ @Mock private SkillRegistry skillRegistry;
+
+ @Mock private Toolkit toolkit;
+
+ @Mock private SkillBox skillBox;
+
+ private SkillToolFactory skillToolFactory;
+
+ @BeforeEach
+ void setUp() {
+ skillToolFactory = new SkillToolFactory(skillRegistry, toolkit, skillBox);
+ }
+
+ @Test
+ @DisplayName("Should include model info in response when skill has model")
+ void test_buildSkillMarkdownResponse_skillWithModel_includesModelInfo() {
+ // Arrange
+ AgentSkill skillWithModel =
+ new AgentSkill(
+ "code_review",
+ "Code review skill",
+ "Review code according to best practices",
+ null,
+ "custom",
+ "qwen-turbo");
+
+ String skillId = skillWithModel.getSkillId();
+ RegisteredSkill registeredSkill = new RegisteredSkill(skillId);
+
+ setupSkillRegistryMocks(skillId, skillWithModel, registeredSkill);
+
+ // Act
+ AgentTool loadTool = skillToolFactory.createSkillAccessToolAgentTool();
+ ToolResultBlock result =
+ loadTool.callAsync(
+ ToolCallParam.builder()
+ .input(Map.of("skillId", skillId, "path", "SKILL.md"))
+ .build())
+ .block();
+
+ // Assert
+ assertNotNull(result);
+ String content = getTextContent(result);
+ assertTrue(content.contains("Model: qwen-turbo"), "Response should contain model info");
+ assertTrue(
+ content.contains("**Note:**"), "Response should contain note about sub-agent tool");
+ assertTrue(
+ content.contains("call_code_review"),
+ "Response should mention the sub-agent tool name");
+ }
+
+ @Test
+ @DisplayName("Should not include model info in response when skill has no model")
+ void test_buildSkillMarkdownResponse_skillWithoutModel_excludesModelInfo() {
+ // Arrange
+ AgentSkill skillWithoutModel =
+ new AgentSkill(
+ "simple_skill", "Simple skill", "Do something", null, "custom", null);
+
+ String skillId = skillWithoutModel.getSkillId();
+ RegisteredSkill registeredSkill = new RegisteredSkill(skillId);
+
+ setupSkillRegistryMocks(skillId, skillWithoutModel, registeredSkill);
+
+ // Act
+ AgentTool loadTool = skillToolFactory.createSkillAccessToolAgentTool();
+ ToolResultBlock result =
+ loadTool.callAsync(
+ ToolCallParam.builder()
+ .input(Map.of("skillId", skillId, "path", "SKILL.md"))
+ .build())
+ .block();
+
+ // Assert
+ assertNotNull(result);
+ String content = getTextContent(result);
+ assertFalse(content.contains("Model:"), "Response should NOT contain model info");
+ assertFalse(
+ content.contains("**Note:**"),
+ "Response should NOT contain note about sub-agent tool");
+ }
+
+ @Test
+ @DisplayName("Should not include model info when model is empty string")
+ void test_buildSkillMarkdownResponse_emptyModel_excludesModelInfo() {
+ // Arrange
+ AgentSkill skillWithEmptyModel =
+ new AgentSkill(
+ "empty_model_skill",
+ "Skill with empty model",
+ "Do something",
+ null,
+ "custom",
+ "");
+
+ String skillId = skillWithEmptyModel.getSkillId();
+ RegisteredSkill registeredSkill = new RegisteredSkill(skillId);
+
+ setupSkillRegistryMocks(skillId, skillWithEmptyModel, registeredSkill);
+
+ // Act
+ AgentTool loadTool = skillToolFactory.createSkillAccessToolAgentTool();
+ ToolResultBlock result =
+ loadTool.callAsync(
+ ToolCallParam.builder()
+ .input(Map.of("skillId", skillId, "path", "SKILL.md"))
+ .build())
+ .block();
+
+ // Assert
+ assertNotNull(result);
+ String content = getTextContent(result);
+ assertFalse(
+ content.contains("Model:"),
+ "Response should NOT contain model info for empty model");
+ assertFalse(
+ content.contains("**Note:**"), "Response should NOT contain note for empty model");
+ }
+
+ @Test
+ @DisplayName("Should include correct tool name format in hint")
+ void test_buildSkillMarkdownResponse_modelHint_correctToolName() {
+ // Arrange
+ AgentSkill skill =
+ new AgentSkill(
+ "my_cool_skill",
+ "My cool skill",
+ "Cool instructions",
+ null,
+ "custom",
+ "qwen-plus");
+
+ String skillId = skill.getSkillId();
+ RegisteredSkill registeredSkill = new RegisteredSkill(skillId);
+
+ setupSkillRegistryMocks(skillId, skill, registeredSkill);
+
+ // Act
+ AgentTool loadTool = skillToolFactory.createSkillAccessToolAgentTool();
+ ToolResultBlock result =
+ loadTool.callAsync(
+ ToolCallParam.builder()
+ .input(Map.of("skillId", skillId, "path", "SKILL.md"))
+ .build())
+ .block();
+
+ // Assert
+ assertNotNull(result);
+ String content = getTextContent(result);
+ assertTrue(
+ content.contains("call_my_cool_skill"),
+ "Response should contain correct tool name format");
+ assertTrue(
+ content.contains("model 'qwen-plus'"),
+ "Response should mention the configured model");
+ }
+
+ /** Sets up common mocks for skill registry. */
+ private void setupSkillRegistryMocks(
+ String skillId, AgentSkill skill, RegisteredSkill registeredSkill) {
+ when(skillRegistry.exists(skillId)).thenReturn(true);
+ when(skillRegistry.getSkill(skillId)).thenReturn(skill);
+ when(skillRegistry.getRegisteredSkill(skillId)).thenReturn(registeredSkill);
+ // Use lenient for stubbing that may not be invoked in all tests
+ lenient()
+ .when(skillRegistry.getAllRegisteredSkills())
+ .thenReturn(Map.of(skillId, registeredSkill));
+ lenient().when(toolkit.getToolGroup(any())).thenReturn(null);
+ }
+
+ /** Extracts text content from ToolResultBlock output. */
+ private String getTextContent(ToolResultBlock result) {
+ return result.getOutput().stream()
+ .filter(block -> block instanceof TextBlock)
+ .map(block -> ((TextBlock) block).getText())
+ .findFirst()
+ .orElse("");
+ }
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillUtilTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillUtilTest.java
index 7e2dd03e2..cf15ee7bb 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillUtilTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillUtilTest.java
@@ -18,6 +18,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -222,6 +223,39 @@ void testCreateFromWithNumericMetadata() {
assertEquals("123", skill.getName());
assertEquals("456", skill.getDescription());
}
+
+ @Test
+ @DisplayName("Should create skill with model from markdown")
+ void shouldCreateSkillWithModelFromMarkdown() {
+ String skillMd =
+ "---\n"
+ + "name: test_skill\n"
+ + "description: Test skill\n"
+ + "model: haiku\n"
+ + "---\n"
+ + "# Content\n";
+
+ AgentSkill skill = SkillUtil.createFrom(skillMd, null);
+
+ assertEquals("test_skill", skill.getName());
+ assertEquals("haiku", skill.getModel());
+ }
+
+ @Test
+ @DisplayName("Should create skill without model when not specified")
+ void shouldCreateSkillWithoutModelWhenNotSpecified() {
+ String skillMd =
+ "---\n"
+ + "name: test_skill\n"
+ + "description: Test skill\n"
+ + "---\n"
+ + "# Content\n";
+
+ AgentSkill skill = SkillUtil.createFrom(skillMd, null);
+
+ assertEquals("test_skill", skill.getName());
+ assertNull(skill.getModel());
+ }
}
@Nested
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitTest.java
index 6aad078f5..5a49d1076 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitTest.java
@@ -686,19 +686,19 @@ void testSetMultipleTypesTool() {
.mcpClient(mcpClientWrapper)
.agentTool(agentTool)
.tool(testToolObject)
- .subAgent(() -> mock(Agent.class));
+ .subAgent(context -> mock(Agent.class));
toolkit.registration()
.agentTool(agentTool)
.mcpClient(mcpClientWrapper)
.tool(testToolObject)
- .subAgent(() -> mock(Agent.class));
+ .subAgent(context -> mock(Agent.class));
toolkit.registration()
.tool(testToolObject)
.agentTool(agentTool)
.mcpClient(mcpClientWrapper)
- .subAgent(() -> mock(Agent.class));
+ .subAgent(context -> mock(Agent.class));
toolkit.registration()
- .subAgent(() -> mock(Agent.class))
+ .subAgent(context -> mock(Agent.class))
.mcpClient(mcpClientWrapper)
.agentTool(agentTool)
.tool(testToolObject);
@@ -764,7 +764,7 @@ void testSetValueThenResetToNull() {
// Test 4: Set subAgent, then reset to null, should throw exception
Toolkit.ToolRegistration registration4 = toolkit.registration();
- registration4.subAgent(() -> mock(Agent.class)).subAgent(null);
+ registration4.subAgent(context -> mock(Agent.class)).subAgent(null);
IllegalStateException exception4 =
assertThrows(IllegalStateException.class, () -> registration4.apply());
assertTrue(
diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/ContextSharingModeTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/ContextSharingModeTest.java
new file mode 100644
index 000000000..f2ff9d979
--- /dev/null
+++ b/agentscope-core/src/test/java/io/agentscope/core/tool/subagent/ContextSharingModeTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Unit tests for ContextSharingMode enum.
+ */
+@DisplayName("ContextSharingMode Tests")
+class ContextSharingModeTest {
+
+ @Test
+ @DisplayName("fromString should return SHARED for 'shared' (case insensitive)")
+ void testFromStringShared() {
+ assertSame(ContextSharingMode.SHARED, ContextSharingMode.fromString("shared"));
+ assertSame(ContextSharingMode.SHARED, ContextSharingMode.fromString("SHARED"));
+ assertSame(ContextSharingMode.SHARED, ContextSharingMode.fromString("Shared"));
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {" ", "shared"})
+ @DisplayName("fromString should return SHARED for null, empty, or 'shared'")
+ void testFromStringSharedDefault(String value) {
+ assertSame(ContextSharingMode.SHARED, ContextSharingMode.fromString(value));
+ }
+
+ @Test
+ @DisplayName("fromString should return FORK for 'fork' (case insensitive)")
+ void testFromStringFork() {
+ assertSame(ContextSharingMode.FORK, ContextSharingMode.fromString("fork"));
+ assertSame(ContextSharingMode.FORK, ContextSharingMode.fromString("FORK"));
+ assertSame(ContextSharingMode.FORK, ContextSharingMode.fromString("Fork"));
+ }
+
+ @Test
+ @DisplayName("fromString should return NEW for 'new' (case insensitive)")
+ void testFromStringNew() {
+ assertSame(ContextSharingMode.NEW, ContextSharingMode.fromString("new"));
+ assertSame(ContextSharingMode.NEW, ContextSharingMode.fromString("NEW"));
+ assertSame(ContextSharingMode.NEW, ContextSharingMode.fromString("New"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"unknown", "invalid", "random", "SHARED_MODE"})
+ @DisplayName("fromString should return SHARED (default) for unknown values")
+ void testFromStringUnknownValues(String value) {
+ assertSame(ContextSharingMode.SHARED, ContextSharingMode.fromString(value));
+ }
+}
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..f71175937 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
@@ -18,24 +18,33 @@
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.assertTrue;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
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.memory.InMemoryMemory;
+import io.agentscope.core.memory.Memory;
+import io.agentscope.core.message.ImageBlock;
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.message.URLSource;
import io.agentscope.core.tool.ToolCallParam;
import io.agentscope.core.tool.ToolEmitter;
+import io.agentscope.core.tool.ToolExecutionContext;
import io.agentscope.core.tool.Toolkit;
import java.util.ArrayList;
import java.util.HashMap;
@@ -57,7 +66,7 @@ void testCreateWithDefaults() {
// Create a mock agent
Agent mockAgent = createMockAgent("TestAgent", "Test description");
- SubAgentTool tool = new SubAgentTool(() -> mockAgent, null);
+ SubAgentTool tool = new SubAgentTool(context -> mockAgent, null);
assertEquals("call_testagent", tool.getName());
assertEquals("Test description", tool.getDescription());
@@ -69,7 +78,7 @@ void testCreateWithDefaults() {
void testToolNameGeneration() {
Agent mockAgent = createMockAgent("Research Agent", "Research tasks");
- SubAgentTool tool = new SubAgentTool(() -> mockAgent, null);
+ SubAgentTool tool = new SubAgentTool(context -> mockAgent, null);
assertEquals("call_research_agent", tool.getName());
}
@@ -85,7 +94,7 @@ void testCustomToolName() {
.description("Custom description")
.build();
- SubAgentTool tool = new SubAgentTool(() -> mockAgent, config);
+ SubAgentTool tool = new SubAgentTool(context -> mockAgent, config);
assertEquals("custom_tool", tool.getName());
assertEquals("Custom description", tool.getDescription());
@@ -96,7 +105,7 @@ void testCustomToolName() {
void testConversationSchema() {
Agent mockAgent = createMockAgent("TestAgent", "Test");
- SubAgentTool tool = new SubAgentTool(() -> mockAgent, SubAgentConfig.defaults());
+ SubAgentTool tool = new SubAgentTool(context -> mockAgent, SubAgentConfig.defaults());
Map The forked memory will have:
+ * Changes to the forked memory will not affect the original memory and vice versa.
+ * This is used by the ContextSharingMode.FORK feature for sub-agent memory management.
+ *
+ * @return a new AutoContextMemory instance with copied messages
+ */
+ @Override
+ public Memory fork() {
+ AutoContextMemory forked = new AutoContextMemory(autoContextConfig, model);
+
+ // Copy working memory messages
+ forked.workingMemoryStorage = new ArrayList<>(this.workingMemoryStorage);
+
+ // Copy original memory messages
+ forked.originalMemoryStorage = new ArrayList<>(this.originalMemoryStorage);
+
+ // Copy offload context (deep copy to ensure true isolation)
+ forked.offloadContext = new HashMap<>();
+ for (Map.Entry
+ *
+ *
+ * @return The context mode string, or null if default (shared)
+ */
+ public String getContext() {
+ return context;
+ }
+
/**
* Gets the skill resources.
*
@@ -261,6 +348,8 @@ public static class Builder {
private String skillContent;
private Map
+ *
+ *
+ * @param context The context mode string
+ * @return This builder
+ */
+ public Builder context(String context) {
+ this.context = context;
+ return this;
+ }
+
/**
* Builds the AgentSkill instance.
*
@@ -377,7 +498,8 @@ public Builder source(String source) {
* @throws IllegalArgumentException if required fields are missing
*/
public AgentSkill build() {
- return new AgentSkill(name, description, skillContent, resources, source);
+ return new AgentSkill(
+ name, description, skillContent, resources, source, model, context);
}
}
}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/MapBasedSkillModelProvider.java b/agentscope-core/src/main/java/io/agentscope/core/skill/MapBasedSkillModelProvider.java
new file mode 100644
index 000000000..427f0f9e3
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/skill/MapBasedSkillModelProvider.java
@@ -0,0 +1,188 @@
+/*
+ * 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.skill;
+
+import io.agentscope.core.model.Model;
+import java.util.HashMap;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A Map-based implementation of {@link SkillModelProvider} that resolves model references using
+ * direct name lookup and alias mapping.
+ *
+ *
+ *
+ *
+ * {@code
+ * MapBasedSkillModelProvider provider = MapBasedSkillModelProvider.builder()
+ * .register("qwen-turbo", qwenModel)
+ * .register("dashscope:qwen-plus", qwenPlusModel)
+ * .alias("qwen-turbo", "qwen-turbo")
+ * .alias("qwen-plus", "dashscope:qwen-plus")
+ * .defaultModel(defaultModel)
+ * .build();
+ *
+ * Model model = provider.getModel("qwen-turbo"); // Returns qwenModel
+ * }
+ */
+public class MapBasedSkillModelProvider implements SkillModelProvider {
+
+ private static final Logger log = LoggerFactory.getLogger(MapBasedSkillModelProvider.class);
+
+ private final Map
+ *
+ *
+ * @param skill The skill to create sub-agent for
+ * @param effectiveToolkit The toolkit to register the tool with
+ * @param modelRef The model reference to resolve
+ * @return true if sub-agent was created successfully, false otherwise
+ */
+ boolean createSubAgentToolForSkill(
+ AgentSkill skill, Toolkit effectiveToolkit, String modelRef) {
+ if (modelRef == null || modelRef.isBlank()) {
+ logger.debug(
+ "Skill '{}' has no model configured, skipping sub-agent creation",
+ skill.getName());
+ return false;
+ }
+
+ if (effectiveToolkit == null) {
+ logger.warn(
+ "No toolkit configured for skill '{}', cannot create sub-agent with model '{}'",
+ skill.getName(),
+ modelRef);
+ return false;
+ }
+
+ if (modelProvider == null) {
+ logger.warn(
+ "No SkillModelProvider configured for skill '{}', "
+ + "cannot create sub-agent with model '{}'",
+ skill.getName(),
+ modelRef);
+ return false;
+ }
+
+ Model model = modelProvider.getModel(modelRef);
+ if (model == null) {
+ logger.warn(
+ "Model '{}' not found for skill '{}', skipping sub-agent creation",
+ modelRef,
+ skill.getName());
+ return false;
+ }
+
+ // Create tool group if needed
+ String skillToolGroup = skill.getSkillId() + "_skill_tools";
+ if (effectiveToolkit.getToolGroup(skillToolGroup) == null) {
+ effectiveToolkit.createToolGroup(skillToolGroup, skillToolGroup, false);
+ }
+
+ // Build system prompt using SkillSubagentPromptBuilder
+ final Model resolvedModel = model;
+ final Toolkit toolkitCopy = effectiveToolkit.copy();
+ final String systemPrompt =
+ SkillSubagentPromptBuilder.builder()
+ .skill(skill)
+ .modelName(resolvedModel.getModelName())
+ .build();
+
+ // Parse context sharing mode from skill
+ final ContextSharingMode contextMode = ContextSharingMode.fromString(skill.getContext());
+
+ // Create SubAgentProvider - context-aware for memory sharing
+ SubAgentProvider
+ *
+ */
+public interface SkillModelProvider {
+
+ /**
+ * Get a Model instance for the given model reference.
+ *
+ * @param modelRef The model reference (e.g., "qwen-turbo", "dashscope:qwen-plus")
+ * @return Model instance, or null if not available
+ */
+ Model getModel(String modelRef);
+
+ /**
+ * Check if a model reference is available.
+ *
+ * @param modelRef The model reference
+ * @return true if the model can be provided
+ */
+ default boolean isAvailable(String modelRef) {
+ return getModel(modelRef) != null;
+ }
+}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillSubagentPromptBuilder.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillSubagentPromptBuilder.java
new file mode 100644
index 000000000..0e6dec4ca
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillSubagentPromptBuilder.java
@@ -0,0 +1,149 @@
+/*
+ * 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.skill;
+
+/**
+ * Builder for constructing system prompts for skill-based sub-agents.
+ *
+ *
+ *
+ *
+ * {@code
+ * String systemPrompt = SkillSubagentPromptBuilder.builder()
+ * .skill(skill)
+ * .modelName("qwen-turbo")
+ * .build();
+ * }
+ */
+public class SkillSubagentPromptBuilder {
+
+ private static final String SKILL_SUBAGENT_TEMPLATE =
+ """
+ You are a specialized agent for the skill: {skillName}.
+
+ ## Your Purpose
+
+ {skillDescription}
+
+ ## Your Instructions
+
+ {skillContent}
+
+ ## Guidelines
+
+ - Focus ONLY on tasks related to this skill
+ - Use the available tools appropriately to complete the task
+ - If the task is outside this skill's scope, clearly state so
+ - Be concise and accurate in your responses
+ - Report your findings clearly without unnecessary elaboration
+ - Do not make assumptions about data or files that are not provided
+ - Always verify information before making claims
+
+ ## Tool Usage
+
+ - You have access to tools through the toolkit
+ - Choose the most appropriate tool for each subtask
+ - Chain tool calls when necessary for complex operations
+ - Handle tool errors gracefully and report issues clearly
+
+ ## Important Constraints
+
+ - Do not perform actions unrelated to the skill's purpose
+ - Do not modify files unless explicitly required by the skill
+ - Do not share sensitive information in your responses
+ - Always respect the skill's intended use case
+
+ ---
+ *Executing with model: {modelName}*
+ """;
+
+ private AgentSkill skill;
+ private String modelName;
+
+ private SkillSubagentPromptBuilder() {}
+
+ /**
+ * Creates a new builder instance.
+ *
+ * @return New builder
+ */
+ public static SkillSubagentPromptBuilder builder() {
+ return new SkillSubagentPromptBuilder();
+ }
+
+ /**
+ * Sets the skill for which to build the system prompt.
+ *
+ * @param skill The skill definition
+ * @return This builder
+ */
+ public SkillSubagentPromptBuilder skill(AgentSkill skill) {
+ this.skill = skill;
+ return this;
+ }
+
+ /**
+ * Sets the model name being used for execution.
+ *
+ * @param modelName The model name
+ * @return This builder
+ */
+ public SkillSubagentPromptBuilder modelName(String modelName) {
+ this.modelName = modelName;
+ return this;
+ }
+
+ /**
+ * Builds the complete system prompt for the skill sub-agent.
+ *
+ * @return The formatted system prompt
+ * @throws IllegalStateException if skill is not set
+ */
+ public String build() {
+ if (skill == null) {
+ throw new IllegalStateException("Skill must be set before building");
+ }
+
+ String name = skill.getName() != null ? skill.getName() : "unknown";
+ String description =
+ skill.getDescription() != null
+ ? skill.getDescription()
+ : "No description provided.";
+ String content =
+ skill.getSkillContent() != null
+ ? skill.getSkillContent()
+ : "No instructions provided.";
+ String model = modelName != null ? modelName : "default";
+
+ return SKILL_SUBAGENT_TEMPLATE
+ .replace("{skillName}", name)
+ .replace("{skillDescription}", description)
+ .replace("{skillContent}", content)
+ .replace("{modelName}", model);
+ }
+}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillToolFactory.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillToolFactory.java
index 35681f6ea..a9a9e6781 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillToolFactory.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillToolFactory.java
@@ -20,8 +20,10 @@
import io.agentscope.core.tool.ToolCallParam;
import io.agentscope.core.tool.Toolkit;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
@@ -34,11 +36,16 @@ class SkillToolFactory {
private static final Logger logger = LoggerFactory.getLogger(SkillToolFactory.class);
private final SkillRegistry skillRegistry;
+ private final SkillBox skillBox;
private Toolkit toolkit;
- SkillToolFactory(SkillRegistry skillRegistry, Toolkit toolkit) {
+ /** Tracks which skills have already had their sub-agent tools created. */
+ private final Set
+ *
+ *
+ *
+ *
+ *
+ * @param context The context string to parse
+ * @return The corresponding ContextSharingMode, defaults to SHARED for unknown values
+ */
+ public static ContextSharingMode fromString(String context) {
+ if (context == null || context.isEmpty() || "shared".equalsIgnoreCase(context)) {
+ return SHARED;
+ } else if ("fork".equalsIgnoreCase(context)) {
+ return FORK;
+ } else if ("new".equalsIgnoreCase(context)) {
+ return NEW;
+ } else {
+ logger.warn(
+ "Unknown context mode '{}', defaulting to SHARED. "
+ + "Supported values: shared, fork, new",
+ context);
+ return SHARED;
+ }
+ }
+}
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..5fb20ceee 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
@@ -27,12 +27,12 @@
* supports smart defaults that derive tool name and description from the agent itself.
*
*
*
*
*
+ *
*
* {@code
- * // Minimal configuration - uses all defaults
+ * // Minimal configuration - uses all defaults (SHARED context)
* SubAgentConfig config = SubAgentConfig.defaults();
*
* // Custom configuration with persistent session
@@ -56,6 +68,16 @@
* .description("Ask the expert a question")
* .session(new JsonSession(Path.of("sessions")))
* .build();
+ *
+ * // Use FORK mode for isolated context
+ * SubAgentConfig config = SubAgentConfig.builder()
+ * .contextSharingMode(ContextSharingMode.FORK)
+ * .build();
+ *
+ * // Use NEW mode for completely independent sub-agent
+ * SubAgentConfig config = SubAgentConfig.builder()
+ * .contextSharingMode(ContextSharingMode.NEW)
+ * .build();
* }
*/
public class SubAgentConfig {
@@ -65,6 +87,7 @@ public class SubAgentConfig {
private final boolean forwardEvents;
private final StreamOptions streamOptions;
private final Session session;
+ private final ContextSharingMode contextSharingMode;
private SubAgentConfig(Builder builder) {
this.toolName = builder.toolName;
@@ -72,6 +95,7 @@ private SubAgentConfig(Builder builder) {
this.forwardEvents = builder.forwardEvents;
this.streamOptions = builder.streamOptions;
this.session = builder.session != null ? builder.session : new InMemorySession();
+ this.contextSharingMode = builder.contextSharingMode;
}
/**
@@ -148,6 +172,23 @@ public Session getSession() {
return session;
}
+ /**
+ * Gets the context sharing mode.
+ *
+ *
+ *
+ *
+ * @return The context sharing mode
+ */
+ public ContextSharingMode getContextSharingMode() {
+ return contextSharingMode;
+ }
+
/** Builder for SubAgentConfig. */
public static class Builder {
private String toolName;
@@ -155,6 +196,7 @@ public static class Builder {
private boolean forwardEvents = true;
private StreamOptions streamOptions;
private Session session;
+ private ContextSharingMode contextSharingMode = ContextSharingMode.SHARED;
private Builder() {}
@@ -229,6 +271,25 @@ public Builder session(Session session) {
return this;
}
+ /**
+ * Sets the context sharing mode.
+ *
+ *
+ *
+ *
+ * @param contextSharingMode The context sharing mode
+ * @return This builder
+ */
+ public Builder contextSharingMode(ContextSharingMode contextSharingMode) {
+ this.contextSharingMode = contextSharingMode;
+ 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..21a7b2612
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentContext.java
@@ -0,0 +1,116 @@
+/*
+ * 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.agent.Agent;
+import io.agentscope.core.memory.Memory;
+
+/**
+ * Context information provided to SubAgentProvider when creating agent instances.
+ *
+ * {@code
+ * SubAgentProvider
+ */
+public class SubAgentContext {
+
+ private final Agent parentAgent;
+ private final ContextSharingMode contextSharingMode;
+ private final Memory memoryToUse;
+
+ /**
+ * Creates a new SubAgentContext.
+ *
+ * @param parentAgent The parent agent that is invoking the sub-agent
+ * @param contextSharingMode The context sharing mode to use
+ * @param memoryToUse The memory to use for the sub-agent (may be null for default)
+ */
+ public SubAgentContext(
+ Agent parentAgent, ContextSharingMode contextSharingMode, Memory memoryToUse) {
+ this.parentAgent = parentAgent;
+ this.contextSharingMode = contextSharingMode;
+ this.memoryToUse = memoryToUse;
+ }
+
+ /**
+ * Creates an empty context with no parent agent information.
+ *
+ *
+ *
+ *
+ * @return The memory to use, or null if the sub-agent should use its own memory
+ */
+ public Memory getMemoryToUse() {
+ return memoryToUse;
+ }
+
+ /**
+ * Checks if this context has valid parent agent information.
+ *
+ * @return true if parent agent information is available
+ */
+ public boolean hasParentAgent() {
+ return parentAgent != null;
+ }
+}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentProvider.java b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentProvider.java
index 1b12df0ae..a1ad53336 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentProvider.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/tool/subagent/SubAgentProvider.java
@@ -23,14 +23,31 @@
*
+ *
+ *
+ * {@code
- * SubAgentProvider
+ *
+ *
+ * @param context The context containing parent agent information and memory to use
+ * @return A new agent instance
+ */
+ T provideWithContext(SubAgentContext context);
+
+ /**
+ * Provides a new agent instance without context information.
*
- *
*
+ *
+ *
+ *
+ *
+ *
*
@@ -130,8 +164,26 @@ private Mono
+ *
+ *
+ * @param parentAgent The parent agent (source of memory for SHARED/FORK modes)
+ * @param contextMode The context sharing mode
+ * @return The memory to use, or null for independent memory
+ */
+ private Memory computeMemoryToUse(Agent parentAgent, ContextSharingMode contextMode) {
+ // Only ReActAgent supports memory sharing
+ if (!(parentAgent instanceof ReActAgent parentReactAgent)) {
+ logger.debug("Parent is not a ReActAgent, sub-agent will use independent memory");
+ return null;
+ }
+
+ Memory parentMemory = parentReactAgent.getMemory();
+ if (parentMemory == null) {
+ logger.debug("Parent has no memory, sub-agent will use independent memory");
+ return null;
+ }
+
+ switch (contextMode) {
+ case SHARED:
+ // DESIGN NOTE: SHARED and FORK currently have identical implementations.
+ // Both fork the parent's memory and remove pending tool calls.
+ //
+ // Why we cannot implement true memory sharing:
+ // - The parent's memory contains the pending tool_use block that invoked this
+ // sub-agent
+ // - Directly sharing this memory would cause validation errors when the sub-agent
+ // tries to add new messages (pending tool calls must be resolved first)
+ // - True sharing would require complex synchronization and state management
+ //
+ // Why we keep SHARED as a separate mode:
+ // - API compatibility with skill.md specifications
+ // - Semantic distinction: SHARED implies "context visibility" while FORK implies
+ // "explicit copy"
+ // - Future extensibility: if we find a way to implement true sharing, SHARED can
+ // be updated
+ Memory sharedMemory = parentMemory.fork();
+ removePendingToolCalls(sharedMemory);
+ logger.debug(
+ "Sub-agent will use SHARED (forked with pending calls removed) memory from"
+ + " parent ({} messages)",
+ sharedMemory.getMessages().size());
+ return sharedMemory;
+
+ case FORK:
+ // Fork parent's memory and remove pending tool calls
+ // NOTE: This is identical to SHARED mode implementation. See SHARED case for
+ // explanation.
+ Memory forkedMemory = parentMemory.fork();
+ removePendingToolCalls(forkedMemory);
+ logger.debug(
+ "Sub-agent will use FORKed memory from parent ({} messages)",
+ forkedMemory.getMessages().size());
+ return forkedMemory;
+
+ case NEW:
+ // Use independent memory (return null)
+ logger.debug("Sub-agent will use NEW independent memory");
+ return null;
+
+ default:
+ logger.debug(
+ "Unknown context sharing mode: {}, using independent memory", contextMode);
+ return null;
+ }
+ }
+
+ /**
+ * Removes pending tool calls from memory.
+ *
+ *
+ *
+ *
+ * @param memory The memory to clean up
+ */
+ private void removePendingToolCalls(Memory memory) {
+ List
*
+ *
+ *
+ *