diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillLoadTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillLoadTool.java
index f0903d7898..3a65a3129a 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillLoadTool.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillLoadTool.java
@@ -20,6 +20,8 @@
import io.agentscope.core.skill.util.MarkdownSkillParser;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
+import io.agentscope.core.tool.ToolGroup;
+import io.agentscope.core.tool.Toolkit;
import io.agentscope.harness.agent.skill.SkillResources;
import java.util.ArrayList;
import java.util.LinkedHashSet;
@@ -50,6 +52,11 @@
* ToolCallParam#getRuntimeContext() tool call's RuntimeContext}, keeping concurrent calls and
* sessions isolated without re-registering the tool. A legacy catalog reference is consulted only
* for deprecated callers that invoke the tool without any RuntimeContext.
+ *
+ *
On-demand tool disclosure: once a resource is found, every {@link
+ * io.agentscope.core.tool.SkillToolGroup} bound to the skill via {@code activateOnSkill} is
+ * activated on the call's toolkit, so the skill's tools become visible to the model for the
+ * following turns (see issue #2653).
*/
@SuppressWarnings("deprecation")
public final class SkillLoadTool implements AgentTool {
@@ -61,10 +68,11 @@ public final class SkillLoadTool implements AgentTool {
private final AtomicReference legacyCatalogRef;
private final boolean exposeLegacyCatalogInSchema;
+ private final AtomicReference toolkitRef;
/** Creates a context-scoped loader with an empty context-free compatibility catalog. */
public SkillLoadTool() {
- this(new AtomicReference<>(SkillCatalog.empty()), false);
+ this(new AtomicReference<>(SkillCatalog.empty()), false, new AtomicReference<>());
}
/**
@@ -80,16 +88,24 @@ public SkillLoadTool() {
*/
@Deprecated(since = "2.2.0")
public SkillLoadTool(AtomicReference catalogRef) {
- this(catalogRef, true);
+ this(catalogRef, true, new AtomicReference<>());
}
SkillLoadTool(
AtomicReference legacyCatalogRef, boolean exposeLegacyCatalogInSchema) {
+ this(legacyCatalogRef, exposeLegacyCatalogInSchema, new AtomicReference<>());
+ }
+
+ SkillLoadTool(
+ AtomicReference legacyCatalogRef,
+ boolean exposeLegacyCatalogInSchema,
+ AtomicReference toolkitRef) {
if (legacyCatalogRef == null) {
throw new IllegalArgumentException("catalogRef must not be null");
}
this.legacyCatalogRef = legacyCatalogRef;
this.exposeLegacyCatalogInSchema = exposeLegacyCatalogInSchema;
+ this.toolkitRef = toolkitRef != null ? toolkitRef : new AtomicReference<>();
}
@Override
@@ -177,7 +193,8 @@ public Mono callAsync(ToolCallParam param) {
+ "'. Check the available skills list."));
}
- return Mono.just(loadOne(entry, path));
+ Toolkit toolkit = toolkitFor(param);
+ return Mono.just(loadOne(entry, path, toolkit));
} catch (Exception e) {
log.error("load_skill_through_path failed", e);
return Mono.just(ToolResultBlock.error(e.getMessage()));
@@ -192,17 +209,35 @@ private SkillCatalog catalogFor(ToolCallParam param) {
return legacy != null ? legacy : SkillCatalog.empty();
}
- private ToolResultBlock loadOne(HarnessSkillEntry entry, String path) {
+ /**
+ * Resolves the toolkit used to activate skill-bound tool groups. Prefers the call-scoped
+ * instance (installed by {@link SkillRuntime#install(SkillCatalog,
+ * io.agentscope.core.agent.RuntimeContext, Toolkit)}), falling back to the shared reference
+ * kept current by {@link SkillRuntime#prepareToolkit(Toolkit)} for context-free callers.
+ */
+ private Toolkit toolkitFor(ToolCallParam param) {
+ if (param.getRuntimeContext() != null) {
+ Toolkit toolkit = param.getRuntimeContext().get(Toolkit.class);
+ if (toolkit != null) {
+ return toolkit;
+ }
+ }
+ return toolkitRef.get();
+ }
+
+ private ToolResultBlock loadOne(HarnessSkillEntry entry, String path, Toolkit toolkit) {
AgentSkill skill = entry.skill();
// 1. Special case: SKILL.md returns the parsed body text.
if (SKILL_FILE.equals(path)) {
+ activateSkillTools(toolkit, skill);
return ToolResultBlock.text(formatSkillMarkdown(entry));
}
// 2. In-memory map (Layer 1/3 host repos + marketplace fully-loaded resources).
Map mem = skill.getResources();
if (mem != null && mem.containsKey(path)) {
+ activateSkillTools(toolkit, skill);
return ToolResultBlock.text(formatResource(entry, path, mem.get(path)));
}
@@ -210,6 +245,7 @@ private ToolResultBlock loadOne(HarnessSkillEntry entry, String path) {
if (entry.lazyResources() != null) {
Optional lazy = entry.lazyResources().read(path);
if (lazy.isPresent()) {
+ activateSkillTools(toolkit, skill);
return ToolResultBlock.text(formatResource(entry, path, lazy.get()));
}
}
@@ -218,6 +254,48 @@ private ToolResultBlock loadOne(HarnessSkillEntry entry, String path) {
return ToolResultBlock.error(formatNotFound(entry, path));
}
+ /**
+ * Activates every {@link io.agentscope.core.tool.SkillToolGroup} bound to the loaded skill via
+ * {@code activateOnSkill} so its tools become visible to the model (on-demand tool disclosure).
+ *
+ * Matches the activation semantics of the core skill access tool ({@code
+ * SkillToolFactory.activateSkill}): a group is only activated after a resource was actually
+ * found, and only when it is not already active. The toolkit's shared activation state is
+ * picked up by {@code ReActAgent}'s post-acting {@code syncToolkitToState}, which persists it
+ * into the session's {@code ToolContextState} for subsequent reasoning turns.
+ */
+ private void activateSkillTools(Toolkit toolkit, AgentSkill skill) {
+ if (toolkit == null || skill == null) {
+ return;
+ }
+ try {
+ List boundGroups =
+ toolkit.findSkillToolGroupsByActivateOnSkill(skill.getName());
+ if (boundGroups.isEmpty()) {
+ return;
+ }
+ List toActivate = new ArrayList<>(boundGroups.size());
+ for (String group : boundGroups) {
+ ToolGroup existing = toolkit.getToolGroup(group);
+ if (existing != null && !existing.isActive()) {
+ toActivate.add(group);
+ }
+ }
+ if (!toActivate.isEmpty()) {
+ toolkit.updateToolGroups(toActivate, true);
+ log.info(
+ "Activated skill-bound tool groups {} for skill '{}'",
+ toActivate,
+ skill.getName());
+ }
+ } catch (Exception e) {
+ log.warn(
+ "Failed to activate skill-bound tool groups for skill '{}': {}",
+ skill.getName(),
+ e.getMessage());
+ }
+ }
+
// ---------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillRuntime.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillRuntime.java
index ced4d49d23..fc1313707c 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillRuntime.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillRuntime.java
@@ -40,6 +40,7 @@ public final class SkillRuntime {
private final RuntimeContext legacyContext = RuntimeContext.empty();
private final AtomicReference legacyCatalogRef =
new AtomicReference<>(SkillCatalog.empty());
+ private final AtomicReference toolkitRef = new AtomicReference<>();
private final SkillLoadTool loadTool;
private final SkillPromptBuilder promptBuilder;
@@ -49,7 +50,7 @@ public SkillRuntime() {
public SkillRuntime(SkillPromptBuilder promptBuilder) {
this.promptBuilder = promptBuilder != null ? promptBuilder : new SkillPromptBuilder();
- this.loadTool = new SkillLoadTool(legacyCatalogRef, false);
+ this.loadTool = new SkillLoadTool(legacyCatalogRef, false, toolkitRef);
}
/**
@@ -84,17 +85,21 @@ public AgentTool loadTool() {
}
/**
- * Ensures the skill loading tool is present as an ungrouped tool.
+ * Ensures the skill loading tool is present as an ungrouped tool and remembers the toolkit
+ * used to activate skill-bound tool groups on load.
*
* Harness calls this while the agent toolkit is still being assembled. The toolkit copy
* made by {@code ReActAgent.Builder} therefore contains the tool before its first model call.
* Keeping the loader ungrouped is intentional: ungrouped tools remain visible regardless of a
* session's persisted active-group list, including sessions created before the loader existed.
+ * Each system-prompt pass re-binds the reference so it always points at the agent's live
+ * toolkit (the one that was deep-copied during agent construction).
*/
public void prepareToolkit(Toolkit toolkit) {
if (toolkit == null) {
return;
}
+ this.toolkitRef.set(toolkit);
try {
AgentTool existing = toolkit.getTool(SkillLoadTool.TOOL_NAME);
if (existing == loadTool) {
@@ -115,7 +120,10 @@ public void prepareToolkit(Toolkit toolkit) {
*
*
The catalog is deliberately stored on {@code context}, not on this shared runtime. This is
* the authorization boundary used by {@link SkillLoadTool}; a skill omitted from the call's
- * filtered catalog cannot be loaded even while another session exposes it.
+ * filtered catalog cannot be loaded even while another session exposes it. The live toolkit is
+ * bound to the same context so that loading a skill can activate the {@link
+ * io.agentscope.core.tool.SkillToolGroup SkillToolGroups} bound to it via {@code
+ * activateOnSkill} (on-demand tool disclosure).
*
* @param catalog the call snapshot; pass {@link SkillCatalog#empty()} to clear visibility
* @param context the current call context; may be {@code null}, in which case no catalog is
@@ -125,6 +133,7 @@ public void prepareToolkit(Toolkit toolkit) {
public void install(SkillCatalog catalog, RuntimeContext context, Toolkit toolkit) {
if (context != null) {
context.put(SkillCatalog.class, catalog != null ? catalog : SkillCatalog.empty());
+ context.put(Toolkit.class, toolkit);
}
prepareToolkit(toolkit);
}
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolToolGroupActivationTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolToolGroupActivationTest.java
new file mode 100644
index 0000000000..84ecd946ce
--- /dev/null
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolToolGroupActivationTest.java
@@ -0,0 +1,433 @@
+/*
+ * 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.harness.agent.skill.runtime;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import io.agentscope.core.agent.RuntimeContext;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolResultState;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.skill.AgentSkill;
+import io.agentscope.core.tool.SkillToolGroup;
+import io.agentscope.core.tool.ToolCallParam;
+import io.agentscope.core.tool.ToolGroup;
+import io.agentscope.core.tool.Toolkit;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies that loading a skill via {@link SkillLoadTool} activates associated
+ * {@link SkillToolGroup} instances (on-demand tool disclosure).
+ *
+ *
Issue #2653: HarnessAgent (Skill + ToolGroup: on-demand tool disclosure) not work.
+ * When a skill is loaded through the HarnessAgent's SkillLoadTool, the associated
+ * SkillToolGroups (bound via {@code activateOnSkill}) should be activated so that
+ * the tools in those groups become available to the model.
+ */
+@SuppressWarnings("deprecation")
+class SkillLoadToolToolGroupActivationTest {
+
+ /**
+ * Demonstrates issue #2653: loading a skill via SkillLoadTool does NOT activate
+ * the associated SkillToolGroup, so tools in that group remain hidden from the model.
+ *
+ *
Expected behavior: After loading a skill, any SkillToolGroup bound to that skill
+ * via {@code activateOnSkill} should be activated, making its tools available.
+ *
+ *
Actual behavior (bug): The SkillToolGroup remains inactive after skill loading.
+ */
+ @Test
+ @DisplayName("Issue #2653: Loading skill should activate associated SkillToolGroup")
+ void loadingSkillShouldActivateAssociatedSkillToolGroup() {
+ // 1. Create a Toolkit
+ Toolkit toolkit = new Toolkit();
+
+ // 2. Create a SkillToolGroup bound to a skill via activateOnSkill
+ // The group is initially inactive (default for SkillToolGroup)
+ String skillName = "data_analysis";
+ String groupName = "analysis_tools";
+ SkillToolGroup skillToolGroup =
+ SkillToolGroup.skillBuilder()
+ .name(groupName)
+ .description("Data analysis tools")
+ .activateOnSkill(skillName)
+ .build();
+ skillToolGroup.addTool("analyze_data");
+ toolkit.registerToolGroup(skillToolGroup);
+
+ // Verify initial state: group is inactive
+ assertFalse(
+ toolkit.getActiveGroups().contains(groupName),
+ "SkillToolGroup should be inactive before skill is loaded");
+
+ // 3. Create a skill that matches the activateOnSkill binding
+ AgentSkill skill =
+ new AgentSkill(
+ skillName,
+ "Analyze data",
+ "# Data Analysis Skill\n\nInstructions here.",
+ null,
+ "custom");
+
+ // 4. Create SkillLoadTool and catalog
+ SkillCatalog catalog = SkillCatalog.of(List.of(HarnessSkillEntry.of(skill, null)));
+ SkillLoadTool tool = new SkillLoadTool();
+ RuntimeContext ctx = RuntimeContext.empty();
+ ctx.put(SkillCatalog.class, catalog);
+ ctx.put(Toolkit.class, toolkit);
+
+ // 5. Load the skill via SkillLoadTool
+ Map input = Map.of("skillId", skill.getSkillId(), "path", "SKILL.md");
+ ToolUseBlock useBlock =
+ ToolUseBlock.builder()
+ .id("test-load")
+ .name(SkillLoadTool.TOOL_NAME)
+ .input(input)
+ .build();
+ ToolCallParam param =
+ ToolCallParam.builder()
+ .toolUseBlock(useBlock)
+ .input(input)
+ .runtimeContext(ctx)
+ .build();
+
+ ToolResultBlock result = tool.callAsync(param).block();
+
+ // Verify the skill was loaded successfully
+ assertTrue(
+ result != null && result.getState() != ToolResultState.ERROR,
+ "Skill loading should succeed");
+
+ // 6. Verify that the SkillToolGroup is now activated
+ // THIS IS THE BUG: The group should be active after skill loading, but it isn't
+ assertTrue(
+ toolkit.getActiveGroups().contains(groupName),
+ "SkillToolGroup '"
+ + groupName
+ + "' should be activated after loading skill '"
+ + skillName
+ + "'. "
+ + "Active groups: "
+ + toolkit.getActiveGroups());
+ }
+
+ /**
+ * Verifies that tools in an activated SkillToolGroup become visible in tool schemas.
+ *
+ * This test demonstrates the end-to-end impact of issue #2653: even after loading
+ * a skill, the tools in the associated SkillToolGroup are not disclosed to the model.
+ */
+ @Test
+ @DisplayName("Issue #2653: Tools in SkillToolGroup should be visible after skill loading")
+ void toolsInSkillToolGroupShouldBeVisibleAfterSkillLoading() {
+ // 1. Create a Toolkit
+ Toolkit toolkit = new Toolkit();
+
+ // 2. Create a SkillToolGroup bound to a skill
+ String skillName = "coding";
+ String groupName = "code_tools";
+ SkillToolGroup skillToolGroup =
+ SkillToolGroup.skillBuilder()
+ .name(groupName)
+ .description("Code execution tools")
+ .activateOnSkill(skillName)
+ .build();
+ skillToolGroup.addTool("run_code");
+ toolkit.registerToolGroup(skillToolGroup);
+
+ // 3. Verify the tool group is not active initially
+ assertFalse(
+ toolkit.getActiveGroups().contains(groupName),
+ "Tool group '" + groupName + "' should not be active before skill loading");
+
+ // 4. Create and load the skill
+ AgentSkill skill =
+ new AgentSkill(
+ skillName,
+ "Code execution",
+ "# Coding Skill\n\nInstructions here.",
+ null,
+ "custom");
+
+ SkillCatalog catalog = SkillCatalog.of(List.of(HarnessSkillEntry.of(skill, null)));
+ SkillLoadTool tool = new SkillLoadTool();
+ RuntimeContext ctx = RuntimeContext.empty();
+ ctx.put(SkillCatalog.class, catalog);
+ ctx.put(Toolkit.class, toolkit);
+
+ Map input = Map.of("skillId", skill.getSkillId(), "path", "SKILL.md");
+ ToolUseBlock useBlock =
+ ToolUseBlock.builder()
+ .id("test-load-2")
+ .name(SkillLoadTool.TOOL_NAME)
+ .input(input)
+ .build();
+ ToolCallParam param =
+ ToolCallParam.builder()
+ .toolUseBlock(useBlock)
+ .input(input)
+ .runtimeContext(ctx)
+ .build();
+
+ tool.callAsync(param).block();
+
+ // 5. Verify the tool group is now active
+ // THIS IS THE BUG: The tool group should be active after skill loading
+ assertTrue(
+ toolkit.getActiveGroups().contains(groupName),
+ "Tool group '"
+ + groupName
+ + "' should be active after loading skill '"
+ + skillName
+ + "'. Active groups: "
+ + toolkit.getActiveGroups());
+ }
+
+ /**
+ * Verifies the fallback path: when the call carries no toolkit in its RuntimeContext,
+ * SkillLoadTool falls back to the shared toolkit reference installed by
+ * {@link SkillRuntime#prepareToolkit(Toolkit)}.
+ */
+ @Test
+ @DisplayName("Loading skill without context toolkit uses the shared toolkit reference")
+ void loadingSkillWithoutContextToolkitUsesSharedToolkitReference() {
+ // 1. Create a Toolkit with a skill-bound tool group
+ Toolkit toolkit = new Toolkit();
+ String skillName = "shell";
+ String groupName = "shell_tools";
+ SkillToolGroup skillToolGroup =
+ SkillToolGroup.skillBuilder()
+ .name(groupName)
+ .description("Shell tools")
+ .activateOnSkill(skillName)
+ .build();
+ skillToolGroup.addTool("run_shell");
+ toolkit.registerToolGroup(skillToolGroup);
+
+ AgentSkill skill =
+ new AgentSkill(
+ skillName,
+ "Shell execution",
+ "# Shell Skill\n\nInstructions here.",
+ null,
+ "custom");
+
+ // 2. Simulate SkillRuntime.prepareToolkit: only the shared reference is available,
+ // the call's RuntimeContext carries no toolkit.
+ SkillCatalog catalog = SkillCatalog.of(List.of(HarnessSkillEntry.of(skill, null)));
+ SkillLoadTool tool =
+ new SkillLoadTool(
+ new AtomicReference<>(catalog), false, new AtomicReference<>(toolkit));
+
+ Map input = Map.of("skillId", skill.getSkillId(), "path", "SKILL.md");
+ ToolUseBlock useBlock =
+ ToolUseBlock.builder()
+ .id("test-fallback")
+ .name(SkillLoadTool.TOOL_NAME)
+ .input(input)
+ .build();
+ ToolCallParam param = ToolCallParam.builder().toolUseBlock(useBlock).input(input).build();
+
+ ToolResultBlock result = tool.callAsync(param).block();
+
+ // 3. Loading succeeds and the group is activated through the shared reference
+ assertTrue(
+ result != null && result.getState() != ToolResultState.ERROR,
+ "Skill loading should succeed without a context toolkit");
+ assertTrue(
+ toolkit.getActiveGroups().contains(groupName),
+ "Tool group '"
+ + groupName
+ + "' should be activated via the shared toolkit reference");
+ }
+
+ /**
+ * Verifies idempotency: reloading an already-active skill must not fail and must not
+ * deactivate the bound tool group.
+ */
+ @Test
+ @DisplayName("Reloading a skill keeps the bound tool group active")
+ void reloadingSkillKeepsBoundToolGroupActive() {
+ // 1. Create a Toolkit with a skill-bound tool group
+ Toolkit toolkit = new Toolkit();
+ String skillName = "qa";
+ String groupName = "qa_tools";
+ SkillToolGroup skillToolGroup =
+ SkillToolGroup.skillBuilder()
+ .name(groupName)
+ .description("QA tools")
+ .activateOnSkill(skillName)
+ .build();
+ skillToolGroup.addTool("run_tests");
+ toolkit.registerToolGroup(skillToolGroup);
+
+ AgentSkill skill =
+ new AgentSkill(
+ skillName,
+ "QA automation",
+ "# QA Skill\n\nInstructions here.",
+ null,
+ "custom");
+
+ SkillCatalog catalog = SkillCatalog.of(List.of(HarnessSkillEntry.of(skill, null)));
+ SkillLoadTool tool = new SkillLoadTool();
+ RuntimeContext ctx = RuntimeContext.empty();
+ ctx.put(SkillCatalog.class, catalog);
+ ctx.put(Toolkit.class, toolkit);
+
+ Map input = Map.of("skillId", skill.getSkillId(), "path", "SKILL.md");
+ ToolUseBlock useBlock =
+ ToolUseBlock.builder()
+ .id("test-reload")
+ .name(SkillLoadTool.TOOL_NAME)
+ .input(input)
+ .build();
+ ToolCallParam param =
+ ToolCallParam.builder()
+ .toolUseBlock(useBlock)
+ .input(input)
+ .runtimeContext(ctx)
+ .build();
+
+ // 2. Load once: group becomes active
+ ToolResultBlock first = tool.callAsync(param).block();
+ assertTrue(
+ first != null && first.getState() != ToolResultState.ERROR,
+ "First skill loading should succeed");
+ assertTrue(
+ toolkit.getActiveGroups().contains(groupName),
+ "Tool group should be active after the first load");
+
+ // 3. Reload: still succeeds and the group stays active (no double-activation errors)
+ ToolResultBlock second = tool.callAsync(param).block();
+ assertTrue(
+ second != null && second.getState() != ToolResultState.ERROR,
+ "Reloading the skill should succeed");
+ assertTrue(
+ toolkit.getActiveGroups().contains(groupName),
+ "Tool group should stay active after reloading the skill");
+ }
+
+ /**
+ * Verifies the defensive path: when no toolkit is reachable (no context value and an empty
+ * shared reference), loading the skill still succeeds and simply skips activation.
+ */
+ @Test
+ @DisplayName("Loading a skill without any reachable toolkit still succeeds")
+ void loadingSkillWithoutAnyReachableToolkitStillSucceeds() {
+ AgentSkill skill =
+ new AgentSkill(
+ "plain",
+ "Plain skill",
+ "# Plain Skill\n\nInstructions here.",
+ null,
+ "custom");
+
+ // No toolkit in the context and the shared reference stays empty.
+ SkillCatalog catalog = SkillCatalog.of(List.of(HarnessSkillEntry.of(skill, null)));
+ SkillLoadTool tool = new SkillLoadTool();
+ RuntimeContext ctx = RuntimeContext.empty();
+ ctx.put(SkillCatalog.class, catalog);
+
+ Map input = Map.of("skillId", skill.getSkillId(), "path", "SKILL.md");
+ ToolUseBlock useBlock =
+ ToolUseBlock.builder()
+ .id("test-plain")
+ .name(SkillLoadTool.TOOL_NAME)
+ .input(input)
+ .build();
+ ToolCallParam param =
+ ToolCallParam.builder()
+ .toolUseBlock(useBlock)
+ .input(input)
+ .runtimeContext(ctx)
+ .build();
+
+ ToolResultBlock result = tool.callAsync(param).block();
+
+ assertTrue(
+ result != null && result.getState() != ToolResultState.ERROR,
+ "Skill loading should succeed without any reachable toolkit");
+ }
+
+ /**
+ * Verifies resilience: when activating the bound tool group fails (e.g. an underlying
+ * group-manager error), the skill still loads successfully and only the activation is
+ * skipped, matching the core {@code SkillToolFactory} error tolerance.
+ */
+ @Test
+ @DisplayName("Tool group activation failure does not fail skill loading")
+ void toolGroupActivationFailureDoesNotFailSkillLoading() {
+ // 1. Mock a toolkit whose updateToolGroups always fails
+ Toolkit toolkit = mock(Toolkit.class);
+ String skillName = "resilient";
+ String groupName = "resilient_tools";
+ ToolGroup inactiveGroup = mock(ToolGroup.class);
+ when(inactiveGroup.isActive()).thenReturn(false);
+ when(toolkit.findSkillToolGroupsByActivateOnSkill(skillName))
+ .thenReturn(List.of(groupName));
+ when(toolkit.getToolGroup(groupName)).thenReturn(inactiveGroup);
+ doThrow(new IllegalStateException("group manager unavailable"))
+ .when(toolkit)
+ .updateToolGroups(anyList(), eq(true));
+
+ // 2. Load a skill bound to the mocked group
+ AgentSkill skill =
+ new AgentSkill(
+ skillName,
+ "Resilient skill",
+ "# Resilient Skill\n\nInstructions here.",
+ null,
+ "custom");
+ SkillCatalog catalog = SkillCatalog.of(List.of(HarnessSkillEntry.of(skill, null)));
+ SkillLoadTool tool = new SkillLoadTool();
+ RuntimeContext ctx = RuntimeContext.empty();
+ ctx.put(SkillCatalog.class, catalog);
+ ctx.put(Toolkit.class, toolkit);
+
+ Map input = Map.of("skillId", skill.getSkillId(), "path", "SKILL.md");
+ ToolUseBlock useBlock =
+ ToolUseBlock.builder()
+ .id("test-resilient")
+ .name(SkillLoadTool.TOOL_NAME)
+ .input(input)
+ .build();
+ ToolCallParam param =
+ ToolCallParam.builder()
+ .toolUseBlock(useBlock)
+ .input(input)
+ .runtimeContext(ctx)
+ .build();
+
+ // 3. Loading still succeeds even though activation threw
+ ToolResultBlock result = tool.callAsync(param).block();
+ assertTrue(
+ result != null && result.getState() != ToolResultState.ERROR,
+ "Skill loading should survive tool group activation failures");
+ verify(toolkit).updateToolGroups(List.of(groupName), true);
+ }
+}