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); + } +}