Repository navigation
fix(harness): activate skill-bound tool groups on skill load #2662
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| * | ||
| * <p>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<SkillCatalog> legacyCatalogRef; | ||
| private final boolean exposeLegacyCatalogInSchema; | ||
| private final AtomicReference<Toolkit> 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<SkillCatalog> catalogRef) { | ||
| this(catalogRef, true); | ||
| this(catalogRef, true, new AtomicReference<>()); | ||
| } | ||
|
|
||
| SkillLoadTool( | ||
| AtomicReference<SkillCatalog> legacyCatalogRef, boolean exposeLegacyCatalogInSchema) { | ||
| this(legacyCatalogRef, exposeLegacyCatalogInSchema, new AtomicReference<>()); | ||
| } | ||
|
|
||
| SkillLoadTool( | ||
| AtomicReference<SkillCatalog> legacyCatalogRef, | ||
| boolean exposeLegacyCatalogInSchema, | ||
| AtomicReference<Toolkit> 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<ToolResultBlock> 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,24 +209,43 @@ 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<String, String> mem = skill.getResources(); | ||
| if (mem != null && mem.containsKey(path)) { | ||
| activateSkillTools(toolkit, skill); | ||
| return ToolResultBlock.text(formatResource(entry, path, mem.get(path))); | ||
| } | ||
|
|
||
| // 3. Lazy fallback (WorkspaceSkillRepository and other LazyResourceCapable sources). | ||
| if (entry.lazyResources() != null) { | ||
| Optional<String> 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). | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Concretely: |
||
| * | ||
| * <p>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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor, for discoverability: matching is |
||
| if (toolkit == null || skill == null) { | ||
| return; | ||
| } | ||
| try { | ||
| List<String> boundGroups = | ||
| toolkit.findSkillToolGroupsByActivateOnSkill(skill.getName()); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Activation is per-resource-load and mutates the toolkit, so on a session whose persisted |
||
| if (boundGroups.isEmpty()) { | ||
| return; | ||
| } | ||
| List<String> 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Activation is written to the shared toolkit (
|
||
| log.info( | ||
| "Activated skill-bound tool groups {} for skill '{}'", | ||
| toActivate, | ||
| skill.getName()); | ||
| } | ||
| } catch (Exception e) { | ||
| log.warn( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| "Failed to activate skill-bound tool groups for skill '{}': {}", | ||
| skill.getName(), | ||
| e.getMessage()); | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------- | ||
| // Formatting helpers | ||
| // --------------------------------------------------------------------- | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,7 @@ public final class SkillRuntime { | |
| private final RuntimeContext legacyContext = RuntimeContext.empty(); | ||
| private final AtomicReference<SkillCatalog> legacyCatalogRef = | ||
| new AtomicReference<>(SkillCatalog.empty()); | ||
| private final AtomicReference<Toolkit> 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. | ||
| * | ||
| * <p>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) { | |
| * | ||
| * <p>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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Could this be narrowed to the one capability the load tool needs, e.g. put a small functional handle instead of the whole toolkit: public interface SkillGroupActivator { void activate(String skillName); }
context.put(SkillGroupActivator.class, (SkillGroupActivator) name -> activateBoundGroups(name, toolkit));If keeping
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| prepareToolkit(toolkit); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
toolkitForfalls back to the sharedtoolkitRefwhenever the call context has noToolkit— which includes the case whereinstallwas called with a null toolkit (agent == nullinHarnessSkillMiddleware.onSystemPrompt, line 266 there). So a context-scoped call can silently activate groups on the last toolkitprepareToolkitsaw (constructor-time toolkit, i.e. the pre-copy()instance atReActAgent.Builder:5535) rather than the agent's live one. Since the deep copy keeps the tool instances but not the group state, that fallback can activate a group that this agent's schema rendering never reads. Worth an explicit "no reachable toolkit -> log at debug and skip" branch instead of mutating whatever reference is cached.