From 8fe0e83cf029b7d9447216c862f5b862ed8703a0 Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Sun, 11 Oct 2026 14:45:28 +0800 Subject: [PATCH 01/13] feat(core): add per-user capability surface with grant trimming for tools, groups and skills Introduce a server-side CapabilityResolver that resolves an immutable, per-call CapabilitySurface from the caller identity. The surface trims what the model sees (tool schemas, skill visibility, reset_equipped_tools enum/description) and is enforced by hard execution gates for tools, skill loading and group activation. System-marked capabilities bypass trimming; all model-facing denials are byte-identical to not-found wording; a missing surface means a trusted direct path (unrestricted). --- .../java/io/agentscope/core/ReActAgent.java | 110 +- .../core/agent/CapabilityResolver.java | 44 + .../core/agent/CapabilitySurface.java | 172 +++ .../io/agentscope/core/skill/AgentSkill.java | 56 +- .../core/skill/DynamicSkillMiddleware.java | 33 + .../io/agentscope/core/skill/SkillBox.java | 9 +- .../core/skill/SkillToolFactory.java | 64 +- .../io/agentscope/core/tool/AgentTool.java | 10 + .../agentscope/core/tool/MetaToolFactory.java | 80 +- .../core/tool/ReflectiveFunctionTool.java | 3 +- .../agentscope/core/tool/SkillToolGroup.java | 30 +- .../java/io/agentscope/core/tool/Tool.java | 7 + .../io/agentscope/core/tool/ToolBase.java | 36 +- .../io/agentscope/core/tool/ToolExecutor.java | 46 +- .../io/agentscope/core/tool/ToolGroup.java | 59 +- .../core/tool/ToolGroupManager.java | 67 +- .../core/tool/ToolRequestConfig.java | 8 +- .../java/io/agentscope/core/tool/Toolkit.java | 254 +++- .../agent/CapabilityGrantIntegrationTest.java | 1026 +++++++++++++++++ .../core/agent/CapabilitySurfaceTest.java | 315 +++++ .../skill/DynamicSkillMiddlewareTest.java | 34 +- .../core/skill/SkillBoxToolsTest.java | 99 +- .../skill/SkillRuntimeIntegrationTest.java | 17 +- .../SkillToolFactoryDiskFallbackTest.java | 30 +- .../agentscope/core/tool/AsyncToolTest.java | 33 +- .../core/tool/ExternalToolSupportTest.java | 10 +- .../core/tool/MetaToolFactoryTest.java | 21 +- .../core/tool/SystemCapabilityMarkerTest.java | 335 ++++++ .../core/tool/ToolEmitterIntegrationTest.java | 32 +- .../ToolExecutionContextIntegrationTest.java | 43 +- .../core/tool/ToolExecutorTest.java | 46 +- .../core/tool/ToolRequestConfigTest.java | 12 +- .../io/agentscope/core/tool/ToolkitTest.java | 127 +- .../tool/ToolkitToolBaseIntegrationTest.java | 8 +- .../core/tool/test/GrantTestUtils.java | 56 + .../docs/building-blocks/capability-grant.md | 139 +++ .../docs/building-blocks/capability-grant.md | 100 ++ 37 files changed, 3375 insertions(+), 196 deletions(-) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/agent/CapabilityResolver.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/tool/SystemCapabilityMarkerTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/tool/test/GrantTestUtils.java create mode 100644 docs/v2/en/docs/building-blocks/capability-grant.md create mode 100644 docs/v2/zh/docs/building-blocks/capability-grant.md diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index ce2064fa59..13526b5d42 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -19,6 +19,8 @@ import io.agentscope.core.agent.Agent; import io.agentscope.core.agent.AgentBase; import io.agentscope.core.agent.AgentRun; +import io.agentscope.core.agent.CapabilityResolver; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.Event; import io.agentscope.core.agent.RunControl; import io.agentscope.core.agent.RuntimeContext; @@ -90,6 +92,7 @@ import io.agentscope.core.middleware.MiddlewareChain; import io.agentscope.core.middleware.ModelCallInput; import io.agentscope.core.middleware.ReasoningInput; +import io.agentscope.core.middleware.TaskReminderMiddleware; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.ChatUsage; import io.agentscope.core.model.ExecutionConfig; @@ -115,6 +118,7 @@ import io.agentscope.core.skill.DynamicSkillMiddleware; import io.agentscope.core.skill.SkillBox; import io.agentscope.core.skill.SkillFilter; +import io.agentscope.core.skill.SkillHook; import io.agentscope.core.skill.repository.AgentSkillRepository; import io.agentscope.core.state.AgentState; import io.agentscope.core.state.AgentStateStore; @@ -131,6 +135,7 @@ import io.agentscope.core.tool.ToolResultMessageBuilder; import io.agentscope.core.tool.ToolValidator; import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.tool.builtin.TodoTools; import io.agentscope.core.util.ExceptionUtils; import io.agentscope.core.util.JsonSchemaUtils; import io.agentscope.core.util.JsonUtils; @@ -242,6 +247,13 @@ public class ReActAgent extends AgentBase implements AutoCloseable { private final ExecutionConfig toolExecutionConfig; private final GenerateOptions generateOptions; + /** + * Resolves the per-request {@link CapabilitySurface} at each call entry and writes + * it to the rc; null = no trimming. Server-side only, never sourced from the + * caller-controlled ToolRequestConfig. + */ + private final CapabilityResolver capabilityResolver; + /** * Agent-owned toolkit (a deep copy made at {@code build()} time, isolated per agent instance). * Shared across this agent's concurrent calls; per-call structured-output tools are NOT @@ -336,6 +348,7 @@ private ReActAgent(Builder builder, Toolkit agentToolkit) { this.modelExecutionConfig = builder.modelExecutionConfig; this.toolExecutionConfig = builder.toolExecutionConfig; this.generateOptions = builder.generateOptions; + this.capabilityResolver = builder.capabilityResolver; this.toolExecutionContext = builder.toolExecutionContext; this.enablePendingToolRecovery = builder.enablePendingToolRecovery; List mws = new ArrayList<>(); @@ -762,6 +775,43 @@ protected Object beforeAgentExecution(List msgs, RuntimeContext rc, RunCont ToolRequestConfig requestConfig = ctx.getToolRequestConfig(); scope.toolRequestConfig = requestConfig != null ? requestConfig : ToolRequestConfig.NONE; scope.activeToolkit = this.toolkit; + + // ==== Grant layer: capability surface, resolved server-side at call entry ==== + // Independent of ToolRequestConfig (the caller-controlled external-tools channel); + // runs before the system prompt is assembled so skill filtering already sees it. + if (capabilityResolver != null) { + CapabilitySurface resolved; + try { + resolved = capabilityResolver.resolve(ctx, this); + } catch (RuntimeException e) { + // A throwing resolver is broken authorization infrastructure, not "no + // grants": abort the call (the Mono.defer wrapper turns this into a + // subscription-time error; run-control cleanup still runs). Developers + // wanting graceful degradation catch inside their resolver and return + // DENY_ALL themselves. + log.error( + "CapabilityResolver failed; aborting the call (userId={}, agentId={})", + ctx.getUserId(), + getAgentId(), + e); + throw e; + } + CapabilitySurface surface = resolved != null ? resolved : CapabilitySurface.ALLOW_ALL; + CapabilitySurface inherited = ctx.get(CapabilitySurface.class); + if (inherited != null && inherited != surface) { + // SubAgentTool passes the parent rc instance: writing in place would + // pollute the parent chain. Write a derived copy and switch scope.rc + // (from() copies all typed attributes). + ctx = RuntimeContext.builder().from(ctx).build(); + scope.rc = ctx; + } + ctx.put(CapabilitySurface.class, surface); + } else if (ctx.get(CapabilitySurface.class) == null) { + // No resolver: default to ALLOW_ALL only when the rc has no surface; keep + // inherited ones (sub-agents reuse the parent surface without re-resolving). + ctx.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); + } + scope.systemMsg = null; scope.interruption = control.interruption(); return scope; @@ -1284,7 +1334,10 @@ private Mono doStructuredCall(List msgs, Class targetClass, JsonNod List activeGroups = scope.state.getToolContext().getActivatedGroups(); boolean hasTools = !scope.activeToolkit - .getToolSchemas(activeGroups, scope.toolRequestConfig) + .getToolSchemas( + activeGroups, + scope.toolRequestConfig, + scope.rc.get(CapabilitySurface.class)) .isEmpty(); boolean useNative = hasTools @@ -1430,8 +1483,7 @@ private Msg wrapNativeStructuredResult(Msg result) { return result; } try { - Object parsed = - io.agentscope.core.util.JsonUtils.getJsonCodec().fromJson(text, Object.class); + Object parsed = JsonUtils.getJsonCodec().fromJson(text, Object.class); Map metadata = new HashMap<>(result.getMetadata() != null ? result.getMetadata() : Map.of()); metadata.put(MessageMetadataKeys.STRUCTURED_OUTPUT, parsed); @@ -2452,7 +2504,8 @@ private Mono reasoning(int iter, boolean ignoreMaxIters) { List tools = activeToolkit.getToolSchemas( state.getToolContext().getActivatedGroups(), - toolRequestConfig); + toolRequestConfig, + rc.get(CapabilitySurface.class)); // Per-call structured-output tool: expose generate_response to the // model for this call only (not registered on the shared toolkit). if (soTool != null) { @@ -4876,6 +4929,9 @@ public static class Builder { private SkillFilter skillFilter; private boolean dynamicSkillsEnabled = true; + /** Computes the per-request grant set; null = no trimming (ALLOW_ALL at call entry). */ + private CapabilityResolver capabilityResolver; + /** * When true, {@link DynamicSkillMiddleware} toggles the prompt provider's code-execution * block (per-skill {@code } listing + instructions). Off by default — @@ -5063,7 +5119,7 @@ public Builder enableTaskList() { * *

When enabled, {@link #build()} registers a {@code todo_write} tool (operating on * {@link io.agentscope.core.state.AgentState#getTasksContext()} with full-list-replace - * semantics) and a {@link io.agentscope.core.middleware.TaskReminderMiddleware} that + * semantics) and a {@link TaskReminderMiddleware} that * re-surfaces the current list before every reasoning step. Default OFF. * * @param enabled true to enable the task list @@ -5225,13 +5281,13 @@ public Builder fallbackModel(Model fallbackModel) { /** * Convenience overload that resolves {@code modelId} via - * {@link io.agentscope.core.model.ModelRegistry#resolve(String)} (named registration or + * {@link ModelRegistry#resolve(String)} (named registration or * {@code provider:model} pattern like {@code openai:gpt-5.5}, {@code dashscope:qwen-max}). * * @throws IllegalArgumentException if the id cannot be resolved */ public Builder fallbackModel(String modelId) { - this.flatFallbackModel = io.agentscope.core.model.ModelRegistry.resolve(modelId); + this.flatFallbackModel = ModelRegistry.resolve(modelId); return this; } @@ -5409,6 +5465,22 @@ public Builder dynamicSkillsEnabled(boolean enabled) { return this; } + /** + * Registers a capability resolver: run once per call entry; the resolved {@link + * io.agentscope.core.agent.CapabilitySurface} is carried on the {@link + * RuntimeContext} for the whole call chain. Sub-agents without their own + * resolver reuse the parent surface instance. + * + *

Only an all-null surface skips trimming; any explicit dimension makes the surface + * a complete grant enumeration (unset dimensions contribute no grants). + * + *

The rc is request-scoped: never share one instance across concurrent calls. + */ + public Builder capabilityResolver(CapabilityResolver resolver) { + this.capabilityResolver = resolver; + return this; + } + /** * Enables the code-execution prompt block emitted by * {@link io.agentscope.core.skill.AgentSkillPromptProvider}. When on: @@ -5571,8 +5643,8 @@ private List mergeAndSortResults(List> allResults) { * middleware. Opt-in via {@link #enableTaskList()}. */ private void configureTodoTools(Toolkit agentToolkit) { - agentToolkit.registerTool(new io.agentscope.core.tool.builtin.TodoTools()); - middlewares.add(new io.agentscope.core.middleware.TaskReminderMiddleware()); + agentToolkit.registerTool(new TodoTools()); + middlewares.add(new TaskReminderMiddleware()); } /** @@ -5586,7 +5658,7 @@ private Hook configureSkillBox(Toolkit agentToolkit) { if (skillBox.isAutoUploadSkill()) { skillBox.uploadSkillFiles(); } - return new io.agentscope.core.skill.SkillHook(skillBox); + return new SkillHook(skillBox); } /** @@ -5630,6 +5702,24 @@ public ReActAgent build() { skillWorkDir)); } + // The static skill path has no per-call visibility filtering: a resolver here + // would silently trim nothing. Log and disable it; use skillRepositories + // instead. + if (capabilityResolver != null + && (skillBox != null + || (!skillRepositories.isEmpty() && !dynamicSkillsEnabled))) { + log.error( + "capabilityResolver is configured but the static skill path is active" + + " (skillBox(...) or dynamicSkillsEnabled=false); skill-prompt" + + " trimming is not supported on this path — resolver disabled" + + " on this agent: top-level calls of this agent are untrimmed;" + + " as a sub-agent, loading and execution stay gated under the" + + " inherited surface, but the static skill prompt still" + + " enumerates the full catalog. Use skillRepositories(...)" + + " for per-call skill trimming."); + capabilityResolver = null; + } + // List.sort is stable: middlewares with equal order retain their registration order. middlewares.sort(Comparator.comparingInt(MiddlewareBase::order).reversed()); diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilityResolver.java b/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilityResolver.java new file mode 100644 index 0000000000..e1a8bcf10d --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilityResolver.java @@ -0,0 +1,44 @@ +/* + * 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.agent; + +/** + * User implementation: computes the capabilities a user is granted (pure whitelists). + * + * @author shanhongyu + */ +@FunctionalInterface +public interface CapabilityResolver { + + /** + * Computes the per-request capability grant set for the user identified by the {@link + * RuntimeContext} (e.g. {@link RuntimeContext#getUserId()}), optionally differentiated per + * agent. + * + *

Only an all-null surface skips trimming. Once any dimension is set — even to an + * explicit empty set — the surface is a complete grant enumeration and unset (null) + * dimensions contribute no grants. + * + * @param ctx the per-call runtime context carrying the caller identity + * @param agent the agent being called (for per-agent granularity; may be ignored) + * @return the grant set; {@code null} = no trimming (normalized to {@link + * CapabilitySurface#ALLOW_ALL}) + * @throws RuntimeException propagates and aborts the call — an authorization + * infrastructure failure is not "no grants"; catch internally and return {@link + * CapabilitySurface#DENY_ALL} if graceful degradation is wanted + */ + CapabilitySurface resolve(RuntimeContext ctx, Agent agent); +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java b/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java new file mode 100644 index 0000000000..0d2789d695 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java @@ -0,0 +1,172 @@ +/* + * 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.agent; + +import io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.SkillToolGroup; +import java.util.Set; +import java.util.function.Function; + +/** + * Immutable per-request grant set, agent-agnostic; supersets are harmless. Only one surface + * shape skips trimming entirely: ALL three dimensions {@code null} (nothing set — the {@link + * #ALLOW_ALL} shape). As soon as any dimension is set — even to an explicit empty set — the + * surface is a complete grant enumeration: unset ({@code null}) dimensions contribute no + * grants, exactly like {@link Set#of()} (explicitly empty); the difference stays visible via + * the accessors. All grant decisions live in the query methods; callers only pass mechanical + * lookups (the tool's groups). + * + *

A tool is granted when any explicit path matches: it is system-marked, per-name + * whitelisted, a member of a granted group, or in a granted skill's tool group. A skill is + * granted when it is system-marked or its skillId is whitelisted. Default is deny: anything + * not reachable from the enumeration does not exist. + * + * @param allowedSkills allowed skillIds; {@code null} = not set (no skill grants) + * @param allowedToolGroups allowed ToolGroup names; {@code null} = not set (no group grants) + * @param allowedTools allowed tool names (per-name, grouping ignored); {@code null} = not set + * (no per-name grants) + * @author shanhongyu + */ +public record CapabilitySurface( + Set allowedSkills, Set allowedToolGroups, Set allowedTools) { + + /** + * Defensive copy locks immutability at construction; unset dimensions stay null so + * "not set" remains distinguishable from an explicit empty set. + */ + public CapabilitySurface { + allowedSkills = allowedSkills == null ? null : Set.copyOf(allowedSkills); + allowedToolGroups = allowedToolGroups == null ? null : Set.copyOf(allowedToolGroups); + allowedTools = allowedTools == null ? null : Set.copyOf(allowedTools); + } + + /** Allow-everything sentinel (all three null), substituted when no resolver is configured. */ + public static final CapabilitySurface ALLOW_ALL = new CapabilitySurface(null, null, null); + + /** + * Explicit deny-everything grant set (system capabilities still pass); also the + * self-service degradation value a resolver may return when it must keep serving. + */ + public static final CapabilitySurface DENY_ALL = + new CapabilitySurface(Set.of(), Set.of(), Set.of()); + + /** Whether nothing is set at all — the only shape that skips trimming. */ + private boolean isUnrestricted() { + return allowedSkills == null && allowedToolGroups == null && allowedTools == null; + } + + /** + * Skill decision by skillId, grant set only. Fallback for callers that do not hold an + * {@link AgentSkill} instance; this overload does not know the system-capability mark, so + * such callers must apply that bypass themselves (or resolve the skill and prefer {@link + * #allowsSkill(AgentSkill)}). + * + * @param skillId the skill identifier to check + * @return {@code true} for an all-null surface or a whitelisted skillId + */ + public boolean allowsSkill(String skillId) { + return isUnrestricted() || (allowedSkills != null && allowedSkills.contains(skillId)); + } + + /** + * Skill decision, full entry point. Prefer this over {@link #allowsSkill(String)} whenever + * the caller holds the {@link AgentSkill}: it applies the system-capability bypass ({@code + * skill.isSystem()}), mirroring how {@link #allowsTool} treats system tools. + * + * @param skill the skill to check; {@code null} (a registry miss) is never granted, not + * even on {@link #ALLOW_ALL} — fail-closed like a missing surface + * @return {@code true} for a system skill, an all-null surface, or a whitelisted skillId + */ + public boolean allowsSkill(AgentSkill skill) { + if (skill == null) { + return false; // registry miss: fail closed + } + return skill.isSystem() || allowsSkill(skill.getSkillId()); + } + + /** + * Tool-group decision. A granted skill implicitly grants its skill tool group. + * + * @param name the group name to check + * @return {@code true} for an all-null surface, a whitelisted group, or a group derived + * from a granted skill + */ + public boolean allowsToolGroup(String name) { + return isUnrestricted() + || (allowedToolGroups != null && allowedToolGroups.contains(name)) + || isSkillGroupName(name); + } + + /** + * Tool decision; any matching path grants (system, per-name, group, skill). + * + * @param tool the tool to check + * @param groupsOfName the tool's groups on this agent (mechanical lookup, may be empty) + * @return {@code true} when the tool is system-marked, nothing is set at all, or any grant + * path matches + */ + public boolean allowsTool(AgentTool tool, Set groupsOfName) { + if (tool.isSystem()) { + return true; // system capability + } + if (isUnrestricted()) { + return true; // nothing set: no trimming + } + if (allowedTools != null && allowedTools.contains(tool.getName())) { + return true; // per-name grant + } + return groupsOfName.stream().anyMatch(this::allowsToolGroup); // group or skill path + } + + /** + * Group activation decision for reset_equipped_tools (enum and execution side). A group is + * also reachable when some per-name granted tool belongs to it (other members still go + * through {@link #allowsTool}). + * + * @param name the group name to check + * @param groupsOf reverse lookup from tool name to its groups on this agent + * @return {@code true} when the group is granted or reachable via a granted member tool + */ + public boolean allowsToolGroupActivation(String name, Function> groupsOf) { + if (allowsToolGroup(name)) { + return true; + } + if (allowedTools == null) { + return false; // no per-name grants (all-null already passed above) + } + for (String t : allowedTools) { + if (groupsOf.apply(t).contains(name)) { + return true; + } + } + return false; + } + + /** + * Whether the group name derives from a granted skill (naming convention: {@code skillId + + * "_skill_tools"}, see {@link SkillToolGroup#skillToolsGroupName}). + * + * @param name the group name to check + * @return {@code true} when the name is a granted skill's tool group + */ + private boolean isSkillGroupName(String name) { + return allowedSkills != null + && allowedSkills.stream() + .map(SkillToolGroup::skillToolsGroupName) + .anyMatch(name::equals); + } +} 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 b171ee0c8c..5b74e33369 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 @@ -79,6 +79,12 @@ public class AgentSkill { */ private final Path originDir; + /** + * Whether this is a system capability exempt from grant-layer (per-user capability) + * trimming. Settable only via the Builder; public constructors default to {@code false}. + */ + private final boolean system; + /** * Creates an AgentSkill with explicit parameters. * @@ -155,6 +161,27 @@ public AgentSkill( Map resources, String source, Path originDir) { + this(metadata, skillContent, resources, source, originDir, false); + } + + /** + * Full constructor including the system-capability flag (Builder path only). + * + * @param metadata Skill metadata including required {@code name} and {@code description} + * @param skillContent The skill implementation or instructions (must not be null or empty) + * @param resources Supporting resources (can be null) + * @param source Source identifier for the skill (null defaults to "custom") + * @param originDir Absolute path to the skill's source directory, or {@code null} + * @param system Whether this skill is exempt from grant-layer trimming + * @throws IllegalArgumentException if metadata is invalid or skillContent is null or empty + */ + private AgentSkill( + Map metadata, + String skillContent, + Map resources, + String source, + Path originDir, + boolean system) { String name = getRequiredMetadataString(metadata, "name"); String description = getRequiredMetadataString(metadata, "description"); if (skillContent == null || skillContent.isEmpty()) { @@ -170,6 +197,7 @@ public AgentSkill( this.resources = resources != null ? new HashMap<>(resources) : new HashMap<>(); this.source = source != null ? source : "custom"; this.originDir = originDir; + this.system = system; } /** @@ -279,6 +307,17 @@ public Optional getOriginDir() { return Optional.ofNullable(originDir); } + /** + * Whether this skill is a system capability exempt from grant-layer (per-user capability) + * trimming. The bypass takes effect only when a surface is present; a missing surface fails + * closed. + * + * @return {@code true} if this skill bypasses grant-layer trimming; {@code false} by default + */ + public boolean isSystem() { + return system; + } + /** * Creates a builder initialized with this skill's values. * @@ -344,6 +383,7 @@ public static class Builder { private Map resources; private String source; private Path originDir; + private boolean system = false; /** * Creates an empty builder. @@ -364,6 +404,7 @@ private Builder(AgentSkill baseSkill) { this.resources = new HashMap<>(baseSkill.resources); this.source = baseSkill.source; this.originDir = baseSkill.originDir; + this.system = baseSkill.system; } /** @@ -502,6 +543,19 @@ public Builder originDir(Path originDir) { return this; } + /** + * Marks the skill as a system capability exempt from grant-layer (per-user capability) + * trimming. Only mark framework/infrastructure skills; business skills marked system + * bypass per-user capability trimming entirely. + * + * @param system true to exempt this skill from grant-layer trimming + * @return This builder + */ + public Builder system(boolean system) { + this.system = system; + return this; + } + /** * Builds the AgentSkill instance. * @@ -509,7 +563,7 @@ public Builder originDir(Path originDir) { * @throws IllegalArgumentException if required fields are missing */ public AgentSkill build() { - return new AgentSkill(metadata, skillContent, resources, source, originDir); + return new AgentSkill(metadata, skillContent, resources, source, originDir, system); } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/DynamicSkillMiddleware.java b/agentscope-core/src/main/java/io/agentscope/core/skill/DynamicSkillMiddleware.java index 00453b4578..b0ce76cd9a 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/DynamicSkillMiddleware.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/DynamicSkillMiddleware.java @@ -16,6 +16,7 @@ package io.agentscope.core.skill; import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.skill.repository.AgentSkillRepository; @@ -162,6 +163,30 @@ private SkillFilter resolveFilter(RuntimeContext rc) { return builderFilter.overlay(runtimeOverlay); } + /** + * Grant visibility filter, fail-closed: a missing surface or an internal error + * yields an empty set (the filterVisible hook's exception handling is fail-open, so + * this filter must catch its own exceptions). + * + * @param visible the skills remaining after the filterVisible hook + * @param ctx the per-call runtime context carrying the grant set + * @return the granted subset; empty on a missing surface or filter error + */ + private List applyCapabilityFilter(List visible, RuntimeContext ctx) { + try { + CapabilitySurface surface = ctx != null ? ctx.get(CapabilitySurface.class) : null; + if (surface == null) { + return List.of(); + } + return visible.stream().filter(surface::allowsSkill).toList(); + } catch (Exception e) { + log.warn( + "Capability filtering failed; failing closed to an empty skill set: {}", + e.getMessage()); + return List.of(); + } + } + private SkillBox reloadSkills(RuntimeContext ctx) { if (repositories.isEmpty()) { return null; @@ -211,6 +236,14 @@ private SkillBox reloadSkills(RuntimeContext ctx) { return null; } + // Grant filtering runs after the overridable filterVisible hook and must reassign + // visible: the signature/cache/box below consume the filtered list, otherwise + // the box (prompt + registry) stays full. + visible = applyCapabilityFilter(visible, ctx); + if (visible.isEmpty()) { + return null; + } + String signature = computeSignature(visible); synchronized (boxCache) { SkillBox box = boxCache.get(signature); diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java index 10c3d3a9af..3fa5c087c2 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java @@ -18,6 +18,7 @@ import io.agentscope.core.skill.util.SkillFileSystemHelper; import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.ExtendedModel; +import io.agentscope.core.tool.SkillToolGroup; import io.agentscope.core.tool.ToolCallParam; import io.agentscope.core.tool.Toolkit; import io.agentscope.core.tool.mcp.McpClientWrapper; @@ -232,7 +233,11 @@ String loadSkillResource(ToolCallParam param) { throw new IllegalArgumentException("Missing or empty required parameter: path"); } return skillToolFactory.loadSkillResourceImpl( - skillId, path, skillToolFactory.resolveToolContext(param)); + skillId, + path, + skillToolFactory.resolveToolContext(param), + // Not gated here: the single decision lives in loadSkillResourceImpl. + param.getRuntimeContext()); } /** @@ -505,7 +510,7 @@ public void apply() { throw new IllegalStateException( "Must bind toolkit or call toolkit() before apply()"); } - String skillToolGroup = skill.getSkillId() + "_skill_tools"; + String skillToolGroup = SkillToolGroup.skillToolsGroupName(skill.getSkillId()); if (toolkit.getToolGroup(skillToolGroup) == null) { toolkit.createToolGroup(skillToolGroup, skillToolGroup, false); } 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 b5dc6cd56f..1cfb94047d 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 @@ -15,11 +15,13 @@ */ package io.agentscope.core.skill; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.state.AgentState; import io.agentscope.core.state.ToolContextState; import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.SkillToolGroup; import io.agentscope.core.tool.ToolCallParam; import io.agentscope.core.tool.Toolkit; import java.io.IOException; @@ -119,6 +121,12 @@ public String getDescription() { return LOAD_TOOL_DESCRIPTION; } + @Override + public boolean isSystem() { + // Framework meta tool: ungrouped, always visible, usable even under DENY_ALL. + return true; + } + @Override public Map getParameters() { // Dynamic skills are not known at build time, so the parameter enum is omitted; @@ -198,6 +206,13 @@ public String getDescription() { return LOAD_TOOL_DESCRIPTION; } + @Override + public boolean isSystem() { + // Aligned with the runtime load tool: ungrouped and always visible; + // grant decisions live in loadSkillResourceImpl. + return true; + } + @Override public Map getParameters() { // Get all available skill IDs @@ -247,7 +262,12 @@ public Mono callAsync(ToolCallParam param) { ToolResultBlock.error("Missing or empty required parameter: path")); } - String result = loadSkillResourceImpl(skillId, path, resolveToolContext(param)); + String result = + loadSkillResourceImpl( + skillId, + path, + resolveToolContext(param), + param.getRuntimeContext()); return Mono.just(ToolResultBlock.text(result)); } catch (IllegalArgumentException e) { logger.warn("Error loading skill resource: {}", e.getMessage()); @@ -265,15 +285,22 @@ public Mono callAsync(ToolCallParam param) { } /** - * Implementation of skill resource loading logic. + * Implementation of skill resource loading logic. Both load paths (per-call box and the + * build-time bound static tool) converge here, so the grant gate lives in this method only. * * @param skillId The unique identifier of the skill * @param path The path to the resource file + * @param tcs the per-call tool context state (may be {@code null}) + * @param rc the tool call's runtime context (may be {@code null} → unrestricted: direct + * execution does not pass through grant resolution) * @return The formatted resource content or error message with available resources - * @throws IllegalArgumentException if skill doesn't exist or resource not found + * @throws IllegalArgumentException if skill doesn't exist, is not granted, or resource not + * found */ - String loadSkillResourceImpl(String skillId, String path, ToolContextState tcs) { + String loadSkillResourceImpl( + String skillId, String path, ToolContextState tcs, RuntimeContext rc) { AgentSkill skill = validateSkillExists(skillId); + requireSkillGranted(skillId, skill, rc); // Special handling for SKILL.md - return the skill's markdown content if ("SKILL.md".equals(path)) { @@ -445,6 +472,33 @@ private static List listOriginDirEntries(Path originDir) { return entries; } + /** + * Grant gate for skill loading (both load paths converge here). A missing surface means + * this load never entered an agent call() (whose funnel always writes one): treat it as + * unrestricted — direct execution is trusted assembler code. + * + * @param skillId the caller-supplied skill identifier (authoritative for the error echo) + * @param skill the resolved skill (from {@link #validateSkillExists}) + * @param rc the tool call's runtime context (may be {@code null}) + * @throws IllegalArgumentException when the skill is not granted — byte-identical to the + * {@link #validateSkillExists} not-found wording: do not reveal existence + */ + private void requireSkillGranted(String skillId, AgentSkill skill, RuntimeContext rc) { + CapabilitySurface surface = rc != null ? rc.get(CapabilitySurface.class) : null; + if (surface == null) { + surface = CapabilitySurface.ALLOW_ALL; + } + if (!surface.allowsSkill(skill)) { + logger.warn( + "Skill load denied (grant): skillId='{}', userId='{}', sessionId='{}'", + skillId, + rc != null ? rc.getUserId() : null, + rc != null ? rc.getSessionId() : null); + throw new IllegalArgumentException( + String.format("Skill not found: '%s'. Please check the skill ID.", skillId)); + } + } + /** * Validates that a skill is registered and returns its instance. * @@ -497,7 +551,7 @@ private void activateSkill(String skillId, ToolContextState tcs) { // (skillId_skill_tools) plus any SkillToolGroup bound via activateOnSkill. Shared group // definitions are never mutated. List groups = new ArrayList<>(); - String toolsGroupName = skillId + "_skill_tools"; + String toolsGroupName = SkillToolGroup.skillToolsGroupName(skillId); if (toolkit != null && toolkit.getToolGroup(toolsGroupName) != null) { groups.add(toolsGroupName); } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java index 63c45edf23..6ccfed5347 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java @@ -112,6 +112,16 @@ default boolean isReadOnly() { return false; } + /** + * Whether this is a framework-provided system capability that must remain available for the + * agent to operate (e.g. meta tools). System tools bypass grant-layer trimming but still + * obey group activation. Marking a business tool {@code system=true} exempts it from grant + * trimming — an explicit declaration by the assembler (trusted boundary). + */ + default boolean isSystem() { + return false; + } + /** * Whether this tool's result should be returned directly to the caller, * skipping the next reasoning iteration. Defaults to {@code false}. diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/MetaToolFactory.java b/agentscope-core/src/main/java/io/agentscope/core/tool/MetaToolFactory.java index 101e04dbf1..80f7587096 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/MetaToolFactory.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/MetaToolFactory.java @@ -15,6 +15,7 @@ */ package io.agentscope.core.tool; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.state.AgentState; @@ -24,6 +25,8 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; /** @@ -31,6 +34,8 @@ */ class MetaToolFactory { + private static final Logger logger = LoggerFactory.getLogger(MetaToolFactory.class); + private final ToolGroupManager groupManager; private final ToolRegistry toolRegistry; @@ -39,6 +44,28 @@ class MetaToolFactory { this.toolRegistry = toolRegistry; } + /** + * The fixed prefix of the {@code reset_equipped_tools} description, excluding the group + * notes. Schema post-processing rebuilds a restricted description from this constant plus + * only the activatable groups' notes; {@link #createResetEquippedToolsAgentTool()} keeps the + * full one. + * + * @return the fixed description prefix, without group notes + */ + static String baseDescription() { + return "Reset your equipped tools based on your current task requirements. " + + "These tools are organized into different groups, and you can " + + "activate/deactivate them by specifying which groups to keep " + + "active.\n\n" + + "**Important: The input list is the FINAL set of active tool " + + "groups, not incremental changes.** Any group not included in " + + "the list will be deactivated, regardless of its previous " + + "state.\n\n" + + "**Best practice**: Activate only what you need for the current " + + "task, and promptly deactivate groups as soon as they are no " + + "longer needed to conserve context space.\n\n"; + } + /** * Create the reset_equipped_tools meta tool. * @@ -53,18 +80,12 @@ public String getName() { @Override public String getDescription() { - return "Reset your equipped tools based on your current task requirements. " - + "These tools are organized into different groups, and you can " - + "activate/deactivate them by specifying which groups to keep " - + "active.\n\n" - + "**Important: The input list is the FINAL set of active tool " - + "groups, not incremental changes.** Any group not included in " - + "the list will be deactivated, regardless of its previous " - + "state.\n\n" - + "**Best practice**: Activate only what you need for the current " - + "task, and promptly deactivate groups as soon as they are no " - + "longer needed to conserve context space.\n\n" - + groupManager.getNotes(); + return baseDescription() + groupManager.getNotes(); + } + + @Override + public boolean isSystem() { + return true; } @Override @@ -110,7 +131,9 @@ public Mono callAsync(ToolCallParam param) { ToolResultBlock.error("Missing required parameter: to_activate")); } - String result = resetEquippedToolsImpl(toActivate, resolveToolContext(param)); + RuntimeContext rc = param.getRuntimeContext(); + String result = + resetEquippedToolsImpl(toActivate, resolveToolContext(param), rc); return Mono.just(ToolResultBlock.text(result)); } catch (Exception e) { return Mono.just(ToolResultBlock.error(e.getMessage())); @@ -143,10 +166,21 @@ private ToolContextState resolveToolContext(ToolCallParam param) { * * @param toActivate List of tool group names to activate (must all be META scope) * @param tcs the per-call tool context state (must be non-null) + * @param rc the tool call's runtime context: carries the per-request capability grant set + * (missing → {@code ALLOW_ALL}: direct execution does not pass through grant resolution) + * and the identity used for denial logging; may be {@code null} * @return Response message describing the resulting state - * @throws IllegalArgumentException if any group doesn't exist + * @throws IllegalArgumentException if any group doesn't exist (or is not activatable) */ - private String resetEquippedToolsImpl(List toActivate, ToolContextState tcs) { + private String resetEquippedToolsImpl( + List toActivate, ToolContextState tcs, RuntimeContext rc) { + // A missing surface means this call never entered an agent call() (whose funnel + // always writes one): treat it as unrestricted — direct execution is trusted + // assembler code. + CapabilitySurface surface = rc != null ? rc.get(CapabilitySurface.class) : null; + if (surface == null) { + surface = CapabilitySurface.ALLOW_ALL; + } // Validate: all groups must exist and be META scope for (String groupName : toActivate) { groupManager.validateGroupExists(groupName); @@ -154,6 +188,22 @@ private String resetEquippedToolsImpl(List toActivate, ToolContextState if (group.getScope() != ToolGroupScope.META) { return "Error: Group '" + groupName + "' is not manageable by this tool."; } + boolean allowed = + group.isSystem() + || surface.allowsToolGroupActivation( + groupName, groupManager::getGroupsOfTool); + if (!allowed) { + logger.warn( + "Denied unauthorized group activation via reset_equipped_tools:" + + " group='{}', userId='{}', sessionId='{}'", + groupName, + rc != null ? rc.getUserId() : null, + rc != null ? rc.getSessionId() : null); + // Byte-identical to the validateGroupExists not-found wording: do not + // reveal existence. + throw new IllegalArgumentException( + String.format("Tool group '%s' does not exist", groupName)); + } } // No per-session context: never fall back to mutating the shared group manager. A missing diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ReflectiveFunctionTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ReflectiveFunctionTool.java index 4dc89740c6..5b8d8d1005 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ReflectiveFunctionTool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ReflectiveFunctionTool.java @@ -138,7 +138,8 @@ static ReflectiveFunctionTool create( .concurrencySafe(annotation.concurrencySafe()) .externalTool(annotation.externalTool()) .stateInjected(annotation.stateInjected()) - .returnDirect(annotation.returnDirect()); + .returnDirect(annotation.returnDirect()) + .system(annotation.system()); if (annotation.dangerousFiles().length > 0) { builder.dangerousFiles(List.of(annotation.dangerousFiles())); } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java b/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java index 202a38e2bd..fd694f4db8 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java @@ -46,13 +46,25 @@ public class SkillToolGroup extends ToolGroup { private final String activateOnSkill; private final String rawDescription; + /** + * Single source of the skill tool-group naming convention: {@code skillId + + * "_skill_tools"}. + * + * @param skillId the skill identifier + * @return the derived tool-group name + */ + public static String skillToolsGroupName(String skillId) { + return skillId + "_skill_tools"; + } + private SkillToolGroup(Builder builder) { super( builder.name, builder.description, builder.active, ToolGroupScope.META, - builder.tools); + builder.tools, + builder.system); this.activateOnSkill = Objects.requireNonNull(builder.activateOnSkill, "activateOnSkill cannot be null"); this.rawDescription = builder.description != null ? builder.description : ""; @@ -89,6 +101,9 @@ public ToolGroup copy() { .active(isActive()) .activateOnSkill(activateOnSkill) .tools(getTools()) + // Rebuilt via skillBuilder: without this the system flag is silently + // dropped on copy. + .system(isSystem()) .build(); return copy; } @@ -110,6 +125,7 @@ public static class Builder { private boolean active = false; private String activateOnSkill; private Set tools = new HashSet<>(); + private boolean system = false; /** * Sets the name of the tool group. @@ -166,6 +182,18 @@ public Builder tools(Set tools) { return this; } + /** + * Marks the group as a system capability exempt from grant-layer trimming. This builder + * does not extend {@link ToolGroup.Builder}, so the flag is declared explicitly here. + * + * @param system true to exempt this group from grant-layer trimming + * @return This builder for method chaining + */ + public Builder system(boolean system) { + this.system = system; + return this; + } + /** * Builds a new SkillToolGroup with the configured settings. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java index 8dabcc2a79..a9d1e131a9 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Tool.java @@ -126,6 +126,13 @@ */ boolean externalTool() default false; + /** + * Whether this tool is a system capability exempt from grant-layer trimming. + * Only mark framework/infrastructure tools; business tools marked system bypass + * per-user capability trimming entirely. + */ + boolean system() default false; + /** * Whether the tool requires the agent state to be injected at call time. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java index ce75463090..950a860d8a 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolBase.java @@ -69,6 +69,9 @@ public abstract class ToolBase implements AgentTool { private final String mcpName; private final boolean returnDirect; + /** Whether this is a system capability exempt from grant-layer trimming. */ + private final boolean system; + /** Sensitive files; subclasses may replace this list to widen or narrow protection. */ protected List dangerousFiles = ToolDangerousPathConstants.DEFAULT_DANGEROUS_FILES; @@ -88,7 +91,8 @@ protected ToolBase(Builder builder) { builder.mcpName, builder.externalTool, builder.stateInjected, - builder.returnDirect); + builder.returnDirect, + builder.system); if (builder.dangerousFiles != null) { this.dangerousFiles = List.copyOf(builder.dangerousFiles); } @@ -121,6 +125,7 @@ protected ToolBase( mcpName, externalTool, stateInjected, + false, false); } @@ -134,7 +139,8 @@ private ToolBase( String mcpName, boolean externalTool, boolean stateInjected, - boolean returnDirect) { + boolean returnDirect, + boolean system) { this.name = Objects.requireNonNull(name, "name must not be null"); this.description = Objects.requireNonNull(description, "description must not be null"); this.inputSchema = Objects.requireNonNull(inputSchema, "inputSchema must not be null"); @@ -145,6 +151,7 @@ private ToolBase( this.externalTool = externalTool; this.stateInjected = stateInjected; this.returnDirect = returnDirect; + this.system = system; if (mcp && (mcpName == null || mcpName.isBlank())) { throw new IllegalArgumentException("mcpName is required when mcp is true"); } @@ -174,6 +181,17 @@ public final boolean isReadOnly() { return readOnly; } + /** + * Whether this is a system capability exempt from grant-layer (per-user capability) + * trimming. Only framework/infrastructure tools should be marked system; a business tool + * marked system bypasses per-user capability trimming entirely (explicit declaration by + * the assembler — trusted boundary). + */ + @Override + public final boolean isSystem() { + return system; + } + /** * Whether this tool's result should short-circuit the ReAct loop and be returned directly * to the caller as the turn's final answer, instead of being fed back to the model for @@ -321,6 +339,7 @@ public static final class Builder { private boolean returnDirect = false; private boolean mcp = false; private String mcpName; + private boolean system = false; private List dangerousFiles; private List dangerousDirectories; @@ -374,6 +393,19 @@ public Builder mcp(String mcpName) { return this; } + /** + * Marks the tool as a system capability exempt from grant-layer trimming. Only mark + * framework/infrastructure tools; business tools marked system bypass per-user + * capability trimming entirely. + * + * @param system true to exempt this tool from grant-layer trimming + * @return This builder for method chaining + */ + public Builder system(boolean system) { + this.system = system; + return this; + } + public Builder dangerousFiles(List dangerousFiles) { this.dangerousFiles = dangerousFiles; return this; diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java index 4745ad8ef6..9e29ce518e 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolExecutor.java @@ -16,6 +16,7 @@ package io.agentscope.core.tool; import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; @@ -185,6 +186,41 @@ Mono execute( () -> executeCore(param, requestConfig, internalChunkCallback)); } + /** + * Grant gate (E1): backend tools must pass the call's capability surface. External tools + * (request-config overrides) are caller-controlled and skip the gate. A missing surface + * means this execution never entered an agent {@code call()} (whose funnel always writes + * one): treat it as unrestricted — direct execution is trusted assembler code (threat + * model: end user vs. capability surface). + * + * @return the denial result, or {@code null} when the call proceeds + */ + private Mono requireToolGranted( + ToolCallParam param, AgentTool tool, boolean fromRequestConfig) { + if (fromRequestConfig) { + return null; + } + RuntimeContext gateRc = param.getRuntimeContext(); + CapabilitySurface surface = gateRc != null ? gateRc.get(CapabilitySurface.class) : null; + if (surface == null) { + surface = CapabilitySurface.ALLOW_ALL; + } + String toolName = param.getToolUseBlock().getName(); + if (surface.allowsTool(tool, groupManager.getGroupsOfTool(toolName))) { + return null; + } + logger.warn( + "Denied ungranted tool call (grant): tool='{}', userId='{}', sessionId='{}'," + + " agentId='{}'", + toolName, + gateRc != null ? gateRc.getUserId() : null, + gateRc != null ? gateRc.getSessionId() : null, + param.getAgent() != null ? param.getAgent().getAgentId() : "unknown"); + // Byte-identical to the resolveTool not-found wording: do not reveal existence (the + // model-facing channel must not distinguish "exists but ungranted" from "missing"). + return Mono.just(ToolResultBlock.error("Tool not found: " + toolName)); + } + /** * Core tool execution logic. * @@ -212,11 +248,17 @@ private Mono executeCore( return Mono.just(ToolResultBlock.error("Tool not found: " + toolCall.getName())); } + boolean fromRequestConfig = requestConfig.overrides(toolCall.getName()); + Mono denial = requireToolGranted(param, tool, fromRequestConfig); + if (denial != null) { + return denial; + } + // Check tool activation. This gate only applies to backend tools: an external tool injected // via the request config is ungrouped and always callable, so a same-named-but-inactive // backend tool must not reject it (the model was shown the external override and the call - // must be honoured). - boolean fromRequestConfig = requestConfig.overrides(toolCall.getName()); + // must be honoured). The activation gate is orthogonal to the grant gate above: both must + // pass for a backend tool. RegisteredToolFunction registered = toolRegistry.getRegisteredTool(toolCall.getName()); if (!fromRequestConfig && registered != null diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java index 907a7cee5c..55352fd474 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java @@ -47,6 +47,9 @@ public class ToolGroup { private final ToolGroupScope scope; private final Set tools; // Tool names in this group + /** Whether this is a system capability exempt from grant-layer trimming. */ + private final boolean system; + /** * Protected constructor for subclass use. * @@ -62,15 +65,45 @@ protected ToolGroup( boolean active, ToolGroupScope scope, Set tools) { + this(name, description, active, scope, tools, false); + } + + /** + * Protected constructor for subclass use, with the system-capability flag. + * + *

{@code system} is independent of {@code scope} (the activation-ownership axis): a + * system group bypasses grant-layer trimming while still obeying activation semantics. + * + * @param name The group name (required) + * @param description The group description + * @param active Initial activation state + * @param scope The group scope + * @param tools Initial set of tool names + * @param system Whether this is a system capability exempt from grant-layer trimming + */ + protected ToolGroup( + String name, + String description, + boolean active, + ToolGroupScope scope, + Set tools, + boolean system) { this.name = Objects.requireNonNull(name, "name cannot be null"); this.description = description != null ? description : ""; this.active = active; this.scope = scope; this.tools = new HashSet<>(tools); + this.system = system; } private ToolGroup(Builder builder) { - this(builder.name, builder.description, builder.active, builder.scope, builder.tools); + this( + builder.name, + builder.description, + builder.active, + builder.scope, + builder.tools, + builder.system); } /** @@ -118,6 +151,16 @@ public ToolGroupScope getScope() { return scope; } + /** + * Whether this group is a system capability exempt from grant-layer (per-user capability) + * trimming. Independent of {@link #getScope()} (the activation-ownership axis). + * + * @return {@code true} if this group bypasses grant-layer trimming; {@code false} by default + */ + public boolean isSystem() { + return system; + } + /** * Gets a defensive copy of the tools in this group. * @@ -167,6 +210,7 @@ public ToolGroup copy() { .active(active) .scope(scope) .tools(tools) + .system(system) .build(); } @@ -187,6 +231,7 @@ public static class Builder { private boolean active = true; private ToolGroupScope scope = ToolGroupScope.META; private Set tools = new HashSet<>(); + private boolean system = false; /** * Sets the name of the tool group. @@ -244,6 +289,18 @@ public Builder tools(Set tools) { return this; } + /** + * Marks the group as a system capability exempt from grant-layer (per-user capability) + * trimming. Independent of {@link #scope(ToolGroupScope)}. + * + * @param system true to exempt this group from grant-layer trimming + * @return This builder for method chaining + */ + public Builder system(boolean system) { + this.system = system; + return this; + } + /** * Builds a new ToolGroup with the configured settings. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroupManager.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroupManager.java index bef549a10a..a25d424fe0 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroupManager.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroupManager.java @@ -253,33 +253,50 @@ public String getActivatedNotes() { * @return Formatted string describing META-scoped tool groups */ public String getNotes() { + return getNotes(toolGroups.keySet()); + } + + /** + * Notes restricted to the given group names (order follows the supplied collection), + * formatted the same way as the no-arg form — one {@code - name: description} entry per + * META-scoped group, grouped into active/inactive sections. Used by the grant layer to + * rebuild the {@code reset_equipped_tools} description from only the activatable groups, + * so names and descriptions of cut-off groups are not leaked to the model. The no-arg + * form delegates here with the full key set. + * + * @param groupNames the groups to include (unknown names are skipped) + * @return formatted notes restricted to the supplied groups + */ + public String getNotes(Collection groupNames) { StringBuilder activatedNotes = new StringBuilder("Activated tool groups:\n"); StringBuilder inactiveNotes = new StringBuilder("Inactive tool groups:\n"); boolean hasActivatedGroup = false; boolean hasInactiveGroup = false; - for (ToolGroup group : toolGroups.values()) { - if (group.getScope() != ToolGroupScope.META) { - continue; - } - if (group.isActive()) { - hasActivatedGroup = true; - activatedNotes.append( - String.format("- %s: %s\n", group.getName(), group.getDescription())); - } else { - hasInactiveGroup = true; - inactiveNotes.append( - String.format("- %s: %s\n", group.getName(), group.getDescription())); + if (groupNames != null) { + for (String groupName : groupNames) { + ToolGroup group = toolGroups.get(groupName); + // Unknown names and non-META scopes are invisible to the meta tool (same + // scope rule as the no-arg form, which delegates with the full key set). + if (group == null || group.getScope() != ToolGroupScope.META) { + continue; + } + if (group.isActive()) { + hasActivatedGroup = true; + activatedNotes.append( + String.format("- %s: %s\n", group.getName(), group.getDescription())); + } else { + hasInactiveGroup = true; + inactiveNotes.append( + String.format("- %s: %s\n", group.getName(), group.getDescription())); + } } } - if (!hasActivatedGroup) { activatedNotes.append("No tool groups are currently activated.\n"); } - if (!hasInactiveGroup) { inactiveNotes.append("No tool groups are currently inactive.\n"); } - activatedNotes.append(inactiveNotes); return activatedNotes.toString(); } @@ -291,7 +308,9 @@ public String getNotes() { * @throws IllegalArgumentException if group doesn't exist */ public void validateGroupExists(String groupName) { - if (!toolGroups.containsKey(groupName)) { + // Null guard: a null entry must be rejected with the disguised not-found wording + // (model input may contain JSON nulls), not with a ConcurrentHashMap NPE. + if (groupName == null || !toolGroups.containsKey(groupName)) { throw new IllegalArgumentException( String.format("Tool group '%s' does not exist", groupName)); } @@ -552,6 +571,22 @@ public ToolGroup getToolGroup(String groupName) { return toolGroups.get(groupName); } + /** + * Read-only snapshot of the groups containing {@code toolName}; empty when ungrouped. + * Group-membership lookup for grant decisions ({@code CapabilitySurface.allowsTool}). + * + * @param toolName the tool name to look up + * @return the set of group names containing the tool; never {@code null}, empty when + * ungrouped or unknown + */ + public Set getGroupsOfTool(String toolName) { + if (toolName == null) { + return Set.of(); + } + Set groups = tools.get(toolName); + return groups != null ? Set.copyOf(groups) : Set.of(); + } + /** * Copy all tool groups (and their activation state) from this manager to another manager. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRequestConfig.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRequestConfig.java index fe139a3fc6..b2c2675f6f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRequestConfig.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolRequestConfig.java @@ -100,8 +100,8 @@ AgentTool resolveTool(String name, ToolRegistry registry) { /** * Whether this config hides the backend registry entirely (request-scoped {@link * ToolMergeMode#EXTERNAL_ONLY}). Shared by {@link #resolveTool(String, ToolRegistry)} - * and {@link Toolkit#getToolSchemas(java.util.Collection, ToolRequestConfig)} so the policy - * cannot drift. + * and {@link Toolkit#getToolSchemas(java.util.Collection, ToolRequestConfig, + * io.agentscope.core.agent.CapabilitySurface)} so the policy cannot drift. */ boolean hidesBackend() { return mergeMode == ToolMergeMode.EXTERNAL_ONLY; @@ -110,8 +110,8 @@ boolean hidesBackend() { /** * Whether {@code name} is shadowed by an external tool, which always overrides the backend * regardless of merge mode. Shared by {@link #resolveTool(String, ToolRegistry)}, the - * executor's availability gate, and {@link - * Toolkit#getToolSchemas(java.util.Collection, ToolRequestConfig)}. + * executor's availability gate, and {@link Toolkit#getToolSchemas(java.util.Collection, + * ToolRequestConfig, io.agentscope.core.agent.CapabilitySurface)}. */ boolean overrides(String name) { return externalTools.containsKey(name); diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java index c96bb653c9..f5e4d7707f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java @@ -16,6 +16,7 @@ package io.agentscope.core.tool; import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; @@ -34,6 +35,7 @@ import java.util.Map; import java.util.Set; import java.util.function.BiConsumer; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; @@ -241,6 +243,13 @@ private void registerAgentTool( groupManager.addToolToGroup(groupName, toolName); } + // system = exempt from trimming; INFO audit trail, framework and business tools alike. + if (tool.isSystem()) { + logger.info( + "Tool '{}' registered with system=true — exempt from grant-layer trimming", + toolName); + } + logger.info( "Registered tool '{}' in group '{}'", toolName, @@ -366,7 +375,7 @@ public boolean isExternalTool(String toolName, ToolRequestConfig requestConfig) * Updated to respect active tool groups. * *

Legacy single-session API: resolves against the shared activation flags. Per-call - * paths use {@link #getToolSchemas(java.util.Collection, ToolRequestConfig)}. + * paths use {@link #getToolSchemas(java.util.Collection, ToolRequestConfig, CapabilitySurface)}. * * @return List of ToolSchema objects */ @@ -391,48 +400,202 @@ public List getToolSchemas(Collection activeGroups) { } /** - * Stateless compose: the backend registry filtered by {@code activeGroups}, then a per-call - * {@link ToolRequestConfig} applied without mutating the shared registry or group manager. + * Un-trimmed compose: the {@link #getToolSchemas(Collection, ToolRequestConfig, + * CapabilitySurface) 3-arg overload} with no surface. Package-private building block — + * not for model-visible schema synthesis. + * + * @param activeGroups the group names treated as active for this resolution + * @param requestConfig per-call request config (may be {@code null} → {@link + * ToolRequestConfig#NONE}) + * @return the composed tool schemas + */ + List getToolSchemas( + Collection activeGroups, ToolRequestConfig requestConfig) { + return getToolSchemas(activeGroups, requestConfig, null); + } + + /** + * Grant-filtered compose: the backend registry filtered by {@code activeGroups}, then a + * per-call {@link ToolRequestConfig} applied without mutating the shared registry or group + * manager, then — when {@code surface} is present — grant-layer trimming. * *

    *
  1. In {@link ToolMergeMode#EXTERNAL_ONLY} the backend is hidden * entirely without deleting any registrations. - *
  2. Backend schemas whose names collide with an external tool are dropped (external tools - * override the backend). - *
  3. External tool schemas are appended. + *
  4. Backend schemas whose names collide with an external tool are dropped (external + * tools override the backend); external tools are caller-controlled and never + * grant-trimmed. + *
  5. With a surface, backend tools failing {@code surface.allowsTool} are dropped + * (treated as non-existent) and the meta tool's {@code to_activate} enum and + * description only present activatable groups. *
* * @param activeGroups the group names treated as active for this resolution * @param requestConfig per-call request config (may be {@code null} → {@link * ToolRequestConfig#NONE}) - * @return the composed tool schemas + * @param surface the per-request capability grant set; {@code null} = no trimming (direct, + * trusted paths outside an agent call — the same stance as the execution gates) + * @return the composed tool schemas, grant-trimmed when a surface is given */ public List getToolSchemas( - Collection activeGroups, ToolRequestConfig requestConfig) { - if (requestConfig == null) { - requestConfig = ToolRequestConfig.NONE; - } - boolean hideBackend = requestConfig.hidesBackend(); + Collection activeGroups, + ToolRequestConfig requestConfig, + CapabilitySurface surface) { + ToolRequestConfig config = requestConfig != null ? requestConfig : ToolRequestConfig.NONE; + // an external tool shadows its same-named backend row (its schema is appended below) + Stream backend = + config.hidesBackend() + ? Stream.empty() + : schemaProvider.getToolSchemas(activeGroups).stream() + .filter(s -> !config.overrides(s.getName())) + .filter( + s -> + surface == null + || isToolGranted(s.getName(), surface)); + List schemas = + Stream.concat( + backend, + config.externalTools().values().stream() + .map(Toolkit::externalToolSchema)) + .toList(); + return surface != null ? rewriteMetaToolSchema(schemas, surface) : schemas; + } + + /** + * @param toolName the backend tool name + * @param surface the per-request grant set (non-null) + * @return {@code true} when the tool passes the grant layer; a missing registry entry + * counts as denied (trimmed = non-existent) + */ + private boolean isToolGranted(String toolName, CapabilitySurface surface) { + AgentTool tool = toolRegistry.getTool(toolName); + return tool != null && surface.allowsTool(tool, groupManager.getGroupsOfTool(toolName)); + } + + /** Maps an external (schema-only) tool to its composed schema; never grant-trimmed. */ + private static ToolSchema externalToolSchema(SchemaOnlyTool tool) { + return ToolSchema.builder() + .name(tool.getName()) + .description(tool.getDescription()) + .parameters(tool.getParameters()) + .strict(tool.getStrict()) + .outputSchema(tool.getOutputSchema()) + .build(); + } + + /** + * Post-processing for the reset_equipped_tools schema: both the to_activate enum and + * the description keep only activatable groups (the original description embeds the + * full group notes, so filtering the enum alone would still leak group names). Only + * called with a non-null surface (an absent surface skips trimming entirely). + * + * @param schemas the grant-filtered schemas to post-process + * @param surface the per-request grant set (never {@code null} here) + * @return schemas with the meta tool's enum and description rewritten + */ + private List rewriteMetaToolSchema( + List schemas, CapabilitySurface surface) { + return schemas.stream() + .map( + s -> + "reset_equipped_tools".equals(s.getName()) + ? rewriteResetToolSchema(s, surface) + : s) + .toList(); + } + + /** Rebuilds one reset_equipped_tools schema from only the activatable groups. */ + private ToolSchema rewriteResetToolSchema(ToolSchema schema, CapabilitySurface surface) { + List activatable = + groupManager.getMetaGroupNames().stream() + .filter(g -> isGroupActivatable(g, surface)) + .sorted() + .toList(); + // Omit the enum key when nothing is activatable (matches the empty-group behaviour). + return ToolSchema.builder() + .name(schema.getName()) + .description(MetaToolFactory.baseDescription() + groupManager.getNotes(activatable)) + .parameters(withFilteredToActivateEnum(schema, activatable)) + .strict(schema.getStrict()) + .outputSchema(schema.getOutputSchema()) + .build(); + } + + /** + * Whether a group may be activated via reset_equipped_tools. + * + * @param groupName the group name to check + * @param surface the per-request grant set + * @return {@code true} for system groups or groups granted by the surface + */ + private boolean isGroupActivatable(String groupName, CapabilitySurface surface) { + ToolGroup group = groupManager.getToolGroup(groupName); + return group != null + && (group.isSystem() + || surface.allowsToolGroupActivation( + groupName, groupManager::getGroupsOfTool)); + } - List schemas = new ArrayList<>(); - if (!hideBackend) { - for (ToolSchema schema : schemaProvider.getToolSchemas(activeGroups)) { - if (!requestConfig.overrides(schema.getName())) { - schemas.add(schema); + /** + * Deep-copies the parameter structure and replaces the {@code to_activate} enum with + * the filtered list; removes the enum key when empty. Never mutates the original + * schema's shared structures. + * + * @param schema the source schema + * @param activatable the activatable group names for the enum + * @return the copied parameters with the replaced enum + */ + private static Map withFilteredToActivateEnum( + ToolSchema schema, List activatable) { + Map parameters = deepCopyParameters(schema.getParameters()); + Object propertiesObj = parameters.get("properties"); + if (propertiesObj instanceof Map properties) { + Object toActivateObj = properties.get("to_activate"); + if (toActivateObj instanceof Map toActivate) { + Object itemsObj = toActivate.get("items"); + if (itemsObj instanceof Map items) { + @SuppressWarnings("unchecked") + Map itemsMutable = (Map) items; + if (activatable.isEmpty()) { + itemsMutable.remove("enum"); + } else { + itemsMutable.put("enum", activatable); + } } } } - for (SchemaOnlyTool tool : requestConfig.externalTools().values()) { - schemas.add( - ToolSchema.builder() - .name(tool.getName()) - .description(tool.getDescription()) - .parameters(tool.getParameters()) - .strict(tool.getStrict()) - .outputSchema(tool.getOutputSchema()) - .build()); + return parameters; + } + + /** Deep copy of a JSON-schema-like map (nested maps and lists). */ + private static Map deepCopyParameters(Map source) { + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + copy.put(entry.getKey(), deepCopySchemaValue(entry.getValue())); + } + return copy; + } + + /** + * @param value a schema value (map, list, or scalar) + * @return a deep copy for maps and lists, the value itself for scalars + */ + private static Object deepCopySchemaValue(Object value) { + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + copy.put(String.valueOf(entry.getKey()), deepCopySchemaValue(entry.getValue())); + } + return copy; + } + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + for (Object item : list) { + copy.add(deepCopySchemaValue(item)); + } + return copy; } - return schemas; + return value; } /** @@ -535,20 +698,19 @@ public void setChunkCallback(BiConsumer callback) /** * Execute a tool with the given parameters. * + *

Direct execution entry: unlike an agent {@code call()} (which resolves the + * capability grant surface at its entry), this path bypasses grant resolution — a + * missing {@link CapabilitySurface} on the {@link RuntimeContext} means unrestricted. + * Place a surface ({@code rc.put(CapabilitySurface.class, surface)}) to have grants + * applied. + * *

Example usage: * *

{@code
-     * // Simple call
      * ToolCallParam param = ToolCallParam.builder()
      *     .toolUseBlock(toolCall)
-     *     .build();
-     * toolkit.callTool(param);
-     *
-     * // With agent and context
-     * ToolCallParam param = ToolCallParam.builder()
-     *     .toolUseBlock(toolCall)
-     *     .agent(agent)
-     *     .context(context)
+     *     .agent(agent)          // optional
+     *     .runtimeContext(rc)    // optional; without a surface on it: unrestricted
      *     .build();
      * toolkit.callTool(param);
      * }
@@ -571,10 +733,14 @@ public Mono callTool(ToolCallParam param) { * applies execution config (timeout, retry) from multiple levels. The agent context is * merged with toolkit default context during tool execution. * + *

Direct execution bypasses grant resolution: a missing {@link CapabilitySurface} on + * the runtime context means unrestricted (backend tools are not trimmed). + * * @param toolCalls List of tool calls to execute * @param agentExecutionConfig Execution config from agent level (can be null) * @param agent The agent making the calls (may be null) - * @param agentRuntimeContext The agent-level runtime context (may be null) + * @param agentRuntimeContext The agent-level runtime context (may be {@code null}; a + * missing surface is treated as unrestricted) * @return Mono containing list of tool responses */ public Mono> callTools( @@ -886,6 +1052,17 @@ public ToolGroup getToolGroup(String groupName) { return groupManager.getToolGroup(groupName); } + /** + * Group-membership lookup (delegates to {@link ToolGroupManager#getGroupsOfTool}) + * for grant decisions ({@code CapabilitySurface.allowsTool}). + * + * @param toolName the tool name to look up + * @return the set of group names containing the tool; empty when ungrouped or unknown + */ + public Set getToolGroupsOf(String toolName) { + return groupManager.getGroupsOfTool(toolName); + } + // ==================== Meta Tool Registration ==================== /** @@ -937,7 +1114,8 @@ public void updateToolPresetParameters( * *

Per-call tool-surface variation is instead expressed via {@link ToolRequestConfig} * composition ({@link #getTool(String, ToolRequestConfig)} / {@link - * #getToolSchemas(java.util.Collection, ToolRequestConfig)}), never by copying a toolkit. This + * #getToolSchemas(java.util.Collection, ToolRequestConfig, CapabilitySurface)}), never by + * copying a toolkit. This * method exists for agent-construction isolation (e.g. the harness sub-agent factories), where * a fresh registry is needed to register workspace-bound tools without polluting the source. * diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java new file mode 100644 index 0000000000..5d4fbf9c0c --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java @@ -0,0 +1,1026 @@ +/* + * 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.agent; + +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.test.MockModel; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.message.UserMessage; +import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.skill.DynamicSkillMiddleware; +import io.agentscope.core.skill.SkillBox; +import io.agentscope.core.skill.repository.AgentSkillRepository; +import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo; +import io.agentscope.core.state.AgentState; +import io.agentscope.core.state.ToolContextState; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.SchemaOnlyTool; +import io.agentscope.core.tool.ToolCallParam; +import io.agentscope.core.tool.ToolMergeMode; +import io.agentscope.core.tool.ToolRequestConfig; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.tool.subagent.SubAgentConfig; +import io.agentscope.core.tool.subagent.SubAgentProvider; +import io.agentscope.core.tool.subagent.SubAgentTool; +import io.agentscope.core.util.JsonUtils; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** + * End-to-end grant-layer tests: entry resolution, schema/skill filtering, gates. + * + * @author shanhongyu + */ +@Tag("integration") +@DisplayName("Capability grant layer integration") +class CapabilityGrantIntegrationTest { + + // ==================== helpers ==================== + + /** Minimal non-system tool returning a fixed text. */ + private static AgentTool textTool(String name) { + return new AgentTool() { + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + return "tool " + name; + } + + @Override + public Map getParameters() { + return Map.of("type", "object"); + } + + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.just(ToolResultBlock.text("ran:" + name)); + } + }; + } + + /** Toolkit with two groups (g1, g2) + one ungrouped tool + the meta tool registered. */ + private Toolkit grantToolkit() { + Toolkit toolkit = new Toolkit(); + toolkit.registerAgentTool(textTool("t1")); + toolkit.registerAgentTool(textTool("t2")); + toolkit.registerAgentTool(textTool("t3")); + toolkit.createToolGroup("g1", "group-one", true); + toolkit.createToolGroup("g2", "group-two", false); + toolkit.addToolToGroup("g1", "t1"); + toolkit.addToolToGroup("g2", "t2"); + toolkit.registerMetaTool(); + return toolkit; + } + + /** A minimal per-call rc (user u1, session s1) without a surface. */ + private static RuntimeContext rc() { + return RuntimeContext.builder().userId("u1").sessionId("s1").build(); + } + + /** The same rc as {@link #rc()} with the given surface seeded. */ + private static RuntimeContext rc(CapabilitySurface surface) { + RuntimeContext ctx = rc(); + ctx.put(CapabilitySurface.class, surface); + return ctx; + } + + /** Builds a ToolCallParam with content JSON (schema validation requires the serialized form). */ + private static ToolCallParam param( + String toolName, Map input, RuntimeContext rc) { + ToolUseBlock toolUse = + ToolUseBlock.builder() + .id("id-" + toolName) + .name(toolName) + .input(input) + .content(JsonUtils.getJsonCodec().toJson(input)) + .build(); + return ToolCallParam.builder() + .toolUseBlock(toolUse) + .input(input) + .runtimeContext(rc) + .build(); + } + + /** Executes via the public Toolkit entry (passes through ToolExecutor's physical gate). */ + private ToolResultBlock call(Toolkit toolkit, String toolName, RuntimeContext rc) { + return toolkit.callTool(param(toolName, Map.of(), rc)).block(); + } + + /** Executes a tool's own callAsync directly (bypasses ToolExecutor's gates). */ + private ToolResultBlock callDirect( + AgentTool tool, Map input, RuntimeContext rc) { + return tool.callAsync(param(tool.getName(), input, rc)).block(); + } + + /** Extracts the first text block. + * + * @return the first text, or an empty string + */ + private static String firstText(ToolResultBlock result) { + List outputs = result.getOutput(); + if (outputs == null || outputs.isEmpty()) { + return ""; + } + return ((TextBlock) outputs.get(0)).getText(); + } + + /** Lists the schema names in order. + * + * @param schemas the schemas to read + * @return the names in order + */ + private static List schemaNames(List schemas) { + return schemas.stream().map(ToolSchema::getName).toList(); + } + + /** Middleware recording the CapabilitySurface visible on the call's rc. */ + private static final class RecordingMiddleware implements MiddlewareBase { + /** Surface visible on the call's rc. */ + final AtomicReference seen = new AtomicReference<>(); + + /** The rc instance seen by the middleware. */ + final AtomicReference seenRc = new AtomicReference<>(); + + @Override + public Mono onSystemPrompt(Agent agent, RuntimeContext ctx, String currentPrompt) { + seen.set(ctx != null ? ctx.get(CapabilitySurface.class) : null); + seenRc.set(ctx); + return Mono.just(currentPrompt); + } + } + + /** Minimal in-memory skill repository stub. */ + private static final class StubRepo implements AgentSkillRepository { + private final List skills; + + StubRepo(List skills) { + this.skills = skills; + } + + @Override + public AgentSkill getSkill(String name) { + return null; + } + + @Override + public List getAllSkillNames() { + return List.of(); + } + + @Override + public List getAllSkills() { + return skills; + } + + @Override + public boolean save(List skills, boolean force) { + return false; + } + + @Override + public boolean delete(String skillName) { + return false; + } + + @Override + public boolean skillExists(String skillName) { + return false; + } + + @Override + public AgentSkillRepositoryInfo getRepositoryInfo() { + return null; + } + + @Override + public String getSource() { + return "stub"; + } + + @Override + public void setWriteable(boolean writeable) {} + + @Override + public boolean isWriteable() { + return false; + } + } + + /** A skill named {@code name} with skillId {@code name_custom}. */ + private static AgentSkill skill(String name) { + return new AgentSkill(name, "Skill " + name, "# " + name, null); + } + + // ==================== entry resolution ==================== + + @Test + @DisplayName("no resolver, or a resolver returning null → ALLOW_ALL carried on rc") + void entrySeedsAllowAllWithoutResolverOrNullReturn() { + ReActAgent plain = ReActAgent.builder().name("a").model(new MockModel("done")).build(); + RuntimeContext ctx1 = rc(); + plain.call(userMsg("hi"), ctx1).block(Duration.ofSeconds(10)); + assertSame(CapabilitySurface.ALLOW_ALL, ctx1.get(CapabilitySurface.class)); + + ReActAgent nullResolver = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .capabilityResolver((ctx, a) -> null) + .build(); + RuntimeContext ctx2 = rc(); + nullResolver.call(userMsg("hi"), ctx2).block(Duration.ofSeconds(10)); + assertSame(CapabilitySurface.ALLOW_ALL, ctx2.get(CapabilitySurface.class)); + } + + @Test + @DisplayName("resolver throwing → the exception aborts the call (no surface written)") + void entryThrowingResolverAbortsTheCall() { + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .capabilityResolver( + (ctx, a) -> { + throw new IllegalStateException("authz backend down"); + }) + .build(); + RuntimeContext ctx = rc(); + IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> agent.call(userMsg("hi"), ctx).block(Duration.ofSeconds(10))); + assertEquals("authz backend down", error.getMessage()); + assertNull( + ctx.get(CapabilitySurface.class), + "a failed resolve must not leave a surface on the rc"); + } + + @Test + @DisplayName("resolver result instance is carried as-is (same reference)") + void entryCarriesResolvedInstance() { + CapabilitySurface granted = new CapabilitySurface(null, Set.of("g1"), Set.of()); + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .capabilityResolver((ctx, a) -> granted) + .build(); + RuntimeContext ctx = rc(); + agent.call(userMsg("hi"), ctx).block(Duration.ofSeconds(10)); + assertSame(granted, ctx.get(CapabilitySurface.class)); + } + + @Test + @DisplayName("skillBox + capabilityResolver → resolver disabled, behavior equals unconfigured") + void staticSkillBoxGuardDisablesResolver() { + Toolkit toolkit = new Toolkit(); + SkillBox skillBox = new SkillBox(toolkit); + skillBox.registerSkill(skill("boxed")); + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .toolkit(toolkit) + .skillBox(skillBox) + .capabilityResolver( + (ctx, a) -> new CapabilitySurface(null, Set.of("g1"), Set.of())) + .build(); + RuntimeContext ctx = rc(); + agent.call(userMsg("hi"), ctx).block(Duration.ofSeconds(10)); + // Guard fired: resolver disabled, ALLOW_ALL fallback (prompt catalog stays full). + assertSame(CapabilitySurface.ALLOW_ALL, ctx.get(CapabilitySurface.class)); + } + + @Test + @DisplayName("two concurrent calls with different users never cross surfaces") + void concurrentCallsWithDistinctUsersDoNotCrossSurfaces() throws Exception { + ConcurrentHashMap seenByUser = new ConcurrentHashMap<>(); + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .capabilityResolver( + (ctx, a) -> + "u1".equals(ctx.getUserId()) + ? new CapabilitySurface( + null, Set.of("g1"), Set.of()) + : new CapabilitySurface( + null, Set.of("g2"), Set.of())) + .middleware( + new MiddlewareBase() { + @Override + public Mono onSystemPrompt( + Agent a, RuntimeContext ctx, String currentPrompt) { + if (ctx != null && ctx.getUserId() != null) { + seenByUser.put( + ctx.getUserId(), + ctx.get(CapabilitySurface.class)); + } + return Mono.just(currentPrompt); + } + }) + .build(); + + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + for (String user : List.of("u1", "u2")) { + RuntimeContext ctx = + RuntimeContext.builder().userId(user).sessionId("s-" + user).build(); + futures.add( + pool.submit( + () -> { + start.await(); + agent.call(userMsg("hi"), ctx).block(Duration.ofSeconds(20)); + return ctx; + })); + } + start.countDown(); + List contexts = new ArrayList<>(); + for (Future f : futures) { + contexts.add((RuntimeContext) f.get(30, TimeUnit.SECONDS)); + } + + // Each rc ends with its own surface; the middleware saw the matching surface mid-call. + assertTrue(seenByUser.get("u1").allowsToolGroup("g1")); + assertFalse(seenByUser.get("u1").allowsToolGroup("g2")); + assertTrue(seenByUser.get("u2").allowsToolGroup("g2")); + assertFalse(seenByUser.get("u2").allowsToolGroup("g1")); + assertTrue(contexts.get(0).get(CapabilitySurface.class).allowsToolGroup("g1")); + assertTrue(contexts.get(1).get(CapabilitySurface.class).allowsToolGroup("g2")); + } finally { + pool.shutdownNow(); + } + } + + // ==================== sub-agent surface reuse ==================== + + @Test + @DisplayName("sub-agent without own resolver reuses the parent surface (reference equality)") + void subAgentWithoutResolverReusesParentSurface() { + CapabilitySurface parentSurface = new CapabilitySurface(null, Set.of("g1"), Set.of()); + RecordingMiddleware recorder = new RecordingMiddleware(); + SubAgentTool subAgentTool = + new SubAgentTool( + (SubAgentProvider) + () -> + ReActAgent.builder() + .name("child") + .model(new MockModel("child done")) + .middleware(recorder) + .build(), + SubAgentConfig.defaults()); + + RuntimeContext parentRc = rc(parentSurface); + callDirect(subAgentTool, Map.of("message", "do it"), parentRc); + + assertSame( + parentSurface, + recorder.seen.get(), + "child without own resolver must see the parent surface instance"); + // The parent rc itself is untouched (no derived-copy needed on the keep path). + assertSame(parentSurface, parentRc.get(CapabilitySurface.class)); + } + + @Test + @DisplayName("sub-agent with own resolver derives a copy; parent rc surface stays intact") + void subAgentWithOwnResolverDerivesCopyWithoutPollution() { + CapabilitySurface parentSurface = new CapabilitySurface(null, Set.of("g1"), Set.of()); + CapabilitySurface childSurface = new CapabilitySurface(null, Set.of("g2"), Set.of()); + RecordingMiddleware recorder = new RecordingMiddleware(); + SubAgentTool subAgentTool = + new SubAgentTool( + (SubAgentProvider) + () -> + ReActAgent.builder() + .name("child") + .model(new MockModel("child done")) + .middleware(recorder) + .capabilityResolver((ctx, a) -> childSurface) + .build(), + SubAgentConfig.defaults()); + + RuntimeContext parentRc = rc(parentSurface); + callDirect(subAgentTool, Map.of("message", "do it"), parentRc); + + assertSame(childSurface, recorder.seen.get(), "child resolves its own surface"); + assertNotSameInstance(recorder.seenRc.get(), parentRc, "child scope.rc is a derived copy"); + assertSame( + parentSurface, + parentRc.get(CapabilitySurface.class), + "parent rc's surface instance must remain the parent's own (zero pollution)"); + } + + private static void assertNotSameInstance(Object a, Object b, String message) { + assertTrue(a != b, message); + } + + // ==================== schema filtering ==================== + + @Test + @DisplayName("DENY_ALL surface → only system tools remain in schemas") + void denyAllLeavesOnlySystemTools() { + Toolkit toolkit = grantToolkit(); + List schemas = + toolkit.getToolSchemas(List.of("g1", "g2"), null, CapabilitySurface.DENY_ALL); + assertEquals(List.of("reset_equipped_tools"), schemaNames(schemas)); + } + + @Test + @DisplayName("granted group + per-name tool enter schemas; un-granted do not") + void whitelistSchemaFiltering() { + Toolkit toolkit = grantToolkit(); + CapabilitySurface surface = new CapabilitySurface(null, Set.of("g1"), Set.of("t3")); + List schemas = toolkit.getToolSchemas(List.of("g1", "g2"), null, surface); + List names = schemaNames(schemas); + assertTrue(names.contains("t1"), "granted group member visible"); + assertTrue(names.contains("t3"), "per-name granted ungrouped tool visible"); + assertFalse(names.contains("t2"), "un-granted group member cut"); + assertTrue(names.contains("reset_equipped_tools"), "system meta tool survives"); + } + + @Test + @DisplayName("external tools from the request config are NOT trimmed") + void externalToolsNotTrimmed() { + Toolkit toolkit = grantToolkit(); + ToolRequestConfig config = + new ToolRequestConfig( + Map.of("ext_tool", schemaOnly("ext_tool")), + ToolMergeMode.MERGE_EXTERNAL_PRIORITY); + List schemas = + toolkit.getToolSchemas(List.of("g1", "g2"), config, CapabilitySurface.DENY_ALL); + assertTrue(schemaNames(schemas).contains("ext_tool")); + // Backend trims to system tools only. + assertEquals( + List.of("ext_tool", "reset_equipped_tools"), + schemaNames(schemas).stream().sorted().toList()); + } + + private static SchemaOnlyTool schemaOnly(String name) { + return new SchemaOnlyTool( + ToolSchema.builder() + .name(name) + .description("external " + name) + .parameters(Map.of("type", "object")) + .build()); + } + + /** Finds the reset_equipped_tools schema. + * + * @param schemas the composed schemas + * @return the meta tool's schema + */ + private static ToolSchema metaSchema(List schemas) { + return schemas.stream() + .filter(s -> "reset_equipped_tools".equals(s.getName())) + .findFirst() + .orElseThrow(); + } + + @Test + @DisplayName( + "enum and description both keep only granted groups; name-reachable group kept in enum") + void enumAndDescriptionFiltering() { + Toolkit toolkit = grantToolkit(); + + // Group-granted surface: g1 in both channels; g2 in neither. + CapabilitySurface granted = new CapabilitySurface(null, Set.of("g1"), Set.of()); + ToolSchema meta = metaSchema(toolkit.getToolSchemas(List.of("g1", "g2"), null, granted)); + List enumValues = toActivateEnum(meta); + assertNotNull(enumValues, "enum must be present when activatable groups exist"); + assertTrue(enumValues.contains("g1")); + assertFalse(enumValues.contains("g2"), "un-granted group must not be activatable"); + assertTrue(meta.getDescription().contains("group-one"), "granted group description kept"); + assertFalse( + meta.getDescription().contains("group-two"), + "un-granted group description must not leak via description"); + assertFalse( + meta.getDescription().contains("g2:"), + "un-granted group NAME must not leak via description"); + + // Name-reachable surface: g2 enters the enum because t2 is per-name authorized. + CapabilitySurface reachable = new CapabilitySurface(null, Set.of("g1"), Set.of("t2")); + ToolSchema reachableMeta = + metaSchema(toolkit.getToolSchemas(List.of("g1", "g2"), null, reachable)); + assertTrue( + toActivateEnum(reachableMeta).contains("g2"), + "name-reachable group is activatable"); + } + + @Test + @DisplayName("DENY_ALL yields no enum key; a null surface = no trimming (direct trusted path)") + void denyAllDropsEnumKeyNullSurfaceUntrimmed() { + Toolkit toolkit = grantToolkit(); + assertNull( + toActivateEnum( + metaSchema( + toolkit.getToolSchemas( + List.of("g1", "g2"), null, CapabilitySurface.DENY_ALL))), + "DENY_ALL → no activatable groups"); + assertNotNull( + toActivateEnum(metaSchema(toolkit.getToolSchemas(List.of("g1", "g2"), null, null))), + "null surface → un-trimmed compose, same as the 2-arg path"); + } + + /** + * Reads the {@code to_activate} enum values. + * + * @param meta the reset_equipped_tools schema + * @return the enum values, or {@code null} when the enum key is absent + */ + @SuppressWarnings("unchecked") + private static List toActivateEnum(ToolSchema meta) { + Map properties = + (Map) meta.getParameters().get("properties"); + if (properties == null) { + return null; + } + Map toActivate = (Map) properties.get("to_activate"); + if (toActivate == null) { + return null; + } + Map items = (Map) toActivate.get("items"); + if (items == null) { + return null; + } + Object enumObj = items.get("enum"); + return enumObj instanceof List list ? (List) list : null; + } + + @Test + @DisplayName("legacy overloads (0/1 args) remain surface-unaware") + void legacyOverloadsUnaffected() { + Toolkit toolkit = grantToolkit(); + // Shared flags: g1 active, g2 inactive → t1 + t3 (ungrouped) + meta; t2 hidden. + assertEquals(3, toolkit.getToolSchemas().size(), "0-arg ignores surface and grant"); + assertEquals( + 3, toolkit.getToolSchemas(List.of("g1")).size(), "1-arg: g1 + ungrouped + meta"); + } + + // ==================== reset_equipped_tools gate ==================== + + @Test + @DisplayName("activating an un-granted group is rejected with the not-found wording") + void resetGateRejectsUngrantedGroup() { + Toolkit toolkit = grantToolkit(); + RuntimeContext ctx = + RuntimeContext.builder() + .userId("u1") + .sessionId("s1") + .agentState( + AgentState.builder() + .toolContext(ToolContextState.builder().build()) + .build()) + .build(); + ctx.put(CapabilitySurface.class, new CapabilitySurface(null, Set.of("g1"), Set.of())); + + Map input = Map.of("to_activate", List.of("g2")); + ToolResultBlock result = + toolkit.callTool(param("reset_equipped_tools", input, ctx)).block(); + + assertEquals( + "Error: Tool group 'g2' does not exist", + firstText(result), + "deny wording must be byte-identical to the not-found wording"); + } + + @Test + @DisplayName("surface = null (direct path) → unrestricted activation") + void resetGateTreatsMissingSurfaceAsAllowAll() { + Toolkit toolkit = grantToolkit(); + ToolContextState tcs = ToolContextState.builder().build(); + RuntimeContext ctx = + RuntimeContext.builder() + .userId("u1") + .sessionId("s1") + .agentState(AgentState.builder().toolContext(tcs).build()) + .build(); + // Direct callAsync so the executor's grant gate does not preempt the tool's own + // gate; a missing surface means the direct path is unrestricted. + AgentTool metaTool = toolkit.getTool("reset_equipped_tools"); + ToolResultBlock result = callDirect(metaTool, Map.of("to_activate", List.of("g1")), ctx); + + assertTrue(firstText(result).contains("g1"), "group activated (no surface = unrestricted)"); + assertTrue(tcs.getActivatedGroups().contains("g1"), "per-session activation written"); + } + + @Test + @DisplayName("granted group activation keeps the replacement semantics") + void resetGateAllowsGrantedGroup() { + Toolkit toolkit = grantToolkit(); + ToolContextState tcs = ToolContextState.builder().build(); + RuntimeContext ctx = + RuntimeContext.builder() + .userId("u1") + .sessionId("s1") + .agentState(AgentState.builder().toolContext(tcs).build()) + .build(); + ctx.put(CapabilitySurface.class, new CapabilitySurface(null, Set.of("g1"), Set.of())); + Map input = Map.of("to_activate", List.of("g1")); + ToolResultBlock result = + toolkit.callTool(param("reset_equipped_tools", input, ctx)).block(); + + String text = firstText(result); + assertTrue(text.contains("g1"), "granted group activated"); + assertTrue(text.contains("group-one"), "activated group notes surfaced"); + assertTrue(tcs.getActivatedGroups().contains("g1"), "per-session activation written"); + } + + @Test + @DisplayName("null entry in to_activate → disguised not-found wording, never an NPE") + void resetGateNullEntryIsDisguisedNotFound() { + Toolkit toolkit = grantToolkit(); + RuntimeContext ctx = + RuntimeContext.builder() + .userId("u1") + .sessionId("s1") + .agentState( + AgentState.builder() + .toolContext(ToolContextState.builder().build()) + .build()) + .build(); + ctx.put(CapabilitySurface.class, new CapabilitySurface(null, Set.of("g1"), Set.of())); + AgentTool metaTool = toolkit.getTool("reset_equipped_tools"); + + ToolResultBlock result = + callDirect(metaTool, Map.of("to_activate", Arrays.asList("g1", null)), ctx); + + assertEquals( + "Error: Tool group 'null' does not exist", + firstText(result), + "a null entry must hit the disguised not-found wording, not a CHM NPE" + + " (which the catch-all would turn into a null-message error block)"); + } + + // ==================== skill visibility ==================== + + @Test + @DisplayName("un-granted skills are cut from the prompt; granted and system kept") + void skillVisibilityFiltering() { + AgentSkill granted = skill("alpha"); + AgentSkill denied = skill("beta"); + AgentSkill systemSkill = + AgentSkill.builder() + .name("gamma") + .description("system skill") + .skillContent("# gamma") + .system(true) + .build(); + DynamicSkillMiddleware mw = + new DynamicSkillMiddleware( + List.of(new StubRepo(List.of(granted, denied, systemSkill))), + new Toolkit(), + null, + false, + null); + + String prompt = + mw.onSystemPrompt( + null, + rc(new CapabilitySurface(Set.of("alpha_custom"), null, null)), + "base") + .block(); + + assertNotNull(prompt); + assertTrue(prompt.contains("alpha"), "granted skill kept"); + assertTrue(prompt.contains("gamma"), "system skill kept"); + assertFalse(prompt.contains("beta"), "un-granted skill cut from the prompt"); + } + + @Test + @DisplayName("missing surface → no skill prompt at all (fail-closed)") + void skillFilterFailsClosedOnMissingSurface() { + DynamicSkillMiddleware mw = + new DynamicSkillMiddleware( + List.of(new StubRepo(List.of(skill("alpha")))), + new Toolkit(), + null, + false, + null); + RuntimeContext ctx = rc(); + String prompt = mw.onSystemPrompt(null, ctx, "base").block(); + assertEquals("base", prompt, "no surface → no skill prompt"); + assertNull(ctx.get(SkillBox.class), "no box exposed"); + } + + @Test + @DisplayName("different surfaces hit different box-cache entries (no cross-talk)") + void boxCacheIsolationAcrossSurfaces() { + DynamicSkillMiddleware mw = + new DynamicSkillMiddleware( + List.of(new StubRepo(List.of(skill("alpha"), skill("beta")))), + new Toolkit(), + null, + false, + null); + + RuntimeContext rc1 = rc(new CapabilitySurface(Set.of("alpha_custom"), null, null)); + mw.onSystemPrompt(null, rc1, "base").block(); + SkillBox box1 = rc1.get(SkillBox.class); + + RuntimeContext rc2 = rc(new CapabilitySurface(Set.of("beta_custom"), null, null)); + mw.onSystemPrompt(null, rc2, "base").block(); + SkillBox box2 = rc2.get(SkillBox.class); + + assertNotNull(box1); + assertNotNull(box2); + assertTrue(box1 != box2, "different grants must materialize different boxes"); + assertTrue(box1.getAllSkillIds().contains("alpha_custom")); + assertFalse(box1.getAllSkillIds().contains("beta_custom")); + assertTrue(box2.getAllSkillIds().contains("beta_custom")); + assertFalse(box2.getAllSkillIds().contains("alpha_custom")); + } + + // ==================== tool execution gate ==================== + + @Test + @DisplayName("un-granted backend tool call → disguised not-found wording (no existence oracle)") + void executorDeniesUngrantedTool() { + Toolkit toolkit = grantToolkit(); + ToolResultBlock result = + call(toolkit, "t2", rc(new CapabilitySurface(null, Set.of("g1"), Set.of()))); + assertEquals("Error: Tool not found: t2", firstText(result)); + } + + @Test + @DisplayName("DENY_ALL surface still allows system tools") + void executorAllowsSystemToolsUnderDenyAll() { + Toolkit toolkit = grantToolkit(); + // reset_equipped_tools (system): must pass the grant gate (then fail later on missing + // AgentState — distinct wording proves the grant gate was passed). + ToolResultBlock result = + call(toolkit, "reset_equipped_tools", rc(CapabilitySurface.DENY_ALL)); + assertNotUnauthorized(result); + } + + @Test + @DisplayName("missing surface (direct path) → treated as unrestricted") + void executorTreatsMissingSurfaceAsAllowAll() { + Toolkit toolkit = grantToolkit(); + ToolResultBlock result = call(toolkit, "t1", rc()); + assertNotUnauthorized(result); + } + + @Test + @DisplayName("external tools bypass the grant gate (suspended short-circuit)") + void externalToolsBypassGrantGate() { + Toolkit toolkit = new Toolkit(); + ToolRequestConfig config = + new ToolRequestConfig( + Map.of("ext_tool", schemaOnly("ext_tool")), + ToolMergeMode.MERGE_EXTERNAL_PRIORITY); + RuntimeContext ctx = rc(CapabilitySurface.DENY_ALL); + ctx.setToolRequestConfig(config); + ToolResultBlock result = toolkit.callTool(param("ext_tool", Map.of(), ctx)).block(); + + assertEquals("[Awaiting external execution]", firstText(result)); + } + + private static void assertNotUnauthorized(ToolResultBlock result) { + String text = firstText(result); + assertFalse( + text.startsWith("Unauthorized tool call"), + "system tool must pass the grant gate; got: " + text); + assertFalse( + text.startsWith("Error: Tool not found"), + "the grant gate must not deny this call (disguised wording); got: " + text); + } + + // ==================== skill load gate ==================== + + @Test + @DisplayName("Dynamic path: un-granted skill load → not-found wording; granted loads") + void dynamicLoadPathGate() { + Toolkit toolkit = new Toolkit(); + DynamicSkillMiddleware mw = + new DynamicSkillMiddleware( + List.of(new StubRepo(List.of(skill("alpha"), skill("beta")))), + toolkit, + null, + false, + null); + RuntimeContext ctx = rc(new CapabilitySurface(Set.of("alpha_custom"), null, null)); + mw.onSystemPrompt(null, ctx, "base").block(); + assertNotNull(ctx.get(SkillBox.class)); + + ToolResultBlock denied = loadViaToolkit(toolkit, "beta_custom", ctx); + assertEquals( + "Error: Skill not found: 'beta_custom'. Please check the skill ID.", + firstText(denied), + "deny wording must be byte-identical to the not-found wording"); + + ToolResultBlock allowed = loadViaToolkit(toolkit, "alpha_custom", ctx); + assertTrue(firstText(allowed).contains("Successfully loaded skill: alpha_custom")); + } + + @Test + @DisplayName("Dynamic path: system skill loads even under a restricted surface") + void dynamicLoadPathSystemSkill() { + Toolkit toolkit = new Toolkit(); + AgentSkill systemSkill = + AgentSkill.builder() + .name("gamma") + .description("system skill") + .skillContent("# gamma") + .system(true) + .build(); + DynamicSkillMiddleware mw = + new DynamicSkillMiddleware( + List.of(new StubRepo(List.of(systemSkill))), toolkit, null, false, null); + RuntimeContext ctx = rc(new CapabilitySurface(Set.of(), null, null)); + mw.onSystemPrompt(null, ctx, "base").block(); + + ToolResultBlock result = loadViaToolkit(toolkit, "gamma_custom", ctx); + assertTrue(firstText(result).contains("Successfully loaded skill: gamma_custom")); + } + + @Test + @DisplayName("Static path: direct load via the static skillBox tool is gated too") + void staticLoadPathGate() { + Toolkit toolkit = new Toolkit(); + SkillBox box = new SkillBox(toolkit); + box.registerSkill(skill("alpha")); + box.registerSkill(skill("secret")); + box.registerSkillLoadTool(); + AgentTool loadTool = toolkit.getTool("load_skill_through_path"); + assertNotNull(loadTool); + + RuntimeContext ctx = rc(new CapabilitySurface(Set.of("alpha_custom"), null, null)); + ToolResultBlock denied = + callDirect(loadTool, Map.of("skillId", "secret_custom", "path", "SKILL.md"), ctx); + assertEquals( + "Error: Skill not found: 'secret_custom'. Please check the skill ID.", + firstText(denied)); + + ToolResultBlock allowed = + callDirect(loadTool, Map.of("skillId", "alpha_custom", "path", "SKILL.md"), ctx); + assertTrue(firstText(allowed).contains("Successfully loaded skill: alpha_custom")); + } + + @Test + @DisplayName("missing surface (direct path) → unrestricted load") + void loadGateTreatsMissingSurfaceAsAllowAll() { + Toolkit toolkit = new Toolkit(); + SkillBox box = new SkillBox(toolkit); + AgentSkill systemSkill = + AgentSkill.builder() + .name("gamma") + .description("system skill") + .skillContent("# gamma") + .system(true) + .build(); + box.registerSkill(systemSkill); + box.registerSkillLoadTool(); + AgentTool loadTool = toolkit.getTool("load_skill_through_path"); + + // Direct callAsync so the executor gate does not preempt the load-gate check. + ToolResultBlock result = + callDirect(loadTool, Map.of("skillId", "gamma_custom", "path", "SKILL.md"), rc()); + assertTrue( + firstText(result).contains("Successfully loaded skill: gamma_custom"), + "no surface (direct path) = unrestricted: " + firstText(result)); + } + + /** Loads SKILL.md through the toolkit entry (executor + load gate). */ + private ToolResultBlock loadViaToolkit(Toolkit toolkit, String skillId, RuntimeContext ctx) { + return toolkit.callTool( + param( + "load_skill_through_path", + Map.of("skillId", skillId, "path", "SKILL.md"), + ctx)) + .block(); + } + + // ==================== end to end ==================== + + @Test + @DisplayName("e2e: resolver grants 1 group; model sees only granted tools in the loop") + void endToEndSchemaInViewOfModel() { + Toolkit toolkit = grantToolkit(); + MockModel model = new MockModel("final"); + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(model) + .toolkit(toolkit) + .capabilityResolver( + (ctx, a) -> new CapabilitySurface(null, Set.of("g1"), Set.of())) + .build(); + agent.call(userMsg("hi"), rc()).block(Duration.ofSeconds(10)); + + List names = model.getLastTools().stream().map(ToolSchema::getName).toList(); + assertTrue(names.contains("t1"), "granted group member visible to the model"); + assertTrue(names.contains("reset_equipped_tools"), "system meta tool visible"); + assertFalse(names.contains("t2"), "un-granted group member invisible to the model"); + } + + @Test + @DisplayName("e2e: model insists on a cut-off tool → ToolResult error feeds back to the model") + void endToEndForcedCallIsDenied() { + Toolkit toolkit = grantToolkit(); + AtomicInteger calls = new AtomicInteger(); + MockModel model = + new MockModel( + msgs -> { + if (calls.getAndIncrement() == 0) { + Map args = new HashMap<>(); + return List.of( + ChatResponse.builder() + .id("m1") + .content( + List.of( + ToolUseBlock.builder() + .id("call-1") + .name("t2") + .input(args) + .build())) + .build()); + } + return List.of( + ChatResponse.builder() + .id("m2") + .content( + List.of( + TextBlock.builder() + .text("final") + .build())) + .build()); + }); + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(model) + .toolkit(toolkit) + .capabilityResolver( + (ctx, a) -> new CapabilitySurface(null, Set.of("g1"), Set.of())) + .build(); + agent.call(userMsg("hi"), rc()).block(Duration.ofSeconds(15)); + + String toolResultText = + model.getLastMessages().stream() + .flatMap(m -> m.getContent().stream()) + .filter(b -> b instanceof ToolResultBlock) + .map(b -> (ToolResultBlock) b) + .map(CapabilityGrantIntegrationTest::firstText) + .filter(t -> t.contains("Tool not found")) + .findFirst() + .orElse(null); + assertEquals("Error: Tool not found: t2", toolResultText); + } + + /** A single-user-message input list. */ + private static List userMsg(String text) { + return List.of( + UserMessage.builder().content(TextBlock.builder().text(text).build()).build()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java new file mode 100644 index 0000000000..a88ba9aecd --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java @@ -0,0 +1,315 @@ +/* + * 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.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.ToolCallParam; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** + * Decision-matrix tests for {@link CapabilitySurface}. + * + * @author shanhongyu + */ +@Tag("unit") +@DisplayName("CapabilitySurface grant decision matrix") +class CapabilitySurfaceTest { + + /** Minimal non-system AgentTool stub. */ + private static AgentTool tool(String name) { + return new AgentTool() { + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + return "stub"; + } + + @Override + public Map getParameters() { + return Map.of("type", "object"); + } + + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.just(ToolResultBlock.text("ok")); + } + }; + } + + /** Minimal system AgentTool stub (isSystem → true). */ + private static AgentTool systemTool(String name) { + return new AgentTool() { + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + return "system stub"; + } + + @Override + public Map getParameters() { + return Map.of("type", "object"); + } + + @Override + public boolean isSystem() { + return true; + } + + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.just(ToolResultBlock.text("ok")); + } + }; + } + + /** Reverse lookup stub: no tool belongs to any group. */ + private static final Function> NO_GROUPS = name -> Set.of(); + + /** Reverse lookup stub: t1 and t2 belong to group g1. */ + private static final Function> GROUPS_OF_G1 = + name -> "t1".equals(name) || "t2".equals(name) ? Set.of("g1") : Set.of(); + + // ==================== tri-state semantics ==================== + + @Test + @DisplayName("skill dimension: all-null = no trimming; unset or empty = no skill grants") + void skillDimensionThreeStates() { + assertTrue(new CapabilitySurface(null, null, null).allowsSkill("anything")); + assertFalse( + new CapabilitySurface(null, Set.of("g1"), null).allowsSkill("anything"), + "an unset skill dimension grants no skills once any dimension is set"); + assertFalse(new CapabilitySurface(Set.of(), null, null).allowsSkill("anything")); + CapabilitySurface surface = new CapabilitySurface(Set.of("s1"), null, null); + assertTrue(surface.allowsSkill("s1")); + assertFalse(surface.allowsSkill("s2")); + } + + @Test + @DisplayName("skill object entry: id delegation, system bypass, and the null guard") + void skillObjectEntryMatrix() { + // Id delegation: builder default source "custom" → skillId "s1_custom". + AgentSkill s1 = AgentSkill.builder().name("s1").description("d").skillContent("c").build(); + AgentSkill s2 = AgentSkill.builder().name("s2").description("d").skillContent("c").build(); + CapabilitySurface surface = new CapabilitySurface(Set.of("s1_custom"), Set.of(), Set.of()); + assertTrue(surface.allowsSkill(s1)); + assertFalse(surface.allowsSkill(s2)); + assertTrue(CapabilitySurface.ALLOW_ALL.allowsSkill(s2)); + + // System bypass survives DENY_ALL, mirroring system tools; the id-only fallback stays + // unaware of the mark. + AgentSkill systemSkill = + AgentSkill.builder() + .name("sys") + .description("d") + .skillContent("c") + .system(true) + .build(); + assertTrue(CapabilitySurface.DENY_ALL.allowsSkill(systemSkill)); + assertFalse(CapabilitySurface.DENY_ALL.allowsSkill(systemSkill.getSkillId())); + + // Null guard: a registry miss is never granted, not even on ALLOW_ALL. + AgentSkill registryMiss = null; + assertFalse(CapabilitySurface.ALLOW_ALL.allowsSkill(registryMiss)); + } + + @Test + @DisplayName("toolGroup dimension: all-null = no trimming; unset or empty = no group grants") + void toolGroupDimensionThreeStates() { + assertTrue(new CapabilitySurface(null, null, null).allowsToolGroup("anyGroup")); + assertFalse( + new CapabilitySurface(null, null, Set.of("t1")).allowsToolGroup("anyGroup"), + "an unset group dimension grants no groups once any dimension is set"); + assertFalse( + new CapabilitySurface(null, Set.of(), null).allowsToolGroup("anyGroup"), + "empty group set denies every group (skill path only rescues derived groups)"); + CapabilitySurface surface = new CapabilitySurface(null, Set.of("g1"), null); + assertTrue(surface.allowsToolGroup("g1")); + assertFalse(surface.allowsToolGroup("g2")); + } + + @Test + @DisplayName("tool dimension: all-null = no trimming; an explicit whitelist trims the rest") + void toolDimensionThreeStates() { + assertTrue( + new CapabilitySurface(null, null, null).allowsTool(tool("t"), Set.of("g1")), + "an all-null surface allows every (non-system) tool"); + CapabilitySurface surface = new CapabilitySurface(null, null, Set.of("t1")); + assertTrue(surface.allowsTool(tool("t1"), Set.of())); + assertFalse( + surface.allowsTool(tool("t2"), Set.of("g1")), + "an unset group dimension does not rescue tools once any dimension is set"); + assertFalse(surface.allowsTool(tool("t2"), Set.of())); + } + + // ==================== allowsTool paths ==================== + + @Test + @DisplayName("per-name authorization ignores grouping") + void perNamePath() { + CapabilitySurface surface = new CapabilitySurface(null, Set.of("otherGroup"), Set.of("t1")); + assertTrue(surface.allowsTool(tool("t1"), Set.of("g1"))); + } + + @Test + @DisplayName("group whitelist path") + void groupPath() { + CapabilitySurface surface = new CapabilitySurface(null, Set.of("g1"), Set.of("unrelated")); + assertTrue(surface.allowsTool(tool("t1"), Set.of("g1", "g2"))); + assertFalse(surface.allowsTool(tool("t1"), Set.of("g2"))); + } + + @Test + @DisplayName("skill path — granted skillId authorizes its skill tool group") + void skillGroupPath() { + // Group dimension restricted (empty) so only the skill path can rescue the tool. + CapabilitySurface surface = new CapabilitySurface(Set.of("s1"), Set.of(), Set.of()); + // skillId s1 → group name "s1_skill_tools" (SkillToolGroup naming convention) + assertTrue(surface.allowsTool(tool("t1"), Set.of("s1_skill_tools"))); + assertFalse(surface.allowsTool(tool("t1"), Set.of("s2_skill_tools"))); + } + + // ==================== trimming intent ==================== + + @Test + @DisplayName("any explicit dimension establishes trimming; only an all-null surface is open") + void anyExplicitDimensionEstablishesTrimming() { + // Group whitelist with unset skill/tool dimensions: only granted members pass — unset + // dimensions are "not set", not "anything goes". + CapabilitySurface surface = new CapabilitySurface(null, Set.of("g1"), null); + assertTrue(surface.allowsTool(tool("t1"), Set.of("g1"))); + assertFalse(surface.allowsTool(tool("tx"), Set.of("g2"))); + assertFalse(surface.allowsTool(tool("ungrouped"), Set.of())); + + // null (not set) and Set.of() (explicitly empty) are distinct values with the same + // grant outcome once any dimension is set. + CapabilitySurface withNulls = new CapabilitySurface(null, Set.of("g1"), null); + CapabilitySurface withEmpties = new CapabilitySurface(Set.of(), Set.of("g1"), Set.of()); + assertNull(withNulls.allowedSkills()); + assertEquals(Set.of(), withEmpties.allowedSkills()); + assertFalse(withNulls.equals(withEmpties)); + assertTrue(withNulls.allowsTool(tool("t1"), Set.of("g1"))); + assertTrue(withEmpties.allowsTool(tool("t1"), Set.of("g1"))); + } + + // ==================== no amplification ==================== + + @Test + @DisplayName("no amplification: name-authorizing one tool does not free its group siblings") + void perNameAuthorizationDoesNotAmplify() { + CapabilitySurface surface = new CapabilitySurface(null, Set.of(), Set.of("t1")); + assertTrue(surface.allowsTool(tool("t1"), Set.of("g1"))); + assertFalse(surface.allowsTool(tool("t2"), Set.of("g1"))); + } + + // ==================== allowsToolGroupActivation ==================== + + @Test + @DisplayName( + "group activation: group path, skill path, per-name reachability, and the null guard") + void allowsToolGroupActivationMatrix() { + // Group whitelist path. + assertTrue( + new CapabilitySurface(null, Set.of("g1"), Set.of()) + .allowsToolGroupActivation("g1", GROUPS_OF_G1)); + // Skill path: the derived skill tool-group name is activatable. + assertTrue( + new CapabilitySurface(Set.of("s1"), Set.of(), Set.of()) + .allowsToolGroupActivation("s1_skill_tools", NO_GROUPS)); + // Reachability: some per-name granted member tool belongs to the group. + assertTrue( + new CapabilitySurface(null, Set.of(), Set.of("t1")) + .allowsToolGroupActivation("g1", GROUPS_OF_G1)); + // Null guard: no NPE and no reachability when allowedTools is null. + assertFalse( + new CapabilitySurface(null, Set.of("gOther"), null) + .allowsToolGroupActivation("g1", GROUPS_OF_G1)); + } + + // ==================== defensive copy ==================== + + @Test + @DisplayName( + "defensive copy: mutating the input sets after construction does not affect decisions") + void defensiveCopy() { + Set skills = new HashSet<>(); + skills.add("s1"); + Set groups = new HashSet<>(); + groups.add("g1"); + Set tools = new HashSet<>(); + tools.add("t1"); + CapabilitySurface surface = new CapabilitySurface(skills, groups, tools); + + skills.add("s2"); + groups.add("g2"); + tools.add("t2"); + + assertFalse(surface.allowsSkill("s2")); + assertFalse(surface.allowsToolGroup("g2")); + assertFalse(surface.allowsTool(tool("t2"), Set.of())); + assertEquals(Set.of("s1"), surface.allowedSkills()); + assertEquals(Set.of("g1"), surface.allowedToolGroups()); + assertEquals(Set.of("t1"), surface.allowedTools()); + } + + // ==================== sentinels ==================== + + @Test + @DisplayName( + "sentinels: all-null = ALLOW_ALL (no trimming); all-empty = DENY_ALL (system only)") + void sentinels() { + assertNull(CapabilitySurface.ALLOW_ALL.allowedSkills()); + assertNull(CapabilitySurface.ALLOW_ALL.allowedToolGroups()); + assertNull(CapabilitySurface.ALLOW_ALL.allowedTools()); + assertEquals(CapabilitySurface.ALLOW_ALL, new CapabilitySurface(null, null, null)); + + assertEquals(Set.of(), CapabilitySurface.DENY_ALL.allowedSkills()); + assertEquals(Set.of(), CapabilitySurface.DENY_ALL.allowedToolGroups()); + assertEquals(Set.of(), CapabilitySurface.DENY_ALL.allowedTools()); + assertFalse(CapabilitySurface.DENY_ALL.allowsSkill("any")); + assertFalse(CapabilitySurface.DENY_ALL.allowsToolGroup("any")); + assertFalse( + CapabilitySurface.DENY_ALL.allowsTool(tool("any"), Set.of("g1")), + "all paths miss → denied"); + assertTrue( + CapabilitySurface.DENY_ALL.allowsTool(systemTool("any"), Set.of()), + "system tools bypass grant trimming even under DENY_ALL"); + assertEquals( + new CapabilitySurface(Set.of("s"), Set.of("g"), Set.of("t")), + new CapabilitySurface(Set.of("s"), Set.of("g"), Set.of("t"))); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java index 9c03b40dd4..c5f7b11c2c 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java @@ -20,9 +20,11 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.skill.repository.AgentSkillRepository; import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo; +import io.agentscope.core.skill.repository.RuntimeContextSkillRepository; import io.agentscope.core.tool.Toolkit; import java.nio.file.Path; import java.util.List; @@ -95,10 +97,20 @@ private DynamicSkillMiddleware middleware(List skills) { List.of(new StubRepo(skills)), new Toolkit(), null, false, workDir); } + /** + * Direct onSystemPrompt calls bypass the agent call() entry, so the grant layer's + * fail-closed skill filtering would cut every skill. Seed ALLOW_ALL explicitly. + */ + private static RuntimeContext grantRc() { + RuntimeContext rc = RuntimeContext.empty(); + rc.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); + return rc; + } + @Test void emptyRepositoryProducesNoBox() { DynamicSkillMiddleware mw = middleware(List.of()); - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); String prompt = mw.onSystemPrompt(null, rc, "base").block(); @@ -110,7 +122,7 @@ void emptyRepositoryProducesNoBox() { void repositorySkillIsExposedViaRuntimeContext() { AgentSkill skill = new AgentSkill("my_skill", "My Skill", "# Content", null); DynamicSkillMiddleware mw = middleware(List.of(skill)); - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); String prompt = mw.onSystemPrompt(null, rc, "base").block(); @@ -125,7 +137,7 @@ void reusedRuntimeContextClearsStaleBox() { StubRepo repo = new StubRepo(List.of(skill)); DynamicSkillMiddleware mw = new DynamicSkillMiddleware(List.of(repo), new Toolkit(), null, false, workDir); - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); mw.onSystemPrompt(null, rc, "base").block(); assertNotNull(rc.get(SkillBox.class)); @@ -148,8 +160,8 @@ void alternatingContentKeepsMaterializedResourcesIsolated() throws Exception { StubRepo repo = new StubRepo(List.of(a)); DynamicSkillMiddleware mw = new DynamicSkillMiddleware(List.of(repo), new Toolkit(), null, true, workDir); - RuntimeContext rcA = RuntimeContext.empty(); - RuntimeContext rcB = RuntimeContext.empty(); + RuntimeContext rcA = grantRc(); + RuntimeContext rcB = grantRc(); mw.onSystemPrompt(null, rcA, "").block(); SkillBox boxA = rcA.get(SkillBox.class); String id = boxA.getAllSkillIds().iterator().next(); @@ -202,7 +214,7 @@ protected List filterVisible( futures.add( pool.submit( () -> { - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); mw.onSystemPrompt(null, rc, "").block(); SkillBox box = rc.get(SkillBox.class); assertEquals( @@ -235,13 +247,13 @@ void evictionDoesNotRewriteResourcesOfAnActiveBox() throws Exception { StubRepo repo = new StubRepo(List.of(skill)); DynamicSkillMiddleware mw = new DynamicSkillMiddleware(List.of(repo), new Toolkit(), null, false, workDir); - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); mw.onSystemPrompt(null, rc, "").block(); SkillBox active = rc.get(SkillBox.class); Path activeFile = active.getUploadDir().resolve(skill.getSkillId()).resolve("data.txt"); for (int i = 0; i < 32; i++) { repo.skills = List.of(new AgentSkill("other" + i, "Other", "content", null)); - mw.onSystemPrompt(null, RuntimeContext.empty(), "").block(); + mw.onSystemPrompt(null, grantRc(), "").block(); } // An executing skill may have modified its workspace; rebuilding must leave it alone. java.nio.file.Files.writeString(activeFile, "in use"); @@ -261,11 +273,11 @@ void evictionDoesNotRewriteResourcesOfAnActiveBox() throws Exception { @Test void contextualRepositoryReceivesExactRequest() { - var repo = - org.mockito.Mockito.mock( - io.agentscope.core.skill.repository.RuntimeContextSkillRepository.class); + var repo = org.mockito.Mockito.mock(RuntimeContextSkillRepository.class); RuntimeContext a = RuntimeContext.builder().userId("A").sessionId("s").build(); + a.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); RuntimeContext b = RuntimeContext.builder().userId("B").sessionId("s").build(); + b.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); org.mockito.Mockito.when(repo.getAllSkills(a)) .thenReturn(List.of(new AgentSkill("skill_a", "A", "A", null))); org.mockito.Mockito.when(repo.getAllSkills(b)) diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java index d3bc986323..29bddd28e8 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; @@ -86,6 +87,21 @@ void setUp() { skillBox.registerSkillLoadTool(); } + /** + * Direct-execution tests bypass the agent call() entry; seed ALLOW_ALL so the grant layer's + * fail-closed load gate does not deny every skill load. + */ + private static RuntimeContext grantRc() { + RuntimeContext rc = RuntimeContext.empty(); + rc.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); + return rc; + } + + private static RuntimeContext withGrant(RuntimeContext rc) { + rc.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); + return rc; + } + private AgentTool createDummyTool(String name) { return new AgentTool() { @Override @@ -168,7 +184,11 @@ void testLoadSkillMarkdownSuccessfully() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -204,7 +224,7 @@ void testActivateSkillAndToolGroupWhenLoadingMarkdown() { ToolCallParam.builder() .toolUseBlock(toolUseBlock) .input(input) - .runtimeContext(runtimeContext) + .runtimeContext(withGrant(runtimeContext)) .build(); tool.callAsync(param).block(TIMEOUT); @@ -230,7 +250,11 @@ void testReturnErrorForNonExistentSkill() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -253,7 +277,11 @@ void testReturnErrorForMissingSkillId() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -276,7 +304,11 @@ void testReturnErrorForEmptySkillId() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -297,7 +329,11 @@ void testLoadSkillResourceSuccessfully() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -333,7 +369,7 @@ void testActivateSkillAndToolGroupWhenLoadingResource() { ToolCallParam.builder() .toolUseBlock(toolUseBlock) .input(input) - .runtimeContext(runtimeContext) + .runtimeContext(withGrant(runtimeContext)) .build(); tool.callAsync(param).block(TIMEOUT); @@ -409,6 +445,7 @@ void testSeparateSessionsLoadDifferentSkillsWithoutCrossTalk() { } private ToolResultBlock loadSkillWithContext(String skillId, RuntimeContext rc) { + withGrant(rc); AgentTool tool = toolkit.getTool("load_skill_through_path"); Map input = Map.of("skillId", skillId, "path", "SKILL.md"); ToolCallParam param = @@ -485,7 +522,11 @@ void testReturnErrorForNonExistentResource() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -512,7 +553,11 @@ void testReturnErrorForMissingPathParameter() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -535,7 +580,11 @@ void testListAllResourcePathsWithInvalidPath() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -560,7 +609,11 @@ void testListOnlySkillMdForSkillWithoutResources() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -584,7 +637,11 @@ void testHandleWhitespaceInSkillId() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -605,7 +662,11 @@ void testHandleWhitespaceInResourcePath() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); @@ -632,7 +693,11 @@ void testLoadSkillWithNoToolsDoesNotFail() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = loader.callAsync(param).block(TIMEOUT); @@ -657,7 +722,11 @@ void testInvalidResourcePathDoesNotActivateSkill() { .input(input) .build(); ToolCallParam param = - ToolCallParam.builder().toolUseBlock(toolUseBlock).input(input).build(); + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java index 36579658e3..eeaec1ccf0 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java @@ -20,8 +20,10 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.ReActAgent; import io.agentscope.core.agent.Agent; import io.agentscope.core.agent.AgentBase; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.hook.Hook; import io.agentscope.core.hook.PreCallEvent; @@ -31,6 +33,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.state.AgentState; import io.agentscope.core.state.ToolContextState; import io.agentscope.core.tool.AgentTool; @@ -77,9 +80,13 @@ void setUp() { } private RuntimeContext sessionContext() { - return RuntimeContext.builder() - .agentState(AgentState.builder().toolContext(toolContext).build()) - .build(); + RuntimeContext rc = + RuntimeContext.builder() + .agentState(AgentState.builder().toolContext(toolContext).build()) + .build(); + // Direct-execution tests bypass the agent call() entry; seed the grant surface. + rc.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); + return rc; } @Test @@ -92,9 +99,9 @@ void reusedBuilderKeepsLegacySkillLoadersBoundToTheirOwnAgent() { String group = skill.getSkillId() + "_skill_tools"; source.createToolGroup(group, "first agent group", false); var builder = - io.agentscope.core.ReActAgent.builder() + ReActAgent.builder() .name("agent") - .model(org.mockito.Mockito.mock(io.agentscope.core.model.Model.class)) + .model(org.mockito.Mockito.mock(Model.class)) .toolkit(source) .skillBox(shared); var first = builder.build(); diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java index 2d9c769f2b..113753c867 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java @@ -19,6 +19,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.agent.CapabilitySurface; +import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.tool.AgentTool; @@ -75,7 +77,12 @@ void disksFallbackHits(@TempDir Path tempDir) throws IOException { .name("load_skill_through_path") .input(input) .build(); - ToolCallParam param = ToolCallParam.builder().toolUseBlock(useBlock).input(input).build(); + ToolCallParam param = + ToolCallParam.builder() + .toolUseBlock(useBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); assertNotNull(result); @@ -116,7 +123,12 @@ void inMemoryTakesPrecedence(@TempDir Path tempDir) throws IOException { .name("load_skill_through_path") .input(input) .build(); - ToolCallParam param = ToolCallParam.builder().toolUseBlock(useBlock).input(input).build(); + ToolCallParam param = + ToolCallParam.builder() + .toolUseBlock(useBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); String text = textOf(result); @@ -157,7 +169,12 @@ void rejectsPathTraversal(@TempDir Path tempDir) throws IOException { .name("load_skill_through_path") .input(input) .build(); - ToolCallParam param = ToolCallParam.builder().toolUseBlock(useBlock).input(input).build(); + ToolCallParam param = + ToolCallParam.builder() + .toolUseBlock(useBlock) + .input(input) + .runtimeContext(grantRc()) + .build(); ToolResultBlock result = tool.callAsync(param).block(TIMEOUT); String text = textOf(result); @@ -165,6 +182,13 @@ void rejectsPathTraversal(@TempDir Path tempDir) throws IOException { assertTrue(text.contains("not found") || text.contains("Resource not found")); } + /** Direct-execution tests bypass the agent call() entry; seed the grant surface. */ + private static RuntimeContext grantRc() { + RuntimeContext rc = RuntimeContext.empty(); + rc.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); + return rc; + } + private static String textOf(ToolResultBlock r) { if (r == null || r.getOutput() == null) { return ""; diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/AsyncToolTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/AsyncToolTest.java index 669f3ffe37..35fcffff85 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/AsyncToolTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/AsyncToolTest.java @@ -23,6 +23,7 @@ import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.tool.test.GrantTestUtils; import io.agentscope.core.tool.test.SampleTools; import io.agentscope.core.util.JsonUtils; import java.time.Duration; @@ -63,7 +64,11 @@ void shouldExecuteCompletableFutureAsyncTool() { .build(); ToolResultBlock response = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()) + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) .block(TIMEOUT); assertNotNull(response, "Response should not be null"); @@ -83,7 +88,11 @@ void shouldExecuteMonoAsyncTool() { .build(); ToolResultBlock response = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()) + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) .block(TIMEOUT); assertNotNull(response, "Response should not be null"); @@ -103,7 +112,11 @@ void shouldExecuteAsyncToolWithDelay() { .build(); ToolResultBlock response = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()) + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) .block(TIMEOUT); assertNotNull(response, "Response should not be null"); @@ -124,7 +137,11 @@ void shouldHandleAsyncToolError() { .build(); ToolResultBlock response = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()) + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) .block(TIMEOUT); assertNotNull(response, "Response should not be null"); @@ -155,7 +172,9 @@ void shouldExecuteMultipleAsyncToolsInParallel() { .build(); List responses = - toolkit.callTools(List.of(addCall, concatCall), null, null, null).block(TIMEOUT); + toolkit.callTools( + List.of(addCall, concatCall), null, null, GrantTestUtils.grantRc()) + .block(TIMEOUT); assertNotNull(responses, "Responses should not be null"); assertEquals(2, responses.size(), "Should have two responses"); @@ -195,7 +214,9 @@ void shouldMixSyncAndAsyncTools() { .build(); List responses = - toolkit.callTools(List.of(syncCall, asyncCall), null, null, null).block(TIMEOUT); + toolkit.callTools( + List.of(syncCall, asyncCall), null, null, GrantTestUtils.grantRc()) + .block(TIMEOUT); assertNotNull(responses, "Responses should not be null"); assertEquals(2, responses.size(), "Should have two responses"); diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ExternalToolSupportTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ExternalToolSupportTest.java index f4a5c580e1..219a78e3c3 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ExternalToolSupportTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ExternalToolSupportTest.java @@ -24,6 +24,8 @@ import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.tool.test.GrantTestUtils; +import io.agentscope.core.util.JsonUtils; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -260,11 +262,11 @@ void testSchemaOnlyToolExecutionReturnsSuspended() { .id("call-ext-1") .name("db_query") .input(input) - .content(io.agentscope.core.util.JsonUtils.getJsonCodec().toJson(input)) + .content(JsonUtils.getJsonCodec().toJson(input)) .build(); List results = - toolkit.callTools(List.of(toolCall), null, null, null) + toolkit.callTools(List.of(toolCall), null, null, GrantTestUtils.grantRc()) .block(java.time.Duration.ofSeconds(3)); assertNotNull(results); @@ -286,11 +288,11 @@ void testAnnotationExternalToolExecutionReturnsSuspended() { .id("call-ext-2") .name("call_api") .input(input) - .content(io.agentscope.core.util.JsonUtils.getJsonCodec().toJson(input)) + .content(JsonUtils.getJsonCodec().toJson(input)) .build(); List results = - toolkit.callTools(List.of(toolCall), null, null, null) + toolkit.callTools(List.of(toolCall), null, null, GrantTestUtils.grantRc()) .block(java.time.Duration.ofSeconds(3)); assertNotNull(results); diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/MetaToolFactoryTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/MetaToolFactoryTest.java index e54f4cd657..d7fc5b94bd 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/MetaToolFactoryTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/MetaToolFactoryTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; @@ -96,9 +97,13 @@ private ToolResultBlock callTool( } private static RuntimeContext runtimeContext(ToolContextState tcs) { - return RuntimeContext.builder() - .agentState(AgentState.builder().toolContext(tcs).build()) - .build(); + RuntimeContext rc = + RuntimeContext.builder() + .agentState(AgentState.builder().toolContext(tcs).build()) + .build(); + // Direct-execution tests bypass the agent call() entry; seed the grant surface. + rc.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); + return rc; } private static String text(ToolResultBlock result) { @@ -608,14 +613,18 @@ void testMetaToolCallAsyncWithoutRuntimeContextFailsWithoutMutatingGroupManager( Map input = new HashMap<>(); input.put("to_activate", List.of("analytics")); - // Act - no runtime context (rc == null → no per-session tool context) + // Act - no runtime context (rc == null → no per-session state to write to) ToolResultBlock result = callTool(metaTool, input, (RuntimeContext) null); - // Assert - fast fail, never mutate the shared group manager + // Assert - fast fail on the per-session guard, never mutate the shared group + // manager (a missing surface means unrestricted: the direct path is trusted) assertNotNull(result); String resultText = text(result); assertTrue(resultText.contains("Error:")); - assertTrue(resultText.contains("requires a per-session runtime context")); + assertTrue( + resultText.contains("requires a per-session runtime context"), + "the per-session branch fails fast when no runtime context is present: " + + resultText); assertFalse(groupManager.getToolGroup("analytics").isActive()); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/SystemCapabilityMarkerTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/SystemCapabilityMarkerTest.java new file mode 100644 index 0000000000..292369592e --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/SystemCapabilityMarkerTest.java @@ -0,0 +1,335 @@ +/* + * 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; + +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.assertTrue; + +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.skill.DynamicSkillMiddleware; +import io.agentscope.core.skill.SkillBox; +import io.agentscope.core.skill.repository.AgentSkillRepository; +import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** + * System-capability marker tests: annotation/builder passthrough, copy retention, + * meta-tool marking, description split, legacy-constructor compatibility. + * + * @author shanhongyu + */ +@Tag("unit") +@DisplayName("System capability markers (isSystem)") +class SystemCapabilityMarkerTest { + + // ==================== helpers ==================== + + /** Tool object with one system-marked and one plain @Tool method. */ + private static final class MarkerTools { + + @Tool(name = "marked_system", description = "system-marked tool", system = true) + public String markedSystem() { + return "ok"; + } + + @Tool(name = "plain_tool", description = "plain tool") + public String plainTool() { + return "ok"; + } + } + + /** Minimal ToolBase subclass for builder-flag assertions. */ + private static final class StubToolBase extends ToolBase { + /** @param builder the pre-configured base builder. */ + private StubToolBase(Builder builder) { + super(builder); + } + + /** + * Creates a stub tool with the given system flag. + * + * @param name the tool name + * @param system the system flag to set + * @return the built stub + */ + static StubToolBase create(String name, boolean system) { + return new StubToolBase( + ToolBase.builder() + .name(name) + .description("stub") + .inputSchema(Map.of("type", "object")) + .system(system)); + } + + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.just(ToolResultBlock.text("ok")); + } + } + + /** Skill repository stub that serves no skills. */ + private static final class EmptyRepo implements AgentSkillRepository { + @Override + public AgentSkill getSkill(String name) { + return null; + } + + @Override + public List getAllSkillNames() { + return List.of(); + } + + @Override + public List getAllSkills() { + return List.of(); + } + + @Override + public boolean save(List skills, boolean force) { + return false; + } + + @Override + public boolean delete(String skillName) { + return false; + } + + @Override + public boolean skillExists(String skillName) { + return false; + } + + @Override + public AgentSkillRepositoryInfo getRepositoryInfo() { + return null; + } + + @Override + public String getSource() { + return "stub"; + } + + @Override + public void setWriteable(boolean writeable) {} + + @Override + public boolean isWriteable() { + return false; + } + } + + // ==================== annotation passthrough ==================== + + @Test + @DisplayName("@Tool(system=true) → isSystem() true; default false") + void annotationPassthrough() { + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(new MarkerTools()); + + assertTrue(toolkit.getTool("marked_system").isSystem()); + assertFalse(toolkit.getTool("plain_tool").isSystem()); + } + + // ==================== builder passthrough ==================== + + @Test + @DisplayName( + "builder system flag takes effect on ToolBase, ToolGroup, SkillToolGroup, AgentSkill") + void builderPassthrough() { + assertTrue(StubToolBase.create("sys", true).isSystem()); + assertFalse(StubToolBase.create("plain", false).isSystem()); + + assertTrue(ToolGroup.builder().name("g").description("d").system(true).build().isSystem()); + assertFalse(ToolGroup.builder().name("g").description("d").build().isSystem()); + + assertTrue( + SkillToolGroup.skillBuilder() + .name("sg") + .description("d") + .activateOnSkill("s") + .system(true) + .build() + .isSystem()); + assertFalse( + SkillToolGroup.skillBuilder() + .name("sg") + .description("d") + .activateOnSkill("s") + .build() + .isSystem()); + + assertTrue( + AgentSkill.builder() + .name("s") + .description("d") + .skillContent("c") + .system(true) + .build() + .isSystem()); + assertFalse( + AgentSkill.builder() + .name("s") + .description("d") + .skillContent("c") + .build() + .isSystem()); + } + + // ==================== copy retention ==================== + + @Test + @DisplayName("copy/toBuilder/Toolkit.copy preserve the system flag across types") + void copyRetention() { + assertTrue( + ToolGroup.builder() + .name("g") + .description("d") + .system(true) + .build() + .copy() + .isSystem()); + + SkillToolGroup skillGroup = + SkillToolGroup.skillBuilder() + .name("sg") + .description("d") + .activateOnSkill("s") + .system(true) + .build(); + ToolGroup copy = skillGroup.copy(); + assertTrue(copy instanceof SkillToolGroup, "copy preserves the concrete type"); + assertTrue(copy.isSystem(), "SkillToolGroup.copy() must not silently drop the flag"); + + AgentSkill skill = + AgentSkill.builder() + .name("s") + .description("d") + .skillContent("c") + .system(true) + .build(); + assertTrue(skill.toBuilder().build().isSystem()); + + Toolkit toolkit = new Toolkit(); + toolkit.registerToolGroup(skillGroup); + assertNotNull(toolkit.copy().getToolGroup("sg")); + assertTrue(toolkit.copy().getToolGroup("sg").isSystem()); + } + + // ==================== meta-tool marking ==================== + + @Test + @DisplayName("framework meta tools (reset + both load-tool registrations) are marked system") + void frameworkMetaToolsAreSystem() { + Toolkit toolkit = new Toolkit(); + toolkit.registerMetaTool(); + assertTrue(toolkit.getTool("reset_equipped_tools").isSystem()); + + // The DynamicSkillMiddleware constructor registers the shared runtime load tool. + new DynamicSkillMiddleware(List.of(new EmptyRepo()), toolkit); + assertTrue(toolkit.getTool("load_skill_through_path").isSystem()); + + // Static skillBox registration: must stay loadable under an inherited surface (grant + // decisions live in loadSkillResourceImpl). + Toolkit staticToolkit = new Toolkit(); + new SkillBox(staticToolkit).registerSkillLoadTool(); + assertTrue(staticToolkit.getTool("load_skill_through_path").isSystem()); + } + + // ==================== description split ==================== + + @Test + @DisplayName("baseDescription() excludes group notes; getDescription() = base + full notes") + void descriptionSplit() { + ToolGroupManager groupManager = new ToolGroupManager(); + groupManager.createToolGroup("g1", "desc-one", true); + groupManager.createToolGroup("g2", "desc-two", false); + MetaToolFactory factory = new MetaToolFactory(groupManager, new ToolRegistry()); + AgentTool metaTool = factory.createResetEquippedToolsAgentTool(); + + String base = MetaToolFactory.baseDescription(); + assertFalse(base.contains("g1"), "base description must not embed group notes"); + assertFalse(base.contains("desc-one")); + + String full = metaTool.getDescription(); + assertTrue(full.startsWith(base), "full description starts with the base"); + assertTrue(full.contains("g1") && full.contains("desc-one")); + assertTrue(full.contains("g2") && full.contains("desc-two")); + assertEquals(base + groupManager.getNotes(), full); + } + + // ==================== legacy constructors ==================== + + @Test + @DisplayName("AgentSkill legacy constructors default isSystem() to false") + void legacySkillConstructorsDefaultFalse() { + Map metadata = new HashMap<>(); + metadata.put("name", "s"); + metadata.put("description", "d"); + assertFalse( + new AgentSkill("s", "d", "content", null).isSystem(), + "4-arg constructor (name, description, content, resources)"); + assertFalse( + new AgentSkill("s", "d", "content", null, "src").isSystem(), + "5-arg constructor (+source)"); + assertFalse( + new AgentSkill(metadata, "content", null, "src").isSystem(), + "metadata-based constructor"); + assertFalse( + new AgentSkill(metadata, "content", null, "src", null).isSystem(), + "metadata + originDir constructor"); + } + + // ==================== helper methods ==================== + + @Test + @DisplayName("helpers: skill group naming, group-membership lookup, restricted notes") + void helperMethods() { + assertEquals("s1_skill_tools", SkillToolGroup.skillToolsGroupName("s1")); + assertEquals( + "report_gen_custom_skill_tools", + SkillToolGroup.skillToolsGroupName("report_gen_custom")); + + Toolkit toolkit = new Toolkit(); + toolkit.createToolGroup("g1", "one", true); + toolkit.createToolGroup("g2", "two", false); + toolkit.registerAgentTool(StubToolBase.create("t1", false)); + toolkit.registerAgentTool(StubToolBase.create("t2", false)); + toolkit.addToolToGroup("g1", "t1"); + toolkit.addToolToGroup("g2", "t1"); + toolkit.addToolToGroup("g1", "t2"); + assertEquals(Set.of("g1", "g2"), toolkit.getToolGroupsOf("t1")); + assertEquals(Set.of("g1"), toolkit.getToolGroupsOf("t2")); + assertEquals(Set.of(), toolkit.getToolGroupsOf("unknown")); + + ToolGroupManager groupManager = new ToolGroupManager(); + groupManager.createToolGroup("g1", "desc-one", true); + groupManager.createToolGroup("g2", "desc-two", false); + String notes = groupManager.getNotes(List.of("g1")); + assertTrue(notes.contains("g1") && notes.contains("desc-one")); + assertFalse(notes.contains("desc-two"), "non-listed group description must not leak"); + assertFalse(notes.contains("g2:"), "non-listed group name must not leak"); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolEmitterIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolEmitterIntegrationTest.java index 36ea412eaa..04a751be10 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolEmitterIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolEmitterIntegrationTest.java @@ -31,6 +31,7 @@ import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.tool.test.GrantTestUtils; import io.agentscope.core.util.JsonUtils; import java.util.ArrayList; import java.util.List; @@ -98,7 +99,11 @@ public ToolResultBlock execute( // Call the tool ToolResultBlock finalResponse = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolUseBlock).build()) + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) .block(); // Verify final response @@ -152,7 +157,11 @@ public ToolResultBlock execute( .build(); ToolResultBlock finalResponse = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolUseBlock).build()) + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolUseBlock) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) .block(); // Verify final response @@ -204,7 +213,12 @@ public ToolResultBlock taskB( .input(inputA) .content(JsonUtils.getJsonCodec().toJson(inputA)) .build(); - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolA).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolA) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); // Call task_b Map inputB = Map.of("data", "y"); @@ -215,7 +229,12 @@ public ToolResultBlock taskB( .input(inputB) .content(JsonUtils.getJsonCodec().toJson(inputB)) .build(); - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolB).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolB) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); // Verify chunks assertEquals(3, capturedChunks.size()); @@ -293,7 +312,7 @@ void testInternalChunkCallbackFailureDoesNotBlockUserCallback() { List.of(createToolCall("stream_task", Map.of("input", "demo"))), null, null, - null, + GrantTestUtils.grantRc(), internal) .block(); @@ -318,6 +337,7 @@ void testUserChunkCallbackFailureDoesNotInterruptToolExecution() { .toolUseBlock( createToolCall( "stream_task", Map.of("input", "demo"))) + .runtimeContext(GrantTestUtils.grantRc()) .build()) .block(); @@ -339,7 +359,7 @@ void testInternalChunkCallbackFailureDoesNotInterruptToolExecution() { List.of(createToolCall("stream_task", Map.of("input", "demo"))), null, null, - null, + GrantTestUtils.grantRc(), internal) .block(); diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutionContextIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutionContextIntegrationTest.java index 6df26853c2..698049a0e8 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutionContextIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutionContextIntegrationTest.java @@ -24,6 +24,7 @@ import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.tool.test.GrantTestUtils; import io.agentscope.core.util.JsonUtils; import java.util.HashMap; import java.util.Map; @@ -189,12 +190,16 @@ void testContextInjectionInTool() { .content(JsonUtils.getJsonCodec().toJson(input)) .build(); - // Execute tool with context + // Execute tool with context (seeded with the grant surface for the direct path) ToolResultBlock result = toolkit.callTool( ToolCallParam.builder() .toolUseBlock(toolUse) - .context(agentContext) + .runtimeContext( + GrantTestUtils.withGrant( + RuntimeContext.builder() + .toolExecutionContext(agentContext) + .build())) .build()) .block(); @@ -239,7 +244,7 @@ public ToolResultBlock read(RuntimeContext context) { toolkit.callTool( ToolCallParam.builder() .toolUseBlock(toolUse) - .runtimeContext(requestContext) + .runtimeContext(GrantTestUtils.withGrant(requestContext)) .build()) .block(); @@ -287,9 +292,14 @@ void testContextMergingLevels() { toolkit.callTool( ToolCallParam.builder() .toolUseBlock(toolUse) - .context( - ToolExecutionContext.merge( - callContext, agentContext)) + .runtimeContext( + GrantTestUtils.withGrant( + RuntimeContext.builder() + .toolExecutionContext( + ToolExecutionContext.merge( + callContext, + agentContext)) + .build())) .build()) .block(); @@ -325,7 +335,12 @@ void testPermissionDenied() { .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolUse).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolUse) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result); assertTrue(isError(result)); @@ -367,7 +382,12 @@ public Mono asyncCheck( .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolUse).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolUse) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result); String text = getText(result); @@ -418,7 +438,12 @@ public ToolResultBlock complexOp( .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolUse).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolUse) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result); String text = getText(result); diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java index 55ae0cb786..9c64ddc7ec 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolExecutorTest.java @@ -19,6 +19,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.agent.CapabilitySurface; +import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; @@ -54,6 +56,16 @@ class ToolExecutorTest { private Toolkit toolkit; + /** + * Direct-execution tests bypass the agent {@code call()} entry, so the grant layer's + * fail-closed semantics would deny every backend tool. Seed ALLOW_ALL explicitly. + */ + private static RuntimeContext grantRc() { + RuntimeContext rc = RuntimeContext.empty(); + rc.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); + return rc; + } + @BeforeEach void setUp() { toolkit = new Toolkit(); @@ -82,7 +94,8 @@ void shouldExecuteToolsInParallel() { .build(); List responses = - toolkit.callTools(List.of(addCall, concatCall), null, null, null).block(TIMEOUT); + toolkit.callTools(List.of(addCall, concatCall), null, null, grantRc()) + .block(TIMEOUT); assertNotNull(responses, "Executor should return responses for tool calls"); assertEquals(2, responses.size(), "All tool calls should be executed"); @@ -115,7 +128,7 @@ void shouldReturnErrorWhenToolThrows() { .build(); List responses = - toolkit.callTools(List.of(errorCall), null, null, null).block(TIMEOUT); + toolkit.callTools(List.of(errorCall), null, null, grantRc()).block(TIMEOUT); assertNotNull(responses, "Executor should return an error response"); assertEquals(1, responses.size(), "Single failing call should yield one response"); @@ -163,7 +176,7 @@ public Mono callAsync(ToolCallParam param) { .build(); List responses = - toolkit.callTools(List.of(emptyCall), null, null, null).block(TIMEOUT); + toolkit.callTools(List.of(emptyCall), null, null, grantRc()).block(TIMEOUT); assertNotNull(responses, "Executor should return an error response"); assertEquals(1, responses.size(), "Empty completion should still yield one response"); @@ -199,7 +212,7 @@ void shouldReturnSuspendedResultForExternalTools() { .build(); List responses = - toolkit.callTools(List.of(externalCall), null, null, null).block(TIMEOUT); + toolkit.callTools(List.of(externalCall), null, null, grantRc()).block(TIMEOUT); assertNotNull(responses, "Executor should return a suspended response"); assertEquals(1, responses.size(), "Single external call should yield one response"); @@ -238,7 +251,8 @@ void shouldValidateExternalToolInputBeforeSuspension() { .build(); List responses = - toolkit.callTools(List.of(invalidExternalCall), null, null, null).block(TIMEOUT); + toolkit.callTools(List.of(invalidExternalCall), null, null, grantRc()) + .block(TIMEOUT); assertNotNull(responses, "Executor should return a validation response"); assertEquals(1, responses.size(), "Single external call should yield one response"); @@ -287,7 +301,7 @@ void shouldRejectInactiveGroupedExternalToolsBeforeSuspension() { .build(); List responses = - toolkit.callTools(List.of(externalCall), null, null, null).block(TIMEOUT); + toolkit.callTools(List.of(externalCall), null, null, grantRc()).block(TIMEOUT); assertNotNull(responses, "Executor should return an authorization response"); assertEquals(1, responses.size(), "Single external call should yield one response"); @@ -344,7 +358,7 @@ public Mono callAsync(ToolCallParam param) { .build(); List responses = - toolkit.callTools(List.of(interruptedCall), null, null, null).block(TIMEOUT); + toolkit.callTools(List.of(interruptedCall), null, null, grantRc()).block(TIMEOUT); assertNotNull(responses, "Should return error response"); assertEquals(1, responses.size(), "Should have one response"); @@ -424,7 +438,8 @@ public Mono callAsync(ToolCallParam param) { .build(); List responses = - toolkit.callTools(List.of(call1, call2, call3), null, null, null).block(TIMEOUT); + toolkit.callTools(List.of(call1, call2, call3), null, null, grantRc()) + .block(TIMEOUT); assertNotNull(responses, "Should return responses"); assertEquals(3, responses.size(), "Should have three responses"); @@ -481,6 +496,7 @@ public ToolResultBlock testOverride( ToolCallParam.builder() .toolUseBlock(toolCall) .input(explicitInput) + .runtimeContext(grantRc()) .build()) .block(TIMEOUT); @@ -515,7 +531,11 @@ public ToolResultBlock presetOnly(@ToolParam(name = "param1") String param1) { .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()) + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(grantRc()) + .build()) .block(TIMEOUT); assertNotNull(result, "Result should not be null"); @@ -574,7 +594,11 @@ RegisteredToolFunction getRegisteredTool(String name) { .build(); ToolResultBlock result = - executor.execute(ToolCallParam.builder().toolUseBlock(toolCall).build()) + executor.execute( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(grantRc()) + .build()) .block(TIMEOUT); assertNotNull(result, "Result should not be null"); @@ -650,7 +674,7 @@ public Mono callAsync(ToolCallParam param) { .build(); List responses = - toolkit.callTools(List.of(npeCall, argCall), null, null, null).block(TIMEOUT); + toolkit.callTools(List.of(npeCall, argCall), null, null, grantRc()).block(TIMEOUT); assertNotNull(responses, "Should return responses"); assertEquals(2, responses.size(), "Should have two responses"); diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolRequestConfigTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolRequestConfigTest.java index 5f037d8c0c..8b8fb3d95d 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolRequestConfigTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolRequestConfigTest.java @@ -26,6 +26,7 @@ import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.tool.test.GrantTestUtils; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -105,7 +106,7 @@ void callTools_externalToolIsSuspendedNotInvoked() { .build(); List results = - toolkit.callTools(List.of(use), null, null, RuntimeContext.empty(), config, null) + toolkit.callTools(List.of(use), null, null, GrantTestUtils.grantRc(), config, null) .block(); assertNotNull(results); @@ -140,7 +141,7 @@ void externalToolOverridingInactiveGroupedBackendIsCallable() { .content("{}") .build(); List results = - toolkit.callTools(List.of(use), null, null, RuntimeContext.empty(), config, null) + toolkit.callTools(List.of(use), null, null, GrantTestUtils.grantRc(), config, null) .block(); assertNotNull(results); @@ -240,7 +241,12 @@ void allExecutionEntrypointsHonorContextOverrideAndBackendHiding() { .block(); toolkit.callTools(List.of(use), null, null, rc).block(); assertEquals(0, backend.calls); - toolkit.callTool(ToolCallParam.builder().toolUseBlock(use).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(use) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertEquals(1, backend.calls); } 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 d85f76fb36..761f49ba50 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 @@ -33,6 +33,7 @@ import io.agentscope.core.tool.mcp.McpClientWrapper; import io.agentscope.core.tool.mcp.McpClientWrapperTestSupport; import io.agentscope.core.tool.mcp.McpTool; +import io.agentscope.core.tool.test.GrantTestUtils; import io.agentscope.core.tool.test.SampleTools; import io.agentscope.core.tool.test.ToolTestUtils; import io.agentscope.core.util.JsonUtils; @@ -445,13 +446,23 @@ void testUnauthorizedToolCallShouldBeRejected() { toolkit.updateToolGroups(List.of("activeGroup"), true); toolkit.updateToolGroups(List.of("inactiveGroup"), false); ToolResultBlock result1 = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result1, "Should execute when group is active"); // Now deactivate the group and try again toolkit.updateToolGroups(List.of("activeGroup"), false); ToolResultBlock result2 = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result2, "Should return error response"); assertTrue( isErrorResult(result2), @@ -482,7 +493,12 @@ void testUngroupedToolsAlwaysCallable() { .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result, "Ungrouped tool should be callable"); assertFalse(isErrorResult(result), "Ungrouped tool should execute successfully"); } @@ -507,7 +523,12 @@ void testAuthorizedToolCallSucceeds() { .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result, "Should execute tool from active group"); assertFalse(isErrorResult(result), "Should succeed: " + getResultText(result)); } @@ -541,7 +562,12 @@ void testToolAvailableWhenAssignedToMultipleGroups() { toolkit.updateToolGroups(List.of("groupA"), true); toolkit.updateToolGroups(List.of("groupB"), false); ToolResultBlock resultWithGroupA = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(resultWithGroupA, "Should execute when groupA is active"); assertFalse( isErrorResult(resultWithGroupA), @@ -550,7 +576,12 @@ void testToolAvailableWhenAssignedToMultipleGroups() { toolkit.updateToolGroups(List.of("groupA"), false); toolkit.updateToolGroups(List.of("groupB"), true); ToolResultBlock resultWithGroupB = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(resultWithGroupB, "Should execute when groupB is active"); assertFalse( isErrorResult(resultWithGroupB), @@ -558,7 +589,12 @@ void testToolAvailableWhenAssignedToMultipleGroups() { toolkit.updateToolGroups(List.of("groupA", "groupB"), true); ToolResultBlock resultWithBoth = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(resultWithBoth, "Should execute when both groups are active"); assertFalse( isErrorResult(resultWithBoth), @@ -566,7 +602,12 @@ void testToolAvailableWhenAssignedToMultipleGroups() { toolkit.updateToolGroups(List.of("groupA", "groupB"), false); ToolResultBlock resultWithNone = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(resultWithNone, "Should execute when no groups are active"); assertTrue( isErrorResult(resultWithNone), @@ -611,7 +652,12 @@ void testToolGroupDeactivationPreventsExecution() { // First call should succeed ToolResultBlock result1 = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result1, "First call should work"); assertFalse(isErrorResult(result1), "First call should succeed"); @@ -620,7 +666,12 @@ void testToolGroupDeactivationPreventsExecution() { // Second call should be rejected ToolResultBlock result2 = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result2, "Should return error response"); assertTrue(isErrorResult(result2), "Second call should fail after group deactivation"); String errorText = getResultText(result2); @@ -687,7 +738,12 @@ public ToolResultBlock testWithContext( .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result, "Result should not be null"); assertFalse(isErrorResult(result), "Execution should succeed"); @@ -731,7 +787,12 @@ public ToolResultBlock testOverride( .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); String resultText = getResultText(result); // Preset values should not be overridden by agent input @@ -768,7 +829,12 @@ public ToolResultBlock dynamicContext(@ToolParam(name = "sessionId") String sess .content(JsonUtils.getJsonCodec().toJson(emptyInput)) .build(); ToolResultBlock result1 = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall1).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall1) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertTrue(getResultText(result1).contains("session_001"), "Should use initial session"); // Update preset parameters @@ -783,7 +849,12 @@ public ToolResultBlock dynamicContext(@ToolParam(name = "sessionId") String sess .content(JsonUtils.getJsonCodec().toJson(emptyInput)) .build(); ToolResultBlock result2 = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall2).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall2) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertTrue(getResultText(result2).contains("session_002"), "Should use updated session"); } @@ -1045,7 +1116,12 @@ void testNestedObjectWithRefIssue893() { // This is the bug: callTool triggers ToolValidator.validateInput which fails // with "Schema validation error: Reference /$defs/Material cannot be resolved" ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result, "Result should not be null"); assertFalse( @@ -1139,7 +1215,12 @@ void testParseConverterFromAnnotation_CustomConverter() { .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); // Verify the custom converter was used assertNotNull(result, "Result should not be null"); @@ -1168,7 +1249,12 @@ void testParseConverterFromAnnotation_DefaultConverter() { .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); // Verify the default converter was used (no custom prefix) assertNotNull(result, "Result should not be null"); @@ -1233,7 +1319,12 @@ void testInstantiateConverter_WithNoArgConstructor() { .build(); ToolResultBlock result = - toolkit.callTool(ToolCallParam.builder().toolUseBlock(toolCall).build()).block(); + toolkit.callTool( + ToolCallParam.builder() + .toolUseBlock(toolCall) + .runtimeContext(GrantTestUtils.grantRc()) + .build()) + .block(); assertNotNull(result, "Tool should execute successfully"); assertFalse(isErrorResult(result), "Tool execution should not have errors"); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitToolBaseIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitToolBaseIntegrationTest.java index 21850d525d..fab43b419e 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitToolBaseIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/ToolkitToolBaseIntegrationTest.java @@ -23,6 +23,7 @@ import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.permission.PermissionContextState; import io.agentscope.core.permission.PermissionDecision; +import io.agentscope.core.tool.test.GrantTestUtils; import io.agentscope.core.util.JsonUtils; import java.util.List; import java.util.Map; @@ -81,7 +82,12 @@ void toolBaseSubclassRegistersAndExecutesViaToolkit() { .input(input) .content(JsonUtils.getJsonCodec().toJson(input)) .build(); - ToolCallParam param = ToolCallParam.builder().toolUseBlock(toolCall).input(input).build(); + ToolCallParam param = + ToolCallParam.builder() + .toolUseBlock(toolCall) + .input(input) + .runtimeContext(GrantTestUtils.grantRc()) + .build(); StepVerifier.create(toolkit.callTool(param)) .assertNext( diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/test/GrantTestUtils.java b/agentscope-core/src/test/java/io/agentscope/core/tool/test/GrantTestUtils.java new file mode 100644 index 0000000000..f21f1048b2 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/test/GrantTestUtils.java @@ -0,0 +1,56 @@ +/* + * 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.test; + +import io.agentscope.core.agent.CapabilitySurface; +import io.agentscope.core.agent.RuntimeContext; + +/** + * Grant-layer test helper: seeds an explicit {@link CapabilitySurface#ALLOW_ALL} for + * direct-execution tests that bypass the agent {@code call()} entry (a missing surface is + * treated as unrestricted there; the explicit seed keeps the grant intent visible). + * + * @author shanhongyu + */ +public final class GrantTestUtils { + + /** Utility class: not instantiable. */ + private GrantTestUtils() {} + + /** + * A fresh RuntimeContext carrying {@link CapabilitySurface#ALLOW_ALL}. + * + * @return a new request-scoped context with the allow-all surface + */ + public static RuntimeContext grantRc() { + RuntimeContext rc = RuntimeContext.empty(); + rc.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); + return rc; + } + + /** + * Seeds {@link CapabilitySurface#ALLOW_ALL} onto the given context. + * + * @param rc the context to seed (no-op when {@code null}) + * @return the same context, for in-line use + */ + public static RuntimeContext withGrant(RuntimeContext rc) { + if (rc != null) { + rc.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); + } + return rc; + } +} diff --git a/docs/v2/en/docs/building-blocks/capability-grant.md b/docs/v2/en/docs/building-blocks/capability-grant.md new file mode 100644 index 0000000000..abaddb3643 --- /dev/null +++ b/docs/v2/en/docs/building-blocks/capability-grant.md @@ -0,0 +1,139 @@ +--- +title: Capability Grant +description: Trim the agent's capability surface to what the current user is authorized for +zh_link: /v2/zh/docs/building-blocks/capability-grant +--- + +## Overview + +In enterprise deployments an agent serves many users with different permissions. The capability +grant layer lets you **cut out every capability the current user is not authorized for** — a cut +capability does not exist for the model: it never enters tool schemas, cannot be enumerated, +cannot be activated via `reset_equipped_tools`, cannot be loaded via `load_skill_through_path`, +and cannot be executed. + +It is orthogonal to the existing layers: + +| Layer | Relation | +|-------|----------| +| **grant (this page)** | When a whitelist is set, only whitelisted capabilities pass; ALLOW_ALL (no trimming) applies when no resolver is configured or it returns null | +| activation (`activatedGroups`) | Session-level switch deciding whether an existing capability is currently active; stacks independently with grant | +| Permission system | Execution-time approval (ALLOW / DENY / ASK); grant trims up-front, the two complement each other | +| External tools (`ToolRequestConfig`) | Caller-controlled via the request; never trimmed | + +## Quick start + +Implement a `CapabilityResolver` and register it on the `ReActAgent`. + +```java +CapabilityResolver resolver = (ctx, agent) -> { + String userId = ctx.getUserId(); + // Granularity is yours: per-(userId, agentId), or ignore agentId and return one + // surface per user — the framework treats both identically. + return new CapabilitySurface( + authzService.allowedSkills(userId), // allowed skillIds; null = not set + authzService.allowedToolGroups(userId), // allowed ToolGroup names; null = not set + authzService.allowedTools(userId)); // allowed tool names; null = not set +}; + +ReActAgent agent = ReActAgent.builder() + .name("enterprise-agent").model(model) + .toolkit(toolkit) + .skillRepositories(repos) // dynamic path for multi-tenancy; avoid static skillBox() + .capabilityResolver(resolver) // invoked by the framework at every call entry + .build(); +``` + +The `CapabilitySurface` is an immutable grant enumeration. The framework resolves it once per +`call()` (server-side — a `null` return means no trimming) and carries it on the +`RuntimeContext` through the whole call chain (frozen per request, never persisted); +sub-agents without their own resolver reuse the parent's surface instance (zero re-computation). + +**Caching**: the resolver runs at every call entry (exactly once per call). If its backend is +an expensive lookup (DB / RPC), consider caching it yourself to match your business needs +(e.g. per `userId` with a TTL), accepting the corresponding permission-propagation delay; the +framework does not cache resolver results. + +Returning a superset is safe: surface contents may exceed any agent's actual capabilities — +names the agent does not have are simply never matched. + +## The three surface shapes (read this) + +A `CapabilitySurface` has exactly three shapes with clear meanings: + +| Shape | Meaning | +|-------|---------| +| All three dimensions **`null`** (nothing set) | **No trimming at all** — everything is allowed (`ALLOW_ALL`) | +| All three dimensions **`Set.of()`** (explicitly empty) | **Deny everything** except system capabilities (`DENY_ALL`) | +| Any 1–3 dimensions set | The surface is a **complete grant enumeration**: a tool passes if it matches **any set dimension** (per-name / its groups / a granted skill's tool group); unset (`null`) dimensions grant nothing | + +```java +// Only tools in the "reporting" group; unset dimensions grant nothing extra +new CapabilitySurface(null, Set.of("reporting"), null); + +// No trimming at all — the only unconditionally open shape +new CapabilitySurface(null, null, null); + +// Deny everything except system capabilities +new CapabilitySurface(Set.of(), Set.of(), Set.of()); +``` + +`Set.of()` (explicitly empty) and `null` (not set) are semantically different: the former +declares "zero grants via this dimension", the latter "not participating". Once the surface is +not all-`null`, the two have the same grant outcome (nothing passes via that dimension); the +accessors (`allowedSkills()` etc.) still preserve the distinction. + +## Grant decision rules + +Positive authorization is hierarchical; matching **any set dimension** grants: + +- Granting a **skill** implicitly grants its manual, scripts and associated tools (the skill + tool group `skillId + "_skill_tools"`); +- Granting a **ToolGroup** implicitly grants every tool in it; +- Granting a **tool** by name grants exactly that tool (grouping is ignored, and it does not + amplify to sibling tools in the same group). + +Grant-by-skillId precision assumes unique skill ids: `skillId = name + "_" + source` is not +injective over `(name, source)` — names containing `_` can collide. Keep skill names globally +unique in multi-tenant deployments. + +Only whitelists are provided; there is no deny (reverse-ban) capability. Default is deny — +anything not reachable from the enumeration does not exist. + +## System capability markers (`system`) + +As a rule, tools marked `system` are **basic core capabilities that keep the agent running** +(the framework meta tools are exactly this); the marker is not a control axis for +business-shaped capability trimming — do not use it as a business capability switch. + +The framework's own meta tools (`reset_equipped_tools`, `load_skill_through_path`) are marked +system and stay usable under any surface (including DENY_ALL). Assemblers may also mark their +own tools/groups/skills `system = true` (`@Tool(system = true)` or the corresponding builder) — +an explicit declaration that the capability is exempt from trimming; registration logs an INFO +trace for auditability. + +> **Trust boundary**: the `system` marker is open to and trusted from the assembler (the agent +> developer). The threat model is "end user vs. the agent's capability surface", not defense +> against the assembler. + +## Fail-closed behavior + +When the resolver throws, **the call is aborted**: the exception propagates to the caller and +ERROR is logged — an authorization-infrastructure failure is not "no grants". Catch inside +your resolver and return `DENY_ALL` if graceful degradation is wanted. + +**Direct execution**: paths that execute tools directly without going through an agent +`call()` entry (e.g. `Toolkit.callTool` / `callTools`) bypass grant resolution — a missing +surface means no trimming; place a surface on the `RuntimeContext` +(`rc.put(CapabilitySurface.class, surface)`) to have grants applied. + +## Static skillBox path limitation + +`skillBox(...)` is deprecated since 2.0.0 (retiring with the hook system); **do not use it in +new code** — register skills with `skillRepositories(...)` instead. For existing code still on +this path, its limitation: the static `skillBox(...)` registration (or +`dynamicSkillsEnabled(false)`) has no per-call skill-visibility filtering — when a resolver is +configured, `build()` logs an ERROR and disables the resolver on that agent: top-level calls +of this agent are untrimmed; as a sub-agent, loading and execution stay gated under the +inherited surface, but the static skill *prompt* still enumerates the full skill catalog (a +residual exposure; the static load tool's embedded `skillId` enum lists the full catalog too). diff --git a/docs/v2/zh/docs/building-blocks/capability-grant.md b/docs/v2/zh/docs/building-blocks/capability-grant.md new file mode 100644 index 0000000000..9dc1f2853a --- /dev/null +++ b/docs/v2/zh/docs/building-blocks/capability-grant.md @@ -0,0 +1,100 @@ +--- +title: Capability Grant (能力裁剪) +description: 按用户/请求的实际权限,把 agent 无权的能力彻底切除 +en_link: /v2/en/docs/building-blocks/capability-grant +--- + +## 概述 + +企业级系统里,agent 通常以**多租户、多角色**形态运行:同一个 agent 面向大量权限不同的用户。Capability grant layer(授权层)让你能**按用户 / 请求的实际权限,把无权能力彻底切除**——被切除的能力对模型**不存在**:不进 tool schema、不可枚举、不可被 `reset_equipped_tools` 激活、不可被 `load_skill_through_path` 加载、不可执行。 + +它与其他既有层正交: + +| 层 | 关系 | +| ------------------------------- | ---------------------------------------------------------------------------------- | +| **grant(本页)** | 设置了白名单则只放行白名单内能力;未配置 resolver 或 resolver 返回 null 时为 ALLOW_ALL(不裁剪) | +| activation(`activatedGroups`) | 会话级软开关,决定已存在的能力「当前是否激活」;与 grant 独立叠加 | +| Permission system | 执行期审批(ALLOW / DENY / ASK);grant 是事前切除,两者互补 | +| 外部工具(`ToolRequestConfig`) | 由调用方经请求传入、外部工具在客户端执行无服务端安全问题、调用方自控,不在裁剪范围 | + +## 快速接入 + +用户只需两件事:实现 `CapabilityResolver`、注册到 `ReActAgent`。 + +```java +CapabilityResolver resolver = (ctx, agent) -> { + String userId = ctx.getUserId(); + // 粒度自选:按 (userId, agentId) 做 per-agent 差异, + // 或忽略 agentId 按用户出一份数据(框架一视同仁) + return new CapabilitySurface( + authzService.allowedSkills(userId), // 允许的 skillId;null = 未设置 + authzService.allowedToolGroups(userId), // 允许的 ToolGroup 名;null = 未设置 + authzService.allowedTools(userId)); // 允许的工具名;null = 未设置 +}; + +ReActAgent agent = ReActAgent.builder() + .name("enterprise-agent").model(model) + .toolkit(toolkit) + .skillRepositories(repos) // 多租户用动态路径;勿用静态 skillBox()(见下文) + .capabilityResolver(resolver) // 框架在每次 call 入口调用 + .build(); +``` + +`CapabilitySurface` 是不可变的授权全集。框架在每次 `call()` 入口(服务端)解析一次——resolver 返回 `null` 等同不裁剪——随 `RuntimeContext` 走完整条调用链(请求内冻结、不持久化);未自配 resolver 的子 agent 直接复用父授权面(零重算)。 + +**缓存建议**:resolver 在每次调用入口都会执行(调用内只此一次)。若其后端是 DB / RPC 等高成本查询,可按业务实际需要自行加缓存(如按 `userId` + TTL),并接受相应的权限变更生效延迟;框架不代管缓存。 + +返回超集是安全的:surface 内容可超出任何 agent 的实际能力范围,本 agent 没有的名字不会被任何读点命中。 + +## 三维语义(必读) + +`CapabilitySurface` 只有三种形态,含义明确: + +| 形态 | 语义 | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| 三个维度**全部为 `null`**(全部未设置) | **不做任何裁剪**,全部放行(`ALLOW_ALL`) | +| 三个维度**全部为 `Set.of()`**(明确为空) | **全部禁止**(system 能力除外,即 `DENY_ALL`) | +| 任意 1~3 个维度已设置 | surface 即一份**完整授权清单**:工具只要命中**任一已设置维度**(逐名 / 所属组 / 技能派生组)即通过;未设置(`null`)的维度不产生任何授权 | + +```java +// 仅授权 reporting 组内的工具;未设置的技能/工具维度不产生额外授权 +new CapabilitySurface(null, Set.of("reporting"), null); + +// 完全不裁剪(唯一无条件放行的形态) +new CapabilitySurface(null, null, null); + +// 全部禁止(除 system 能力) +new CapabilitySurface(Set.of(), Set.of(), Set.of()); +``` + +`Set.of()`(明确为空)与 `null`(未设置)语义不同:前者是显式声明“该维度零授权”,后者是“未参与设置”。一旦 surface 不是三维全 `null`,两者在授权判定上效果一致(都不放行该维度);访问器(`allowedSkills()` 等)仍保留这一区分。 + +## 授权判定规则 + +正向授权层次包含,命中**任一已设置维度**即放行: + +- 授权一个 **skill** 即隐含授权其手册、脚本与关联工具(技能组 `skillId + "_skill_tools"`); +- 授权一个 **ToolGroup** 即隐含授权组内全部工具; +- 逐名授权一个**工具**即授权该工具本身(不问分组,也不放大到组内其他工具)。 + +按 skillId 授权的精度依赖 skillId 唯一:`skillId = name + "_" + source` 对 `(name, source)` 非单射,name 含 `_` 时可能碰撞——多租户部署请保证 skill 命名全局唯一。 + +只提供白名单,不提供 deny(反向禁用);默认拒绝——不在授权清单内的能力即「不存在」。 + +## 系统能力标记(`system`) + +一般而言,标记 `system` 的能力是**保证 agent 正常运行的基本核心能力**(框架元工具即属此类);该标记不是业务形态的裁剪控制手段,不应拿它充当业务能力的开关。 + +框架自身的元工具(`reset_equipped_tools`、`load_skill_through_path`)标记为 system,在任何授权面下(包括 DENY_ALL)保持可用。装配方也可将自己的工具/组/skill 标记 `system = true`(`@Tool(system = true)` 或对应 Builder)——这等同于装配方显式声明该能力免裁剪,注册时会输出 INFO 留痕。 + +> **信任边界**:`system` 标记对装配方(agent 开发者)开放且可信。该授权层的威胁模型是「终端用户 vs agent 能力面」,不设防装配方自身。 + +## fail-closed 行为 + +resolver 抛异常时**中止本次调用**:异常传播给调用方并记录 ERROR(授权体系故障 ≠「无授权」);需要降级继续服务请在 resolver 内部捕获并返回 `DENY_ALL`。 + +**直连执行**:`Toolkit.callTool` / `callTools` 等不经 agent `call()` 入口直接执行工具的路径不经过授权解析——无 surface 即视为不裁剪;需要按权限裁剪时,在 `RuntimeContext` 中显式放入 surface 即可(`rc.put(CapabilitySurface.class, surface)`)。 + +## 静态 skillBox 路径限制 + +`skillBox(...)` 已随 hook 系统于 2.0.0 标记为过时,**新代码不要使用**——skill 接入一律使用 `skillRepositories(...)` 动态路径。存量代码若仍停留在该路径,其限制如下:`skillBox(...)` 构建期注册(或 `dynamicSkillsEnabled(false)`)没有 per-call 可见面过滤——配置 resolver 时构建期会输出 ERROR 并使该 agent 的 resolver 失效——该 agent 直调时不裁剪;作为子代理运行时继承父授权面,skill 加载与工具执行仍被硬门拦截,但静态**提示词会枚举全量 skill 目录**(残留暴露;静态 load 工具 schema 内嵌的 `skillId` enum 同样全量)。 From 4b9fad880d74a0b314912cb7b4e92b0d7eb66101 Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Sun, 11 Oct 2026 15:34:07 +0800 Subject: [PATCH 02/13] docs: register capability-grant pages in v2 navigation (en+zh) --- docs/docs.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs.json b/docs/docs.json index 5c88999548..5c88b35516 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -54,6 +54,7 @@ "group": "Building Blocks", "pages": [ "v2/en/docs/building-blocks/agent", + "v2/en/docs/building-blocks/capability-grant", "v2/en/docs/building-blocks/message-and-event", "v2/en/docs/building-blocks/middleware", "v2/en/docs/building-blocks/model", @@ -513,6 +514,7 @@ "group": "核心组件", "pages": [ "v2/zh/docs/building-blocks/agent", + "v2/zh/docs/building-blocks/capability-grant", "v2/zh/docs/building-blocks/message-and-event", "v2/zh/docs/building-blocks/middleware", "v2/zh/docs/building-blocks/model", From be2e9904faa1f62cc83b644a0e7bfe9d5fdb0826 Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Sun, 11 Oct 2026 15:36:00 +0800 Subject: [PATCH 03/13] fix(test): agui adapter test uses the 3-arg schema compose The 2-arg getToolSchemas is package-private since the grant layer landed; pass a null surface (un-trimmed compose) for the EXTERNAL_ONLY assertion. --- .../io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java index f0e8450f72..21871053e0 100644 --- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java +++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/adapter/AguiAgentAdapterTest.java @@ -305,7 +305,9 @@ void externalOnlyWithNoExternalToolsExposesNoBackendTools() { invocation -> { RuntimeContext rc = invocation.getArgument(2); assertTrue( - toolkit.getToolSchemas(List.of(), rc.getToolRequestConfig()) + // no surface: un-trimmed compose (2-arg is package-private) + toolkit.getToolSchemas( + List.of(), rc.getToolRequestConfig(), null) .isEmpty()); assertNull(toolkit.getTool("backend", rc.getToolRequestConfig())); assertNotNull(toolkit.getTool("backend")); From 688921598c9840f4ac88abadbb28b5d792a4da0a Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Mon, 12 Oct 2026 15:18:22 +0800 Subject: [PATCH 04/13] docs(core): clarify system tool-group marker is group-level only --- .../agentscope/core/tool/SkillToolGroup.java | 9 ++++++--- .../io/agentscope/core/tool/ToolGroup.java | 20 ++++++++++++++----- .../docs/building-blocks/capability-grant.md | 6 ++++++ .../docs/building-blocks/capability-grant.md | 2 ++ 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java b/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java index fd694f4db8..56035bffa6 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java @@ -183,10 +183,13 @@ public Builder tools(Set tools) { } /** - * Marks the group as a system capability exempt from grant-layer trimming. This builder - * does not extend {@link ToolGroup.Builder}, so the flag is declared explicitly here. + * Marks the group as a system capability exempt from grant-layer trimming — + * group-level only (enumeration/activation); member tools are exempt only when each is + * itself marked system. This builder does not extend {@link ToolGroup.Builder}, so the + * flag is declared explicitly here. * - * @param system true to exempt this group from grant-layer trimming + * @param system true to exempt this group's enumeration/activation from grant-layer + * trimming * @return This builder for method chaining */ public Builder system(boolean system) { diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java index 55352fd474..ba1cc7317a 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolGroup.java @@ -47,7 +47,11 @@ public class ToolGroup { private final ToolGroupScope scope; private final Set tools; // Tool names in this group - /** Whether this is a system capability exempt from grant-layer trimming. */ + /** + * Whether this is a system capability exempt from grant-layer trimming. Group-level only: + * exempts the group's enumeration and activation; member tools are governed by their own + * {@code system} markers. + */ private final boolean system; /** @@ -153,9 +157,12 @@ public ToolGroupScope getScope() { /** * Whether this group is a system capability exempt from grant-layer (per-user capability) - * trimming. Independent of {@link #getScope()} (the activation-ownership axis). + * trimming. Group-level only: the group stays enumerable and activatable under any grant + * set; member tools are NOT exempt unless each is itself marked system. Independent of + * {@link #getScope()} (the activation-ownership axis). * - * @return {@code true} if this group bypasses grant-layer trimming; {@code false} by default + * @return {@code true} if this group bypasses grant-layer trimming for enumeration and + * activation; {@code false} by default */ public boolean isSystem() { return system; @@ -291,9 +298,12 @@ public Builder tools(Set tools) { /** * Marks the group as a system capability exempt from grant-layer (per-user capability) - * trimming. Independent of {@link #scope(ToolGroupScope)}. + * trimming — group-level only: enumeration and activation stay available under any + * grant set, while member tools are exempt only when each is itself marked system. + * Independent of {@link #scope(ToolGroupScope)}. * - * @param system true to exempt this group from grant-layer trimming + * @param system true to exempt this group's enumeration/activation from grant-layer + * trimming * @return This builder for method chaining */ public Builder system(boolean system) { diff --git a/docs/v2/en/docs/building-blocks/capability-grant.md b/docs/v2/en/docs/building-blocks/capability-grant.md index abaddb3643..94a7fa20c9 100644 --- a/docs/v2/en/docs/building-blocks/capability-grant.md +++ b/docs/v2/en/docs/building-blocks/capability-grant.md @@ -112,6 +112,12 @@ own tools/groups/skills `system = true` (`@Tool(system = true)` or the correspon an explicit declaration that the capability is exempt from trimming; registration logs an INFO trace for auditability. +**The marker applies only to the object it marks — it does not propagate**: a tool's `system` +exempts that tool's visibility and execution; **a group's `system` exempts only the group's own +enumeration and activation** — member tools stay governed by the grant surface (mark each +member `system` if it needs the exemption); a skill's `system` exempts its prompt visibility +and loading. + > **Trust boundary**: the `system` marker is open to and trusted from the assembler (the agent > developer). The threat model is "end user vs. the agent's capability surface", not defense > against the assembler. diff --git a/docs/v2/zh/docs/building-blocks/capability-grant.md b/docs/v2/zh/docs/building-blocks/capability-grant.md index 9dc1f2853a..72a49c5356 100644 --- a/docs/v2/zh/docs/building-blocks/capability-grant.md +++ b/docs/v2/zh/docs/building-blocks/capability-grant.md @@ -87,6 +87,8 @@ new CapabilitySurface(Set.of(), Set.of(), Set.of()); 框架自身的元工具(`reset_equipped_tools`、`load_skill_through_path`)标记为 system,在任何授权面下(包括 DENY_ALL)保持可用。装配方也可将自己的工具/组/skill 标记 `system = true`(`@Tool(system = true)` 或对应 Builder)——这等同于装配方显式声明该能力免裁剪,注册时会输出 INFO 留痕。 +**标记只作用于被标记的对象,不传染**:工具的 `system` 豁免该工具的可见与执行;**组的 `system` 仅豁免该组自身的枚举与激活**,组内成员工具仍受授权面控制(成员需要豁免时逐一标记 `system`);skill 的 `system` 豁免其提示词可见与加载。 + > **信任边界**:`system` 标记对装配方(agent 开发者)开放且可信。该授权层的威胁模型是「终端用户 vs agent 能力面」,不设防装配方自身。 ## fail-closed 行为 From fd31e7582899f93dace133f7bace858b84e8b7bd Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Mon, 12 Oct 2026 21:37:39 +0800 Subject: [PATCH 05/13] refactor(core,harness): identify skills by name across the grant layer Unify the skill identifier vocabulary to skillName (the business identity, unique per agent catalog) end to end; the composite name_source skillId is now an internal storage detail: - Grant surface: allowedSkills holds skill names; the skill tool-group naming convention derives from skillName ("_skill_tools"), so granting a skill reliably releases its tools without any id<->name translation - SkillRegistry keys by name and enforces per-catalog uniqueness (re-registering the same instance is idempotent; a different instance under a used name is rejected) - Loading: load_skill_through_path now takes a "skillName" parameter in both core and harness; prompts emit ; not-found and denial wordings updated to match - AgentSkill.getName() documents the industry-standard unique identity; getSkillId() is deprecated for removal - Harness skill chain (catalog keying, load tool, prompt builder, usage middleware) aligned to the same vocabulary, closing the grant/addressing mismatch surfaced in review - Synced docs/v2 pages (zh/en) and tests; full mvn test green --- .../core/agent/CapabilitySurface.java | 36 ++- .../io/agentscope/core/skill/AgentSkill.java | 11 + .../core/skill/AgentSkillPromptProvider.java | 32 +-- .../io/agentscope/core/skill/SkillBox.java | 82 +++--- .../agentscope/core/skill/SkillRegistry.java | 48 ++-- .../core/skill/SkillToolFactory.java | 106 +++---- .../agentscope/core/tool/SkillToolGroup.java | 30 +- .../java/io/agentscope/core/tool/Toolkit.java | 12 +- .../agent/CapabilityGrantIntegrationTest.java | 93 ++++-- .../core/agent/CapabilitySurfaceTest.java | 10 +- .../skill/AgentSkillPromptProviderTest.java | 40 +-- .../agentscope/core/skill/AgentSkillTest.java | 13 +- .../skill/DynamicSkillMiddlewareTest.java | 8 +- .../agentscope/core/skill/SkillBoxTest.java | 39 +-- .../core/skill/SkillBoxToolsTest.java | 80 +++--- .../core/skill/SkillRegistryTest.java | 56 ++-- .../skill/SkillRuntimeIntegrationTest.java | 24 +- .../SkillToolFactoryDiskFallbackTest.java | 6 +- .../agentscope/core/skill/SkillUtilTest.java | 1 - .../core/tool/SystemCapabilityMarkerTest.java | 4 +- .../io/agentscope/core/e2e/SkillE2ETest.java | 12 +- .../nacos/skill/NacosSkillRepositoryTest.java | 4 +- .../middleware/HarnessSkillMiddleware.java | 28 +- .../middleware/SkillUsageMiddleware.java | 6 +- .../agent/skill/runtime/SkillCatalog.java | 4 +- .../agent/skill/runtime/SkillLoadTool.java | 82 ++++-- .../skill/runtime/SkillPromptBuilder.java | 8 +- .../HarnessAgentDynamicHookBuilderTest.java | 4 +- ...ssSkillMiddlewareContextIsolationTest.java | 4 +- .../HarnessSkillMiddlewareGrantTest.java | 264 ++++++++++++++++++ .../middleware/SkillUsageMiddlewareTest.java | 8 +- .../SkillLoadToolFrontmatterViewTest.java | 4 +- .../agent/skill/runtime/SkillRuntimeTest.java | 71 +++-- .../docs/building-blocks/capability-grant.md | 17 +- docs/v2/en/docs/building-blocks/tool.md | 8 +- docs/v2/en/docs/harness/skill.md | 8 +- .../docs/building-blocks/capability-grant.md | 10 +- docs/v2/zh/docs/building-blocks/tool.md | 8 +- docs/v2/zh/docs/harness/skill.md | 8 +- 39 files changed, 839 insertions(+), 450 deletions(-) create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareGrantTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java b/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java index 0d2789d695..cb2ad23160 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java @@ -32,10 +32,11 @@ * *

A tool is granted when any explicit path matches: it is system-marked, per-name * whitelisted, a member of a granted group, or in a granted skill's tool group. A skill is - * granted when it is system-marked or its skillId is whitelisted. Default is deny: anything + * granted when it is system-marked or its name is whitelisted. Default is deny: anything * not reachable from the enumeration does not exist. * - * @param allowedSkills allowed skillIds; {@code null} = not set (no skill grants) + * @param allowedSkills allowed skill names (the business identity, e.g. from an + * entitlement list); {@code null} = not set (no skill grants) * @param allowedToolGroups allowed ToolGroup names; {@code null} = not set (no group grants) * @param allowedTools allowed tool names (per-name, grouping ignored); {@code null} = not set * (no per-name grants) @@ -70,16 +71,16 @@ private boolean isUnrestricted() { } /** - * Skill decision by skillId, grant set only. Fallback for callers that do not hold an + * Skill decision by name, grant set only. Fallback for callers that do not hold an * {@link AgentSkill} instance; this overload does not know the system-capability mark, so * such callers must apply that bypass themselves (or resolve the skill and prefer {@link * #allowsSkill(AgentSkill)}). * - * @param skillId the skill identifier to check - * @return {@code true} for an all-null surface or a whitelisted skillId + * @param skillName the skill name to check + * @return {@code true} for an all-null surface or a whitelisted skill name */ - public boolean allowsSkill(String skillId) { - return isUnrestricted() || (allowedSkills != null && allowedSkills.contains(skillId)); + public boolean allowsSkill(String skillName) { + return isUnrestricted() || (allowedSkills != null && allowedSkills.contains(skillName)); } /** @@ -89,13 +90,14 @@ public boolean allowsSkill(String skillId) { * * @param skill the skill to check; {@code null} (a registry miss) is never granted, not * even on {@link #ALLOW_ALL} — fail-closed like a missing surface - * @return {@code true} for a system skill, an all-null surface, or a whitelisted skillId + * @return {@code true} for a system skill, an all-null surface, or a whitelisted skill + * name */ public boolean allowsSkill(AgentSkill skill) { if (skill == null) { return false; // registry miss: fail closed } - return skill.isSystem() || allowsSkill(skill.getSkillId()); + return skill.isSystem() || allowsSkill(skill.getName()); } /** @@ -145,20 +147,14 @@ public boolean allowsToolGroupActivation(String name, Function groupsOf.apply(t).contains(name)); } /** - * Whether the group name derives from a granted skill (naming convention: {@code skillId + - * "_skill_tools"}, see {@link SkillToolGroup#skillToolsGroupName}). + * Whether the group name derives from a granted skill (naming convention: {@code + * skillName + "_skill_tools"}, see {@link SkillToolGroup#skillToolsGroupName}). * * @param name the group name to check * @return {@code true} when the name is a granted skill's tool group 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 5b74e33369..5a04905e69 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 @@ -203,6 +203,11 @@ private AgentSkill( /** * Gets the skill name. * + *

The name is the skill's business identity and follows the industry convention of unique + * skill naming: it is the single identifier used across the grant surface, registry keying, + * loading, and skill tool-group naming. It must be unique within an agent's catalog; the + * {@link #getSource() source} is provenance metadata only, not part of the identity. + * * @return The skill name (never null) */ public String getName() { @@ -291,7 +296,13 @@ public Set getResourcePaths() { *

The ID is composed of name and source: "name_source". * * @return Unique skill identifier (never null) + * @deprecated Skills are identified by {@link #getName()} everywhere (grant surface, + * registry key, load addressing, skill tool-group naming). The name is the + * industry-standard unique identity and must be unique within an agent's catalog; the + * {@code name + "_" + source} composite is a storage detail and is not injective over + * {@code (name, source)}. */ + @Deprecated(forRemoval = true, since = "2.0.5") public String getSkillId() { return getName() + "_" + source; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java index 009af4bcff..9c9a680eda 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java @@ -54,20 +54,20 @@ public class AgentSkillPromptProvider { Skills provide specialized capabilities and domain knowledge. Use them when they match your current task. How to use skills: - - Load skill: load_skill_through_path(skillId="", path="SKILL.md") + - Load skill: load_skill_through_path(skillName="", path="SKILL.md") - The skill will be activated and its documentation loaded with detailed instructions - Additional resources (scripts, assets, references) can be loaded using the same tool with different paths Example: - 1. User asks to analyze data → find a matching skill below (e.g. data-analysis_builtin) - 2. Load it: load_skill_through_path(skillId="data-analysis_builtin", path="SKILL.md") + 1. User asks to analyze data → find a matching skill below (e.g. data-analysis) + 2. Load it: load_skill_through_path(skillName="data-analysis", path="SKILL.md") 3. Follow the instructions returned by the skill Metadata is rendered as XML under each element: - scalar metadata becomes a simple child element - nested maps become nested XML elements - lists become repeated elements - - is always appended for tool loading + - is always appended for tool loading @@ -86,9 +86,9 @@ public class AgentSkillPromptProvider { than describing or suggesting commands to the user. Skills root directory: %s - Each skill's files are located under a subdirectory named by its : - %s//scripts/ - %s//assets/ + Each skill's files are located under a subdirectory named by its : + %s//scripts/ + %s//assets/ Workflow: 1. After loading a skill, use ls to explore its directory structure and discover available scripts/assets @@ -102,10 +102,10 @@ public class AgentSkillPromptProvider { Example: # Explore what scripts are available for a skill - execute_shell_command(command="ls %s/data-analysis_builtin/scripts/") + execute_shell_command(command="ls %s/data-analysis/scripts/") # Run an existing script with absolute path - execute_shell_command(command="python3 %s/data-analysis_builtin/scripts/analyze.py") + execute_shell_command(command="python3 %s/data-analysis/scripts/analyze.py") """; @@ -183,8 +183,8 @@ public String getSkillSystemPrompt() { public String getSkillSystemPrompt(SkillFilter filter) { SkillFilter effectiveFilter = filter != null ? filter : SkillFilter.all(); - Set skillIds = skillRegistry.getSkillIds(); - if (skillIds.isEmpty()) { + Set skillNames = skillRegistry.getSkillNames(); + if (skillNames.isEmpty()) { return ""; } @@ -193,11 +193,11 @@ public String getSkillSystemPrompt(SkillFilter filter) { int visibleCount = 0; int withOriginDir = 0; - for (String skillId : skillIds) { - if (!effectiveFilter.isAllowed(skillId)) { + for (String skillName : skillNames) { + if (!effectiveFilter.isAllowed(skillName)) { continue; } - AgentSkill skill = skillRegistry.getSkill(skillId); + AgentSkill skill = skillRegistry.getSkill(skillName); if (skill == null) { continue; } @@ -291,7 +291,7 @@ public void setCodeExecutionInstruction(String codeExecutionInstruction) { /** * Sets whether all metadata fields are exposed to the LLM. * - *

When disabled, only {@code name}, {@code description}, and {@code skill-id} + *

When disabled, only {@code name}, {@code description}, and {@code skill-name} * are rendered into the skill prompt. * * @param exposeAllMetadata {@code true} to expose all metadata, {@code false} to expose only @@ -309,7 +309,7 @@ private void appendSkill(StringBuilder sb, AgentSkill skill) { } appendXmlNode(sb, entry.getKey(), entry.getValue(), 1); } - appendXmlNode(sb, "skill-id", skill.getSkillId(), 1); + appendXmlNode(sb, "skill-name", skill.getName(), 1); // Per-skill files-root: only emitted when code execution is enabled AND the skill // carries an originDir (filesystem-backed). Lets the LLM build absolute shell paths // without going through uploadSkillFiles or guessing layouts. diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java index 3fa5c087c2..c9ba9d9b3b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java @@ -74,8 +74,8 @@ public SkillBox(Toolkit toolkit, String instruction) { } private SkillBox(SkillBox source, Toolkit toolkit) { - for (String id : source.getAllSkillIds()) { - skillRegistry.registerSkill(id, source.getSkill(id)); + for (String name : source.getAllSkillNames()) { + skillRegistry.registerSkill(name, source.getSkill(name)); } skillPromptProvider = source.skillPromptProvider.copyFor(skillRegistry); skillToolFactory = new SkillToolFactory(skillRegistry, toolkit); @@ -122,7 +122,7 @@ public String getSkillPrompt(SkillFilter filter) { /** * Controls whether the skill prompt exposes all metadata fields or only the core fields. * - *

When disabled, only {@code name}, {@code description}, and {@code skill-id} + *

When disabled, only {@code name}, {@code description}, and {@code skill-name} * are included in the skill prompt. * * @param exposeAllMetadata {@code true} to expose all metadata, {@code false} to expose only @@ -201,17 +201,17 @@ public void registerSkill(AgentSkill skill) { throw new IllegalArgumentException("AgentSkill cannot be null"); } - String skillId = skill.getSkillId(); - skillRegistry.registerSkill(skillId, skill); - logger.info("Registered skill '{}'", skillId); + String skillName = skill.getName(); + skillRegistry.registerSkill(skillName, skill); + logger.info("Registered skill '{}'", skillName); } /** - * Gets all skill IDs. - * @return All skill IDs + * Gets all skill names (the skill's business identity, unique in this box). + * @return All skill names */ - public Set getAllSkillIds() { - return skillRegistry.getSkillIds(); + public Set getAllSkillNames() { + return skillRegistry.getSkillNames(); } /** @@ -219,21 +219,21 @@ public Set getAllSkillIds() { * point used by the shared {@code load_skill_through_path} tool: skill activation is written to * the per-session tool context, never to the shared group manager. * - * @param param the tool call parameters carrying {@code skillId} and {@code path} + * @param param the tool call parameters carrying {@code skillName} and {@code path} * @return the formatted resource content, or a message describing the failure */ String loadSkillResource(ToolCallParam param) { Map input = param.getInput(); - String skillId = (String) input.get("skillId"); - if (skillId == null || skillId.trim().isEmpty()) { - throw new IllegalArgumentException("Missing or empty required parameter: skillId"); + String skillName = (String) input.get("skillName"); + if (skillName == null || skillName.trim().isEmpty()) { + throw new IllegalArgumentException("Missing or empty required parameter: skillName"); } String path = (String) input.get("path"); if (path == null || path.trim().isEmpty()) { throw new IllegalArgumentException("Missing or empty required parameter: path"); } return skillToolFactory.loadSkillResourceImpl( - skillId, + skillName, path, skillToolFactory.resolveToolContext(param), // Not gated here: the single decision lives in loadSkillResourceImpl. @@ -241,45 +241,45 @@ String loadSkillResource(ToolCallParam param) { } /** - * Gets a skill by ID (latest version). + * Gets a skill by name (the skill's business identity). * - * @param skillId The skill ID + * @param skillName The skill name * @return The skill instance, or null if not found - * @throws IllegalArgumentException if skillId is null + * @throws IllegalArgumentException if skillName is null */ - public AgentSkill getSkill(String skillId) { - if (skillId == null) { - throw new IllegalArgumentException("Skill ID cannot be null"); + public AgentSkill getSkill(String skillName) { + if (skillName == null) { + throw new IllegalArgumentException("Skill name cannot be null"); } - return skillRegistry.getSkill(skillId); + return skillRegistry.getSkill(skillName); } /** * Removes a skill completely. * - * @param skillId The skill ID - * @throws IllegalArgumentException if skillId is null + * @param skillName The skill name + * @throws IllegalArgumentException if skillName is null */ - public void removeSkill(String skillId) { - if (skillId == null) { - throw new IllegalArgumentException("Skill ID cannot be null"); + public void removeSkill(String skillName) { + if (skillName == null) { + throw new IllegalArgumentException("Skill name cannot be null"); } - skillRegistry.removeSkill(skillId); - logger.info("Removed skill '{}'", skillId); + skillRegistry.removeSkill(skillName); + logger.info("Removed skill '{}'", skillName); } /** * Checks if a skill exists. * - * @param skillId The skill ID + * @param skillName The skill name * @return true if the skill exists, false otherwise - * @throws IllegalArgumentException if skillId is null + * @throws IllegalArgumentException if skillName is null */ - public boolean exists(String skillId) { - if (skillId == null) { - throw new IllegalArgumentException("Skill ID cannot be null"); + public boolean exists(String skillName) { + if (skillName == null) { + throw new IllegalArgumentException("Skill name cannot be null"); } - return skillRegistry.exists(skillId); + return skillRegistry.exists(skillName); } /** @@ -510,7 +510,7 @@ public void apply() { throw new IllegalStateException( "Must bind toolkit or call toolkit() before apply()"); } - String skillToolGroup = SkillToolGroup.skillToolsGroupName(skill.getSkillId()); + String skillToolGroup = SkillToolGroup.skillToolsGroupName(skill.getName()); if (toolkit.getToolGroup(skillToolGroup) == null) { toolkit.createToolGroup(skillToolGroup, skillToolGroup, false); } @@ -599,7 +599,7 @@ public Path getCodeExecutionWorkDir() { * lifetime so {@code uploadSkillFiles} no longer mints a fresh {@code agentscope-code-execution-*} * tempdir each round). * - * @param workDir the directory under which {@code skills//} subtrees are written; + * @param workDir the directory under which {@code skills//} subtrees are written; * {@code null} (the default) preserves the legacy per-instance tempdir behavior */ public void setWorkDir(Path workDir) { @@ -701,8 +701,8 @@ public void uploadSkillFiles() { SkillFileFilter filter = fileFilter != null ? fileFilter : SkillFileFilter.acceptAll(); int fileCount = 0; - for (String skillId : getAllSkillIds()) { - AgentSkill skill = getSkill(skillId); + for (String skillName : getAllSkillNames()) { + AgentSkill skill = getSkill(skillName); // Skills with a populated originDir already live on disk at a known absolute path, // which the prompt provider exposes as . Re-materialising them under @@ -717,7 +717,7 @@ public void uploadSkillFiles() { continue; } - Path skillDir = targetDir.resolve(skillId); + Path skillDir = targetDir.resolve(skillName); for (String resourcePath : resourcePaths) { if (!filter.accept(resourcePath)) { @@ -726,7 +726,7 @@ public void uploadSkillFiles() { String content = skill.getResource(resourcePath); if (content == null) { - logger.warn("Resource not found: {} in skill {}", resourcePath, skillId); + logger.warn("Resource not found: {} in skill {}", resourcePath, skillName); continue; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillRegistry.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillRegistry.java index a60aabb0e6..2ece17a392 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillRegistry.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillRegistry.java @@ -21,13 +21,14 @@ import java.util.concurrent.ConcurrentHashMap; /** - * Pure storage layer for {@link AgentSkill}s, keyed by skill id. + * Pure storage layer for {@link AgentSkill}s, keyed by skill name. + * + *

The skill name is the business identity (industry-standard unique skill naming) and must be + * unique within one catalog; re-registering the same instance is idempotent, while registering a + * different instance under an already-used name fails fast. * *

The skill activation state is not stored here: it is the per-session {@code activatedGroups} * on {@link io.agentscope.core.state.ToolContextState}. - * - *

This is a pure storage layer: parameters are assumed non-null and validation is performed at - * the {@code Toolkit} layer. */ class SkillRegistry { private final Map skills = new ConcurrentHashMap<>(); @@ -35,44 +36,49 @@ class SkillRegistry { // ==================== Registration ==================== /** - * Registers a skill, replacing any existing skill with the same id. + * Registers a skill under its name. * - * @param skillId The unique skill identifier (must not be null) + * @param skillName The unique skill name (must not be null) * @param skill The skill implementation (must not be null) + * @throws IllegalArgumentException when the name is already taken by a different instance */ - void registerSkill(String skillId, AgentSkill skill) { - skills.put(skillId, skill); + void registerSkill(String skillName, AgentSkill skill) { + AgentSkill existing = skills.putIfAbsent(skillName, skill); + if (existing != null && existing != skill) { + throw new IllegalArgumentException( + "Duplicate skill name: '" + skillName + "' is already registered"); + } } // ==================== Query Operations ==================== /** - * Gets a skill by ID. + * Gets a skill by name. * - * @param skillId The skill ID (must not be null) + * @param skillName The skill name (must not be null) * @return The skill instance, or null if not found */ - AgentSkill getSkill(String skillId) { - return skills.get(skillId); + AgentSkill getSkill(String skillName) { + return skills.get(skillName); } /** - * Gets all skill IDs. + * Gets all skill names. * - * @return Set of skill IDs (never null, may be empty) + * @return Set of skill names (never null, may be empty) */ - Set getSkillIds() { + Set getSkillNames() { return new TreeSet<>(skills.keySet()); } /** * Checks if a skill exists. * - * @param skillId The skill ID (must not be null) + * @param skillName The skill name (must not be null) * @return true if the skill exists, false otherwise */ - boolean exists(String skillId) { - return skills.containsKey(skillId); + boolean exists(String skillName) { + return skills.containsKey(skillName); } // ==================== Removal Operations ==================== @@ -80,9 +86,9 @@ boolean exists(String skillId) { /** * Removes a skill completely. * - * @param skillId The skill ID (must not be null) + * @param skillName The skill name (must not be null) */ - void removeSkill(String skillId) { - skills.remove(skillId); + void removeSkill(String skillName) { + skills.remove(skillName); } } 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 1cfb94047d..f2becf69fb 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 @@ -50,7 +50,7 @@ class SkillToolFactory { static final String LOAD_TOOL_NAME = "load_skill_through_path"; private static final String LOAD_TOOL_DESCRIPTION = - "Load and activate a skill resource by its ID and resource path.\n\n" + "Load and activate a skill resource by its name and resource path.\n\n" + "**Functionality:**\n" + "1. Activates the specified skill for this session (adding its tool groups to" + " this session's active set)\n" @@ -135,12 +135,12 @@ public Map getParameters() { "type", "object", "properties", Map.of( - "skillId", + "skillName", Map.of( "type", "string", "description", - "The unique identifier of the skill."), + "The unique name of the skill."), "path", Map.of( "type", @@ -151,7 +151,7 @@ public Map getParameters() { + " the skill instructions. Do not" + " use '.', './', directories, or" + " absolute paths.")), - "required", List.of("skillId", "path")); + "required", List.of("skillName", "path")); } @Override @@ -215,21 +215,21 @@ public boolean isSystem() { @Override public Map getParameters() { - // Get all available skill IDs - List availableSkillIds = new ArrayList<>(skillRegistry.getSkillIds()); + // The catalog is keyed by name (the skill's business identity). + List availableSkillNames = new ArrayList<>(skillRegistry.getSkillNames()); return Map.of( "type", "object", "properties", Map.of( - "skillId", + "skillName", Map.of( "type", "string", "description", - "The unique identifier of the" + " skill.", + "The unique name of the skill.", "enum", - availableSkillIds), + availableSkillNames), "path", Map.of( "type", @@ -240,7 +240,7 @@ public Map getParameters() { + " skill instructions. Do not use" + " '.', './', directories, or" + " absolute paths.")), - "required", List.of("skillId", "path")); + "required", List.of("skillName", "path")); } @Override @@ -249,11 +249,11 @@ public Mono callAsync(ToolCallParam param) { Map input = param.getInput(); // Validate parameters - String skillId = (String) input.get("skillId"); - if (skillId == null || skillId.trim().isEmpty()) { + String skillName = (String) input.get("skillName"); + if (skillName == null || skillName.trim().isEmpty()) { return Mono.just( ToolResultBlock.error( - "Missing or empty required parameter: skillId")); + "Missing or empty required parameter: skillName")); } String path = (String) input.get("path"); @@ -264,7 +264,7 @@ public Mono callAsync(ToolCallParam param) { String result = loadSkillResourceImpl( - skillId, + skillName, path, resolveToolContext(param), param.getRuntimeContext()); @@ -288,7 +288,7 @@ public Mono callAsync(ToolCallParam param) { * Implementation of skill resource loading logic. Both load paths (per-call box and the * build-time bound static tool) converge here, so the grant gate lives in this method only. * - * @param skillId The unique identifier of the skill + * @param skillName The unique name of the skill * @param path The path to the resource file * @param tcs the per-call tool context state (may be {@code null}) * @param rc the tool call's runtime context (may be {@code null} → unrestricted: direct @@ -298,21 +298,21 @@ public Mono callAsync(ToolCallParam param) { * found */ String loadSkillResourceImpl( - String skillId, String path, ToolContextState tcs, RuntimeContext rc) { - AgentSkill skill = validateSkillExists(skillId); - requireSkillGranted(skillId, skill, rc); + String skillName, String path, ToolContextState tcs, RuntimeContext rc) { + AgentSkill skill = validateSkillExists(skillName); + requireSkillGranted(skillName, skill, rc); // Special handling for SKILL.md - return the skill's markdown content if ("SKILL.md".equals(path)) { - activateSkill(skillId, tcs); - return buildSkillMarkdownResponse(skillId, skill); + activateSkill(skillName, tcs); + return buildSkillMarkdownResponse(skillName, skill); } // 1. In-memory map (eager FS repos, classpath repos, marketplace prefetch). Map resources = skill.getResources(); if (resources != null && resources.containsKey(path)) { - activateSkill(skillId, tcs); - return buildResourceResponse(skillId, path, resources.get(path)); + activateSkill(skillName, tcs); + return buildResourceResponse(skillName, path, resources.get(path)); } // 2. Disk fallback via skill.originDir (lazy FS repos, or any FS-backed skill whose @@ -323,15 +323,15 @@ String loadSkillResourceImpl( Optional diskContent = readFromOriginDir(skill.getOriginDir().get(), sanitized.get()); if (diskContent.isPresent()) { - activateSkill(skillId, tcs); - return buildResourceResponse(skillId, path, diskContent.get()); + activateSkill(skillName, tcs); + return buildResourceResponse(skillName, path, diskContent.get()); } } // 3. Not found — enumerate from both sources so the model gets real options. throw new IllegalArgumentException( buildResourceNotFoundMessage( - skillId, path, resources, skill.getOriginDir().orElse(null))); + skillName, path, resources, skill.getOriginDir().orElse(null))); } private static Optional sanitizeRelativePath(String path) { @@ -381,13 +381,13 @@ private static Optional readFromOriginDir(Path originDir, String relPath /** * Build response for SKILL.md content. * - * @param skillId The skill ID + * @param skillName The skill name * @param skill The skill instance * @return Formatted skill markdown response */ - private String buildSkillMarkdownResponse(String skillId, AgentSkill skill) { + private String buildSkillMarkdownResponse(String skillName, AgentSkill skill) { StringBuilder result = new StringBuilder(); - result.append("Successfully loaded skill: ").append(skillId).append("\n\n"); + result.append("Successfully loaded skill: ").append(skillName).append("\n\n"); result.append("Name: ").append(skill.getName()).append("\n"); result.append("Description: ").append(skill.getDescription()).append("\n"); result.append("Source: ").append(skill.getSource()).append("\n\n"); @@ -401,14 +401,14 @@ private String buildSkillMarkdownResponse(String skillId, AgentSkill skill) { /** * Build response for regular resource content. * - * @param skillId The skill ID + * @param skillName The skill name * @param path The resource path * @param resourceContent The resource content * @return Formatted resource response */ - private String buildResourceResponse(String skillId, String path, String resourceContent) { + private String buildResourceResponse(String skillName, String path, String resourceContent) { StringBuilder result = new StringBuilder(); - result.append("Successfully loaded resource from skill: ").append(skillId).append("\n"); + result.append("Successfully loaded resource from skill: ").append(skillName).append("\n"); result.append("Resource path: ").append(path).append("\n\n"); result.append("Content:\n"); result.append("---\n"); @@ -420,18 +420,18 @@ private String buildResourceResponse(String skillId, String path, String resourc /** * Build error message with available resource paths when resource is not found. * - * @param skillId The skill ID + * @param skillName The skill name * @param path The requested path that was not found * @param resources The available resources map * @return Formatted error message with available resources */ private String buildResourceNotFoundMessage( - String skillId, String path, Map resources, Path originDir) { + String skillName, String path, Map resources, Path originDir) { StringBuilder message = new StringBuilder(); message.append("Resource not found: '") .append(path) .append("' in skill '") - .append(skillId) + .append(skillName) .append("'.\n\n"); // Build a deduped list spanning in-memory keys and on-disk entries so the model can @@ -477,49 +477,51 @@ private static List listOriginDirEntries(Path originDir) { * this load never entered an agent call() (whose funnel always writes one): treat it as * unrestricted — direct execution is trusted assembler code. * - * @param skillId the caller-supplied skill identifier (authoritative for the error echo) + * @param skillName the caller-supplied skill name (authoritative for the error echo) * @param skill the resolved skill (from {@link #validateSkillExists}) * @param rc the tool call's runtime context (may be {@code null}) * @throws IllegalArgumentException when the skill is not granted — byte-identical to the * {@link #validateSkillExists} not-found wording: do not reveal existence */ - private void requireSkillGranted(String skillId, AgentSkill skill, RuntimeContext rc) { + private void requireSkillGranted(String skillName, AgentSkill skill, RuntimeContext rc) { CapabilitySurface surface = rc != null ? rc.get(CapabilitySurface.class) : null; if (surface == null) { surface = CapabilitySurface.ALLOW_ALL; } if (!surface.allowsSkill(skill)) { logger.warn( - "Skill load denied (grant): skillId='{}', userId='{}', sessionId='{}'", - skillId, + "Skill load denied (grant): skillName='{}', userId='{}', sessionId='{}'", + skillName, rc != null ? rc.getUserId() : null, rc != null ? rc.getSessionId() : null); throw new IllegalArgumentException( - String.format("Skill not found: '%s'. Please check the skill ID.", skillId)); + String.format( + "Skill not found: '%s'. Please check the skill name.", skillName)); } } /** * Validates that a skill is registered and returns its instance. * - * @param skillId The unique identifier of the skill + * @param skillName The unique name of the skill * @return The registered skill instance * @throws IllegalArgumentException if the skill is not registered * @throws IllegalStateException if the skill cannot be loaded after validation */ - private AgentSkill validateSkillExists(String skillId) { - if (!skillRegistry.exists(skillId)) { + private AgentSkill validateSkillExists(String skillName) { + if (!skillRegistry.exists(skillName)) { throw new IllegalArgumentException( - String.format("Skill not found: '%s'. Please check the skill ID.", skillId)); + String.format( + "Skill not found: '%s'. Please check the skill name.", skillName)); } // Get skill - AgentSkill skill = skillRegistry.getSkill(skillId); + AgentSkill skill = skillRegistry.getSkill(skillName); if (skill == null) { throw new IllegalStateException( String.format( "Failed to load skill '%s' after validation. This is an internal" + " error.", - skillId)); + skillName)); } return skill; } @@ -538,7 +540,7 @@ ToolContextState resolveToolContext(ToolCallParam param) { return state != null ? state.getToolContext() : null; } - private void activateSkill(String skillId, ToolContextState tcs) { + private void activateSkill(String skillName, ToolContextState tcs) { // No per-session context: skip the tool-group gate without mutating shared state. if (tcs == null) { logger.warn( @@ -548,17 +550,15 @@ private void activateSkill(String skillId, ToolContextState tcs) { } // Read-only discovery of the groups owned by this skill: the name-convention group - // (skillId_skill_tools) plus any SkillToolGroup bound via activateOnSkill. Shared group - // definitions are never mutated. + // (skillName_skill_tools) plus any SkillToolGroup bound via activateOnSkill (bindings + // match by skill name). Shared group definitions are never mutated. List groups = new ArrayList<>(); - String toolsGroupName = SkillToolGroup.skillToolsGroupName(skillId); + String toolsGroupName = SkillToolGroup.skillToolsGroupName(skillName); if (toolkit != null && toolkit.getToolGroup(toolsGroupName) != null) { groups.add(toolsGroupName); } - AgentSkill agentSkill = skillRegistry.getSkill(skillId); - if (agentSkill != null && toolkit != null) { - for (String group : - toolkit.findSkillToolGroupsByActivateOnSkill(agentSkill.getName())) { + if (toolkit != null) { + for (String group : toolkit.findSkillToolGroupsByActivateOnSkill(skillName)) { if (!groups.contains(group)) { groups.add(group); } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java b/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java index 56035bffa6..64e8f19e4d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/SkillToolGroup.java @@ -47,14 +47,14 @@ public class SkillToolGroup extends ToolGroup { private final String rawDescription; /** - * Single source of the skill tool-group naming convention: {@code skillId + + * Single source of the skill tool-group naming convention: {@code skillName + * "_skill_tools"}. * - * @param skillId the skill identifier + * @param skillName the skill name (the skill's business identity) * @return the derived tool-group name */ - public static String skillToolsGroupName(String skillId) { - return skillId + "_skill_tools"; + public static String skillToolsGroupName(String skillName) { + return skillName + "_skill_tools"; } private SkillToolGroup(Builder builder) { @@ -94,18 +94,16 @@ public String getDescription() { @Override public ToolGroup copy() { - SkillToolGroup copy = - SkillToolGroup.skillBuilder() - .name(getName()) - .description(rawDescription) - .active(isActive()) - .activateOnSkill(activateOnSkill) - .tools(getTools()) - // Rebuilt via skillBuilder: without this the system flag is silently - // dropped on copy. - .system(isSystem()) - .build(); - return copy; + return SkillToolGroup.skillBuilder() + .name(getName()) + .description(rawDescription) + .active(isActive()) + .activateOnSkill(activateOnSkill) + .tools(getTools()) + // Rebuilt via skillBuilder: without this the system flag is silently + // dropped on copy. + .system(isSystem()) + .build(); } /** diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java index 1dced87265..8200f51ad3 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/Toolkit.java @@ -458,7 +458,7 @@ public List getToolSchemas( config.externalTools().values().stream() .map(Toolkit::externalToolSchema)) .toList(); - return surface != null ? rewriteMetaToolSchema(schemas, surface) : schemas; + return surface != null ? rewriteMetaToolSchema(schemas, config, surface) : schemas; } /** @@ -488,18 +488,22 @@ private static ToolSchema externalToolSchema(SchemaOnlyTool tool) { * Post-processing for the reset_equipped_tools schema: both the to_activate enum and * the description keep only activatable groups (the original description embeds the * full group notes, so filtering the enum alone would still leak group names). Only - * called with a non-null surface (an absent surface skips trimming entirely). + * called with a non-null surface (an absent surface skips trimming entirely). Only the + * framework's backend meta tool is rewritten: an external override of the same name is + * caller-controlled and passes through untouched. * * @param schemas the grant-filtered schemas to post-process + * @param config the per-call request config, for external-override detection * @param surface the per-request grant set (never {@code null} here) - * @return schemas with the meta tool's enum and description rewritten + * @return schemas with the backend meta tool's enum and description rewritten */ private List rewriteMetaToolSchema( - List schemas, CapabilitySurface surface) { + List schemas, ToolRequestConfig config, CapabilitySurface surface) { return schemas.stream() .map( s -> "reset_equipped_tools".equals(s.getName()) + && !config.overrides(s.getName()) ? rewriteResetToolSchema(s, surface) : s) .toList(); diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java index d9b4dacf27..2617d5efc9 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java @@ -255,7 +255,7 @@ public boolean isWriteable() { } } - /** A skill named {@code name} with skillId {@code name_custom}. */ + /** A skill named {@code name} with skillName {@code name_custom}. */ private static AgentSkill skill(String name) { return new AgentSkill(name, "Skill " + name, "# " + name, null); } @@ -513,6 +513,51 @@ void externalToolsNotTrimmed() { schemaNames(schemas).stream().sorted().toList()); } + @Test + @DisplayName( + "an external override of reset_equipped_tools keeps the caller's schema (no rewrite)") + void externalMetaToolOverrideNotRewritten() { + Toolkit toolkit = grantToolkit(); + SchemaOnlyTool callerReset = + new SchemaOnlyTool( + ToolSchema.builder() + .name("reset_equipped_tools") + .description("caller-hosted tool-group reset (preview)") + .parameters( + Map.of( + "type", + "object", + "properties", + Map.of("groups", Map.of("type", "array")))) + .build()); + ToolRequestConfig config = + new ToolRequestConfig( + Map.of("reset_equipped_tools", callerReset), ToolMergeMode.EXTERNAL_ONLY); + + List schemas = + toolkit.getToolSchemas( + List.of("g1", "g2"), + config, + new CapabilitySurface(null, Set.of("g1"), Set.of())); + + // The external override shadows the backend meta tool: exactly one row, the caller's. + assertEquals(List.of("reset_equipped_tools"), schemaNames(schemas)); + ToolSchema reset = metaSchema(schemas); + assertEquals( + "caller-hosted tool-group reset (preview)", + reset.getDescription(), + "the caller-controlled schema must pass through untouched"); + assertTrue( + reset.getParameters().toString().contains("groups"), + "the caller's parameter shape is preserved"); + assertFalse( + reset.getDescription().contains("Reset your equipped tools"), + "backend base description must not replace the caller's"); + assertFalse( + reset.getDescription().contains("group-one"), + "backend group notes must not leak through the external tool"); + } + private static SchemaOnlyTool schemaOnly(String name) { return new SchemaOnlyTool( ToolSchema.builder() @@ -734,7 +779,7 @@ void skillVisibilityFiltering() { String prompt = mw.onSystemPrompt( null, - rc(new CapabilitySurface(Set.of("alpha_custom"), null, null)), + rc(new CapabilitySurface(Set.of("alpha"), null, null)), "base") .block(); @@ -771,21 +816,21 @@ void boxCacheIsolationAcrossSurfaces() { false, null); - RuntimeContext rc1 = rc(new CapabilitySurface(Set.of("alpha_custom"), null, null)); + RuntimeContext rc1 = rc(new CapabilitySurface(Set.of("alpha"), null, null)); mw.onSystemPrompt(null, rc1, "base").block(); SkillBox box1 = rc1.get(SkillBox.class); - RuntimeContext rc2 = rc(new CapabilitySurface(Set.of("beta_custom"), null, null)); + RuntimeContext rc2 = rc(new CapabilitySurface(Set.of("beta"), null, null)); mw.onSystemPrompt(null, rc2, "base").block(); SkillBox box2 = rc2.get(SkillBox.class); assertNotNull(box1); assertNotNull(box2); assertTrue(box1 != box2, "different grants must materialize different boxes"); - assertTrue(box1.getAllSkillIds().contains("alpha_custom")); - assertFalse(box1.getAllSkillIds().contains("beta_custom")); - assertTrue(box2.getAllSkillIds().contains("beta_custom")); - assertFalse(box2.getAllSkillIds().contains("alpha_custom")); + assertTrue(box1.getAllSkillNames().contains("alpha")); + assertFalse(box1.getAllSkillNames().contains("beta")); + assertTrue(box2.getAllSkillNames().contains("beta")); + assertFalse(box2.getAllSkillNames().contains("alpha")); } // ==================== tool execution gate ==================== @@ -856,18 +901,18 @@ void dynamicLoadPathGate() { null, false, null); - RuntimeContext ctx = rc(new CapabilitySurface(Set.of("alpha_custom"), null, null)); + RuntimeContext ctx = rc(new CapabilitySurface(Set.of("alpha"), null, null)); mw.onSystemPrompt(null, ctx, "base").block(); assertNotNull(ctx.get(SkillBox.class)); - ToolResultBlock denied = loadViaToolkit(toolkit, "beta_custom", ctx); + ToolResultBlock denied = loadViaToolkit(toolkit, "beta", ctx); assertEquals( - "Error: Skill not found: 'beta_custom'. Please check the skill ID.", + "Error: Skill not found: 'beta'. Please check the skill name.", firstText(denied), "deny wording must be byte-identical to the not-found wording"); - ToolResultBlock allowed = loadViaToolkit(toolkit, "alpha_custom", ctx); - assertTrue(firstText(allowed).contains("Successfully loaded skill: alpha_custom")); + ToolResultBlock allowed = loadViaToolkit(toolkit, "alpha", ctx); + assertTrue(firstText(allowed).contains("Successfully loaded skill: alpha")); } @Test @@ -887,8 +932,8 @@ void dynamicLoadPathSystemSkill() { RuntimeContext ctx = rc(new CapabilitySurface(Set.of(), null, null)); mw.onSystemPrompt(null, ctx, "base").block(); - ToolResultBlock result = loadViaToolkit(toolkit, "gamma_custom", ctx); - assertTrue(firstText(result).contains("Successfully loaded skill: gamma_custom")); + ToolResultBlock result = loadViaToolkit(toolkit, "gamma", ctx); + assertTrue(firstText(result).contains("Successfully loaded skill: gamma")); } @Test @@ -902,16 +947,16 @@ void staticLoadPathGate() { AgentTool loadTool = toolkit.getTool("load_skill_through_path"); assertNotNull(loadTool); - RuntimeContext ctx = rc(new CapabilitySurface(Set.of("alpha_custom"), null, null)); + RuntimeContext ctx = rc(new CapabilitySurface(Set.of("alpha"), null, null)); ToolResultBlock denied = - callDirect(loadTool, Map.of("skillId", "secret_custom", "path", "SKILL.md"), ctx); + callDirect(loadTool, Map.of("skillName", "secret", "path", "SKILL.md"), ctx); assertEquals( - "Error: Skill not found: 'secret_custom'. Please check the skill ID.", + "Error: Skill not found: 'secret'. Please check the skill name.", firstText(denied)); ToolResultBlock allowed = - callDirect(loadTool, Map.of("skillId", "alpha_custom", "path", "SKILL.md"), ctx); - assertTrue(firstText(allowed).contains("Successfully loaded skill: alpha_custom")); + callDirect(loadTool, Map.of("skillName", "alpha", "path", "SKILL.md"), ctx); + assertTrue(firstText(allowed).contains("Successfully loaded skill: alpha")); } @Test @@ -932,18 +977,18 @@ void loadGateTreatsMissingSurfaceAsAllowAll() { // Direct callAsync so the executor gate does not preempt the load-gate check. ToolResultBlock result = - callDirect(loadTool, Map.of("skillId", "gamma_custom", "path", "SKILL.md"), rc()); + callDirect(loadTool, Map.of("skillName", "gamma", "path", "SKILL.md"), rc()); assertTrue( - firstText(result).contains("Successfully loaded skill: gamma_custom"), + firstText(result).contains("Successfully loaded skill: gamma"), "no surface (direct path) = unrestricted: " + firstText(result)); } /** Loads SKILL.md through the toolkit entry (executor + load gate). */ - private ToolResultBlock loadViaToolkit(Toolkit toolkit, String skillId, RuntimeContext ctx) { + private ToolResultBlock loadViaToolkit(Toolkit toolkit, String skillName, RuntimeContext ctx) { return toolkit.callTool( param( "load_skill_through_path", - Map.of("skillId", skillId, "path", "SKILL.md"), + Map.of("skillName", skillName, "path", "SKILL.md"), ctx)) .block(); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java index a88ba9aecd..b8608e87b9 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java @@ -122,10 +122,10 @@ void skillDimensionThreeStates() { @Test @DisplayName("skill object entry: id delegation, system bypass, and the null guard") void skillObjectEntryMatrix() { - // Id delegation: builder default source "custom" → skillId "s1_custom". + // Id delegation: builder default source "custom" → skillName "s1". AgentSkill s1 = AgentSkill.builder().name("s1").description("d").skillContent("c").build(); AgentSkill s2 = AgentSkill.builder().name("s2").description("d").skillContent("c").build(); - CapabilitySurface surface = new CapabilitySurface(Set.of("s1_custom"), Set.of(), Set.of()); + CapabilitySurface surface = new CapabilitySurface(Set.of("s1"), Set.of(), Set.of()); assertTrue(surface.allowsSkill(s1)); assertFalse(surface.allowsSkill(s2)); assertTrue(CapabilitySurface.ALLOW_ALL.allowsSkill(s2)); @@ -140,7 +140,7 @@ void skillObjectEntryMatrix() { .system(true) .build(); assertTrue(CapabilitySurface.DENY_ALL.allowsSkill(systemSkill)); - assertFalse(CapabilitySurface.DENY_ALL.allowsSkill(systemSkill.getSkillId())); + assertFalse(CapabilitySurface.DENY_ALL.allowsSkill(systemSkill.getName())); // Null guard: a registry miss is never granted, not even on ALLOW_ALL. AgentSkill registryMiss = null; @@ -194,11 +194,11 @@ void groupPath() { } @Test - @DisplayName("skill path — granted skillId authorizes its skill tool group") + @DisplayName("skill path — granted skillName authorizes its skill tool group") void skillGroupPath() { // Group dimension restricted (empty) so only the skill path can rescue the tool. CapabilitySurface surface = new CapabilitySurface(Set.of("s1"), Set.of(), Set.of()); - // skillId s1 → group name "s1_skill_tools" (SkillToolGroup naming convention) + // skillName s1 → group name "s1_skill_tools" (SkillToolGroup naming convention) assertTrue(surface.allowsTool(tool("t1"), Set.of("s1_skill_tools"))); assertFalse(surface.allowsTool(tool("t1"), Set.of("s2_skill_tools"))); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java index e9072f5ff8..83bdcd91b5 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java @@ -53,7 +53,7 @@ void testNoSkillsReturnsEmpty() { void testSingleSkill() { AgentSkill skill = new AgentSkill("test_skill", "Test Skill Description", "# Content", null); - skillRegistry.registerSkill("test_skill_custom", skill); + skillRegistry.registerSkill("test_skill", skill); String prompt = provider.getSkillSystemPrompt(); @@ -62,7 +62,7 @@ void testSingleSkill() { assertTrue(prompt.contains("")); assertTrue(prompt.contains("test_skill")); assertTrue(prompt.contains("Test Skill Description")); - assertTrue(prompt.contains("test_skill_custom")); + assertTrue(prompt.contains("test_skill")); assertTrue(prompt.contains("")); assertTrue(prompt.contains("")); } @@ -75,7 +75,7 @@ void testMetadataOrder() { metadata.put("description", "Manage Trello boards"); metadata.put("homepage", "https://developer.atlassian.com/cloud/trello/rest/"); AgentSkill skill = new AgentSkill(metadata, "# Content", null, null); - skillRegistry.registerSkill("trello_custom", skill); + skillRegistry.registerSkill("trello", skill); String prompt = provider.getSkillSystemPrompt(); @@ -84,11 +84,11 @@ void testMetadataOrder() { int homepageIndex = prompt.indexOf( "https://developer.atlassian.com/cloud/trello/rest/"); - int skillIdIndex = prompt.indexOf("trello_custom"); + int skillNameIndex = prompt.indexOf("trello"); assertTrue(nameIndex < descriptionIndex); assertTrue(descriptionIndex < homepageIndex); - assertTrue(homepageIndex < skillIdIndex); + assertTrue(homepageIndex < skillNameIndex); } @Test @@ -110,7 +110,7 @@ void testNestedMetadataXml() { "env", List.of("TRELLO_API_KEY", "TRELLO_TOKEN"))))); AgentSkill skill = new AgentSkill(metadata, "# Content", null, null); - skillRegistry.registerSkill("trello_custom", skill); + skillRegistry.registerSkill("trello", skill); String prompt = provider.getSkillSystemPrompt(); @@ -134,7 +134,7 @@ void testExposeOnlyCoreMetadata() { metadata.put("homepage", "https://developer.atlassian.com/cloud/trello/rest/"); metadata.put("metadata", Map.of("clawdbot", Map.of("emoji", "📋"))); AgentSkill skill = new AgentSkill(metadata, "# Content", null, null); - skillRegistry.registerSkill("trello_custom", skill); + skillRegistry.registerSkill("trello", skill); provider.setExposeAllMetadata(false); @@ -142,7 +142,7 @@ void testExposeOnlyCoreMetadata() { assertTrue(prompt.contains("trello")); assertTrue(prompt.contains("Manage Trello boards")); - assertTrue(prompt.contains("trello_custom")); + assertTrue(prompt.contains("trello")); assertFalse(prompt.contains("")); assertFalse(prompt.contains("")); } @@ -155,7 +155,7 @@ void testOmitNullMetadataValues() { metadata.put("description", "Manage Trello boards"); metadata.put("homepage", null); AgentSkill skill = new AgentSkill(metadata, "# Content", null, null); - skillRegistry.registerSkill("trello_custom", skill); + skillRegistry.registerSkill("trello", skill); String prompt = provider.getSkillSystemPrompt(); @@ -171,7 +171,7 @@ void testXmlEscaping() { metadata.put("name", "test_skill"); metadata.put("description", "Description with & \"quotes\" and 'apostrophes'"); AgentSkill skill = new AgentSkill(metadata, "# Content", null, null); - skillRegistry.registerSkill("test_skill_custom", skill); + skillRegistry.registerSkill("test_skill", skill); String prompt = provider.getSkillSystemPrompt(); @@ -189,7 +189,7 @@ void testInvalidMetadataKeyFallback() { metadata.put("description", "desc"); metadata.put("tool:config", "enabled"); AgentSkill skill = new AgentSkill(metadata, "# Content", null, null); - skillRegistry.registerSkill("test_skill_custom", skill); + skillRegistry.registerSkill("test_skill", skill); String prompt = provider.getSkillSystemPrompt(); @@ -200,7 +200,7 @@ void testInvalidMetadataKeyFallback() { @DisplayName("Should not include code execution section when not enabled") void testNoCodeExecutionSectionByDefault() { AgentSkill skill = new AgentSkill("test_skill", "Test Skill", "# Content", null); - skillRegistry.registerSkill("test_skill_custom", skill); + skillRegistry.registerSkill("test_skill", skill); String prompt = provider.getSkillSystemPrompt(); @@ -212,7 +212,7 @@ void testNoCodeExecutionSectionByDefault() { @DisplayName("Should not include code execution section when enabled but uploadDir not set") void testNoCodeExecutionSectionWhenEnabledButNoUploadDir() { AgentSkill skill = new AgentSkill("test_skill", "Test Skill", "# Content", null); - skillRegistry.registerSkill("test_skill_custom", skill); + skillRegistry.registerSkill("test_skill", skill); provider.setCodeExecutionEnable(true); @@ -226,7 +226,7 @@ void testNoCodeExecutionSectionWhenEnabledButNoUploadDir() { @DisplayName("Should include code execution section with uploadDir when enabled") void testCodeExecutionSectionIncludedWhenEnabled(@TempDir Path tempDir) { AgentSkill skill = new AgentSkill("test_skill", "Test Skill", "# Content", null); - skillRegistry.registerSkill("test_skill_custom", skill); + skillRegistry.registerSkill("test_skill", skill); provider.setCodeExecutionEnable(true); provider.setUploadDir(tempDir); @@ -243,7 +243,7 @@ void testCodeExecutionSectionIncludedWhenEnabled(@TempDir Path tempDir) { @DisplayName("Code execution section should appear after ") void testCodeExecutionSectionAppearsAfterAvailableSkills(@TempDir Path tempDir) { AgentSkill skill = new AgentSkill("test_skill", "Test Skill", "# Content", null); - skillRegistry.registerSkill("test_skill_custom", skill); + skillRegistry.registerSkill("test_skill", skill); provider.setCodeExecutionEnable(true); provider.setUploadDir(tempDir); @@ -259,7 +259,7 @@ void testCodeExecutionSectionAppearsAfterAvailableSkills(@TempDir Path tempDir) @DisplayName("Should use custom code execution instruction when set") void testCustomCodeExecutionInstruction(@TempDir Path tempDir) { AgentSkill skill = new AgentSkill("test_skill", "Test Skill", "# Content", null); - skillRegistry.registerSkill("test_skill_custom", skill); + skillRegistry.registerSkill("test_skill", skill); provider.setCodeExecutionEnable(true); provider.setUploadDir(tempDir); @@ -285,7 +285,7 @@ void testEmitsPerSkillFilesRoot(@TempDir Path tempDir) { .skillContent("# Content") .originDir(origin) .build(); - skillRegistry.registerSkill("alpha_custom", skill); + skillRegistry.registerSkill("alpha", skill); provider.setCodeExecutionEnable(true); @@ -308,7 +308,7 @@ void testNoFilesRootWhenCodeExecutionOff(@TempDir Path tempDir) { .skillContent("# Content") .originDir(origin) .build(); - skillRegistry.registerSkill("alpha_custom", skill); + skillRegistry.registerSkill("alpha", skill); // codeExecutionEnabled defaults to false String prompt = provider.getSkillSystemPrompt(); @@ -330,8 +330,8 @@ void testFallbackToUploadDirTemplate(@TempDir Path tempDir) { .originDir(origin) .build(); AgentSkill noOrigin = new AgentSkill("beta", "beta skill", "# B", null); // no originDir - skillRegistry.registerSkill("alpha_custom", withOrigin); - skillRegistry.registerSkill("beta_custom", noOrigin); + skillRegistry.registerSkill("alpha", withOrigin); + skillRegistry.registerSkill("beta", noOrigin); provider.setCodeExecutionEnable(true); provider.setUploadDir(tempDir); diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillTest.java index ecf97c3187..694d1f456f 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillTest.java @@ -98,7 +98,7 @@ void testConstructorWithCustomSource() { "github_skill", "From GitHub", "GitHub content", resources, "github"); assertEquals("github", skill.getSource()); - assertEquals("github_skill_github", skill.getSkillId()); + assertEquals("github_skill", skill.getName()); } @Test @@ -169,13 +169,14 @@ void testConstructorValidatesContent() { } @Test - @DisplayName("Should skill id uniqueness") - void testSkillIdUniqueness() { + @DisplayName("Should skill identity be the name; source is provenance only") + void testSkillIdentityIsTheName() { AgentSkill skill1 = new AgentSkill("same_name", "desc", "content", null, "source1"); AgentSkill skill2 = new AgentSkill("same_name", "desc", "content", null, "source2"); - assertEquals("same_name_source1", skill1.getSkillId()); - assertEquals("same_name_source2", skill2.getSkillId()); + assertEquals(skill1.getName(), skill2.getName()); + assertEquals("source1", skill1.getSource()); + assertEquals("source2", skill2.getSource()); } @Test @@ -345,7 +346,7 @@ void testEdgeCases() { // Unicode AgentSkill skill3 = new AgentSkill("技能名称", "技能描述:测试", "技能内容\n包含中文", null, "来源"); assertEquals("技能名称", skill3.getName()); - assertEquals("技能名称_来源", skill3.getSkillId()); + assertEquals("来源", skill3.getSource()); // Many resources Map manyResources = new HashMap<>(); diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java index c5f7b11c2c..f4c062ec65 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java @@ -164,7 +164,7 @@ void alternatingContentKeepsMaterializedResourcesIsolated() throws Exception { RuntimeContext rcB = grantRc(); mw.onSystemPrompt(null, rcA, "").block(); SkillBox boxA = rcA.get(SkillBox.class); - String id = boxA.getAllSkillIds().iterator().next(); + String id = boxA.getAllSkillNames().iterator().next(); Path fileA = boxA.getUploadDir().resolve(id).resolve("data.txt"); repo.skills = List.of(b); mw.onSystemPrompt(null, rcB, "").block(); @@ -221,7 +221,7 @@ protected List filterVisible( "complete resource", java.nio.file.Files.readString( box.getUploadDir() - .resolve(skill.getSkillId()) + .resolve(skill.getName()) .resolve("data.txt"))); return box; })); @@ -250,7 +250,7 @@ void evictionDoesNotRewriteResourcesOfAnActiveBox() throws Exception { RuntimeContext rc = grantRc(); mw.onSystemPrompt(null, rc, "").block(); SkillBox active = rc.get(SkillBox.class); - Path activeFile = active.getUploadDir().resolve(skill.getSkillId()).resolve("data.txt"); + Path activeFile = active.getUploadDir().resolve(skill.getName()).resolve("data.txt"); for (int i = 0; i < 32; i++) { repo.skills = List.of(new AgentSkill("other" + i, "Other", "content", null)); mw.onSystemPrompt(null, grantRc(), "").block(); @@ -267,7 +267,7 @@ void evictionDoesNotRewriteResourcesOfAnActiveBox() throws Exception { java.nio.file.Files.readString( rc.get(SkillBox.class) .getUploadDir() - .resolve(skill.getSkillId()) + .resolve(skill.getName()) .resolve("data.txt"))); } 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 3216af53ba..323065ff96 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 @@ -70,7 +70,7 @@ void testGetSkillById() { AgentSkill skill = new AgentSkill("test_skill", "Test Skill", "# Content", null); skillBox.registerSkill(skill); - AgentSkill retrieved = skillBox.getSkill(skill.getSkillId()); + AgentSkill retrieved = skillBox.getSkill(skill.getName()); assertNotNull(retrieved); assertEquals("test_skill", retrieved.getName()); @@ -89,11 +89,11 @@ void testRemoveSkill() { AgentSkill skill = new AgentSkill("test_skill", "Test Skill", "# Content", null); skillBox.registerSkill(skill); - assertTrue(skillBox.exists("test_skill_custom")); + assertTrue(skillBox.exists("test_skill")); - skillBox.removeSkill("test_skill_custom"); + skillBox.removeSkill("test_skill"); - assertFalse(skillBox.exists(skill.getSkillId())); + assertFalse(skillBox.exists(skill.getName())); } @Test @@ -104,7 +104,7 @@ void testCheckSkillExists() { AgentSkill skill = new AgentSkill("test_skill", "Test Skill", "# Content", null); skillBox.registerSkill(skill); - assertTrue(skillBox.exists(skill.getSkillId())); + assertTrue(skillBox.exists(skill.getName())); } @Test @@ -121,7 +121,7 @@ void testCreateToolGroupWhenRegisteringSkill() { AgentSkill skill = new AgentSkill("my_skill", "My Skill", "# Content", null); // Before registration, the tool group should not exist - String toolsGroupName = skill.getSkillId() + "_skill_tools"; + String toolsGroupName = skill.getName() + "_skill_tools"; assertNull( toolkit.getToolGroup(toolsGroupName), "Tool group should not exist before skill registration"); @@ -163,7 +163,7 @@ void testSuccessfullyRegisterWhenOnlyToolObjectProvided() { // Should not throw - only one registration type skillBox.registration().skill(skill).tool(toolObject).apply(); - assertTrue(skillBox.exists(skill.getSkillId())); + assertTrue(skillBox.exists(skill.getName())); assertNotNull(toolkit.getTool("test_tool_method"), "Tool should be registered"); } @@ -181,7 +181,7 @@ void testSuccessfullyRegisterWhenOnlyAgentToolProvided() { // Should not throw - only one registration type skillBox.registration().skill(skill).agentTool(agentTool).apply(); - assertTrue(skillBox.exists(skill.getSkillId())); + assertTrue(skillBox.exists(skill.getName())); assertNotNull(toolkit.getTool("agent_tool_only"), "Agent tool should be registered"); } @@ -210,22 +210,23 @@ void testSuccessfullyRegisterWhenOnlyMcpClientProvided() { // Should not throw - only one registration type skillBox.registration().skill(skill).mcpClient(mcpClient).apply(); - assertTrue(skillBox.exists(skill.getSkillId())); + assertTrue(skillBox.exists(skill.getName())); assertNotNull(toolkit.getTool("mcp_only_tool"), "MCP tool should be registered"); } @Test @DisplayName("Should return empty set when no skills are registered") void testGetAllSkillIdsWhenEmpty() { - var skillIds = skillBox.getAllSkillIds(); + var skillNames = skillBox.getAllSkillNames(); - assertNotNull(skillIds, "Skill IDs set should not be null"); + assertNotNull(skillNames, "Skill names set should not be null"); assertTrue( - skillIds.isEmpty(), "Skill IDs set should be empty when no skills registered"); + skillNames.isEmpty(), + "Skill names set should be empty when no skills registered"); } @Test - @DisplayName("Should return all skill IDs when multiple skills are registered") + @DisplayName("Should return all skill names when multiple skills are registered") void testGetAllSkillIdsWithMultipleSkills() { AgentSkill skill1 = new AgentSkill("skill_one", "Skill One", "# Content 1", null); AgentSkill skill2 = new AgentSkill("skill_two", "Skill Two", "# Content 2", null); @@ -235,13 +236,13 @@ void testGetAllSkillIdsWithMultipleSkills() { skillBox.registerSkill(skill2); skillBox.registerSkill(skill3); - var skillIds = skillBox.getAllSkillIds(); + var skillNames = skillBox.getAllSkillNames(); - assertNotNull(skillIds, "Skill IDs set should not be null"); - assertEquals(3, skillIds.size(), "Should have exactly three skill IDs"); - assertTrue(skillIds.contains(skill1.getSkillId()), "Should contain first skill ID"); - assertTrue(skillIds.contains(skill2.getSkillId()), "Should contain second skill ID"); - assertTrue(skillIds.contains(skill3.getSkillId()), "Should contain third skill ID"); + assertNotNull(skillNames, "Skill names set should not be null"); + assertEquals(3, skillNames.size(), "Should have exactly three skill names"); + assertTrue(skillNames.contains(skill1.getName()), "Should contain first skill name"); + assertTrue(skillNames.contains(skill2.getName()), "Should contain second skill name"); + assertTrue(skillNames.contains(skill3.getName()), "Should contain third skill name"); } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java index 29bddd28e8..b358fe6c1c 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java @@ -151,18 +151,18 @@ void testLoadSkillResourceToolParameters() { @SuppressWarnings("unchecked") Map properties = (Map) params.get("properties"); assertNotNull(properties); - assertTrue(properties.containsKey("skillId")); + assertTrue(properties.containsKey("skillName")); assertTrue(properties.containsKey("path")); @SuppressWarnings("unchecked") List required = (List) params.get("required"); - assertTrue(required.contains("skillId")); + assertTrue(required.contains("skillName")); assertTrue(required.contains("path")); - // Verify skillId has enum with available skills + // Verify skillName has enum with available skills @SuppressWarnings("unchecked") - Map skillIdParam = (Map) properties.get("skillId"); - assertNotNull(skillIdParam.get("enum")); + Map skillNameParam = (Map) properties.get("skillName"); + assertNotNull(skillNameParam.get("enum")); @SuppressWarnings("unchecked") Map pathParam = (Map) properties.get("path"); @@ -176,7 +176,7 @@ void testLoadSkillResourceToolParameters() { void testLoadSkillMarkdownSuccessfully() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", "test_skill_custom", "path", "SKILL.md"); + Map input = Map.of("skillName", "test_skill", "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-001") @@ -204,8 +204,8 @@ void testLoadSkillMarkdownSuccessfully() { void testActivateSkillAndToolGroupWhenLoadingMarkdown() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - String skillId = "test_skill_custom"; - String toolGroupName = skillId + "_skill_tools"; + String skillName = "test_skill"; + String toolGroupName = skillName + "_skill_tools"; ToolContextState toolContext = ToolContextState.builder().build(); RuntimeContext runtimeContext = @@ -213,7 +213,7 @@ void testActivateSkillAndToolGroupWhenLoadingMarkdown() { .agentState(AgentState.builder().toolContext(toolContext).build()) .build(); - Map input = Map.of("skillId", skillId, "path", "SKILL.md"); + Map input = Map.of("skillName", skillName, "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-002") @@ -242,7 +242,7 @@ void testActivateSkillAndToolGroupWhenLoadingMarkdown() { void testReturnErrorForNonExistentSkill() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", "non_existent_skill", "path", "SKILL.md"); + Map input = Map.of("skillName", "non_existent_skill", "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-003") @@ -296,7 +296,7 @@ void testReturnErrorForMissingSkillId() { void testReturnErrorForEmptySkillId() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", "", "path", "SKILL.md"); + Map input = Map.of("skillName", "", "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-005") @@ -321,7 +321,7 @@ void testReturnErrorForEmptySkillId() { void testLoadSkillResourceSuccessfully() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", "test_skill_custom", "path", "config.json"); + Map input = Map.of("skillName", "test_skill", "path", "config.json"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-006") @@ -349,8 +349,8 @@ void testLoadSkillResourceSuccessfully() { void testActivateSkillAndToolGroupWhenLoadingResource() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - String skillId = "test_skill_custom"; - String toolGroupName = skillId + "_skill_tools"; + String skillName = "test_skill"; + String toolGroupName = skillName + "_skill_tools"; ToolContextState toolContext = ToolContextState.builder().build(); RuntimeContext runtimeContext = @@ -358,7 +358,7 @@ void testActivateSkillAndToolGroupWhenLoadingResource() { .agentState(AgentState.builder().toolContext(toolContext).build()) .build(); - Map input = Map.of("skillId", skillId, "path", "data.txt"); + Map input = Map.of("skillName", skillName, "path", "data.txt"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-007") @@ -386,10 +386,10 @@ void testActivateSkillAndToolGroupWhenLoadingResource() { @DisplayName("Loading without a runtime context does not mutate the shared toolkit") void testLoadWithoutRuntimeContextDoesNotMutateSharedToolkit() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - String skillId = "test_skill_custom"; - String toolGroupName = skillId + "_skill_tools"; + String skillName = "test_skill"; + String toolGroupName = skillName + "_skill_tools"; - Map input = Map.of("skillId", skillId, "path", "SKILL.md"); + Map input = Map.of("skillName", skillName, "path", "SKILL.md"); ToolCallParam param = ToolCallParam.builder() .toolUseBlock( @@ -417,8 +417,8 @@ void testSeparateSessionsLoadDifferentSkillsWithoutCrossTalk() { // Register a second tool-bearing skill. AgentSkill other = new AgentSkill("other_skill", "Other Skill", "# Other", null); skillBox.registration().skill(other).agentTool(createDummyTool("other_skill_tool")).apply(); - String groupA = "test_skill_custom_skill_tools"; - String groupB = "other_skill_custom_skill_tools"; + String groupA = "test_skill_skill_tools"; + String groupB = "other_skill_skill_tools"; ToolContextState tcsA = ToolContextState.builder().build(); ToolContextState tcsB = ToolContextState.builder().build(); @@ -431,8 +431,8 @@ void testSeparateSessionsLoadDifferentSkillsWithoutCrossTalk() { .agentState(AgentState.builder().toolContext(tcsB).build()) .build(); - loadSkillWithContext("test_skill_custom", rcA); - loadSkillWithContext("other_skill_custom", rcB); + loadSkillWithContext("test_skill", rcA); + loadSkillWithContext("other_skill", rcB); assertTrue(tcsA.getActivatedGroups().contains(groupA), "Session A activates its own group"); assertFalse( @@ -444,15 +444,15 @@ void testSeparateSessionsLoadDifferentSkillsWithoutCrossTalk() { "Session B must not see session A's group"); } - private ToolResultBlock loadSkillWithContext(String skillId, RuntimeContext rc) { + private ToolResultBlock loadSkillWithContext(String skillName, RuntimeContext rc) { withGrant(rc); AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", skillId, "path", "SKILL.md"); + Map input = Map.of("skillName", skillName, "path", "SKILL.md"); ToolCallParam param = ToolCallParam.builder() .toolUseBlock( ToolUseBlock.builder() - .id("call-" + skillId) + .id("call-" + skillName) .name("load_skill_through_path") .input(input) .build()) @@ -473,7 +473,7 @@ void testParallelLoadsIntoSameSessionDoNotLoseActivations() throws Exception { .skill(skill) .agentTool(createDummyTool("parallel_tool_" + i)) .apply(); - groupNames.add(skill.getSkillId() + "_skill_tools"); + groupNames.add(skill.getName() + "_skill_tools"); } ToolContextState tcs = ToolContextState.builder().build(); @@ -486,12 +486,12 @@ void testParallelLoadsIntoSameSessionDoNotLoseActivations() throws Exception { CountDownLatch start = new CountDownLatch(1); List> futures = new ArrayList<>(); for (int i = 0; i < n; i++) { - final String skillId = "parallel_" + i + "_custom"; + final String skillName = "parallel_" + i + ""; futures.add( pool.submit( () -> { start.await(); - loadSkillWithContext(skillId, rc); + loadSkillWithContext(skillName, rc); return null; })); } @@ -513,8 +513,7 @@ void testParallelLoadsIntoSameSessionDoNotLoseActivations() throws Exception { void testReturnErrorForNonExistentResource() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = - Map.of("skillId", "test_skill_custom", "path", "non_existent.txt"); + Map input = Map.of("skillName", "test_skill", "path", "non_existent.txt"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-008") @@ -545,7 +544,7 @@ void testReturnErrorForNonExistentResource() { void testReturnErrorForMissingPathParameter() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", "test_skill_custom"); + Map input = Map.of("skillName", "test_skill"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-009") @@ -572,7 +571,7 @@ void testReturnErrorForMissingPathParameter() { void testListAllResourcePathsWithInvalidPath() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", "test_skill_custom", "path", "list"); + Map input = Map.of("skillName", "test_skill", "path", "list"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-010") @@ -601,7 +600,7 @@ void testListAllResourcePathsWithInvalidPath() { void testListOnlySkillMdForSkillWithoutResources() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", "empty_skill_custom", "path", "invalid"); + Map input = Map.of("skillName", "empty_skill", "path", "invalid"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-011") @@ -629,7 +628,7 @@ void testListOnlySkillMdForSkillWithoutResources() { void testHandleWhitespaceInSkillId() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", " ", "path", "SKILL.md"); + Map input = Map.of("skillName", " ", "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-012") @@ -654,7 +653,7 @@ void testHandleWhitespaceInSkillId() { void testHandleWhitespaceInResourcePath() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", "test_skill_custom", "path", " "); + Map input = Map.of("skillName", "test_skill", "path", " "); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-013") @@ -681,11 +680,11 @@ void testLoadSkillWithNoToolsDoesNotFail() { AgentSkill emptySkill = new AgentSkill("no_tools_skill", "No Tools", "# Empty", null); skillBox.registerSkill(emptySkill); - String skillId = emptySkill.getSkillId(); + String skillName = emptySkill.getName(); // Load skill AgentTool loader = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", skillId, "path", "SKILL.md"); + Map input = Map.of("skillName", skillName, "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-015") @@ -705,16 +704,17 @@ void testLoadSkillWithNoToolsDoesNotFail() { assertNotNull(result); assertFalse(isErrorResult(result), "Should not fail when skill has no tools"); assertNull( - toolkit.getToolGroup(skillId + "_skill_tools"), "Tool group should not be created"); + toolkit.getToolGroup(skillName + "_skill_tools"), + "Tool group should not be created"); } @Test @DisplayName("Should not activate skill when resource path is invalid") void testInvalidResourcePathDoesNotActivateSkill() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - String skillId = "test_skill_custom"; + String skillName = "test_skill"; - Map input = Map.of("skillId", skillId, "path", "."); + Map input = Map.of("skillName", skillName, "path", "."); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-016") diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRegistryTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRegistryTest.java index 2b09c97df5..f04ab1cff2 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRegistryTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRegistryTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; @@ -43,55 +44,62 @@ private AgentSkill createSkill(String name) { void testRegisterNewSkill() { AgentSkill skill = createSkill("test"); - registry.registerSkill("test_custom", skill); + registry.registerSkill("test", skill); - assertTrue(registry.exists("test_custom")); - assertEquals(skill, registry.getSkill("test_custom")); + assertTrue(registry.exists("test")); + assertEquals(skill, registry.getSkill("test")); } @Test - @DisplayName("Should register same skill id replaces existing") - void testRegisterSameSkillIdReplacesExisting() { - AgentSkill skill1 = createSkill("v1"); - registry.registerSkill("test_custom", skill1); - - AgentSkill skill2 = createSkill("v2"); - registry.registerSkill("test_custom", skill2); + @DisplayName("Should re-registering the same instance be idempotent") + void testRegisterSameInstanceIsIdempotent() { + AgentSkill skill = createSkill("v1"); + registry.registerSkill("test", skill); + registry.registerSkill("test", skill); + assertTrue(registry.exists("test")); + assertEquals(skill, registry.getSkill("test")); + } - assertTrue(registry.exists("test_custom")); - assertEquals(skill2, registry.getSkill("test_custom")); + @Test + @DisplayName("Should reject a different instance under an already-used name") + void testRegisterDuplicateNameDifferentInstanceRejected() { + registry.registerSkill("test", createSkill("v1")); + assertThrows( + IllegalArgumentException.class, + () -> registry.registerSkill("test", createSkill("v2"))); + assertTrue(registry.exists("test")); } @Test @DisplayName("Should get skill ids") void testGetSkillIds() { - registry.registerSkill("test1_custom", createSkill("test1")); - registry.registerSkill("test2_custom", createSkill("test2")); + registry.registerSkill("test1", createSkill("test1")); + registry.registerSkill("test2", createSkill("test2")); - var skillIds = registry.getSkillIds(); - assertEquals(2, skillIds.size()); - assertTrue(skillIds.contains("test1_custom")); - assertTrue(skillIds.contains("test2_custom")); + var skillNames = registry.getSkillNames(); + assertEquals(2, skillNames.size()); + assertTrue(skillNames.contains("test1")); + assertTrue(skillNames.contains("test2")); } @Test @DisplayName("Should exists") void testExists() { - registry.registerSkill("test_custom", createSkill("test")); + registry.registerSkill("test", createSkill("test")); - assertTrue(registry.exists("test_custom")); + assertTrue(registry.exists("test")); assertFalse(registry.exists("non-existent")); } @Test @DisplayName("Should remove skill") void testRemoveSkill() { - registry.registerSkill("test_custom", createSkill("test")); + registry.registerSkill("test", createSkill("test")); - registry.removeSkill("test_custom"); + registry.removeSkill("test"); - assertFalse(registry.exists("test_custom")); - assertNull(registry.getSkill("test_custom")); + assertFalse(registry.exists("test")); + assertNull(registry.getSkill("test")); } @Test diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java index eeaec1ccf0..ebee7b1363 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java @@ -96,7 +96,7 @@ void reusedBuilderKeepsLegacySkillLoadersBoundToTheirOwnAgent() { shared.setAutoUploadSkill(false); AgentSkill skill = new AgentSkill("shared", "Shared", "instructions", null); shared.registerSkill(skill); - String group = skill.getSkillId() + "_skill_tools"; + String group = skill.getName() + "_skill_tools"; source.createToolGroup(group, "first agent group", false); var builder = ReActAgent.builder() @@ -110,7 +110,7 @@ void reusedBuilderKeepsLegacySkillLoadersBoundToTheirOwnAgent() { assertEquals(1, first.getHooks().stream().filter(SkillHook.class::isInstance).count()); assertEquals(1, second.getHooks().stream().filter(SkillHook.class::isInstance).count()); - Map input = Map.of("skillId", skill.getSkillId(), "path", "SKILL.md"); + Map input = Map.of("skillName", skill.getName(), "path", "SKILL.md"); first.getToolkit() .getTool("load_skill_through_path") .callAsync( @@ -131,7 +131,7 @@ void reusedBuilderKeepsLegacySkillLoadersBoundToTheirOwnAgent() { .block(); assertFalse(toolContext.getActivatedGroups().contains(group)); - shared.removeSkill(skill.getSkillId()); + shared.removeSkill(skill.getName()); PreCallEvent event = new PreCallEvent(first, new ArrayList<>()); first.getHooks().stream() .filter(SkillHook.class::isInstance) @@ -162,8 +162,8 @@ void testCompleteSkillActivationFlow() { skillBox.registration().skill(calculatorSkill).agentTool(calculatorAdd).apply(); skillBox.registration().skill(calculatorSkill).agentTool(calculatorMultiply).apply(); - String skillId = calculatorSkill.getSkillId(); - String toolGroupName = skillId + "_skill_tools"; + String skillName = calculatorSkill.getName(); + String toolGroupName = skillName + "_skill_tools"; // Step 2: Verify initial state - skill and tool group inactive assertNotNull(toolkit.getToolGroup(toolGroupName), "ToolGroup should exist"); @@ -174,7 +174,7 @@ void testCompleteSkillActivationFlow() { // Step 3: Simulate LLM calling load_skill_through_path AgentTool skillLoader = toolkit.getTool("load_skill_through_path"); Map loadParams = new HashMap<>(); - loadParams.put("skillId", skillId); + loadParams.put("skillName", skillName); loadParams.put("path", "SKILL.md"); ToolCallParam callParam = @@ -248,8 +248,8 @@ void testMultipleSkillsActivationSequence() { skillBox.registration().skill(mathSkill).agentTool(mathTool).apply(); skillBox.registration().skill(weatherSkill).agentTool(weatherTool).apply(); - String mathSkillId = mathSkill.getSkillId(); - String weatherSkillId = weatherSkill.getSkillId(); + String mathSkillId = mathSkill.getName(); + String weatherSkillId = weatherSkill.getName(); String mathToolGroupName = mathSkillId + "_skill_tools"; String weatherToolGroupName = weatherSkillId + "_skill_tools"; @@ -315,10 +315,10 @@ public Mono callAsync(ToolCallParam param) { AgentSkill counterSkill = new AgentSkill("counter", "Counter Skill", "# Counter", null); skillBox.registration().skill(counterSkill).agentTool(counterTool).apply(); - String skillId = counterSkill.getSkillId(); + String skillName = counterSkill.getName(); // Load skill - loadSkill(skillId); + loadSkill(skillName); // Verify skill activated // Call the tool multiple times @@ -348,10 +348,10 @@ public Mono callAsync(ToolCallParam param) { /** * Helper method to load a skill by calling the load_skill_through_path tool. */ - private void loadSkill(String skillId) { + private void loadSkill(String skillName) { AgentTool skillLoader = toolkit.getTool("load_skill_through_path"); Map loadParams = new HashMap<>(); - loadParams.put("skillId", skillId); + loadParams.put("skillName", skillName); loadParams.put("path", "SKILL.md"); ToolCallParam callParam = diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java index 113753c867..3bfcf33a67 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java @@ -70,7 +70,7 @@ void disksFallbackHits(@TempDir Path tempDir) throws IOException { AgentTool tool = toolkit.getTool("load_skill_through_path"); assertNotNull(tool); - Map input = Map.of("skillId", skill.getSkillId(), "path", "scripts/run.sh"); + Map input = Map.of("skillName", skill.getName(), "path", "scripts/run.sh"); ToolUseBlock useBlock = ToolUseBlock.builder() .id("fallback-1") @@ -116,7 +116,7 @@ void inMemoryTakesPrecedence(@TempDir Path tempDir) throws IOException { box.registerSkillLoadTool(); AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillId", skill.getSkillId(), "path", "scripts/run.sh"); + Map input = Map.of("skillName", skill.getName(), "path", "scripts/run.sh"); ToolUseBlock useBlock = ToolUseBlock.builder() .id("precedence-1") @@ -162,7 +162,7 @@ void rejectsPathTraversal(@TempDir Path tempDir) throws IOException { AgentTool tool = toolkit.getTool("load_skill_through_path"); Map input = - Map.of("skillId", skill.getSkillId(), "path", "../forbidden.txt"); + Map.of("skillName", skill.getName(), "path", "../forbidden.txt"); ToolUseBlock useBlock = ToolUseBlock.builder() .id("traversal-1") 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 1e4d5cfd8a..ea88223c50 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 @@ -80,7 +80,6 @@ void testCreateFromMarkdownWithCustomSource() { assertEquals("github_skill", skill.getName()); assertEquals("github", skill.getSource()); - assertEquals("github_skill_github", skill.getSkillId()); } @Test diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/SystemCapabilityMarkerTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/SystemCapabilityMarkerTest.java index 292369592e..3cc1b0bafa 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/SystemCapabilityMarkerTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/SystemCapabilityMarkerTest.java @@ -308,9 +308,7 @@ void legacySkillConstructorsDefaultFalse() { @DisplayName("helpers: skill group naming, group-membership lookup, restricted notes") void helperMethods() { assertEquals("s1_skill_tools", SkillToolGroup.skillToolsGroupName("s1")); - assertEquals( - "report_gen_custom_skill_tools", - SkillToolGroup.skillToolsGroupName("report_gen_custom")); + assertEquals("report_gen_skill_tools", SkillToolGroup.skillToolsGroupName("report_gen")); Toolkit toolkit = new Toolkit(); toolkit.createToolGroup("g1", "one", true); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/SkillE2ETest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/SkillE2ETest.java index 4caf670262..0016f1ea8a 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/SkillE2ETest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/SkillE2ETest.java @@ -197,8 +197,8 @@ private void runSkillRecallTest(ModelProvider provider, String targetSkillName, skillBox.registerSkillLoadTool(); AgentSkill targetSkill = repo.getSkill(targetSkillName); - String expectedSkillId = targetSkill.getSkillId(); - System.out.println(" expected skillId : " + expectedSkillId); + String expectedSkillName = targetSkill.getName(); + System.out.println(" expected skillName : " + expectedSkillName); System.out.println(" prompt : " + prompt); AtomicBoolean loadedCorrectSkill = new AtomicBoolean(false); @@ -215,9 +215,9 @@ public Mono onEvent(T event) { if (toolUse != null && "load_skill_through_path" .equals(toolUse.getName()) - && expectedSkillId.equals( + && expectedSkillName.equals( toolUse.getInput() - .get("skillId"))) { + .get("skillName"))) { loadedCorrectSkill.set(true); postActing.stopAgent(); } @@ -248,8 +248,8 @@ public Mono onEvent(T event) { + targetSkillName + "] | " + provider.getProviderName() - + " — did not call load_skill_through_path(skillId='" - + expectedSkillId + + " — did not call load_skill_through_path(skillName='" + + expectedSkillName + "')"); } } diff --git a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java index ac0948900d..994aff3be7 100644 --- a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java +++ b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java @@ -405,7 +405,7 @@ void testDefaultNamespace() { } @Test - @DisplayName("Should upload skill resources using a Windows-safe skill ID") + @DisplayName("Should upload skill resources using a Windows-safe skill name") void testUploadSkillResourcesWithWindowsSafeSkillId(@TempDir Path tempDir) throws NacosException, IOException { when(aiService.downloadSkillZip("resource-skill")) @@ -424,7 +424,7 @@ void testUploadSkillResourcesWithWindowsSafeSkillId(@TempDir Path tempDir) assertDoesNotThrow(skillBox::uploadSkillFiles); Path uploadedResource = - tempDir.resolve("skills").resolve(skill.getSkillId()).resolve("scripts/run.sh"); + tempDir.resolve("skills").resolve(skill.getName()).resolve("scripts/run.sh"); assertTrue(Files.isRegularFile(uploadedResource)); assertEquals("echo hello", Files.readString(uploadedResource)); } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java index 2a8ccb41b2..406abfba51 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java @@ -16,6 +16,7 @@ package io.agentscope.harness.agent.middleware; import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.SkillFilter; @@ -260,7 +261,8 @@ public void prestageMarketplaceSkills(RuntimeContext ctx) { return; } List visible = applyVisibility(merged.values(), ctx); - List enabled = applySkillFilter(visible, effectiveFilter(ctx)); + List enabled = + applyCapabilityFilter(applySkillFilter(visible, effectiveFilter(ctx)), ctx); if (!enabled.isEmpty()) { stager.stage(enabled, sourceNamespaces, scopeKeyFor(ctx)); } @@ -281,7 +283,8 @@ public Mono onSystemPrompt(Agent agent, RuntimeContext ctx, String curre } List visible = applyVisibility(merged.values(), ctx); - List enabled = applySkillFilter(visible, effectiveFilter(ctx)); + List enabled = + applyCapabilityFilter(applySkillFilter(visible, effectiveFilter(ctx)), ctx); if (enabled.isEmpty()) { runtime.install(SkillCatalog.empty(), ctx, agentToolkit); return Mono.just(currentPrompt); @@ -334,6 +337,27 @@ private SkillFilter effectiveFilter(RuntimeContext ctx) { return builderFilter.overlay(ctx != null ? ctx.get(SkillFilter.class) : null); } + /** + * Grant-layer skill visibility. Runs after the subclass hooks so an override cannot drop + * authorization filtering; the returned list feeds the prompt, the per-call catalog (the + * load tool's authorization boundary) and marketplace staging. + * + *

A missing surface means this pass never entered an agent {@code call()} (whose funnel + * always writes one): treat it as unrestricted — direct middleware use is trusted assembler + * code, the same stance as the core execution gates. + * + * @param input the skills remaining after the visibility and skill filters + * @param ctx the per-call runtime context carrying the grant set + * @return the granted subset; the input unchanged when no surface is present + */ + private List applyCapabilityFilter(List input, RuntimeContext ctx) { + CapabilitySurface surface = ctx != null ? ctx.get(CapabilitySurface.class) : null; + if (surface == null || input.isEmpty()) { + return input; + } + return input.stream().filter(bound -> surface.allowsSkill(bound.skill())).toList(); + } + private List applySkillFilter( java.util.Collection input, SkillFilter filter) { if (input.isEmpty()) { diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SkillUsageMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SkillUsageMiddleware.java index 56b43cdcde..4d4de90d30 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SkillUsageMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SkillUsageMiddleware.java @@ -81,7 +81,7 @@ public Flux onActing( } /** - * If {@code call} targets a SkillBox loader / user, extract the {@code skillId} parameter + * If {@code call} targets a SkillBox loader / user, extract the {@code skillName} parameter * (or fallback equivalents) and bump the right counter on the store. Failures are swallowed * — telemetry must never break the agent loop. */ @@ -115,7 +115,7 @@ private void trackInvocation(ToolUseBlock call) { } /** - * Pulls the skill identifier out of a tool-use block. {@code skillId} is the canonical key + * Pulls the skill name out of a tool-use block. {@code skillName} is the canonical key * used by the SkillBox loader; {@code skill_id} / {@code name} are tolerated as fallbacks * for forward compatibility with potential renames. */ @@ -124,7 +124,7 @@ private static String extractSkillName(ToolUseBlock call) { if (input == null) { return null; } - for (String key : new String[] {"skillId", "skill_id", "name"}) { + for (String key : new String[] {"skillName", "skillId", "skill_id", "name"}) { Object v = input.get(key); if (v != null) { String s = v.toString().trim(); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillCatalog.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillCatalog.java index b39a90a495..5d12761d59 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillCatalog.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillCatalog.java @@ -24,7 +24,7 @@ /** * Immutable snapshot of the skills visible to a single {@code onSystemPrompt} pass. * - *

Keyed by {@code skillId} (the AgentSkill's {@code name + "_" + source}), insertion-order + *

Keyed by skill name (the AgentSkill's business identity), insertion-order * preserved so the prompt renders skills in compose order. */ public final class SkillCatalog { @@ -45,7 +45,7 @@ public static SkillCatalog of(List orderedEntries) { if (e == null) { continue; } - map.put(e.skill().getSkillId(), e); + map.put(e.skill().getName(), e); } return new SkillCatalog(map); } 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..7357ab0614 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 @@ -15,6 +15,7 @@ */ package io.agentscope.harness.agent.skill.runtime; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.util.MarkdownSkillParser; @@ -104,8 +105,8 @@ public String getName() { @Override public String getDescription() { - return "Load and return a skill resource by its skill id and a path relative to the" - + " skill root.\n\n" + return "Load and return a skill resource by its skill name and a path relative" + + " to the skill root.\n\n" + "Path rules:\n" + "- Use path=\"SKILL.md\" to load the skill's markdown documentation.\n" + "- Use exact resource paths listed by the skill, e.g." @@ -115,30 +116,30 @@ public String getDescription() { @Override public Map getParameters() { - Map skillIdSchema; + Map skillNameSchema; if (exposeLegacyCatalogInSchema) { SkillCatalog snapshot = legacyCatalogRef.get(); List ids = snapshot != null ? snapshot.ids() : List.of(); - skillIdSchema = + skillNameSchema = Map.of( "type", "string", - "description", "Unique skill identifier.", + "description", "Unique skill name.", "enum", ids); } else { - skillIdSchema = + skillNameSchema = Map.of( "type", "string", "description", - "Unique skill identifier listed in the current " + "Unique skill name listed in the current " + " block."); } return Map.of( "type", "object", "properties", Map.of( - "skillId", - skillIdSchema, + "skillName", + skillNameSchema, "path", Map.of( "type", @@ -149,18 +150,18 @@ public Map getParameters() { + " instructions; do not use '.'," + " './', directories, or absolute" + " paths.")), - "required", List.of("skillId", "path")); + "required", List.of("skillName", "path")); } @Override public Mono callAsync(ToolCallParam param) { try { Map input = param.getInput(); - String skillId = stringOrNull(input.get("skillId")); + String skillName = stringOrNull(input.get("skillName")); String path = stringOrNull(input.get("path")); - if (skillId == null || skillId.isEmpty()) { + if (skillName == null || skillName.isEmpty()) { return Mono.just( - ToolResultBlock.error("Missing or empty required parameter: skillId")); + ToolResultBlock.error("Missing or empty required parameter: skillName")); } if (path == null || path.isEmpty()) { return Mono.just( @@ -168,13 +169,12 @@ public Mono callAsync(ToolCallParam param) { } SkillCatalog catalog = catalogFor(param); - HarnessSkillEntry entry = catalog.get(skillId); + HarnessSkillEntry entry = catalog.get(skillName); if (entry == null) { - return Mono.just( - ToolResultBlock.error( - "Skill not found: '" - + skillId - + "'. Check the available skills list.")); + return Mono.just(ToolResultBlock.error(skillNotFound(skillName))); + } + if (!isSkillGranted(param, entry)) { + return Mono.just(ToolResultBlock.error(skillNotFound(skillName))); } return Mono.just(loadOne(entry, path)); @@ -184,6 +184,42 @@ public Mono callAsync(ToolCallParam param) { } } + /** + * Grant gate for skill loading. Defense in depth on top of the catalog boundary: the + * per-call catalog installed by the skill middleware is already grant-filtered, this check + * also covers catalogs installed through other paths. A missing surface means the call never + * entered an agent {@code call()} funnel: treat it as unrestricted — direct execution is + * trusted assembler code, the same stance as the core execution gates. + * + * @param param the tool call parameters + * @param entry the resolved catalog entry + * @return {@code true} when the skill passes the call's grant set + */ + private boolean isSkillGranted(ToolCallParam param, HarnessSkillEntry entry) { + CapabilitySurface surface = + param.getRuntimeContext() != null + ? param.getRuntimeContext().get(CapabilitySurface.class) + : null; + if (surface == null || surface.allowsSkill(entry.skill())) { + return true; + } + log.warn( + "Skill load denied (grant): skillName='{}', userId='{}', sessionId='{}'", + entry.skill().getName(), + param.getRuntimeContext().getUserId(), + param.getRuntimeContext().getSessionId()); + return false; + } + + /** + * @param skillName the caller-supplied skill name (authoritative for the error echo) + * @return the not-found error text — byte-identical for unknown and un-granted skills so + * the model cannot distinguish existence + */ + private static String skillNotFound(String skillName) { + return "Skill not found: '" + skillName + "'. Check the available skills list."; + } + private SkillCatalog catalogFor(ToolCallParam param) { if (param.getRuntimeContext() != null) { return SkillRuntime.catalogFor(param.getRuntimeContext()); @@ -232,7 +268,7 @@ private ToolResultBlock loadOne(HarnessSkillEntry entry, String path) { private String formatSkillMarkdown(HarnessSkillEntry entry) { AgentSkill skill = entry.skill(); StringBuilder sb = new StringBuilder(); - sb.append("Successfully loaded skill: ").append(skill.getSkillId()).append("\n\n"); + sb.append("Successfully loaded skill: ").append(skill.getName()).append("\n\n"); sb.append("Name: ").append(skill.getName()).append("\n"); sb.append("Description: ").append(skill.getDescription()).append("\n"); sb.append("Source: ").append(skill.getSource()).append("\n"); @@ -258,7 +294,7 @@ private String formatSkillMarkdown(HarnessSkillEntry entry) { private String formatResource(HarnessSkillEntry entry, String path, String content) { StringBuilder sb = new StringBuilder(); sb.append("Successfully loaded resource from skill: ") - .append(entry.skill().getSkillId()) + .append(entry.skill().getName()) .append("\n"); sb.append("Resource path: ").append(path).append("\n"); if (entry.filesRoot() != null && !entry.filesRoot().isBlank()) { @@ -279,7 +315,7 @@ private String formatNotFound(HarnessSkillEntry entry, String missingPath) { sb.append("Resource not found: '") .append(missingPath) .append("' in skill '") - .append(entry.skill().getSkillId()) + .append(entry.skill().getName()) .append("'.\n\n"); LinkedHashSet available = new LinkedHashSet<>(); @@ -297,7 +333,7 @@ private String formatNotFound(HarnessSkillEntry entry, String missingPath) { } catch (Exception e) { log.debug( "lazyResources.list() failed for '{}': {}", - entry.skill().getSkillId(), + entry.skill().getName(), e.getMessage()); } } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillPromptBuilder.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillPromptBuilder.java index 73f42d4b21..86e2fefb8e 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillPromptBuilder.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillPromptBuilder.java @@ -52,13 +52,13 @@ public final class SkillPromptBuilder { Skills provide specialized capabilities and domain knowledge. Use them when they match your current task. How to use skills: - - Load skill: load_skill_through_path(skillId="", path="SKILL.md") + - Load skill: load_skill_through_path(skillName="", path="SKILL.md") - The skill will be activated and its documentation loaded with detailed instructions - Additional resources (scripts, assets, references) can be loaded with the same tool and other paths Example: - 1. User asks to analyze data -> find a matching skill below (e.g. data-analysis_workspace) - 2. Load it: load_skill_through_path(skillId="data-analysis_workspace", path="SKILL.md") + 1. User asks to analyze data -> find a matching skill below (e.g. data-analysis) + 2. Load it: load_skill_through_path(skillName="data-analysis", path="SKILL.md") 3. Follow the instructions returned by the skill Metadata is rendered as XML under each element: @@ -171,7 +171,7 @@ private void appendSkill(StringBuilder sb, HarnessSkillEntry entry) { } appendXmlNode(sb, e.getKey(), e.getValue(), 1); } - appendXmlNode(sb, "skill-id", skill.getSkillId(), 1); + appendXmlNode(sb, "skill-name", skill.getName(), 1); if (entry.filesRoot() != null && !entry.filesRoot().isBlank()) { appendXmlNode(sb, "files-root", entry.filesRoot(), 1); } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java index 340fd9b567..f745817d67 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java @@ -337,8 +337,8 @@ void disableDynamicSkills_keepsWorkspaceLazyResourcesLoadable() throws Exception .runtimeContext(ctx) .input( Map.of( - "skillId", - "lazy-resource_workspace-namespaced", + "skillName", + "lazy-resource", "path", "references/guide.md")) .build()) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareContextIsolationTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareContextIsolationTest.java index 8c7b7b05af..66192598cb 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareContextIsolationTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareContextIsolationTest.java @@ -57,10 +57,10 @@ void usesCallContextWhenSharedActiveContextPointsToAnotherSession() throws IOExc assertFalse(prompt.contains("Bob private description")); assertTrue( middleware.runtime().currentCatalog(alice).ids().stream() - .anyMatch(id -> id.startsWith("alice-skill_"))); + .anyMatch(id -> id.startsWith("alice-skill"))); assertFalse( middleware.runtime().currentCatalog(alice).ids().stream() - .anyMatch(id -> id.startsWith("bob-skill_"))); + .anyMatch(id -> id.startsWith("bob-skill"))); } private void writeSkill(String tenant, String name, String description, String body) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareGrantTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareGrantTest.java new file mode 100644 index 0000000000..a43cb52e2a --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareGrantTest.java @@ -0,0 +1,264 @@ +/* + * 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.middleware; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.agent.CapabilitySurface; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.skill.repository.AgentSkillRepository; +import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.ToolCallParam; +import io.agentscope.core.tool.Toolkit; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Grant-layer coverage of the harness skill chain: prompt visibility, per-call catalog, + * and the load tool's hard gate (including its defense-in-depth path and byte-identical + * denial wording). + * + * @author shanhongyu + */ +@DisplayName("Harness skill chain grant coverage") +class HarnessSkillMiddlewareGrantTest { + + /** In-memory repository stub serving a fixed skill list. */ + private static final class StubRepo implements AgentSkillRepository { + private final List skills; + + StubRepo(List skills) { + this.skills = skills; + } + + @Override + public AgentSkill getSkill(String name) { + return null; + } + + @Override + public List getAllSkillNames() { + return List.of(); + } + + @Override + public List getAllSkills() { + return skills; + } + + @Override + public boolean save(List skills, boolean force) { + return false; + } + + @Override + public boolean delete(String skillName) { + return false; + } + + @Override + public boolean skillExists(String skillName) { + return false; + } + + @Override + public AgentSkillRepositoryInfo getRepositoryInfo() { + return null; + } + + @Override + public String getSource() { + return "stub"; + } + + @Override + public void setWriteable(boolean writeable) {} + + @Override + public boolean isWriteable() { + return false; + } + } + + /** A skill named {@code name} with skillId {@code name_custom}. */ + private static AgentSkill skill(String name) { + return new AgentSkill(name, "Skill " + name, "# " + name, null); + } + + private static AgentSkill systemSkill(String name) { + return AgentSkill.builder() + .name(name) + .description("Skill " + name) + .skillContent("# " + name) + .system(true) + .build(); + } + + private static RuntimeContext rc(CapabilitySurface surface) { + RuntimeContext ctx = RuntimeContext.builder().userId("u1").sessionId("s1").build(); + if (surface != null) { + ctx.put(CapabilitySurface.class, surface); + } + return ctx; + } + + private static HarnessSkillMiddleware middleware(List skills) { + return new HarnessSkillMiddleware(List.of(new StubRepo(skills)), new Toolkit()); + } + + /** Grants exactly the alpha skill. */ + private static final CapabilitySurface ALPHA_ONLY = + new CapabilitySurface(java.util.Set.of("alpha"), null, null); + + // ==================== prompt + catalog visibility ==================== + + @Test + @DisplayName("granted skill only: prompt and per-call catalog keep the granted subset") + void grantedSkillOnlyInPromptAndCatalog() { + HarnessSkillMiddleware mw = middleware(List.of(skill("alpha"), skill("beta"))); + RuntimeContext ctx = rc(ALPHA_ONLY); + + String prompt = mw.onSystemPrompt(null, ctx, "base").block(); + + assertTrue(prompt.contains("alpha"), "granted skill kept in the prompt"); + assertFalse(prompt.contains("beta"), "un-granted skill cut from the prompt"); + List ids = mw.runtime().currentCatalog(ctx).ids(); + assertTrue(ids.contains("alpha")); + assertFalse(ids.contains("beta")); + } + + @Test + @DisplayName("DENY_ALL: empty catalog, prompt unchanged") + void denyAllYieldsEmptyCatalogAndUnchangedPrompt() { + HarnessSkillMiddleware mw = middleware(List.of(skill("alpha"), skill("beta"))); + RuntimeContext ctx = rc(CapabilitySurface.DENY_ALL); + + String prompt = mw.onSystemPrompt(null, ctx, "base").block(); + + assertEquals("base", prompt); + assertTrue(mw.runtime().currentCatalog(ctx).ids().isEmpty()); + } + + @Test + @DisplayName("system skill survives a restrictive surface") + void systemSkillSurvivesRestrictiveSurface() { + HarnessSkillMiddleware mw = middleware(List.of(systemSkill("gamma"), skill("beta"))); + RuntimeContext ctx = rc(new CapabilitySurface(java.util.Set.of(), null, null)); + + String prompt = mw.onSystemPrompt(null, ctx, "base").block(); + + assertTrue(prompt.contains("gamma"), "system skill kept"); + assertFalse(prompt.contains("beta"), "non-system skill cut"); + assertTrue(mw.runtime().currentCatalog(ctx).ids().contains("gamma")); + } + + @Test + @DisplayName("missing surface: no filtering (trusted direct middleware use)") + void missingSurfaceLeavesAllSkillsVisible() { + HarnessSkillMiddleware mw = middleware(List.of(skill("alpha"), skill("beta"))); + RuntimeContext ctx = rc(null); + + String prompt = mw.onSystemPrompt(null, ctx, "base").block(); + + assertTrue(prompt.contains("alpha") && prompt.contains("beta")); + assertEquals(2, mw.runtime().currentCatalog(ctx).ids().size()); + } + + // ==================== load tool gate ==================== + + /** Loads SKILL.md through the middleware's load tool and returns the result text. */ + private static String load(HarnessSkillMiddleware mw, RuntimeContext ctx, String skillId) { + AgentTool loadTool = mw.runtime().loadTool(); + Map input = Map.of("skillName", skillId, "path", "SKILL.md"); + ToolUseBlock use = + ToolUseBlock.builder() + .id("call-" + skillId) + .name(loadTool.getName()) + .input(input) + .build(); + ToolCallParam param = + ToolCallParam.builder().toolUseBlock(use).input(input).runtimeContext(ctx).build(); + ToolResultBlock result = loadTool.callAsync(param).block(); + return ((TextBlock) result.getOutput().get(0)).getText(); + } + + @Test + @DisplayName("filtered catalog denies the un-granted skill; granted skill loads") + void loadToolRespectsFilteredCatalog() { + HarnessSkillMiddleware mw = middleware(List.of(skill("alpha"), skill("beta"))); + RuntimeContext ctx = rc(ALPHA_ONLY); + mw.onSystemPrompt(null, ctx, "base").block(); + + assertTrue(load(mw, ctx, "alpha").contains("Successfully loaded skill")); + assertFalse(load(mw, ctx, "beta").contains("Successfully loaded skill")); + } + + @Test + @DisplayName("denial wording is byte-identical for unknown and un-granted skills") + void loadToolDenialWordingIsIndistinguishable() { + HarnessSkillMiddleware mw = middleware(List.of(skill("alpha"), skill("beta"))); + RuntimeContext ctx = rc(ALPHA_ONLY); + mw.onSystemPrompt(null, ctx, "base").block(); + + String ungranted = load(mw, ctx, "beta"); + String unknown = load(mw, ctx, "nope"); + assertEquals("Error: Skill not found: 'beta'. Check the available skills list.", ungranted); + assertEquals( + ungranted.replace("beta", "nope"), + unknown, + "unknown and un-granted denials must be indistinguishable"); + } + + @Test + @DisplayName("defense in depth: the gate denies even when an unfiltered catalog is installed") + void loadToolGateCoversUnfilteredCatalogInstall() { + HarnessSkillMiddleware mw = middleware(List.of(skill("alpha"), skill("beta"))); + // No surface during the prompt pass: the full catalog is installed (trusted direct). + RuntimeContext ctx = rc(null); + mw.onSystemPrompt(null, ctx, "base").block(); + // A surface appearing afterwards (alternate installer / ordering) must still gate. + ctx.put(CapabilitySurface.class, ALPHA_ONLY); + + assertTrue(load(mw, ctx, "alpha").contains("Successfully loaded skill")); + assertEquals( + "Error: Skill not found: 'beta'. Check the available skills list.", + load(mw, ctx, "beta")); + } + + @Test + @DisplayName("system skill loads under a restrictive surface; unrestricted without a surface") + void loadToolSystemSkillAndMissingSurface() { + HarnessSkillMiddleware mw = + middleware(List.of(systemSkill("gamma"), skill("beta"), skill("alpha"))); + RuntimeContext restricted = + rc(new CapabilitySurface(java.util.Set.of("alpha"), null, null)); + mw.onSystemPrompt(null, restricted, "base").block(); + assertTrue(load(mw, restricted, "gamma").contains("Successfully loaded skill")); + + RuntimeContext direct = rc(null); + mw.onSystemPrompt(null, direct, "base").block(); + assertTrue(load(mw, direct, "beta").contains("Successfully loaded skill")); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SkillUsageMiddlewareTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SkillUsageMiddlewareTest.java index b47462cc5e..ba19005567 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SkillUsageMiddlewareTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SkillUsageMiddlewareTest.java @@ -51,7 +51,7 @@ void loadSkillThroughPath_bumpsViewForAgentTrackedSkill() { store.markAgentDraft("csv-sum", null); ActingInput in = new ActingInput( - List.of(callOf("load_skill_through_path", Map.of("skillId", "csv-sum")))); + List.of(callOf("load_skill_through_path", Map.of("skillName", "csv-sum")))); mw.onActing(null, null, in, x -> Flux.empty()).blockLast(); @@ -65,7 +65,7 @@ void loadSkillThroughPath_skipsUntrackedSkill() { List.of( callOf( "load_skill_through_path", - Map.of("skillId", "external-skill")))); + Map.of("skillName", "external-skill")))); mw.onActing(null, null, in, x -> Flux.empty()).blockLast(); @@ -89,8 +89,8 @@ void multipleCallsInOneActing_allBump() { ActingInput in = new ActingInput( List.of( - callOf("load_skill_through_path", Map.of("skillId", "a")), - callOf("load_skill_through_path", Map.of("skillId", "b")), + callOf("load_skill_through_path", Map.of("skillName", "a")), + callOf("load_skill_through_path", Map.of("skillName", "b")), callOf("read_file", Map.of("path", "/tmp/x")))); mw.onActing(null, null, in, x -> Flux.empty()).blockLast(); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java index 29d45c74dc..ba7240cd18 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java @@ -56,7 +56,7 @@ void includesFrontmatter() { RuntimeContext ctx = RuntimeContext.empty(); ctx.put(SkillCatalog.class, catalog); - String text = runTool(tool, ctx, Map.of("skillId", skill.getSkillId(), "path", "SKILL.md")); + String text = runTool(tool, ctx, Map.of("skillName", skill.getName(), "path", "SKILL.md")); assertTrue( text.contains("name: alpha"), @@ -91,7 +91,7 @@ void resourceFenceConsistent() { ctx.put(SkillCatalog.class, catalog); String text = - runTool(tool, ctx, Map.of("skillId", skill.getSkillId(), "path", "scripts/run.sh")); + runTool(tool, ctx, Map.of("skillName", skill.getName(), "path", "scripts/run.sh")); assertTrue(text.contains("scripts/run.sh"), "resource path should appear in header"); assertTrue(text.contains("echo hi"), "resource body should be present"); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java index 92e51b1fdd..b8619485a1 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java @@ -69,7 +69,7 @@ void preservesInsertionOrder() { HarnessSkillEntry b = HarnessSkillEntry.of(skill("beta", "src"), null); HarnessSkillEntry c = HarnessSkillEntry.of(skill("gamma", "src"), null); SkillCatalog cat = SkillCatalog.of(List.of(a, b, c)); - assertEquals(List.of("alpha_src", "beta_src", "gamma_src"), cat.ids()); + assertEquals(List.of("alpha", "beta", "gamma"), cat.ids()); } @Test @@ -78,7 +78,7 @@ void laterEntryWithSameIdOverwrites() { HarnessSkillEntry aDup = HarnessSkillEntry.of(skill("alpha", "src"), null); SkillCatalog cat = SkillCatalog.of(List.of(a, aDup)); assertEquals(1, cat.size()); - assertSame(aDup.skill(), cat.get("alpha_src").skill()); + assertSame(aDup.skill(), cat.get("alpha").skill()); } } @@ -156,10 +156,10 @@ void emptyCatalogReturnsEmptyString() { } @Test - void rendersSkillIdAndOmitsFilesRootWhenAbsent() { + void rendersSkillNameAndOmitsFilesRootWhenAbsent() { HarnessSkillEntry e = HarnessSkillEntry.of(skill("alpha", "wkspace"), null); String out = new SkillPromptBuilder().render(SkillCatalog.of(List.of(e))); - assertTrue(out.contains("alpha_wkspace")); + assertTrue(out.contains("alpha")); // No actual ... element rendered. // (The header text mentions "" descriptively without a closing tag.) assertFalse(out.contains("")); @@ -199,8 +199,8 @@ void filterRemovesHiddenSkills() { SkillFilter only = SkillFilter.only("visible"); String out = new SkillPromptBuilder().render(cat, only); - assertTrue(out.contains("visible_src")); - assertFalse(out.contains("hidden_src")); + assertTrue(out.contains("visible")); + assertFalse(out.contains("hidden")); } @Test @@ -213,7 +213,7 @@ void filterRejectingAllReturnsEmpty() { } @Test - void filterWithBareNameMatchesCompositeId() { + void filterBySkillNameMatchesEntry() { HarnessSkillEntry visible = HarnessSkillEntry.of( skill("host-forensics-client", "filesystem-agentscope_skills"), null); @@ -225,11 +225,8 @@ void filterWithBareNameMatchesCompositeId() { SkillFilter only = SkillFilter.only("host-forensics-client"); String out = new SkillPromptBuilder().render(cat, only); - assertTrue( - out.contains( - "host-forensics-client_filesystem-agentscope_skills")); - assertFalse( - out.contains("other-skill_filesystem-agentscope_skills")); + assertTrue(out.contains("host-forensics-client")); + assertFalse(out.contains("other-skill")); } } @@ -249,9 +246,9 @@ void returnsSkillMarkdownForSkillMdPath() { skillWith("alpha", "Alpha description.", "# Body line\n", null), null); r.install(SkillCatalog.of(List.of(e)), ctx, null); - ToolResultBlock res = invoke(r, ctx, "alpha_workspace", "SKILL.md"); + ToolResultBlock res = invoke(r, ctx, "alpha", "SKILL.md"); String text = textOf(res); - assertTrue(text.contains("Successfully loaded skill: alpha_workspace")); + assertTrue(text.contains("Successfully loaded skill: alpha")); assertTrue(text.contains("# Body line")); } @@ -266,7 +263,7 @@ void inMemoryResourceHitsBeforeLazyFallback() { HarnessSkillEntry.of(skillWith("alpha", "Alpha desc.", "# body", mem), lazy); r.install(SkillCatalog.of(List.of(e)), ctx, null); - String text = textOf(invoke(r, ctx, "alpha_workspace", "scripts/run.py")); + String text = textOf(invoke(r, ctx, "alpha", "scripts/run.py")); assertTrue(text.contains("in-memory-body")); assertFalse(text.contains("lazy-body")); } @@ -280,7 +277,7 @@ void lazyFallbackUsedWhenInMemoryMisses() { HarnessSkillEntry.of(skillWith("alpha", "Alpha desc.", "# body", null), lazy); r.install(SkillCatalog.of(List.of(e)), ctx, null); - String text = textOf(invoke(r, ctx, "alpha_workspace", "references/guide.md")); + String text = textOf(invoke(r, ctx, "alpha", "references/guide.md")); assertTrue(text.contains("from-fs")); } @@ -294,7 +291,7 @@ void notFoundEnumeratesBothSources() { HarnessSkillEntry.of(skillWith("alpha", "Alpha desc.", "# body", mem), lazy); r.install(SkillCatalog.of(List.of(e)), ctx, null); - String err = errorOf(invoke(r, ctx, "alpha_workspace", "does-not-exist")); + String err = errorOf(invoke(r, ctx, "alpha", "does-not-exist")); assertTrue(err.contains("Resource not found")); assertTrue(err.contains("SKILL.md")); assertTrue(err.contains("references/a.md")); @@ -324,15 +321,15 @@ void missingParametersReturnsError() { .input(Map.of()) .build()) .block()); - assertTrue(err1.contains("skillId")); + assertTrue(err1.contains("skillName")); } private ToolResultBlock invoke( - SkillRuntime r, RuntimeContext ctx, String skillId, String path) { + SkillRuntime r, RuntimeContext ctx, String skillName, String path) { ToolCallParam p = ToolCallParam.builder() .runtimeContext(ctx) - .input(Map.of("skillId", skillId, "path", path)) + .input(Map.of("skillName", skillName, "path", path)) .build(); return r.loadTool().callAsync(p).block(); } @@ -374,18 +371,16 @@ void catalogsRemainIsolatedByRuntimeContextWithoutReRegistering() { HarnessSkillEntry second = HarnessSkillEntry.of(skill("second", "src"), null); r.install(SkillCatalog.of(List.of(second)), secondCtx, tk); - assertEquals(List.of("first_src"), r.currentCatalog(firstCtx).ids()); - assertEquals(List.of("second_src"), r.currentCatalog(secondCtx).ids()); + assertEquals(List.of("first"), r.currentCatalog(firstCtx).ids()); + assertEquals(List.of("second"), r.currentCatalog(secondCtx).ids()); assertTrue( - textOf(invoke(r, firstCtx, "first_src", "SKILL.md")) + textOf(invoke(r, firstCtx, "first", "SKILL.md")) .contains("Successfully loaded skill")); + assertTrue(errorOf(invoke(r, firstCtx, "second", "SKILL.md")).contains("not found")); assertTrue( - errorOf(invoke(r, firstCtx, "second_src", "SKILL.md")).contains("not found")); - assertTrue( - textOf(invoke(r, secondCtx, "second_src", "SKILL.md")) + textOf(invoke(r, secondCtx, "second", "SKILL.md")) .contains("Successfully loaded skill")); - assertTrue( - errorOf(invoke(r, secondCtx, "first_src", "SKILL.md")).contains("not found")); + assertTrue(errorOf(invoke(r, secondCtx, "first", "SKILL.md")).contains("not found")); // Both contexts use the same stateless registered tool instance. assertSame(r.loadTool(), tk.getTool(SkillLoadTool.TOOL_NAME)); @@ -410,25 +405,25 @@ void concurrentSessionsCannotLoadEachOthersSkills() throws Exception { Future first = executor.submit( () -> - textOf(invoke(r, firstCtx, "first_src", "SKILL.md")) + textOf(invoke(r, firstCtx, "first", "SKILL.md")) .contains("Successfully loaded skill") && errorOf( invoke( r, firstCtx, - "second_src", + "second", "SKILL.md")) .contains("not found")); Future second = executor.submit( () -> - textOf(invoke(r, secondCtx, "second_src", "SKILL.md")) + textOf(invoke(r, secondCtx, "second", "SKILL.md")) .contains("Successfully loaded skill") && errorOf( invoke( r, secondCtx, - "first_src", + "first", "SKILL.md")) .contains("not found")); @@ -447,12 +442,12 @@ void deprecatedRuntimeApiRemainsFunctionalWithoutLeakingIntoScopedCalls() { r.install(SkillCatalog.of(List.of(legacy)), tk); - assertEquals(List.of("legacy_src"), r.currentCatalog().ids()); + assertEquals(List.of("legacy"), r.currentCatalog().ids()); assertTrue( - textOf(invoke(r, null, "legacy_src", "SKILL.md")) + textOf(invoke(r, null, "legacy", "SKILL.md")) .contains("Successfully loaded skill")); assertTrue( - errorOf(invoke(r, RuntimeContext.empty(), "legacy_src", "SKILL.md")) + errorOf(invoke(r, RuntimeContext.empty(), "legacy", "SKILL.md")) .contains("not found")); } @@ -462,7 +457,7 @@ void deprecatedLoadToolConstructorRemainsContextFreeOnly() { AtomicReference ref = new AtomicReference<>(SkillCatalog.of(List.of(legacy))); SkillLoadTool tool = new SkillLoadTool(ref); - Map input = Map.of("skillId", "legacy_src", "path", "SKILL.md"); + Map input = Map.of("skillName", "legacy", "path", "SKILL.md"); ToolResultBlock contextFree = tool.callAsync(ToolCallParam.builder().input(input).build()).block(); @@ -479,12 +474,12 @@ void deprecatedLoadToolConstructorRemainsContextFreeOnly() { } private ToolResultBlock invoke( - SkillRuntime r, RuntimeContext ctx, String skillId, String path) { + SkillRuntime r, RuntimeContext ctx, String skillName, String path) { return r.loadTool() .callAsync( ToolCallParam.builder() .runtimeContext(ctx) - .input(Map.of("skillId", skillId, "path", path)) + .input(Map.of("skillName", skillName, "path", path)) .build()) .block(); } diff --git a/docs/v2/en/docs/building-blocks/capability-grant.md b/docs/v2/en/docs/building-blocks/capability-grant.md index 94a7fa20c9..7056e6ae64 100644 --- a/docs/v2/en/docs/building-blocks/capability-grant.md +++ b/docs/v2/en/docs/building-blocks/capability-grant.md @@ -10,7 +10,8 @@ In enterprise deployments an agent serves many users with different permissions. grant layer lets you **cut out every capability the current user is not authorized for** — a cut capability does not exist for the model: it never enters tool schemas, cannot be enumerated, cannot be activated via `reset_equipped_tools`, cannot be loaded via `load_skill_through_path`, -and cannot be executed. +and cannot be executed. The harness skill chain (`HarnessSkillMiddleware`'s prompt and catalog, +plus the harness `load_skill_through_path`) is equally governed by the grant surface. It is orthogonal to the existing layers: @@ -31,7 +32,7 @@ CapabilityResolver resolver = (ctx, agent) -> { // Granularity is yours: per-(userId, agentId), or ignore agentId and return one // surface per user — the framework treats both identically. return new CapabilitySurface( - authzService.allowedSkills(userId), // allowed skillIds; null = not set + authzService.allowedSkills(userId), // allowed skillName values; null = not set authzService.allowedToolGroups(userId), // allowed ToolGroup names; null = not set authzService.allowedTools(userId)); // allowed tool names; null = not set }; @@ -88,14 +89,16 @@ accessors (`allowedSkills()` etc.) still preserve the distinction. Positive authorization is hierarchical; matching **any set dimension** grants: - Granting a **skill** implicitly grants its manual, scripts and associated tools (the skill - tool group `skillId + "_skill_tools"`); + tool group `skillName + "_skill_tools"`); - Granting a **ToolGroup** implicitly grants every tool in it; - Granting a **tool** by name grants exactly that tool (grouping is ignored, and it does not amplify to sibling tools in the same group). -Grant-by-skillId precision assumes unique skill ids: `skillId = name + "_" + source` is not -injective over `(name, source)` — names containing `_` can collide. Keep skill names globally -unique in multi-tenant deployments. +Skills are granted and addressed **by skillName** (the industry-standard unique identity): +`allowedSkills` holds the skillName values from the business entitlement list; the registry keys +by skillName and enforces per-agent-catalog uniqueness (same name + same instance is idempotent, +a different instance is rejected). The internal `name + "_" + source` storage identifier never +enters the grant surface. Only whitelists are provided; there is no deny (reverse-ban) capability. Default is deny — anything not reachable from the enumeration does not exist. @@ -142,4 +145,4 @@ this path, its limitation: the static `skillBox(...)` registration (or configured, `build()` logs an ERROR and disables the resolver on that agent: top-level calls of this agent are untrimmed; as a sub-agent, loading and execution stay gated under the inherited surface, but the static skill *prompt* still enumerates the full skill catalog (a -residual exposure; the static load tool's embedded `skillId` enum lists the full catalog too). +residual exposure; the static load tool's embedded `skillName` enum lists the full catalog too). diff --git a/docs/v2/en/docs/building-blocks/tool.md b/docs/v2/en/docs/building-blocks/tool.md index aa2564d600..fd9b31cbc8 100644 --- a/docs/v2/en/docs/building-blocks/tool.md +++ b/docs/v2/en/docs/building-blocks/tool.md @@ -450,7 +450,7 @@ At runtime, the agent invokes the viewer with two required arguments: | Parameter | Type | Description | | --- | --- | --- | -| `skillId` | `string` (enum of registered skill IDs) | The skill to load. | +| `skillName` | `string` (enum of registered skill names) | The skill to load. | | `path` | `string` | Use `"SKILL.md"` to fetch the skill's markdown instructions, or an exact resource path declared by the skill such as `"references/guide.md"` or `"scripts/run.py"`. Do not pass `"."`, `"./"`, a directory, or an absolute path. | Example tool call payload: @@ -458,7 +458,7 @@ Example tool call payload: ```json { "name": "load_skill_through_path", - "input": { "skillId": "pdf-extractor", "path": "SKILL.md" } + "input": { "skillName": "pdf-extractor", "path": "SKILL.md" } } ``` @@ -506,7 +506,7 @@ ReActAgent agent = ### Skill + ToolGroup: on-demand tool disclosure -`SkillToolGroup` binds a group of tools to a skill name — the group activates automatically when the agent loads that skill, and stays hidden from the model's schema otherwise, reducing context noise. +`SkillToolGroup` binds a group of tools to a skillName — the group activates automatically when the agent loads that skill, and stays hidden from the model's schema otherwise, reducing context noise. ```java import io.agentscope.core.ReActAgent; @@ -519,7 +519,7 @@ toolkit.createSkillToolGroup( "analysis-tools", // group name "Data analysis tools", // description false, // initially inactive - "data-analysis"); // bound skill name + "data-analysis"); // bound skillName // 2. Register tools into that group toolkit.registration() diff --git a/docs/v2/en/docs/harness/skill.md b/docs/v2/en/docs/harness/skill.md index 74690622fb..27ffe999f3 100644 --- a/docs/v2/en/docs/harness/skill.md +++ b/docs/v2/en/docs/harness/skill.md @@ -281,7 +281,7 @@ When the agent reasons, it sees an `` block in the system prom code-reviewer Use when the user asks for code review, style feedback, or PR audits. - code-reviewer_workspace-namespaced + code-reviewer /workspace/skills/code-reviewer ... @@ -294,8 +294,8 @@ Each entry carries just enough metadata for the agent to decide whether to load To activate a skill the agent calls a built-in tool — `load_skill_through_path`: -- `load_skill_through_path(skillId, path="SKILL.md")` returns the markdown body -- `load_skill_through_path(skillId, path="references/style-guide.md")` returns any other file under the skill directory +- `load_skill_through_path(skillName, path="SKILL.md")` returns the markdown body +- `load_skill_through_path(skillName, path="references/style-guide.md")` returns any other file under the skill directory How the file gets fetched depends on where the skill came from: @@ -330,7 +330,7 @@ Marketplace skill resources start as in-memory bytes. For shell execution to wor Workspace skills (Layer 3 / Layer 4) need no staging — they already live in the workspace tree. -If two repositories report the same `getSource()`, the second is auto-suffixed (`_2`, `_3`, …) with a warning log, so paths and skill-ids never collide. +If two repositories report the same `getSource()`, the second is auto-suffixed (`_2`, `_3`, …) with a warning log, so paths and skill names never collide. ## Running skills in a sandbox diff --git a/docs/v2/zh/docs/building-blocks/capability-grant.md b/docs/v2/zh/docs/building-blocks/capability-grant.md index 72a49c5356..1ba0ad4787 100644 --- a/docs/v2/zh/docs/building-blocks/capability-grant.md +++ b/docs/v2/zh/docs/building-blocks/capability-grant.md @@ -6,7 +6,7 @@ en_link: /v2/en/docs/building-blocks/capability-grant ## 概述 -企业级系统里,agent 通常以**多租户、多角色**形态运行:同一个 agent 面向大量权限不同的用户。Capability grant layer(授权层)让你能**按用户 / 请求的实际权限,把无权能力彻底切除**——被切除的能力对模型**不存在**:不进 tool schema、不可枚举、不可被 `reset_equipped_tools` 激活、不可被 `load_skill_through_path` 加载、不可执行。 +企业级系统里,agent 通常以**多租户、多角色**形态运行:同一个 agent 面向大量权限不同的用户。Capability grant layer(授权层)让你能**按用户 / 请求的实际权限,把无权能力彻底切除**——被切除的能力对模型**不存在**:不进 tool schema、不可枚举、不可被 `reset_equipped_tools` 激活、不可被 `load_skill_through_path` 加载、不可执行。Harness 的技能链路(`HarnessSkillMiddleware` 的提示词与技能目录、harness 版 `load_skill_through_path`)同样受授权面约束。 它与其他既有层正交: @@ -27,7 +27,7 @@ CapabilityResolver resolver = (ctx, agent) -> { // 粒度自选:按 (userId, agentId) 做 per-agent 差异, // 或忽略 agentId 按用户出一份数据(框架一视同仁) return new CapabilitySurface( - authzService.allowedSkills(userId), // 允许的 skillId;null = 未设置 + authzService.allowedSkills(userId), // 允许的 skillName;null = 未设置 authzService.allowedToolGroups(userId), // 允许的 ToolGroup 名;null = 未设置 authzService.allowedTools(userId)); // 允许的工具名;null = 未设置 }; @@ -73,11 +73,11 @@ new CapabilitySurface(Set.of(), Set.of(), Set.of()); 正向授权层次包含,命中**任一已设置维度**即放行: -- 授权一个 **skill** 即隐含授权其手册、脚本与关联工具(技能组 `skillId + "_skill_tools"`); +- 授权一个 **skill** 即隐含授权其手册、脚本与关联工具(技能组 `skillName + "_skill_tools"`); - 授权一个 **ToolGroup** 即隐含授权组内全部工具; - 逐名授权一个**工具**即授权该工具本身(不问分组,也不放大到组内其他工具)。 -按 skillId 授权的精度依赖 skillId 唯一:`skillId = name + "_" + source` 对 `(name, source)` 非单射,name 含 `_` 时可能碰撞——多租户部署请保证 skill 命名全局唯一。 +skill 以 **skillName** 授权与寻址(行业标准的唯一定位):`allowedSkills` 存放业务权限清单中的 skillName;`SkillRegistry` 以 skillName 为键并强制单个 agent 目录内唯一(同名同实例幂等、同名异实例拒绝)。`name + "_" + source` 形式的内部存储标识不进入授权面。 只提供白名单,不提供 deny(反向禁用);默认拒绝——不在授权清单内的能力即「不存在」。 @@ -99,4 +99,4 @@ resolver 抛异常时**中止本次调用**:异常传播给调用方并记录 ## 静态 skillBox 路径限制 -`skillBox(...)` 已随 hook 系统于 2.0.0 标记为过时,**新代码不要使用**——skill 接入一律使用 `skillRepositories(...)` 动态路径。存量代码若仍停留在该路径,其限制如下:`skillBox(...)` 构建期注册(或 `dynamicSkillsEnabled(false)`)没有 per-call 可见面过滤——配置 resolver 时构建期会输出 ERROR 并使该 agent 的 resolver 失效——该 agent 直调时不裁剪;作为子代理运行时继承父授权面,skill 加载与工具执行仍被硬门拦截,但静态**提示词会枚举全量 skill 目录**(残留暴露;静态 load 工具 schema 内嵌的 `skillId` enum 同样全量)。 +`skillBox(...)` 已随 hook 系统于 2.0.0 标记为过时,**新代码不要使用**——skill 接入一律使用 `skillRepositories(...)` 动态路径。存量代码若仍停留在该路径,其限制如下:`skillBox(...)` 构建期注册(或 `dynamicSkillsEnabled(false)`)没有 per-call 可见面过滤——配置 resolver 时构建期会输出 ERROR 并使该 agent 的 resolver 失效——该 agent 直调时不裁剪;作为子代理运行时继承父授权面,skill 加载与工具执行仍被硬门拦截,但静态**提示词会枚举全量 skill 目录**(残留暴露;静态 load 工具 schema 内嵌的 `skillName` enum 同样全量)。 diff --git a/docs/v2/zh/docs/building-blocks/tool.md b/docs/v2/zh/docs/building-blocks/tool.md index 166591d00c..e385abaa21 100644 --- a/docs/v2/zh/docs/building-blocks/tool.md +++ b/docs/v2/zh/docs/building-blocks/tool.md @@ -450,7 +450,7 @@ ReActAgent agent = | 参数 | 类型 | 说明 | | --- | --- | --- | -| `skillId` | `string`(枚举:已注册的 skill ID) | 要加载的 skill。 | +| `skillName` | `string`(枚举:已注册的 skillName) | 要加载的 skill。 | | `path` | `string` | 传 `"SKILL.md"` 取该 skill 的 markdown 指令;或传 skill 声明过的精确资源路径,例如 `"references/guide.md"`、`"scripts/run.py"`。不要传 `"."`、`"./"`、目录或绝对路径。 | 调用示例: @@ -458,7 +458,7 @@ ReActAgent agent = ```json { "name": "load_skill_through_path", - "input": { "skillId": "pdf-extractor", "path": "SKILL.md" } + "input": { "skillName": "pdf-extractor", "path": "SKILL.md" } } ``` @@ -506,7 +506,7 @@ ReActAgent agent = ### Skill + ToolGroup:按需披露工具 -`SkillToolGroup` 把一组 tool 绑定到某个 skill name —— agent 加载该 skill 时 tool group 自动激活,未加载时 tool 不出现在模型 schema 中,减少上下文噪音。 +`SkillToolGroup` 把一组 tool 绑定到某个 skillName —— agent 加载该 skill 时 tool group 自动激活,未加载时 tool 不出现在模型 schema 中,减少上下文噪音。 ```java import io.agentscope.core.ReActAgent; @@ -519,7 +519,7 @@ toolkit.createSkillToolGroup( "analysis-tools", // group 名 "Data analysis tools", // 描述 false, // 初始不激活 - "data-analysis"); // 绑定的 skill name + "data-analysis"); // 绑定的 skillName // 2. 把 tool 注册到该 group toolkit.registration() diff --git a/docs/v2/zh/docs/harness/skill.md b/docs/v2/zh/docs/harness/skill.md index 4c10acb675..fe6a35e283 100644 --- a/docs/v2/zh/docs/harness/skill.md +++ b/docs/v2/zh/docs/harness/skill.md @@ -281,7 +281,7 @@ agent.promoteSkill("notes-taker", "alice") // 手动晋升一 code-reviewer 当用户需要代码评审、风格反馈或 PR 审核时使用。 - code-reviewer_workspace-namespaced + code-reviewer /workspace/skills/code-reviewer ... @@ -294,8 +294,8 @@ agent.promoteSkill("notes-taker", "alice") // 手动晋升一 加载某个 skill 时 agent 会调用内置工具 `load_skill_through_path`: -- `load_skill_through_path(skillId, path="SKILL.md")` 返回 markdown 正文 -- `load_skill_through_path(skillId, path="references/style-guide.md")` 返回该 skill 目录下的任意文件 +- `load_skill_through_path(skillName, path="SKILL.md")` 返回 markdown 正文 +- `load_skill_through_path(skillName, path="references/style-guide.md")` 返回该 skill 目录下的任意文件 具体怎么取文件,取决于 skill 来自哪里: @@ -330,7 +330,7 @@ agent 感知不到这种差异,`load_skill_through_path` 调起来都一样。 工作区 skill(Layer 3 / Layer 4)不需要 stage——它们本来就在工作区目录里。 -如果两个仓库返回了相同的 `getSource()`,第二个会自动加后缀(`_2`、`_3` …),并打 warning log,所以路径和 skill-id 不会撞。 +如果两个仓库返回了相同的 `getSource()`,第二个会自动加后缀(`_2`、`_3` …),并打 warning log,所以路径和 skillName 不会撞。 ## 在沙箱里运行 skill From 3e4164c837cef18d663f50486f2ddde4cb6d09df Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Tue, 13 Oct 2026 07:39:44 +0800 Subject: [PATCH 06/13] feat(core): materialize activateOnSkill-bound groups into the grant surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SkillToolGroup bound to a granted skill (activateOnSkill) is now added to allowedToolGroups once at call entry, so granting a skill implicitly grants its custom-bound tool groups alongside the naming-convention group. The expansion is a mechanical whitelist union performed while the entry holds both the surface and the toolkit: the surface stays pure data, every gate keeps consuming only the query methods, and groups bound to ungranted skills stay denied. It is idempotent — the same instance is returned when nothing is added, so sub-agents reusing an inherited surface keep reference equality across toolkits with identical bindings. --- .../java/io/agentscope/core/ReActAgent.java | 56 ++++++- .../agent/CapabilityGrantIntegrationTest.java | 155 ++++++++++++++++++ 2 files changed, 205 insertions(+), 6 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 34035765dd..3188df6ba6 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -960,6 +960,7 @@ protected Object beforeAgentExecution(List msgs, RuntimeContext rc, RunCont // ==== Grant layer: capability surface, resolved server-side at call entry ==== // Independent of ToolRequestConfig (the caller-controlled external-tools channel); // runs before the system prompt is assembled so skill filtering already sees it. + CapabilitySurface surface; if (capabilityResolver != null) { CapabilitySurface resolved; try { @@ -977,9 +978,19 @@ protected Object beforeAgentExecution(List msgs, RuntimeContext rc, RunCont e); throw e; } - CapabilitySurface surface = resolved != null ? resolved : CapabilitySurface.ALLOW_ALL; + surface = resolved != null ? resolved : CapabilitySurface.ALLOW_ALL; + } else { CapabilitySurface inherited = ctx.get(CapabilitySurface.class); - if (inherited != null && inherited != surface) { + // No resolver: default to ALLOW_ALL only when the rc has no surface; keep + // inherited ones (sub-agents reuse the parent surface without re-resolving). + surface = inherited != null ? inherited : CapabilitySurface.ALLOW_ALL; + } + // Materialize activateOnSkill-bound groups of granted skills into the grant set. + // Idempotent: returns the same instance when nothing is added, so inherited + // surfaces are reused by reference across toolkits with identical bindings. + surface = expandSkillBoundGroups(surface, scope.activeToolkit); + if (ctx.get(CapabilitySurface.class) != surface) { + if (ctx.get(CapabilitySurface.class) != null) { // SubAgentTool passes the parent rc instance: writing in place would // pollute the parent chain. Write a derived copy and switch scope.rc // (from() copies all typed attributes). @@ -987,10 +998,6 @@ protected Object beforeAgentExecution(List msgs, RuntimeContext rc, RunCont scope.rc = ctx; } ctx.put(CapabilitySurface.class, surface); - } else if (ctx.get(CapabilitySurface.class) == null) { - // No resolver: default to ALLOW_ALL only when the rc has no surface; keep - // inherited ones (sub-agents reuse the parent surface without re-resolving). - ctx.put(CapabilitySurface.class, CapabilitySurface.ALLOW_ALL); } scope.systemMsg = null; @@ -998,6 +1005,43 @@ protected Object beforeAgentExecution(List msgs, RuntimeContext rc, RunCont return scope; } + /** + * Materializes skill-bound tool groups into the grant set: every {@code + * SkillToolGroup} bound (activateOnSkill) to a granted skill joins {@code + * allowedToolGroups}. Mechanical enrichment done once at call entry — the surface + * stays pure data and every gate keeps consuming only the query methods. + * + *

Idempotent: returns the same instance when there is nothing to add, so + * sub-agent reference reuse is preserved when parent and child toolkits share the + * same bindings. Only a whitelist union is performed; groups bound to ungranted + * skills are ignored. + * + * @param surface the resolved or inherited grant set + * @param toolkit the agent's toolkit holding the skill bindings + * @return the enriched surface, or the same instance when nothing was added + */ + private static CapabilitySurface expandSkillBoundGroups( + CapabilitySurface surface, Toolkit toolkit) { + Set grantedSkills = surface.allowedSkills(); + if (grantedSkills == null || grantedSkills.isEmpty()) { + return surface; + } + Set boundGroups = new HashSet<>(); + for (String skillName : grantedSkills) { + boundGroups.addAll(toolkit.findSkillToolGroupsByActivateOnSkill(skillName)); + } + if (boundGroups.isEmpty()) { + return surface; + } + Set existing = surface.allowedToolGroups(); + if (existing != null && existing.containsAll(boundGroups)) { + return surface; + } + Set merged = new HashSet<>(existing != null ? existing : Set.of()); + merged.addAll(boundGroups); + return new CapabilitySurface(surface.allowedSkills(), merged, surface.allowedTools()); + } + /** * Invokes {@link MiddlewareBase#onAgentStateReady} for every {@code ON_AGENT_STATE_READY} * participant ({@link #middlewaresAt}) in list order (= {@code order()} descending). No diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java index 2617d5efc9..6ccdfc3abb 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java @@ -18,6 +18,7 @@ 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.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -43,6 +44,7 @@ import io.agentscope.core.state.ToolContextState; import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.SchemaOnlyTool; +import io.agentscope.core.tool.SkillToolGroup; import io.agentscope.core.tool.ToolCallParam; import io.agentscope.core.tool.ToolMergeMode; import io.agentscope.core.tool.ToolRequestConfig; @@ -1070,6 +1072,159 @@ void endToEndForcedCallIsDenied() { assertEquals("Error: Tool not found: t2", toolResultText); } + // ==================== skill-bound group expansion ==================== + + /** + * Toolkit with a custom {@code SkillToolGroup} ("pdf-toolbox") bound to skill name + * "pdf-helper" plus one bound to an ungranted skill ("secret-box" → "other-skill"). + */ + private Toolkit toolkitWithBoundGroups() { + Toolkit toolkit = grantToolkit(); + toolkit.registerToolGroup( + SkillToolGroup.skillBuilder() + .name("pdf-toolbox") + .description("PDF tools") + .activateOnSkill("pdf-helper") + .build()); + toolkit.registerAgentTool(textTool("merge_pdf")); + toolkit.addToolToGroup("pdf-toolbox", "merge_pdf"); + toolkit.registerToolGroup( + SkillToolGroup.skillBuilder() + .name("secret-box") + .description("Secret tools") + .activateOnSkill("other-skill") + .build()); + toolkit.registerAgentTool(textTool("secret_op")); + toolkit.addToolToGroup("secret-box", "secret_op"); + return toolkit; + } + + @Test + @DisplayName("activateOnSkill-bound groups of granted skills are materialized at entry") + void boundGroupsOfGrantedSkillsExpanded() { + Toolkit toolkit = toolkitWithBoundGroups(); + CapabilitySurface granted = new CapabilitySurface(Set.of("pdf-helper"), Set.of(), Set.of()); + RecordingMiddleware recorder = new RecordingMiddleware(); + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .toolkit(toolkit) + .capabilityResolver((ctx, a) -> granted) + .middleware(recorder) + .build(); + agent.call(userMsg("hi"), rc()).block(Duration.ofSeconds(10)); + + CapabilitySurface expanded = recorder.seen.get(); + assertNotNull(expanded, "the call's rc must carry a surface"); + assertNotSame(granted, expanded, "expansion materialized a new record"); + assertTrue(expanded.allowedToolGroups().contains("pdf-toolbox")); + assertTrue(expanded.allowsToolGroup("pdf-toolbox"), "bound group granted via the skill"); + assertFalse(expanded.allowsToolGroup("secret-box"), "ungranted skill's group stays denied"); + + // Schema channel: the bound tool enters, the ungranted one does not. + List names = + schemaNames( + toolkit.getToolSchemas( + List.of("g1", "pdf-toolbox", "secret-box"), null, expanded)); + assertTrue(names.contains("merge_pdf")); + assertFalse(names.contains("secret_op")); + + // Execution channel: the bound tool runs, the ungranted one is denied. + ToolContextState tcs = ToolContextState.builder().build(); + tcs.addActivatedGroups(List.of("pdf-toolbox", "secret-box")); + RuntimeContext execRc = + RuntimeContext.builder() + .userId("u1") + .sessionId("s1") + .agentState(AgentState.builder().toolContext(tcs).build()) + .build(); + execRc.put(CapabilitySurface.class, expanded); + assertEquals( + "ran:merge_pdf", + firstText(toolkit.callTool(param("merge_pdf", Map.of(), execRc)).block())); + assertEquals( + "Error: Tool not found: secret_op", + firstText(toolkit.callTool(param("secret_op", Map.of(), execRc)).block())); + } + + @Test + @DisplayName("expansion is idempotent: nothing to add keeps the same instance") + void expansionIdempotentKeepsSameInstance() { + // No SkillToolGroup bindings at all. + CapabilitySurface granted = + new CapabilitySurface(Set.of("ghost-skill"), Set.of(), Set.of()); + RecordingMiddleware recorder1 = new RecordingMiddleware(); + ReActAgent plain = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .capabilityResolver((ctx, a) -> granted) + .middleware(recorder1) + .build(); + plain.call(userMsg("hi"), rc()).block(Duration.ofSeconds(10)); + assertSame(granted, recorder1.seen.get(), "no bindings → same instance"); + + // Binding present but the group is already whitelisted. + Toolkit toolkit = toolkitWithBoundGroups(); + CapabilitySurface covered = + new CapabilitySurface(Set.of("pdf-helper"), Set.of("pdf-toolbox"), Set.of()); + RecordingMiddleware recorder2 = new RecordingMiddleware(); + ReActAgent covering = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .toolkit(toolkit) + .capabilityResolver((ctx, a) -> covered) + .middleware(recorder2) + .build(); + covering.call(userMsg("hi"), rc()).block(Duration.ofSeconds(10)); + assertSame(covered, recorder2.seen.get(), "already covered → same instance"); + } + + @Test + @DisplayName("sub-agent without resolver reuses the parent's expanded surface instance") + void subAgentReusesExpandedParentSurface() { + Toolkit toolkit = toolkitWithBoundGroups(); + RecordingMiddleware parentRecorder = new RecordingMiddleware(); + ReActAgent parent = + ReActAgent.builder() + .name("parent") + .model(new MockModel("done")) + .toolkit(toolkit) + .capabilityResolver( + (ctx, a) -> + new CapabilitySurface( + Set.of("pdf-helper"), Set.of(), Set.of())) + .middleware(parentRecorder) + .build(); + parent.call(userMsg("hi"), rc()).block(Duration.ofSeconds(10)); + CapabilitySurface expanded = parentRecorder.seen.get(); + assertNotNull(expanded); + assertTrue(expanded.allowedToolGroups().contains("pdf-toolbox")); + + // Sub-agent without a resolver, same toolkit: the inherited expanded surface is + // reused by reference (the per-agent expansion is a no-op). + RecordingMiddleware childRecorder = new RecordingMiddleware(); + SubAgentTool subAgentTool = + new SubAgentTool( + (SubAgentProvider) + () -> + ReActAgent.builder() + .name("child") + .model(new MockModel("child done")) + .toolkit(toolkit) + .middleware(childRecorder) + .build(), + SubAgentConfig.defaults()); + callDirect(subAgentTool, Map.of("message", "do it"), rc(expanded)); + + assertSame( + expanded, + childRecorder.seen.get(), + "same bindings → expansion is a no-op → parent instance reused by reference"); + } + /** A single-user-message input list. */ private static List userMsg(String text) { return List.of( From 62e5ed3fbd52bd0c6cd0dbc8260f8f058a5e97e0 Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Tue, 13 Oct 2026 09:51:04 +0800 Subject: [PATCH 07/13] =?UTF-8?q?refactor(skill):=20dual-form=20skillId=20?= =?UTF-8?q?=E2=80=94=20bare=20name=20or=20name=5F=5Fs=5F=5Fsource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skillId equals the skill name normally, extended to name__s__source only when a source differentiates same-named skills. Grant entries accept both forms (lazy failure on unknown sources); model-facing vocabulary back to skillId; the "custom" source default is removed. --- .../java/io/agentscope/core/ReActAgent.java | 7 +- .../core/agent/CapabilitySurface.java | 45 ++++++-- .../io/agentscope/core/skill/AgentSkill.java | 73 ++++++++---- .../core/skill/AgentSkillPromptProvider.java | 18 +-- .../io/agentscope/core/skill/SkillBox.java | 14 +-- .../core/skill/SkillToolFactory.java | 105 +++++++++-------- .../agentscope/core/skill/util/SkillUtil.java | 27 +++-- .../agent/CapabilityGrantIntegrationTest.java | 107 ++++++++++++++++-- .../core/agent/CapabilitySurfaceTest.java | 35 ++++++ .../skill/AgentSkillPromptProviderTest.java | 6 +- .../agentscope/core/skill/AgentSkillTest.java | 36 +++++- .../core/skill/SkillBoxToolsTest.java | 38 +++---- .../skill/SkillRuntimeIntegrationTest.java | 6 +- .../SkillToolFactoryDiskFallbackTest.java | 7 +- .../agentscope/core/skill/SkillUtilTest.java | 5 +- .../io/agentscope/core/e2e/SkillE2ETest.java | 10 +- .../nacos/skill/NacosSkillRepositoryTest.java | 2 +- .../middleware/SkillUsageMiddleware.java | 2 +- .../agent/skill/runtime/SkillLoadTool.java | 40 +++---- .../skill/runtime/SkillPromptBuilder.java | 6 +- .../HarnessAgentDynamicHookBuilderTest.java | 2 +- .../HarnessSkillMiddlewareGrantTest.java | 2 +- .../middleware/SkillUsageMiddlewareTest.java | 8 +- .../SkillLoadToolFrontmatterViewTest.java | 4 +- .../agent/skill/runtime/SkillRuntimeTest.java | 24 ++-- .../docs/building-blocks/capability-grant.md | 15 +-- docs/v2/en/docs/building-blocks/tool.md | 4 +- docs/v2/en/docs/harness/skill.md | 6 +- .../docs/building-blocks/capability-grant.md | 8 +- docs/v2/zh/docs/building-blocks/tool.md | 4 +- docs/v2/zh/docs/harness/skill.md | 6 +- 31 files changed, 455 insertions(+), 217 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 3188df6ba6..77639fad4c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -130,6 +130,7 @@ import io.agentscope.core.shutdown.GracefulShutdownManager; import io.agentscope.core.shutdown.GracefulShutdownMiddleware; import io.agentscope.core.shutdown.PartialReasoningPolicy; +import io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.DynamicSkillMiddleware; import io.agentscope.core.skill.SkillBox; import io.agentscope.core.skill.SkillFilter; @@ -1027,8 +1028,10 @@ private static CapabilitySurface expandSkillBoundGroups( return surface; } Set boundGroups = new HashSet<>(); - for (String skillName : grantedSkills) { - boundGroups.addAll(toolkit.findSkillToolGroupsByActivateOnSkill(skillName)); + for (String entry : grantedSkills) { + // Entries may be bare names or qualified ids; bindings match by name. + boundGroups.addAll( + toolkit.findSkillToolGroupsByActivateOnSkill(AgentSkill.parseSkillName(entry))); } if (boundGroups.isEmpty()) { return surface; diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java b/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java index cb2ad23160..a3b864537e 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java @@ -35,8 +35,10 @@ * granted when it is system-marked or its name is whitelisted. Default is deny: anything * not reachable from the enumeration does not exist. * - * @param allowedSkills allowed skill names (the business identity, e.g. from an - * entitlement list); {@code null} = not set (no skill grants) + * @param allowedSkills skill grant entries: normally the bare skill name (the business + * identity, source-unrestricted); optionally the combined skillId form when a specific + * repository's copy must be granted (see {@link AgentSkill#getSkillId()}); {@code null} = + * not set (no skill grants) * @param allowedToolGroups allowed ToolGroup names; {@code null} = not set (no group grants) * @param allowedTools allowed tool names (per-name, grouping ignored); {@code null} = not set * (no per-name grants) @@ -71,16 +73,30 @@ private boolean isUnrestricted() { } /** - * Skill decision by name, grant set only. Fallback for callers that do not hold an + * Skill decision by skillId, grant set only. Fallback for callers that do not hold an * {@link AgentSkill} instance; this overload does not know the system-capability mark, so * such callers must apply that bypass themselves (or resolve the skill and prefer {@link * #allowsSkill(AgentSkill)}). * - * @param skillName the skill name to check - * @return {@code true} for an all-null surface or a whitelisted skill name + *

A skillId is normally just the skill name; only when a source is set does it take the + * combined form {@code name__s__source} (composition rules: {@link + * AgentSkill#getSkillId()}). A name-only skillId matches a bare-name grant entry but never + * a repository-qualified one — the qualified entry demands a source the caller has not + * supplied (fail-closed). + * + * @param skillId the skillId to check (the skill name, or the combined form) + * @return {@code true} for an all-null surface or a matching grant entry */ - public boolean allowsSkill(String skillName) { - return isUnrestricted() || (allowedSkills != null && allowedSkills.contains(skillName)); + public boolean allowsSkill(String skillId) { + if (isUnrestricted() || allowedSkills == null) { + return isUnrestricted(); + } + if (allowedSkills.contains(skillId)) { + return true; // exact entry match (bare name or an exactly equal combined id) + } + // A combined id is also granted by a bare-name entry of its name part. + return skillId.contains(AgentSkill.SKILL_ID_SEPARATOR) + && allowedSkills.contains(AgentSkill.parseSkillName(skillId)); } /** @@ -88,16 +104,25 @@ public boolean allowsSkill(String skillName) { * the caller holds the {@link AgentSkill}: it applies the system-capability bypass ({@code * skill.isSystem()}), mirroring how {@link #allowsTool} treats system tools. * + *

Matches either grant-entry form: the bare skill name (source-unrestricted) or the + * skill's combined id (repository-qualified). + * * @param skill the skill to check; {@code null} (a registry miss) is never granted, not * even on {@link #ALLOW_ALL} — fail-closed like a missing surface - * @return {@code true} for a system skill, an all-null surface, or a whitelisted skill - * name + * @return {@code true} for a system skill, an all-null surface, or a matching grant entry */ public boolean allowsSkill(AgentSkill skill) { if (skill == null) { return false; // registry miss: fail closed } - return skill.isSystem() || allowsSkill(skill.getName()); + if (skill.isSystem() || isUnrestricted()) { + return true; + } + if (allowedSkills == null) { + return false; + } + return allowedSkills.contains(skill.getName()) + || allowedSkills.contains(skill.getSkillId()); } /** 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 5a04905e69..5ac77f34bd 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 @@ -66,6 +66,16 @@ * Manage markdown skill catalogs in application code. */ public class AgentSkill { + + /** + * Single source of the skillId composition separator: {@code skillId = name + "__s__" + + * source}. Neither the name nor the source may contain this separator (enforced at + * construction), so the separator occurs in a well-formed skillId if and only if it is the + * delimiter — grant entries can therefore distinguish "bare name" (source-unrestricted) + * from "name__s__source" (repository-qualified) by simply looking for it. + */ + public static final String SKILL_ID_SEPARATOR = "__s__"; + private final Map metadata; private final String skillContent; private final Map resources; @@ -89,7 +99,7 @@ public class AgentSkill { * Creates an AgentSkill with explicit parameters. * *

Use this constructor when you want to create a skill directly without parsing - * markdown. Uses "custom" as the default source. + * markdown. No source is set (see {@link #getSource()}). * * @param name Skill name (must not be null or empty) * @param description Skill description (must not be null or empty) @@ -99,7 +109,7 @@ public class AgentSkill { */ public AgentSkill( String name, String description, String skillContent, Map resources) { - this(name, description, skillContent, resources, "custom"); + this(name, description, skillContent, resources, null); } /** @@ -112,7 +122,7 @@ public AgentSkill( * @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 source Source identifier for the skill ({@code null} = no provenance) * @throws IllegalArgumentException if name, description, or skillContent is null or empty */ public AgentSkill( @@ -133,7 +143,7 @@ public AgentSkill( * @param metadata Skill metadata including required {@code name} and {@code description} * @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 source Source identifier for the skill ({@code null} = no provenance) * @throws IllegalArgumentException if metadata is invalid or skillContent is null or empty */ public AgentSkill( @@ -150,7 +160,7 @@ public AgentSkill( * @param metadata Skill metadata including required {@code name} and {@code description} * @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 source Source identifier for the skill ({@code null} = no provenance) * @param originDir Absolute path to the skill's source directory, or {@code null} if not * filesystem-backed (e.g. classpath / remote / synthetic). Stored as-is. * @throws IllegalArgumentException if metadata is invalid or skillContent is null or empty @@ -170,7 +180,7 @@ public AgentSkill( * @param metadata Skill metadata including required {@code name} and {@code description} * @param skillContent The skill implementation or instructions (must not be null or empty) * @param resources Supporting resources (can be null) - * @param source Source identifier for the skill (null defaults to "custom") + * @param source Source identifier for the skill ({@code null} = no provenance) * @param originDir Absolute path to the skill's source directory, or {@code null} * @param system Whether this skill is exempt from grant-layer trimming * @throws IllegalArgumentException if metadata is invalid or skillContent is null or empty @@ -187,6 +197,19 @@ private AgentSkill( if (skillContent == null || skillContent.isEmpty()) { throw new IllegalArgumentException("The skill must have content"); } + if (name.contains(SKILL_ID_SEPARATOR)) { + throw new IllegalArgumentException( + "The skill name must not contain '" + + SKILL_ID_SEPARATOR + + "' (reserved as the skillId separator)"); + } + String resolvedSource = source != null && !source.isBlank() ? source : null; + if (resolvedSource != null && resolvedSource.contains(SKILL_ID_SEPARATOR)) { + throw new IllegalArgumentException( + "The skill source must not contain '" + + SKILL_ID_SEPARATOR + + "' (reserved as the skillId separator)"); + } LinkedHashMap metadataCopy = new LinkedHashMap<>(metadata); metadataCopy.put("name", name); @@ -195,7 +218,7 @@ private AgentSkill( this.metadata = Collections.unmodifiableMap(metadataCopy); this.skillContent = skillContent; this.resources = resources != null ? new HashMap<>(resources) : new HashMap<>(); - this.source = source != null ? source : "custom"; + this.source = resolvedSource; this.originDir = originDir; this.system = system; } @@ -254,9 +277,9 @@ public String getSkillContent() { } /** - * Gets the skill source identifier. + * Gets the skill source identifier (provenance only, not part of the identity). * - * @return The source identifier (never null) + * @return The source identifier, or {@code null} when the skill carries no provenance */ public String getSource() { return source; @@ -291,20 +314,30 @@ public Set getResourcePaths() { } /** - * Gets a unique identifier for this skill. - * - *

The ID is composed of name and source: "name_source". + * Gets a unique identifier for this skill. With no source set (the common case — skills + * are not differentiated by repository), the id is simply the name; with a source set it + * extends to {@code name + "__s__" + source} (see {@link #SKILL_ID_SEPARATOR}). Neither + * part may contain the separator, so a qualified id parses back unambiguously; grant + * entries use either the bare name (source-unrestricted) or the qualified form + * (repository-specific). * - * @return Unique skill identifier (never null) - * @deprecated Skills are identified by {@link #getName()} everywhere (grant surface, - * registry key, load addressing, skill tool-group naming). The name is the - * industry-standard unique identity and must be unique within an agent's catalog; the - * {@code name + "_" + source} composite is a storage detail and is not injective over - * {@code (name, source)}. + * @return the skill identifier (never null) */ - @Deprecated(forRemoval = true, since = "2.0.5") public String getSkillId() { - return getName() + "_" + source; + return source == null || source.isBlank() + ? getName() + : getName() + SKILL_ID_SEPARATOR + source; + } + + /** + * Parses the name part out of a skill identifier. + * + * @param skillIdOrName a bare skill name or a full skillId ({@code name__s__source}) + * @return the name part (the whole input when it contains no separator) + */ + public static String parseSkillName(String skillIdOrName) { + int idx = skillIdOrName.indexOf(SKILL_ID_SEPARATOR); + return idx < 0 ? skillIdOrName : skillIdOrName.substring(0, idx); } /** diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java index 9c9a680eda..70a68b367f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java @@ -54,20 +54,20 @@ public class AgentSkillPromptProvider { Skills provide specialized capabilities and domain knowledge. Use them when they match your current task. How to use skills: - - Load skill: load_skill_through_path(skillName="", path="SKILL.md") + - Load skill: load_skill_through_path(skillId="", path="SKILL.md") - The skill will be activated and its documentation loaded with detailed instructions - Additional resources (scripts, assets, references) can be loaded using the same tool with different paths Example: - 1. User asks to analyze data → find a matching skill below (e.g. data-analysis) - 2. Load it: load_skill_through_path(skillName="data-analysis", path="SKILL.md") + 1. User asks to analyze data → find a matching skill below (e.g. data-analysis) + 2. Load it: load_skill_through_path(skillId="data-analysis", path="SKILL.md") 3. Follow the instructions returned by the skill Metadata is rendered as XML under each element: - scalar metadata becomes a simple child element - nested maps become nested XML elements - lists become repeated elements - - is always appended for tool loading + - is always appended for tool loading (normally just the skill name) @@ -86,9 +86,9 @@ public class AgentSkillPromptProvider { than describing or suggesting commands to the user. Skills root directory: %s - Each skill's files are located under a subdirectory named by its : - %s//scripts/ - %s//assets/ + Each skill's files are located under a subdirectory named by its : + %s//scripts/ + %s//assets/ Workflow: 1. After loading a skill, use ls to explore its directory structure and discover available scripts/assets @@ -291,7 +291,7 @@ public void setCodeExecutionInstruction(String codeExecutionInstruction) { /** * Sets whether all metadata fields are exposed to the LLM. * - *

When disabled, only {@code name}, {@code description}, and {@code skill-name} + *

When disabled, only {@code name}, {@code description}, and {@code skill-id} * are rendered into the skill prompt. * * @param exposeAllMetadata {@code true} to expose all metadata, {@code false} to expose only @@ -309,7 +309,7 @@ private void appendSkill(StringBuilder sb, AgentSkill skill) { } appendXmlNode(sb, entry.getKey(), entry.getValue(), 1); } - appendXmlNode(sb, "skill-name", skill.getName(), 1); + appendXmlNode(sb, "skill-id", skill.getSkillId(), 1); // Per-skill files-root: only emitted when code execution is enabled AND the skill // carries an originDir (filesystem-backed). Lets the LLM build absolute shell paths // without going through uploadSkillFiles or guessing layouts. diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java index c9ba9d9b3b..2eb1c1fcdb 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java @@ -122,7 +122,7 @@ public String getSkillPrompt(SkillFilter filter) { /** * Controls whether the skill prompt exposes all metadata fields or only the core fields. * - *

When disabled, only {@code name}, {@code description}, and {@code skill-name} + *

When disabled, only {@code name}, {@code description}, and {@code skill-id} * are included in the skill prompt. * * @param exposeAllMetadata {@code true} to expose all metadata, {@code false} to expose only @@ -219,21 +219,21 @@ public Set getAllSkillNames() { * point used by the shared {@code load_skill_through_path} tool: skill activation is written to * the per-session tool context, never to the shared group manager. * - * @param param the tool call parameters carrying {@code skillName} and {@code path} + * @param param the tool call parameters carrying {@code skillId} and {@code path} * @return the formatted resource content, or a message describing the failure */ String loadSkillResource(ToolCallParam param) { Map input = param.getInput(); - String skillName = (String) input.get("skillName"); - if (skillName == null || skillName.trim().isEmpty()) { - throw new IllegalArgumentException("Missing or empty required parameter: skillName"); + String skillId = (String) input.get("skillId"); + if (skillId == null || skillId.trim().isEmpty()) { + throw new IllegalArgumentException("Missing or empty required parameter: skillId"); } String path = (String) input.get("path"); if (path == null || path.trim().isEmpty()) { throw new IllegalArgumentException("Missing or empty required parameter: path"); } return skillToolFactory.loadSkillResourceImpl( - skillName, + skillId, path, skillToolFactory.resolveToolContext(param), // Not gated here: the single decision lives in loadSkillResourceImpl. @@ -717,7 +717,7 @@ public void uploadSkillFiles() { continue; } - Path skillDir = targetDir.resolve(skillName); + Path skillDir = targetDir.resolve(skill.getSkillId()); for (String resourcePath : resourcePaths) { if (!filter.accept(resourcePath)) { 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 f2becf69fb..c5bf3ce479 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 @@ -50,7 +50,7 @@ class SkillToolFactory { static final String LOAD_TOOL_NAME = "load_skill_through_path"; private static final String LOAD_TOOL_DESCRIPTION = - "Load and activate a skill resource by its name and resource path.\n\n" + "Load and activate a skill resource by its skillId and resource path.\n\n" + "**Functionality:**\n" + "1. Activates the specified skill for this session (adding its tool groups to" + " this session's active set)\n" @@ -135,12 +135,16 @@ public Map getParameters() { "type", "object", "properties", Map.of( - "skillName", + "skillId", Map.of( "type", "string", "description", - "The unique name of the skill."), + "The skill identifier (normally the" + + " skill name; the combined" + + " name__s__source form only" + + " when same-named skills come" + + " from different repositories)."), "path", Map.of( "type", @@ -151,7 +155,7 @@ public Map getParameters() { + " the skill instructions. Do not" + " use '.', './', directories, or" + " absolute paths.")), - "required", List.of("skillName", "path")); + "required", List.of("skillId", "path")); } @Override @@ -215,21 +219,27 @@ public boolean isSystem() { @Override public Map getParameters() { - // The catalog is keyed by name (the skill's business identity). - List availableSkillNames = new ArrayList<>(skillRegistry.getSkillNames()); + // The catalog is keyed by name; the skillId equals the name unless a + // source differentiates same-named skills. + List availableSkillIds = + new ArrayList<>( + skillRegistry.getSkillNames().stream() + .map(n -> skillRegistry.getSkill(n).getSkillId()) + .toList()); return Map.of( "type", "object", "properties", Map.of( - "skillName", + "skillId", Map.of( "type", "string", "description", - "The unique name of the skill.", + "The skill identifier (normally the" + + " skill name).", "enum", - availableSkillNames), + availableSkillIds), "path", Map.of( "type", @@ -240,7 +250,7 @@ public Map getParameters() { + " skill instructions. Do not use" + " '.', './', directories, or" + " absolute paths.")), - "required", List.of("skillName", "path")); + "required", List.of("skillId", "path")); } @Override @@ -249,11 +259,11 @@ public Mono callAsync(ToolCallParam param) { Map input = param.getInput(); // Validate parameters - String skillName = (String) input.get("skillName"); - if (skillName == null || skillName.trim().isEmpty()) { + String skillId = (String) input.get("skillId"); + if (skillId == null || skillId.trim().isEmpty()) { return Mono.just( ToolResultBlock.error( - "Missing or empty required parameter: skillName")); + "Missing or empty required parameter: skillId")); } String path = (String) input.get("path"); @@ -264,7 +274,7 @@ public Mono callAsync(ToolCallParam param) { String result = loadSkillResourceImpl( - skillName, + skillId, path, resolveToolContext(param), param.getRuntimeContext()); @@ -288,7 +298,7 @@ public Mono callAsync(ToolCallParam param) { * Implementation of skill resource loading logic. Both load paths (per-call box and the * build-time bound static tool) converge here, so the grant gate lives in this method only. * - * @param skillName The unique name of the skill + * @param skillId The skill identifier (normally the skill name) * @param path The path to the resource file * @param tcs the per-call tool context state (may be {@code null}) * @param rc the tool call's runtime context (may be {@code null} → unrestricted: direct @@ -298,21 +308,21 @@ public Mono callAsync(ToolCallParam param) { * found */ String loadSkillResourceImpl( - String skillName, String path, ToolContextState tcs, RuntimeContext rc) { - AgentSkill skill = validateSkillExists(skillName); - requireSkillGranted(skillName, skill, rc); + String skillId, String path, ToolContextState tcs, RuntimeContext rc) { + AgentSkill skill = validateSkillExists(skillId); + requireSkillGranted(skillId, skill, rc); // Special handling for SKILL.md - return the skill's markdown content if ("SKILL.md".equals(path)) { - activateSkill(skillName, tcs); - return buildSkillMarkdownResponse(skillName, skill); + activateSkill(skillId, tcs); + return buildSkillMarkdownResponse(skillId, skill); } // 1. In-memory map (eager FS repos, classpath repos, marketplace prefetch). Map resources = skill.getResources(); if (resources != null && resources.containsKey(path)) { - activateSkill(skillName, tcs); - return buildResourceResponse(skillName, path, resources.get(path)); + activateSkill(skillId, tcs); + return buildResourceResponse(skillId, path, resources.get(path)); } // 2. Disk fallback via skill.originDir (lazy FS repos, or any FS-backed skill whose @@ -323,15 +333,15 @@ String loadSkillResourceImpl( Optional diskContent = readFromOriginDir(skill.getOriginDir().get(), sanitized.get()); if (diskContent.isPresent()) { - activateSkill(skillName, tcs); - return buildResourceResponse(skillName, path, diskContent.get()); + activateSkill(skillId, tcs); + return buildResourceResponse(skillId, path, diskContent.get()); } } // 3. Not found — enumerate from both sources so the model gets real options. throw new IllegalArgumentException( buildResourceNotFoundMessage( - skillName, path, resources, skill.getOriginDir().orElse(null))); + skillId, path, resources, skill.getOriginDir().orElse(null))); } private static Optional sanitizeRelativePath(String path) { @@ -381,16 +391,19 @@ private static Optional readFromOriginDir(Path originDir, String relPath /** * Build response for SKILL.md content. * - * @param skillName The skill name + * @param skillId The skill identifier (echoed as supplied) * @param skill The skill instance * @return Formatted skill markdown response */ - private String buildSkillMarkdownResponse(String skillName, AgentSkill skill) { + private String buildSkillMarkdownResponse(String skillId, AgentSkill skill) { StringBuilder result = new StringBuilder(); - result.append("Successfully loaded skill: ").append(skillName).append("\n\n"); + result.append("Successfully loaded skill: ").append(skillId).append("\n\n"); result.append("Name: ").append(skill.getName()).append("\n"); result.append("Description: ").append(skill.getDescription()).append("\n"); - result.append("Source: ").append(skill.getSource()).append("\n\n"); + if (skill.getSource() != null) { + result.append("Source: ").append(skill.getSource()).append("\n"); + } + result.append("\n"); result.append("Content:\n"); result.append("---\n"); result.append(skill.getSkillContent()); @@ -401,14 +414,14 @@ private String buildSkillMarkdownResponse(String skillName, AgentSkill skill) { /** * Build response for regular resource content. * - * @param skillName The skill name + * @param skillId The skill identifier (echoed as supplied) * @param path The resource path * @param resourceContent The resource content * @return Formatted resource response */ - private String buildResourceResponse(String skillName, String path, String resourceContent) { + private String buildResourceResponse(String skillId, String path, String resourceContent) { StringBuilder result = new StringBuilder(); - result.append("Successfully loaded resource from skill: ").append(skillName).append("\n"); + result.append("Successfully loaded resource from skill: ").append(skillId).append("\n"); result.append("Resource path: ").append(path).append("\n\n"); result.append("Content:\n"); result.append("---\n"); @@ -420,18 +433,18 @@ private String buildResourceResponse(String skillName, String path, String resou /** * Build error message with available resource paths when resource is not found. * - * @param skillName The skill name + * @param skillId The skill identifier (echoed as supplied) * @param path The requested path that was not found * @param resources The available resources map * @return Formatted error message with available resources */ private String buildResourceNotFoundMessage( - String skillName, String path, Map resources, Path originDir) { + String skillId, String path, Map resources, Path originDir) { StringBuilder message = new StringBuilder(); message.append("Resource not found: '") .append(path) .append("' in skill '") - .append(skillName) + .append(skillId) .append("'.\n\n"); // Build a deduped list spanning in-memory keys and on-disk entries so the model can @@ -477,42 +490,42 @@ private static List listOriginDirEntries(Path originDir) { * this load never entered an agent call() (whose funnel always writes one): treat it as * unrestricted — direct execution is trusted assembler code. * - * @param skillName the caller-supplied skill name (authoritative for the error echo) + * @param skillId the caller-supplied skill identifier (authoritative for the error echo) * @param skill the resolved skill (from {@link #validateSkillExists}) * @param rc the tool call's runtime context (may be {@code null}) * @throws IllegalArgumentException when the skill is not granted — byte-identical to the * {@link #validateSkillExists} not-found wording: do not reveal existence */ - private void requireSkillGranted(String skillName, AgentSkill skill, RuntimeContext rc) { + private void requireSkillGranted(String skillId, AgentSkill skill, RuntimeContext rc) { CapabilitySurface surface = rc != null ? rc.get(CapabilitySurface.class) : null; if (surface == null) { surface = CapabilitySurface.ALLOW_ALL; } if (!surface.allowsSkill(skill)) { logger.warn( - "Skill load denied (grant): skillName='{}', userId='{}', sessionId='{}'", - skillName, + "Skill load denied (grant): skillId='{}', userId='{}', sessionId='{}'", + skillId, rc != null ? rc.getUserId() : null, rc != null ? rc.getSessionId() : null); throw new IllegalArgumentException( - String.format( - "Skill not found: '%s'. Please check the skill name.", skillName)); + String.format("Skill not found: '%s'. Please check the skill ID.", skillId)); } } /** * Validates that a skill is registered and returns its instance. * - * @param skillName The unique name of the skill + * @param skillId The skill identifier (normally the skill name) * @return The registered skill instance * @throws IllegalArgumentException if the skill is not registered * @throws IllegalStateException if the skill cannot be loaded after validation */ - private AgentSkill validateSkillExists(String skillName) { + private AgentSkill validateSkillExists(String skillId) { + // The catalog is keyed by name; a combined skillId carries the same name. + String skillName = AgentSkill.parseSkillName(skillId); if (!skillRegistry.exists(skillName)) { throw new IllegalArgumentException( - String.format( - "Skill not found: '%s'. Please check the skill name.", skillName)); + String.format("Skill not found: '%s'. Please check the skill ID.", skillId)); } // Get skill AgentSkill skill = skillRegistry.getSkill(skillName); @@ -521,7 +534,7 @@ private AgentSkill validateSkillExists(String skillName) { String.format( "Failed to load skill '%s' after validation. This is an internal" + " error.", - skillName)); + skillId)); } return skill; } 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 99f41b6db1..fab1662bd3 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 @@ -53,7 +53,6 @@ public class SkillUtil { private static final String SKILL_FILE_NAME = "SKILL.md"; - private static final String DEFAULT_SOURCE = "custom"; /** * Private constructor to prevent instantiation. @@ -64,7 +63,7 @@ private SkillUtil() {} * Creates an AgentSkill from markdown with YAML frontmatter. * *

Parses the markdown to extract metadata (name, description) from YAML frontmatter - * and content. Uses "custom" as the default source. + * and content. No source is set by default. * * @param skillMd Markdown content with YAML frontmatter containing name and description * @param resources Supporting resources referenced by the skill (can be null) @@ -72,7 +71,7 @@ private SkillUtil() {} * @throws IllegalArgumentException if name or description is missing, or if content is empty */ public static AgentSkill createFrom(String skillMd, Map resources) { - return createFrom(skillMd, resources, DEFAULT_SOURCE); + return createFrom(skillMd, resources, null); } /** @@ -83,7 +82,7 @@ public static AgentSkill createFrom(String skillMd, Map resource * * @param skillMd Markdown content with YAML frontmatter containing name and description * @param resources Supporting resources referenced by the skill (can be null) - * @param source Source identifier for the skill (null defaults to "custom") + * @param source Source identifier for the skill ({@code null} = no provenance) * @return Created AgentSkill instance * @throws IllegalArgumentException if name or description is missing, or if content is empty */ @@ -124,7 +123,7 @@ public static AgentSkill createFrom( * zip entries are invalid */ public static AgentSkill createFromZip(byte[] zipBytes) { - return createFromZip(zipBytes, DEFAULT_SOURCE, StandardCharsets.UTF_8); + return createFromZip(zipBytes, null, StandardCharsets.UTF_8); } /** @@ -139,7 +138,7 @@ public static AgentSkill createFromZip(byte[] zipBytes) { * @throws RuntimeException if the zip file cannot be read */ public static AgentSkill createFromZip(Path zipPath) { - return createFromZip(zipPath, DEFAULT_SOURCE, StandardCharsets.UTF_8); + return createFromZip(zipPath, null, StandardCharsets.UTF_8); } /** @@ -156,7 +155,7 @@ public static AgentSkill createFromZip(Path zipPath) { * @return Created AgentSkill instance */ public static AgentSkill createFromZip(Path zipPath, Charset charset) { - return createFromZip(zipPath, DEFAULT_SOURCE, charset); + return createFromZip(zipPath, null, charset); } /** @@ -170,7 +169,7 @@ public static AgentSkill createFromZip(Path zipPath, Charset charset) { * @throws IllegalArgumentException if zipStream is null */ public static AgentSkill createFromZip(InputStream zipStream) { - return createFromZip(zipStream, DEFAULT_SOURCE, StandardCharsets.UTF_8); + return createFromZip(zipStream, null, StandardCharsets.UTF_8); } /** @@ -180,7 +179,7 @@ public static AgentSkill createFromZip(InputStream zipStream) { * files. The .skill and .zip extensions are aliases for the same package format. * * @param zipBytes Zip content as bytes - * @param source Source identifier for the skill (null defaults to "custom") + * @param source Source identifier for the skill ({@code null} = no provenance) * @return Created AgentSkill instance * @throws IllegalArgumentException if zipBytes is null/empty, if no SKILL.md is found, or if * zip entries are invalid @@ -196,7 +195,7 @@ public static AgentSkill createFromZip(byte[] zipBytes, String source) { * files. The .skill and .zip extensions are aliases for the same package format. * * @param zipBytes Zip content as bytes - * @param source Source identifier for the skill (null defaults to "custom") + * @param source Source identifier for the skill ({@code null} = no provenance) * @param charset Charset used to decode zip entry names and file contents (null defaults to * UTF-8). Use this when the zip was created with a non-UTF-8 charset such as * GBK/GB18030 and the default overload fails during zip entry decoding with @@ -219,7 +218,7 @@ public static AgentSkill createFromZip(byte[] zipBytes, String source, Charset c * files. The .skill and .zip extensions are aliases for the same package format. * * @param zipPath Zip file path - * @param source Source identifier for the skill (null defaults to "custom") + * @param source Source identifier for the skill ({@code null} = no provenance) * @return Created AgentSkill instance * @throws IllegalArgumentException if zipPath is null * @throws RuntimeException if the zip file cannot be read @@ -235,7 +234,7 @@ public static AgentSkill createFromZip(Path zipPath, String source) { * files. The .skill and .zip extensions are aliases for the same package format. * * @param zipPath Zip file path - * @param source Source identifier for the skill (null defaults to "custom") + * @param source Source identifier for the skill ({@code null} = no provenance) * @param charset Charset used to decode zip entry names and file contents (null defaults to * UTF-8). Use this when the zip was created with a non-UTF-8 charset such as * GBK/GB18030 and the default overload fails during zip entry decoding with @@ -262,7 +261,7 @@ public static AgentSkill createFromZip(Path zipPath, String source, Charset char * files. The .skill and .zip extensions are aliases for the same package format. * * @param zipStream Zip content stream - * @param source Source identifier for the skill (null defaults to "custom") + * @param source Source identifier for the skill ({@code null} = no provenance) * @return Created AgentSkill instance * @throws IllegalArgumentException if zipStream is null * @throws RuntimeException if the zip content cannot be read @@ -278,7 +277,7 @@ public static AgentSkill createFromZip(InputStream zipStream, String source) { * files. The .skill and .zip extensions are aliases for the same package format. * * @param zipStream Zip content stream - * @param source Source identifier for the skill (null defaults to "custom") + * @param source Source identifier for the skill ({@code null} = no provenance) * @param charset Charset used to decode zip entry names and file contents (null defaults to * UTF-8). Use this when the zip was created with a non-UTF-8 charset such as * GBK/GB18030 and the default overload fails during zip entry decoding with diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java index 6ccdfc3abb..8dd29e989f 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java @@ -791,6 +791,100 @@ void skillVisibilityFiltering() { assertFalse(prompt.contains("beta"), "un-granted skill cut from the prompt"); } + @Test + @DisplayName("repository-qualified entry grants only that repository's skill") + void qualifiedEntryGrantsOnlyTheQualifiedSkill() { + AgentSkill alphaCustom = skill("alpha"); // source unset: id is simply "alpha" + AgentSkill alphaMarket = + AgentSkill.builder() + .name("alpha") + .description("from market") + .skillContent("# market") + .source("market") + .build(); + // Same-name merge keeps the later entry (alphaMarket), so the catalog holds the + // market copy whose skillId is "alpha__s__market". + DynamicSkillMiddleware mw = + new DynamicSkillMiddleware( + List.of(new StubRepo(List.of(alphaCustom, alphaMarket))), + new Toolkit(), + null, + false, + null); + + // Qualified entry "alpha__s__market" grants exactly that copy. + String prompt = + mw.onSystemPrompt( + null, + rc(new CapabilitySurface(Set.of("alpha__s__market"), null, null)), + "base") + .block(); + assertNotNull(prompt); + assertTrue(prompt.contains("from market"), "the qualified copy is granted"); + + // A qualified entry for the source-less copy does not grant the market copy. + String mismatched = + mw.onSystemPrompt( + null, + rc(new CapabilitySurface(Set.of("alpha"), Set.of(), Set.of())), + "base") + .block(); + assertNotNull(mismatched); + assertTrue(mismatched.contains("from market"), "bare-name entry grants any source"); + } + + @Test + @DisplayName("qualified entry with an unknown source fails lazily: call runs, skill denied") + void qualifiedEntryWithUnknownSourceFailsLazily() { + // No eager entry validation: a misspelled source simply never matches, so the skill + // stays invisible and loading it reports the not-found wording at runtime. + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .skillRepository(new StubRepo(List.of(skill("alpha")))) + .capabilityResolver( + (ctx, a) -> + new CapabilitySurface(Set.of("alpha__s__nope"), null, null)) + .build(); + agent.call(userMsg("hi"), rc()).block(java.time.Duration.ofSeconds(10)); + + DynamicSkillMiddleware mw = + new DynamicSkillMiddleware( + List.of(new StubRepo(List.of(skill("alpha")))), + new Toolkit(), + null, + false, + null); + String prompt = + mw.onSystemPrompt( + null, + rc(new CapabilitySurface(Set.of("alpha__s__nope"), null, null)), + "base") + .block(); + assertEquals( + "base", + prompt, + "an unknown-source qualified entry grants nothing; the skill stays invisible"); + } + + @Test + @DisplayName("qualified entry with a known repository source passes entry validation") + void qualifiedEntryWithKnownSourceLoads() { + // StubRepo.getSource() = "stub": the qualified entry references a configured source. + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .skillRepository(new StubRepo(List.of(skill("alpha")))) + .capabilityResolver( + (ctx, a) -> + new CapabilitySurface(Set.of("alpha__s__stub"), null, null)) + .build(); + // Validation passes and the call completes (the qualified copy is loadable). + agent.call(userMsg("hi"), rc()).block(java.time.Duration.ofSeconds(10)); + } + @Test @DisplayName("missing surface → no skill prompt at all (fail-closed)") void skillFilterFailsClosedOnMissingSurface() { @@ -909,7 +1003,7 @@ void dynamicLoadPathGate() { ToolResultBlock denied = loadViaToolkit(toolkit, "beta", ctx); assertEquals( - "Error: Skill not found: 'beta'. Please check the skill name.", + "Error: Skill not found: 'beta'. Please check the skill ID.", firstText(denied), "deny wording must be byte-identical to the not-found wording"); @@ -951,13 +1045,12 @@ void staticLoadPathGate() { RuntimeContext ctx = rc(new CapabilitySurface(Set.of("alpha"), null, null)); ToolResultBlock denied = - callDirect(loadTool, Map.of("skillName", "secret", "path", "SKILL.md"), ctx); + callDirect(loadTool, Map.of("skillId", "secret", "path", "SKILL.md"), ctx); assertEquals( - "Error: Skill not found: 'secret'. Please check the skill name.", - firstText(denied)); + "Error: Skill not found: 'secret'. Please check the skill ID.", firstText(denied)); ToolResultBlock allowed = - callDirect(loadTool, Map.of("skillName", "alpha", "path", "SKILL.md"), ctx); + callDirect(loadTool, Map.of("skillId", "alpha", "path", "SKILL.md"), ctx); assertTrue(firstText(allowed).contains("Successfully loaded skill: alpha")); } @@ -979,7 +1072,7 @@ void loadGateTreatsMissingSurfaceAsAllowAll() { // Direct callAsync so the executor gate does not preempt the load-gate check. ToolResultBlock result = - callDirect(loadTool, Map.of("skillName", "gamma", "path", "SKILL.md"), rc()); + callDirect(loadTool, Map.of("skillId", "gamma", "path", "SKILL.md"), rc()); assertTrue( firstText(result).contains("Successfully loaded skill: gamma"), "no surface (direct path) = unrestricted: " + firstText(result)); @@ -990,7 +1083,7 @@ private ToolResultBlock loadViaToolkit(Toolkit toolkit, String skillName, Runtim return toolkit.callTool( param( "load_skill_through_path", - Map.of("skillName", skillName, "path", "SKILL.md"), + Map.of("skillId", skillName, "path", "SKILL.md"), ctx)) .block(); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java index b8608e87b9..43676d6a66 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java @@ -147,6 +147,41 @@ void skillObjectEntryMatrix() { assertFalse(CapabilitySurface.ALLOW_ALL.allowsSkill(registryMiss)); } + @Test + @DisplayName("skill grant entries: bare name vs repository-qualified id forms") + void skillGrantEntryDualForms() { + AgentSkill alphaCustom = + AgentSkill.builder() + .name("alpha") + .description("d") + .skillContent("c") + .build(); // source defaults to "custom" → id "alpha__s__custom" + AgentSkill alphaMarket = + AgentSkill.builder() + .name("alpha") + .description("d") + .skillContent("c") + .source("market") + .build(); + + // Bare-name entry grants the name under any source. + CapabilitySurface byName = new CapabilitySurface(Set.of("alpha"), Set.of(), Set.of()); + assertTrue(byName.allowsSkill(alphaCustom)); + assertTrue(byName.allowsSkill(alphaMarket)); + + // Qualified entry grants only the exact repository's skill. + CapabilitySurface byId = + new CapabilitySurface(Set.of("alpha__s__market"), Set.of(), Set.of()); + assertTrue(byId.allowsSkill(alphaMarket)); + assertFalse(byId.allowsSkill(alphaCustom)); + + // String fallback: a bare-name input never hits a qualified entry (no source known); + // a full id is also granted by a bare-name entry. + assertFalse(byId.allowsSkill("alpha")); + assertTrue(byName.allowsSkill(alphaCustom.getSkillId())); + assertTrue(AgentSkill.parseSkillName("alpha__s__market").equals("alpha")); + } + @Test @DisplayName("toolGroup dimension: all-null = no trimming; unset or empty = no group grants") void toolGroupDimensionThreeStates() { diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java index 83bdcd91b5..f7fd80d19f 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java @@ -62,7 +62,7 @@ void testSingleSkill() { assertTrue(prompt.contains("")); assertTrue(prompt.contains("test_skill")); assertTrue(prompt.contains("Test Skill Description")); - assertTrue(prompt.contains("test_skill")); + assertTrue(prompt.contains("test_skill")); assertTrue(prompt.contains("")); assertTrue(prompt.contains("")); } @@ -84,7 +84,7 @@ void testMetadataOrder() { int homepageIndex = prompt.indexOf( "https://developer.atlassian.com/cloud/trello/rest/"); - int skillNameIndex = prompt.indexOf("trello"); + int skillNameIndex = prompt.indexOf("trello"); assertTrue(nameIndex < descriptionIndex); assertTrue(descriptionIndex < homepageIndex); @@ -142,7 +142,7 @@ void testExposeOnlyCoreMetadata() { assertTrue(prompt.contains("trello")); assertTrue(prompt.contains("Manage Trello boards")); - assertTrue(prompt.contains("trello")); + assertTrue(prompt.contains("trello")); assertFalse(prompt.contains("")); assertFalse(prompt.contains("")); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillTest.java index 694d1f456f..915f915bf4 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillTest.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; @@ -46,7 +47,8 @@ void testConstructorWithAllParameters() { assertEquals("test_skill", skill.getName()); assertEquals("A test skill", skill.getDescription()); assertEquals("Skill content here", skill.getSkillContent()); - assertEquals("custom", skill.getSource()); + assertNull(skill.getSource()); + assertEquals("test_skill", skill.getSkillId()); assertEquals("test_skill", skill.getMetadata().get("name")); assertEquals("A test skill", skill.getMetadata().get("description")); assertEquals(2, skill.getResources().size()); @@ -99,6 +101,28 @@ void testConstructorWithCustomSource() { assertEquals("github", skill.getSource()); assertEquals("github_skill", skill.getName()); + assertEquals("github_skill__s__github", skill.getSkillId()); + } + + @Test + @DisplayName("Should reject names or sources containing the skillId separator") + void testRejectsSeparatorInNameOrSource() { + assertThrows( + IllegalArgumentException.class, + () -> new AgentSkill("bad__s__name", "d", "c", null), + "name containing the separator is rejected"); + assertThrows( + IllegalArgumentException.class, + () -> new AgentSkill("ok", "d", "c", null, "bad__s__source"), + "source containing the separator is rejected"); + } + + @Test + @DisplayName("Should parse the name part out of a skill identifier") + void testParseSkillName() { + assertEquals("alpha", AgentSkill.parseSkillName("alpha__s__market")); + assertEquals("alpha", AgentSkill.parseSkillName("alpha")); + assertEquals("a_b", AgentSkill.parseSkillName("a_b__s__src")); } @Test @@ -109,9 +133,15 @@ void testConstructorWithNullParameters() { assertNotNull(skill1.getResources()); assertTrue(skill1.getResources().isEmpty()); - // Null source defaults to "custom" + // Null source: no provenance; the skillId is simply the name. AgentSkill skill2 = new AgentSkill("skill", "description", "content", null, null); - assertEquals("custom", skill2.getSource()); + assertNull(skill2.getSource()); + assertEquals("skill", skill2.getSkillId()); + + // Blank sources are treated as absent too. + AgentSkill skill3 = new AgentSkill("skill", "description", "content", null, " "); + assertNull(skill3.getSource()); + assertEquals("skill", skill3.getSkillId()); } @Test diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java index b358fe6c1c..e79c61576a 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxToolsTest.java @@ -151,17 +151,17 @@ void testLoadSkillResourceToolParameters() { @SuppressWarnings("unchecked") Map properties = (Map) params.get("properties"); assertNotNull(properties); - assertTrue(properties.containsKey("skillName")); + assertTrue(properties.containsKey("skillId")); assertTrue(properties.containsKey("path")); @SuppressWarnings("unchecked") List required = (List) params.get("required"); - assertTrue(required.contains("skillName")); + assertTrue(required.contains("skillId")); assertTrue(required.contains("path")); // Verify skillName has enum with available skills @SuppressWarnings("unchecked") - Map skillNameParam = (Map) properties.get("skillName"); + Map skillNameParam = (Map) properties.get("skillId"); assertNotNull(skillNameParam.get("enum")); @SuppressWarnings("unchecked") @@ -176,7 +176,7 @@ void testLoadSkillResourceToolParameters() { void testLoadSkillMarkdownSuccessfully() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", "test_skill", "path", "SKILL.md"); + Map input = Map.of("skillId", "test_skill", "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-001") @@ -213,7 +213,7 @@ void testActivateSkillAndToolGroupWhenLoadingMarkdown() { .agentState(AgentState.builder().toolContext(toolContext).build()) .build(); - Map input = Map.of("skillName", skillName, "path", "SKILL.md"); + Map input = Map.of("skillId", skillName, "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-002") @@ -242,7 +242,7 @@ void testActivateSkillAndToolGroupWhenLoadingMarkdown() { void testReturnErrorForNonExistentSkill() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", "non_existent_skill", "path", "SKILL.md"); + Map input = Map.of("skillId", "non_existent_skill", "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-003") @@ -296,7 +296,7 @@ void testReturnErrorForMissingSkillId() { void testReturnErrorForEmptySkillId() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", "", "path", "SKILL.md"); + Map input = Map.of("skillId", "", "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-005") @@ -321,7 +321,7 @@ void testReturnErrorForEmptySkillId() { void testLoadSkillResourceSuccessfully() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", "test_skill", "path", "config.json"); + Map input = Map.of("skillId", "test_skill", "path", "config.json"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-006") @@ -358,7 +358,7 @@ void testActivateSkillAndToolGroupWhenLoadingResource() { .agentState(AgentState.builder().toolContext(toolContext).build()) .build(); - Map input = Map.of("skillName", skillName, "path", "data.txt"); + Map input = Map.of("skillId", skillName, "path", "data.txt"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-007") @@ -389,7 +389,7 @@ void testLoadWithoutRuntimeContextDoesNotMutateSharedToolkit() { String skillName = "test_skill"; String toolGroupName = skillName + "_skill_tools"; - Map input = Map.of("skillName", skillName, "path", "SKILL.md"); + Map input = Map.of("skillId", skillName, "path", "SKILL.md"); ToolCallParam param = ToolCallParam.builder() .toolUseBlock( @@ -447,7 +447,7 @@ void testSeparateSessionsLoadDifferentSkillsWithoutCrossTalk() { private ToolResultBlock loadSkillWithContext(String skillName, RuntimeContext rc) { withGrant(rc); AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", skillName, "path", "SKILL.md"); + Map input = Map.of("skillId", skillName, "path", "SKILL.md"); ToolCallParam param = ToolCallParam.builder() .toolUseBlock( @@ -513,7 +513,7 @@ void testParallelLoadsIntoSameSessionDoNotLoseActivations() throws Exception { void testReturnErrorForNonExistentResource() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", "test_skill", "path", "non_existent.txt"); + Map input = Map.of("skillId", "test_skill", "path", "non_existent.txt"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-008") @@ -544,7 +544,7 @@ void testReturnErrorForNonExistentResource() { void testReturnErrorForMissingPathParameter() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", "test_skill"); + Map input = Map.of("skillId", "test_skill"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-009") @@ -571,7 +571,7 @@ void testReturnErrorForMissingPathParameter() { void testListAllResourcePathsWithInvalidPath() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", "test_skill", "path", "list"); + Map input = Map.of("skillId", "test_skill", "path", "list"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-010") @@ -600,7 +600,7 @@ void testListAllResourcePathsWithInvalidPath() { void testListOnlySkillMdForSkillWithoutResources() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", "empty_skill", "path", "invalid"); + Map input = Map.of("skillId", "empty_skill", "path", "invalid"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-011") @@ -628,7 +628,7 @@ void testListOnlySkillMdForSkillWithoutResources() { void testHandleWhitespaceInSkillId() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", " ", "path", "SKILL.md"); + Map input = Map.of("skillId", " ", "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-012") @@ -653,7 +653,7 @@ void testHandleWhitespaceInSkillId() { void testHandleWhitespaceInResourcePath() { AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", "test_skill", "path", " "); + Map input = Map.of("skillId", "test_skill", "path", " "); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-013") @@ -684,7 +684,7 @@ void testLoadSkillWithNoToolsDoesNotFail() { // Load skill AgentTool loader = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", skillName, "path", "SKILL.md"); + Map input = Map.of("skillId", skillName, "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-015") @@ -714,7 +714,7 @@ void testInvalidResourcePathDoesNotActivateSkill() { AgentTool tool = toolkit.getTool("load_skill_through_path"); String skillName = "test_skill"; - Map input = Map.of("skillName", skillName, "path", "."); + Map input = Map.of("skillId", skillName, "path", "."); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-016") diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java index ebee7b1363..65937e6ab7 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java @@ -110,7 +110,7 @@ void reusedBuilderKeepsLegacySkillLoadersBoundToTheirOwnAgent() { assertEquals(1, first.getHooks().stream().filter(SkillHook.class::isInstance).count()); assertEquals(1, second.getHooks().stream().filter(SkillHook.class::isInstance).count()); - Map input = Map.of("skillName", skill.getName(), "path", "SKILL.md"); + Map input = Map.of("skillId", skill.getName(), "path", "SKILL.md"); first.getToolkit() .getTool("load_skill_through_path") .callAsync( @@ -174,7 +174,7 @@ void testCompleteSkillActivationFlow() { // Step 3: Simulate LLM calling load_skill_through_path AgentTool skillLoader = toolkit.getTool("load_skill_through_path"); Map loadParams = new HashMap<>(); - loadParams.put("skillName", skillName); + loadParams.put("skillId", skillName); loadParams.put("path", "SKILL.md"); ToolCallParam callParam = @@ -351,7 +351,7 @@ public Mono callAsync(ToolCallParam param) { private void loadSkill(String skillName) { AgentTool skillLoader = toolkit.getTool("load_skill_through_path"); Map loadParams = new HashMap<>(); - loadParams.put("skillName", skillName); + loadParams.put("skillId", skillName); loadParams.put("path", "SKILL.md"); ToolCallParam callParam = diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java index 3bfcf33a67..391da63b85 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillToolFactoryDiskFallbackTest.java @@ -70,7 +70,7 @@ void disksFallbackHits(@TempDir Path tempDir) throws IOException { AgentTool tool = toolkit.getTool("load_skill_through_path"); assertNotNull(tool); - Map input = Map.of("skillName", skill.getName(), "path", "scripts/run.sh"); + Map input = Map.of("skillId", skill.getName(), "path", "scripts/run.sh"); ToolUseBlock useBlock = ToolUseBlock.builder() .id("fallback-1") @@ -116,7 +116,7 @@ void inMemoryTakesPrecedence(@TempDir Path tempDir) throws IOException { box.registerSkillLoadTool(); AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = Map.of("skillName", skill.getName(), "path", "scripts/run.sh"); + Map input = Map.of("skillId", skill.getName(), "path", "scripts/run.sh"); ToolUseBlock useBlock = ToolUseBlock.builder() .id("precedence-1") @@ -161,8 +161,7 @@ void rejectsPathTraversal(@TempDir Path tempDir) throws IOException { box.registerSkillLoadTool(); AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = - Map.of("skillName", skill.getName(), "path", "../forbidden.txt"); + Map input = Map.of("skillId", skill.getName(), "path", "../forbidden.txt"); ToolUseBlock useBlock = ToolUseBlock.builder() .id("traversal-1") 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 ea88223c50..6226701da6 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; @@ -62,7 +63,7 @@ void testCreateFromMarkdownWithDefaultSource() { assertEquals("test_skill", skill.getName()); assertEquals("A test skill for validation", skill.getDescription()); assertTrue(skill.getSkillContent().contains("Skill Content")); - assertEquals("custom", skill.getSource()); + assertNull(skill.getSource()); assertEquals(1, skill.getResources().size()); } @@ -100,7 +101,7 @@ void testCreateFromMarkdownWithNullSource() { AgentSkill skill = SkillUtil.createFrom(skillMd, null, null); - assertEquals("custom", skill.getSource()); + assertNull(skill.getSource()); } @Test diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/SkillE2ETest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/SkillE2ETest.java index 0016f1ea8a..e710fe5896 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/SkillE2ETest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/SkillE2ETest.java @@ -197,8 +197,8 @@ private void runSkillRecallTest(ModelProvider provider, String targetSkillName, skillBox.registerSkillLoadTool(); AgentSkill targetSkill = repo.getSkill(targetSkillName); - String expectedSkillName = targetSkill.getName(); - System.out.println(" expected skillName : " + expectedSkillName); + String expectedSkillId = targetSkill.getSkillId(); + System.out.println(" expected skillId : " + expectedSkillId); System.out.println(" prompt : " + prompt); AtomicBoolean loadedCorrectSkill = new AtomicBoolean(false); @@ -215,9 +215,9 @@ public Mono onEvent(T event) { if (toolUse != null && "load_skill_through_path" .equals(toolUse.getName()) - && expectedSkillName.equals( + && expectedSkillId.equals( toolUse.getInput() - .get("skillName"))) { + .get("skillId"))) { loadedCorrectSkill.set(true); postActing.stopAgent(); } @@ -249,7 +249,7 @@ public Mono onEvent(T event) { + "] | " + provider.getProviderName() + " — did not call load_skill_through_path(skillName='" - + expectedSkillName + + expectedSkillId + "')"); } } diff --git a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java index 994aff3be7..d4adddca52 100644 --- a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java +++ b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java @@ -424,7 +424,7 @@ void testUploadSkillResourcesWithWindowsSafeSkillId(@TempDir Path tempDir) assertDoesNotThrow(skillBox::uploadSkillFiles); Path uploadedResource = - tempDir.resolve("skills").resolve(skill.getName()).resolve("scripts/run.sh"); + tempDir.resolve("skills").resolve(skill.getSkillId()).resolve("scripts/run.sh"); assertTrue(Files.isRegularFile(uploadedResource)); assertEquals("echo hello", Files.readString(uploadedResource)); } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SkillUsageMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SkillUsageMiddleware.java index 4d4de90d30..18433d2875 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SkillUsageMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SkillUsageMiddleware.java @@ -124,7 +124,7 @@ private static String extractSkillName(ToolUseBlock call) { if (input == null) { return null; } - for (String key : new String[] {"skillName", "skillId", "skill_id", "name"}) { + for (String key : new String[] {"skillId", "skill_id", "name"}) { Object v = input.get(key); if (v != null) { String s = v.toString().trim(); 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 7357ab0614..2186634e1a 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 @@ -105,7 +105,7 @@ public String getName() { @Override public String getDescription() { - return "Load and return a skill resource by its skill name and a path relative" + return "Load and return a skill resource by its skillId and a path relative" + " to the skill root.\n\n" + "Path rules:\n" + "- Use path=\"SKILL.md\" to load the skill's markdown documentation.\n" @@ -116,30 +116,30 @@ public String getDescription() { @Override public Map getParameters() { - Map skillNameSchema; + Map skillIdSchema; if (exposeLegacyCatalogInSchema) { SkillCatalog snapshot = legacyCatalogRef.get(); List ids = snapshot != null ? snapshot.ids() : List.of(); - skillNameSchema = + skillIdSchema = Map.of( "type", "string", - "description", "Unique skill name.", + "description", "Unique skill identifier.", "enum", ids); } else { - skillNameSchema = + skillIdSchema = Map.of( "type", "string", "description", - "Unique skill name listed in the current " + "Unique skill identifier listed in the current " + " block."); } return Map.of( "type", "object", "properties", Map.of( - "skillName", - skillNameSchema, + "skillId", + skillIdSchema, "path", Map.of( "type", @@ -150,18 +150,18 @@ public Map getParameters() { + " instructions; do not use '.'," + " './', directories, or absolute" + " paths.")), - "required", List.of("skillName", "path")); + "required", List.of("skillId", "path")); } @Override public Mono callAsync(ToolCallParam param) { try { Map input = param.getInput(); - String skillName = stringOrNull(input.get("skillName")); + String skillId = stringOrNull(input.get("skillId")); String path = stringOrNull(input.get("path")); - if (skillName == null || skillName.isEmpty()) { + if (skillId == null || skillId.isEmpty()) { return Mono.just( - ToolResultBlock.error("Missing or empty required parameter: skillName")); + ToolResultBlock.error("Missing or empty required parameter: skillId")); } if (path == null || path.isEmpty()) { return Mono.just( @@ -169,12 +169,14 @@ public Mono callAsync(ToolCallParam param) { } SkillCatalog catalog = catalogFor(param); - HarnessSkillEntry entry = catalog.get(skillName); + // Addressing normalizes through the name part: the catalog is keyed by + // name, and a combined skillId carries the same name. + HarnessSkillEntry entry = catalog.get(AgentSkill.parseSkillName(skillId)); if (entry == null) { - return Mono.just(ToolResultBlock.error(skillNotFound(skillName))); + return Mono.just(ToolResultBlock.error(skillNotFound(skillId))); } if (!isSkillGranted(param, entry)) { - return Mono.just(ToolResultBlock.error(skillNotFound(skillName))); + return Mono.just(ToolResultBlock.error(skillNotFound(skillId))); } return Mono.just(loadOne(entry, path)); @@ -204,7 +206,7 @@ private boolean isSkillGranted(ToolCallParam param, HarnessSkillEntry entry) { return true; } log.warn( - "Skill load denied (grant): skillName='{}', userId='{}', sessionId='{}'", + "Skill load denied (grant): skillId='{}', userId='{}', sessionId='{}'", entry.skill().getName(), param.getRuntimeContext().getUserId(), param.getRuntimeContext().getSessionId()); @@ -212,12 +214,12 @@ private boolean isSkillGranted(ToolCallParam param, HarnessSkillEntry entry) { } /** - * @param skillName the caller-supplied skill name (authoritative for the error echo) + * @param skillId the caller-supplied skill identifier (authoritative for the error echo) * @return the not-found error text — byte-identical for unknown and un-granted skills so * the model cannot distinguish existence */ - private static String skillNotFound(String skillName) { - return "Skill not found: '" + skillName + "'. Check the available skills list."; + private static String skillNotFound(String skillId) { + return "Skill not found: '" + skillId + "'. Check the available skills list."; } private SkillCatalog catalogFor(ToolCallParam param) { diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillPromptBuilder.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillPromptBuilder.java index 86e2fefb8e..e2bcfc3bcd 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillPromptBuilder.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillPromptBuilder.java @@ -52,12 +52,12 @@ public final class SkillPromptBuilder { Skills provide specialized capabilities and domain knowledge. Use them when they match your current task. How to use skills: - - Load skill: load_skill_through_path(skillName="", path="SKILL.md") + - Load skill: load_skill_through_path(skillId="", path="SKILL.md") - The skill will be activated and its documentation loaded with detailed instructions - Additional resources (scripts, assets, references) can be loaded with the same tool and other paths Example: - 1. User asks to analyze data -> find a matching skill below (e.g. data-analysis) + 1. User asks to analyze data -> find a matching skill below (e.g. data-analysis) 2. Load it: load_skill_through_path(skillName="data-analysis", path="SKILL.md") 3. Follow the instructions returned by the skill @@ -171,7 +171,7 @@ private void appendSkill(StringBuilder sb, HarnessSkillEntry entry) { } appendXmlNode(sb, e.getKey(), e.getValue(), 1); } - appendXmlNode(sb, "skill-name", skill.getName(), 1); + appendXmlNode(sb, "skill-id", skill.getSkillId(), 1); if (entry.filesRoot() != null && !entry.filesRoot().isBlank()) { appendXmlNode(sb, "files-root", entry.filesRoot(), 1); } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java index f745817d67..f2d099f6cb 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java @@ -337,7 +337,7 @@ void disableDynamicSkills_keepsWorkspaceLazyResourcesLoadable() throws Exception .runtimeContext(ctx) .input( Map.of( - "skillName", + "skillId", "lazy-resource", "path", "references/guide.md")) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareGrantTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareGrantTest.java index a43cb52e2a..693debbc66 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareGrantTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareGrantTest.java @@ -191,7 +191,7 @@ void missingSurfaceLeavesAllSkillsVisible() { /** Loads SKILL.md through the middleware's load tool and returns the result text. */ private static String load(HarnessSkillMiddleware mw, RuntimeContext ctx, String skillId) { AgentTool loadTool = mw.runtime().loadTool(); - Map input = Map.of("skillName", skillId, "path", "SKILL.md"); + Map input = Map.of("skillId", skillId, "path", "SKILL.md"); ToolUseBlock use = ToolUseBlock.builder() .id("call-" + skillId) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SkillUsageMiddlewareTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SkillUsageMiddlewareTest.java index ba19005567..b47462cc5e 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SkillUsageMiddlewareTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/SkillUsageMiddlewareTest.java @@ -51,7 +51,7 @@ void loadSkillThroughPath_bumpsViewForAgentTrackedSkill() { store.markAgentDraft("csv-sum", null); ActingInput in = new ActingInput( - List.of(callOf("load_skill_through_path", Map.of("skillName", "csv-sum")))); + List.of(callOf("load_skill_through_path", Map.of("skillId", "csv-sum")))); mw.onActing(null, null, in, x -> Flux.empty()).blockLast(); @@ -65,7 +65,7 @@ void loadSkillThroughPath_skipsUntrackedSkill() { List.of( callOf( "load_skill_through_path", - Map.of("skillName", "external-skill")))); + Map.of("skillId", "external-skill")))); mw.onActing(null, null, in, x -> Flux.empty()).blockLast(); @@ -89,8 +89,8 @@ void multipleCallsInOneActing_allBump() { ActingInput in = new ActingInput( List.of( - callOf("load_skill_through_path", Map.of("skillName", "a")), - callOf("load_skill_through_path", Map.of("skillName", "b")), + callOf("load_skill_through_path", Map.of("skillId", "a")), + callOf("load_skill_through_path", Map.of("skillId", "b")), callOf("read_file", Map.of("path", "/tmp/x")))); mw.onActing(null, null, in, x -> Flux.empty()).blockLast(); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java index ba7240cd18..ca30274be5 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java @@ -56,7 +56,7 @@ void includesFrontmatter() { RuntimeContext ctx = RuntimeContext.empty(); ctx.put(SkillCatalog.class, catalog); - String text = runTool(tool, ctx, Map.of("skillName", skill.getName(), "path", "SKILL.md")); + String text = runTool(tool, ctx, Map.of("skillId", skill.getName(), "path", "SKILL.md")); assertTrue( text.contains("name: alpha"), @@ -91,7 +91,7 @@ void resourceFenceConsistent() { ctx.put(SkillCatalog.class, catalog); String text = - runTool(tool, ctx, Map.of("skillName", skill.getName(), "path", "scripts/run.sh")); + runTool(tool, ctx, Map.of("skillId", skill.getName(), "path", "scripts/run.sh")); assertTrue(text.contains("scripts/run.sh"), "resource path should appear in header"); assertTrue(text.contains("echo hi"), "resource body should be present"); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java index b8619485a1..6ab476b95b 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java @@ -156,10 +156,10 @@ void emptyCatalogReturnsEmptyString() { } @Test - void rendersSkillNameAndOmitsFilesRootWhenAbsent() { + void rendersSkillIdAndOmitsFilesRootWhenAbsent() { HarnessSkillEntry e = HarnessSkillEntry.of(skill("alpha", "wkspace"), null); String out = new SkillPromptBuilder().render(SkillCatalog.of(List.of(e))); - assertTrue(out.contains("alpha")); + assertTrue(out.contains("alpha__s__wkspace")); // No actual ... element rendered. // (The header text mentions "" descriptively without a closing tag.) assertFalse(out.contains("")); @@ -199,8 +199,8 @@ void filterRemovesHiddenSkills() { SkillFilter only = SkillFilter.only("visible"); String out = new SkillPromptBuilder().render(cat, only); - assertTrue(out.contains("visible")); - assertFalse(out.contains("hidden")); + assertTrue(out.contains("visible__s__src")); + assertFalse(out.contains("hidden__s__src")); } @Test @@ -225,8 +225,12 @@ void filterBySkillNameMatchesEntry() { SkillFilter only = SkillFilter.only("host-forensics-client"); String out = new SkillPromptBuilder().render(cat, only); - assertTrue(out.contains("host-forensics-client")); - assertFalse(out.contains("other-skill")); + assertTrue( + out.contains( + "host-forensics-client__s__filesystem-agentscope_skills")); + assertFalse( + out.contains( + "other-skill__s__filesystem-agentscope_skills")); } } @@ -321,7 +325,7 @@ void missingParametersReturnsError() { .input(Map.of()) .build()) .block()); - assertTrue(err1.contains("skillName")); + assertTrue(err1.contains("skillId")); } private ToolResultBlock invoke( @@ -329,7 +333,7 @@ private ToolResultBlock invoke( ToolCallParam p = ToolCallParam.builder() .runtimeContext(ctx) - .input(Map.of("skillName", skillName, "path", path)) + .input(Map.of("skillId", skillName, "path", path)) .build(); return r.loadTool().callAsync(p).block(); } @@ -457,7 +461,7 @@ void deprecatedLoadToolConstructorRemainsContextFreeOnly() { AtomicReference ref = new AtomicReference<>(SkillCatalog.of(List.of(legacy))); SkillLoadTool tool = new SkillLoadTool(ref); - Map input = Map.of("skillName", "legacy", "path", "SKILL.md"); + Map input = Map.of("skillId", "legacy", "path", "SKILL.md"); ToolResultBlock contextFree = tool.callAsync(ToolCallParam.builder().input(input).build()).block(); @@ -479,7 +483,7 @@ private ToolResultBlock invoke( .callAsync( ToolCallParam.builder() .runtimeContext(ctx) - .input(Map.of("skillName", skillName, "path", path)) + .input(Map.of("skillId", skillName, "path", path)) .build()) .block(); } diff --git a/docs/v2/en/docs/building-blocks/capability-grant.md b/docs/v2/en/docs/building-blocks/capability-grant.md index 7056e6ae64..b2c8f19d33 100644 --- a/docs/v2/en/docs/building-blocks/capability-grant.md +++ b/docs/v2/en/docs/building-blocks/capability-grant.md @@ -32,7 +32,7 @@ CapabilityResolver resolver = (ctx, agent) -> { // Granularity is yours: per-(userId, agentId), or ignore agentId and return one // surface per user — the framework treats both identically. return new CapabilitySurface( - authzService.allowedSkills(userId), // allowed skillName values; null = not set + authzService.allowedSkills(userId), // allowed skillIds (normally the skill name); null = not set authzService.allowedToolGroups(userId), // allowed ToolGroup names; null = not set authzService.allowedTools(userId)); // allowed tool names; null = not set }; @@ -89,16 +89,17 @@ accessors (`allowedSkills()` etc.) still preserve the distinction. Positive authorization is hierarchical; matching **any set dimension** grants: - Granting a **skill** implicitly grants its manual, scripts and associated tools (the skill - tool group `skillName + "_skill_tools"`); + tool group `skillId + "_skill_tools"`, where a source-less skillId is simply the skill name); - Granting a **ToolGroup** implicitly grants every tool in it; - Granting a **tool** by name grants exactly that tool (grouping is ignored, and it does not amplify to sibling tools in the same group). -Skills are granted and addressed **by skillName** (the industry-standard unique identity): -`allowedSkills` holds the skillName values from the business entitlement list; the registry keys -by skillName and enforces per-agent-catalog uniqueness (same name + same instance is idempotent, -a different instance is rejected). The internal `name + "_" + source` storage identifier never -enters the grant surface. +Skills are granted and addressed **by skillId**: normally just the skill name (the +industry-standard unique identity); the combined `name + "__s__" + source` form only when +same-named skills come from different repositories (entitlement lists use the bare name; the +combined form for the rare precise case). Catalogs key by skill name and enforce +per-agent-catalog uniqueness (same name + same instance is idempotent, a different +instance is rejected). Only whitelists are provided; there is no deny (reverse-ban) capability. Default is deny — anything not reachable from the enumeration does not exist. diff --git a/docs/v2/en/docs/building-blocks/tool.md b/docs/v2/en/docs/building-blocks/tool.md index fd9b31cbc8..e6308e3313 100644 --- a/docs/v2/en/docs/building-blocks/tool.md +++ b/docs/v2/en/docs/building-blocks/tool.md @@ -450,7 +450,7 @@ At runtime, the agent invokes the viewer with two required arguments: | Parameter | Type | Description | | --- | --- | --- | -| `skillName` | `string` (enum of registered skill names) | The skill to load. | +| `skillId` | `string` (enum of registered skill ids, normally the skill name) | The skill to load. | | `path` | `string` | Use `"SKILL.md"` to fetch the skill's markdown instructions, or an exact resource path declared by the skill such as `"references/guide.md"` or `"scripts/run.py"`. Do not pass `"."`, `"./"`, a directory, or an absolute path. | Example tool call payload: @@ -458,7 +458,7 @@ Example tool call payload: ```json { "name": "load_skill_through_path", - "input": { "skillName": "pdf-extractor", "path": "SKILL.md" } + "input": { "skillId": "pdf-extractor", "path": "SKILL.md" } } ``` diff --git a/docs/v2/en/docs/harness/skill.md b/docs/v2/en/docs/harness/skill.md index 27ffe999f3..62d7745c5d 100644 --- a/docs/v2/en/docs/harness/skill.md +++ b/docs/v2/en/docs/harness/skill.md @@ -281,7 +281,7 @@ When the agent reasons, it sees an `` block in the system prom code-reviewer Use when the user asks for code review, style feedback, or PR audits. - code-reviewer + code-reviewer__s__workspace-namespaced /workspace/skills/code-reviewer ... @@ -294,8 +294,8 @@ Each entry carries just enough metadata for the agent to decide whether to load To activate a skill the agent calls a built-in tool — `load_skill_through_path`: -- `load_skill_through_path(skillName, path="SKILL.md")` returns the markdown body -- `load_skill_through_path(skillName, path="references/style-guide.md")` returns any other file under the skill directory +- `load_skill_through_path(skillId, path="SKILL.md")` returns the markdown body +- `load_skill_through_path(skillId, path="references/style-guide.md")` returns any other file under the skill directory How the file gets fetched depends on where the skill came from: diff --git a/docs/v2/zh/docs/building-blocks/capability-grant.md b/docs/v2/zh/docs/building-blocks/capability-grant.md index 1ba0ad4787..e2c99a072e 100644 --- a/docs/v2/zh/docs/building-blocks/capability-grant.md +++ b/docs/v2/zh/docs/building-blocks/capability-grant.md @@ -27,7 +27,7 @@ CapabilityResolver resolver = (ctx, agent) -> { // 粒度自选:按 (userId, agentId) 做 per-agent 差异, // 或忽略 agentId 按用户出一份数据(框架一视同仁) return new CapabilitySurface( - authzService.allowedSkills(userId), // 允许的 skillName;null = 未设置 + authzService.allowedSkills(userId), // 允许的 skillId(通常即 skill 名);null = 未设置 authzService.allowedToolGroups(userId), // 允许的 ToolGroup 名;null = 未设置 authzService.allowedTools(userId)); // 允许的工具名;null = 未设置 }; @@ -73,11 +73,11 @@ new CapabilitySurface(Set.of(), Set.of(), Set.of()); 正向授权层次包含,命中**任一已设置维度**即放行: -- 授权一个 **skill** 即隐含授权其手册、脚本与关联工具(技能组 `skillName + "_skill_tools"`); +- 授权一个 **skill** 即隐含授权其手册、脚本与关联工具(技能组 `skillId + "_skill_tools"`,无 source 时 skillId 即 skill 名); - 授权一个 **ToolGroup** 即隐含授权组内全部工具; - 逐名授权一个**工具**即授权该工具本身(不问分组,也不放大到组内其他工具)。 -skill 以 **skillName** 授权与寻址(行业标准的唯一定位):`allowedSkills` 存放业务权限清单中的 skillName;`SkillRegistry` 以 skillName 为键并强制单个 agent 目录内唯一(同名同实例幂等、同名异实例拒绝)。`name + "_" + source` 形式的内部存储标识不进入授权面。 +skill 以 **skillId** 授权与寻址:skillId 通常就是 skill 名(行业标准唯一定位),仅在同名 skill 来自不同仓库时取 `name + "__s__" + source` 组合形态精确到具体仓库(业务授权清单可以直接使用 skill 名,极端情况下使用组合形态)。目录以 skill 名为键并强制单个 agent 目录内唯一(同名同实例幂等、同名异实例拒绝)。 只提供白名单,不提供 deny(反向禁用);默认拒绝——不在授权清单内的能力即「不存在」。 @@ -99,4 +99,4 @@ resolver 抛异常时**中止本次调用**:异常传播给调用方并记录 ## 静态 skillBox 路径限制 -`skillBox(...)` 已随 hook 系统于 2.0.0 标记为过时,**新代码不要使用**——skill 接入一律使用 `skillRepositories(...)` 动态路径。存量代码若仍停留在该路径,其限制如下:`skillBox(...)` 构建期注册(或 `dynamicSkillsEnabled(false)`)没有 per-call 可见面过滤——配置 resolver 时构建期会输出 ERROR 并使该 agent 的 resolver 失效——该 agent 直调时不裁剪;作为子代理运行时继承父授权面,skill 加载与工具执行仍被硬门拦截,但静态**提示词会枚举全量 skill 目录**(残留暴露;静态 load 工具 schema 内嵌的 `skillName` enum 同样全量)。 +`skillBox(...)` 已随 hook 系统于 2.0.0 标记为过时,**新代码不要使用**——skill 接入一律使用 `skillRepositories(...)` 动态路径。存量代码若仍停留在该路径,其限制如下:`skillBox(...)` 构建期注册(或 `dynamicSkillsEnabled(false)`)没有 per-call 可见面过滤——配置 resolver 时构建期会输出 ERROR 并使该 agent 的 resolver 失效——该 agent 直调时不裁剪;作为子代理运行时继承父授权面,skill 加载与工具执行仍被硬门拦截,但静态**提示词会枚举全量 skill 目录**(残留暴露;静态 load 工具 schema 内嵌的 `skillId` enum 同样全量)。 diff --git a/docs/v2/zh/docs/building-blocks/tool.md b/docs/v2/zh/docs/building-blocks/tool.md index e385abaa21..cf32a583f7 100644 --- a/docs/v2/zh/docs/building-blocks/tool.md +++ b/docs/v2/zh/docs/building-blocks/tool.md @@ -450,7 +450,7 @@ ReActAgent agent = | 参数 | 类型 | 说明 | | --- | --- | --- | -| `skillName` | `string`(枚举:已注册的 skillName) | 要加载的 skill。 | +| `skillId` | `string`(枚举:已注册的 skillId,通常即 skill 名) | 要加载的 skill。 | | `path` | `string` | 传 `"SKILL.md"` 取该 skill 的 markdown 指令;或传 skill 声明过的精确资源路径,例如 `"references/guide.md"`、`"scripts/run.py"`。不要传 `"."`、`"./"`、目录或绝对路径。 | 调用示例: @@ -458,7 +458,7 @@ ReActAgent agent = ```json { "name": "load_skill_through_path", - "input": { "skillName": "pdf-extractor", "path": "SKILL.md" } + "input": { "skillId": "pdf-extractor", "path": "SKILL.md" } } ``` diff --git a/docs/v2/zh/docs/harness/skill.md b/docs/v2/zh/docs/harness/skill.md index fe6a35e283..b4c7156dd8 100644 --- a/docs/v2/zh/docs/harness/skill.md +++ b/docs/v2/zh/docs/harness/skill.md @@ -281,7 +281,7 @@ agent.promoteSkill("notes-taker", "alice") // 手动晋升一 code-reviewer 当用户需要代码评审、风格反馈或 PR 审核时使用。 - code-reviewer + code-reviewer__s__workspace-namespaced /workspace/skills/code-reviewer ... @@ -294,8 +294,8 @@ agent.promoteSkill("notes-taker", "alice") // 手动晋升一 加载某个 skill 时 agent 会调用内置工具 `load_skill_through_path`: -- `load_skill_through_path(skillName, path="SKILL.md")` 返回 markdown 正文 -- `load_skill_through_path(skillName, path="references/style-guide.md")` 返回该 skill 目录下的任意文件 +- `load_skill_through_path(skillId, path="SKILL.md")` 返回 markdown 正文 +- `load_skill_through_path(skillId, path="references/style-guide.md")` 返回该 skill 目录下的任意文件 具体怎么取文件,取决于 skill 来自哪里: From e4a7a2b8f01fda5f4f0a11ade310af0fd3978dd8 Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Tue, 13 Oct 2026 11:41:41 +0800 Subject: [PATCH 08/13] feat(skill): key catalogs by skillId so same-named skills coexist Registry, dynamic middleware and harness catalogs key by skillId: same-named skills from different repositories coexist, the same skillId re-registration replaces (version override), and lookups are exact (a bare name is the source-less id form). SkillFilter matches the exact skillId; upload subdirs and load echoes use skillId. --- .../core/skill/AgentSkillPromptProvider.java | 10 ++-- .../core/skill/DynamicSkillMiddleware.java | 20 ++++--- .../io/agentscope/core/skill/SkillBox.java | 29 +++++----- .../agentscope/core/skill/SkillRegistry.java | 58 +++++++++---------- .../core/skill/SkillToolFactory.java | 32 ++-------- .../agent/CapabilityGrantIntegrationTest.java | 14 +++-- .../skill/DynamicSkillMiddlewareTest.java | 2 +- .../agentscope/core/skill/SkillBoxTest.java | 4 +- .../core/skill/SkillRegistryTest.java | 53 +++++++++++------ .../middleware/HarnessSkillMiddleware.java | 4 +- .../agent/skill/runtime/SkillCatalog.java | 15 +++-- .../agent/skill/runtime/SkillLoadTool.java | 6 +- .../HarnessAgentDynamicHookBuilderTest.java | 2 +- .../SkillLoadToolFrontmatterViewTest.java | 4 +- .../agent/skill/runtime/SkillRuntimeTest.java | 42 ++++++++------ 15 files changed, 155 insertions(+), 140 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java index 70a68b367f..77a1be12be 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java @@ -183,8 +183,8 @@ public String getSkillSystemPrompt() { public String getSkillSystemPrompt(SkillFilter filter) { SkillFilter effectiveFilter = filter != null ? filter : SkillFilter.all(); - Set skillNames = skillRegistry.getSkillNames(); - if (skillNames.isEmpty()) { + Set skillIds = skillRegistry.getSkillIds(); + if (skillIds.isEmpty()) { return ""; } @@ -193,11 +193,11 @@ public String getSkillSystemPrompt(SkillFilter filter) { int visibleCount = 0; int withOriginDir = 0; - for (String skillName : skillNames) { - if (!effectiveFilter.isAllowed(skillName)) { + for (String skillId : skillIds) { + if (!effectiveFilter.isAllowed(skillId)) { continue; } - AgentSkill skill = skillRegistry.getSkill(skillName); + AgentSkill skill = skillRegistry.getSkill(skillId); if (skill == null) { continue; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/DynamicSkillMiddleware.java b/agentscope-core/src/main/java/io/agentscope/core/skill/DynamicSkillMiddleware.java index 189c74626a..e132a17b83 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/DynamicSkillMiddleware.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/DynamicSkillMiddleware.java @@ -46,8 +46,9 @@ * skill prompt with a per-call view that supports per-user skill isolation and one-click * marketplace integration. * - *

The repository list is iterated low-priority first; when two repositories provide a skill - * with the same {@link AgentSkill#getName()}, the later (higher-priority) entry wins. + *

The repository list is iterated low-priority first; skills merge by skillId — same-named + * skills from different repositories coexist, while the same skillId (same name and source) + * keeps only the later (higher-priority) entry. * *

Rebuilding on every call is intentional: per-user namespaced repositories may return * different content under the same skill name as the {@link RuntimeContext} switches users, so @@ -198,7 +199,10 @@ private SkillBox reloadSkills(RuntimeContext ctx) { if (repositories.isEmpty()) { return null; } - Map skillsByName = new LinkedHashMap<>(); + // Merged by skillId: same-named skills from different repositories coexist; a + // same-id re-registration (same name and source) is the version-override case — + // the later (higher-priority) repository wins. + Map skillsById = new LinkedHashMap<>(); for (AgentSkillRepository repo : repositories) { List skills; try { @@ -220,24 +224,24 @@ private SkillBox reloadSkills(RuntimeContext ctx) { if (skill == null || skill.getName() == null) { continue; } - skillsByName.put(skill.getName(), skill); + skillsById.put(skill.getSkillId(), skill); } } - if (skillsByName.isEmpty()) { + if (skillsById.isEmpty()) { return null; } // Apply subclass visibility hook before the SkillBox is rebuilt so the prompt only // sees skills the current ctx is allowed to see. List visible; try { - List filtered = filterVisible(new ArrayList<>(skillsByName.values()), ctx); - visible = filtered != null ? filtered : new ArrayList<>(skillsByName.values()); + List filtered = filterVisible(new ArrayList<>(skillsById.values()), ctx); + visible = filtered != null ? filtered : new ArrayList<>(skillsById.values()); } catch (Exception e) { log.warn( "filterVisible() in {} failed; treating as pass-through: {}", getClass().getSimpleName(), e.getMessage()); - visible = new ArrayList<>(skillsByName.values()); + visible = new ArrayList<>(skillsById.values()); } if (visible.isEmpty()) { return null; diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java index 2eb1c1fcdb..108ba12d61 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java @@ -74,8 +74,8 @@ public SkillBox(Toolkit toolkit, String instruction) { } private SkillBox(SkillBox source, Toolkit toolkit) { - for (String name : source.getAllSkillNames()) { - skillRegistry.registerSkill(name, source.getSkill(name)); + for (String skillId : source.getAllSkillIds()) { + skillRegistry.registerSkill(skillId, source.getSkill(skillId)); } skillPromptProvider = source.skillPromptProvider.copyFor(skillRegistry); skillToolFactory = new SkillToolFactory(skillRegistry, toolkit); @@ -201,17 +201,18 @@ public void registerSkill(AgentSkill skill) { throw new IllegalArgumentException("AgentSkill cannot be null"); } - String skillName = skill.getName(); - skillRegistry.registerSkill(skillName, skill); - logger.info("Registered skill '{}'", skillName); + String skillId = skill.getSkillId(); + skillRegistry.registerSkill(skillId, skill); + logger.info("Registered skill '{}'", skillId); } /** - * Gets all skill names (the skill's business identity, unique in this box). - * @return All skill names + * Gets all registered skillIds — the skill name when no source is set, or the combined + * {@code name__s__source} form when one is. Unique in this box. + * @return All skillIds */ - public Set getAllSkillNames() { - return skillRegistry.getSkillNames(); + public Set getAllSkillIds() { + return skillRegistry.getSkillIds(); } /** @@ -241,9 +242,9 @@ String loadSkillResource(ToolCallParam param) { } /** - * Gets a skill by name (the skill's business identity). + * Gets a skill by its exact skillId (a bare name is the source-less form). * - * @param skillName The skill name + * @param skillName The skillId * @return The skill instance, or null if not found * @throws IllegalArgumentException if skillName is null */ @@ -701,8 +702,8 @@ public void uploadSkillFiles() { SkillFileFilter filter = fileFilter != null ? fileFilter : SkillFileFilter.acceptAll(); int fileCount = 0; - for (String skillName : getAllSkillNames()) { - AgentSkill skill = getSkill(skillName); + for (String skillId : getAllSkillIds()) { + AgentSkill skill = getSkill(skillId); // Skills with a populated originDir already live on disk at a known absolute path, // which the prompt provider exposes as . Re-materialising them under @@ -726,7 +727,7 @@ public void uploadSkillFiles() { String content = skill.getResource(resourcePath); if (content == null) { - logger.warn("Resource not found: {} in skill {}", resourcePath, skillName); + logger.warn("Resource not found: {} in skill {}", resourcePath, skillId); continue; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillRegistry.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillRegistry.java index 2ece17a392..3caa2b4138 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillRegistry.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillRegistry.java @@ -21,11 +21,13 @@ import java.util.concurrent.ConcurrentHashMap; /** - * Pure storage layer for {@link AgentSkill}s, keyed by skill name. + * Pure storage layer for {@link AgentSkill}s, keyed by skillId. * - *

The skill name is the business identity (industry-standard unique skill naming) and must be - * unique within one catalog; re-registering the same instance is idempotent, while registering a - * different instance under an already-used name fails fast. + *

The key is {@link AgentSkill#getSkillId()}: the skill name when no source is set, or the + * combined {@code name__s__source} form when one is — so same-named skills from different + * repositories coexist as separate entries, while re-registering under the same key (same name + * and source) replaces the previous instance (version override). Lookups are exact: a bare name + * is itself a skillId (the source-less form) and never resolves a sourced copy. * *

The skill activation state is not stored here: it is the per-session {@code activatedGroups} * on {@link io.agentscope.core.state.ToolContextState}. @@ -36,59 +38,57 @@ class SkillRegistry { // ==================== Registration ==================== /** - * Registers a skill under its name. + * Registers a skill under its skillId. Same-named skills from different sources coexist; + * re-registering the same skillId replaces the previous instance. * - * @param skillName The unique skill name (must not be null) + * @param skillId the skill's identifier (its own key, see {@link AgentSkill#getSkillId()}) * @param skill The skill implementation (must not be null) - * @throws IllegalArgumentException when the name is already taken by a different instance */ - void registerSkill(String skillName, AgentSkill skill) { - AgentSkill existing = skills.putIfAbsent(skillName, skill); - if (existing != null && existing != skill) { - throw new IllegalArgumentException( - "Duplicate skill name: '" + skillName + "' is already registered"); - } + void registerSkill(String skillId, AgentSkill skill) { + skills.put(skillId, skill); } // ==================== Query Operations ==================== /** - * Gets a skill by name. + * Gets a skill by its exact skillId. * - * @param skillName The skill name (must not be null) - * @return The skill instance, or null if not found + * @param skillId the skillId (a bare name is the source-less form) + * @return The matching skill instance, or null if not found */ - AgentSkill getSkill(String skillName) { - return skills.get(skillName); + AgentSkill getSkill(String skillId) { + return skillId == null ? null : skills.get(skillId); } /** - * Gets all skill names. + * Gets all registered skillIds (each unique in this catalog). * - * @return Set of skill names (never null, may be empty) + * @return Set of skillIds (never null, may be empty) */ - Set getSkillNames() { + Set getSkillIds() { return new TreeSet<>(skills.keySet()); } /** - * Checks if a skill exists. + * Checks if a skill with this exact skillId exists. * - * @param skillName The skill name (must not be null) + * @param skillId the skillId (a bare name is the source-less form) * @return true if the skill exists, false otherwise */ - boolean exists(String skillName) { - return skills.containsKey(skillName); + boolean exists(String skillId) { + return skillId != null && skills.containsKey(skillId); } // ==================== Removal Operations ==================== /** - * Removes a skill completely. + * Removes the skill with this exact skillId (no-op when absent). * - * @param skillName The skill name (must not be null) + * @param skillId the skillId (a bare name is the source-less form) */ - void removeSkill(String skillName) { - skills.remove(skillName); + void removeSkill(String skillId) { + if (skillId != null) { + skills.remove(skillId); + } } } 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 c5bf3ce479..43370264a7 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 @@ -140,11 +140,7 @@ public Map getParameters() { "type", "string", "description", - "The skill identifier (normally the" - + " skill name; the combined" - + " name__s__source form only" - + " when same-named skills come" - + " from different repositories)."), + "The unique identifier of the skill."), "path", Map.of( "type", @@ -219,13 +215,7 @@ public boolean isSystem() { @Override public Map getParameters() { - // The catalog is keyed by name; the skillId equals the name unless a - // source differentiates same-named skills. - List availableSkillIds = - new ArrayList<>( - skillRegistry.getSkillNames().stream() - .map(n -> skillRegistry.getSkill(n).getSkillId()) - .toList()); + List availableSkillIds = new ArrayList<>(skillRegistry.getSkillIds()); return Map.of( "type", "object", @@ -236,8 +226,7 @@ public Map getParameters() { "type", "string", "description", - "The skill identifier (normally the" - + " skill name).", + "The unique identifier of the skill.", "enum", availableSkillIds), "path", @@ -521,21 +510,12 @@ private void requireSkillGranted(String skillId, AgentSkill skill, RuntimeContex * @throws IllegalStateException if the skill cannot be loaded after validation */ private AgentSkill validateSkillExists(String skillId) { - // The catalog is keyed by name; a combined skillId carries the same name. - String skillName = AgentSkill.parseSkillName(skillId); - if (!skillRegistry.exists(skillName)) { + // Exact skillId lookup; a bare name is itself the source-less id form. + AgentSkill skill = skillRegistry.getSkill(skillId); + if (skill == null) { throw new IllegalArgumentException( String.format("Skill not found: '%s'. Please check the skill ID.", skillId)); } - // Get skill - AgentSkill skill = skillRegistry.getSkill(skillName); - if (skill == null) { - throw new IllegalStateException( - String.format( - "Failed to load skill '%s' after validation. This is an internal" - + " error.", - skillId)); - } return skill; } diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java index 8dd29e989f..29bf0a6525 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java @@ -812,7 +812,8 @@ void qualifiedEntryGrantsOnlyTheQualifiedSkill() { false, null); - // Qualified entry "alpha__s__market" grants exactly that copy. + // Qualified entry "alpha__s__market" grants exactly that copy; the same-named + // source-less copy coexists in the catalog but stays ungranted. String prompt = mw.onSystemPrompt( null, @@ -821,6 +822,9 @@ void qualifiedEntryGrantsOnlyTheQualifiedSkill() { .block(); assertNotNull(prompt); assertTrue(prompt.contains("from market"), "the qualified copy is granted"); + assertFalse( + prompt.contains("Skill alpha"), + "the coexisting source-less copy stays ungranted by the qualified entry"); // A qualified entry for the source-less copy does not grant the market copy. String mismatched = @@ -923,10 +927,10 @@ void boxCacheIsolationAcrossSurfaces() { assertNotNull(box1); assertNotNull(box2); assertTrue(box1 != box2, "different grants must materialize different boxes"); - assertTrue(box1.getAllSkillNames().contains("alpha")); - assertFalse(box1.getAllSkillNames().contains("beta")); - assertTrue(box2.getAllSkillNames().contains("beta")); - assertFalse(box2.getAllSkillNames().contains("alpha")); + assertTrue(box1.getAllSkillIds().contains("alpha")); + assertFalse(box1.getAllSkillIds().contains("beta")); + assertTrue(box2.getAllSkillIds().contains("beta")); + assertFalse(box2.getAllSkillIds().contains("alpha")); } // ==================== tool execution gate ==================== diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java index f4c062ec65..cec4b91118 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/DynamicSkillMiddlewareTest.java @@ -164,7 +164,7 @@ void alternatingContentKeepsMaterializedResourcesIsolated() throws Exception { RuntimeContext rcB = grantRc(); mw.onSystemPrompt(null, rcA, "").block(); SkillBox boxA = rcA.get(SkillBox.class); - String id = boxA.getAllSkillNames().iterator().next(); + String id = boxA.getAllSkillIds().iterator().next(); Path fileA = boxA.getUploadDir().resolve(id).resolve("data.txt"); repo.skills = List.of(b); mw.onSystemPrompt(null, rcB, "").block(); 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 323065ff96..1c3c3443df 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 @@ -217,7 +217,7 @@ void testSuccessfullyRegisterWhenOnlyMcpClientProvided() { @Test @DisplayName("Should return empty set when no skills are registered") void testGetAllSkillIdsWhenEmpty() { - var skillNames = skillBox.getAllSkillNames(); + var skillNames = skillBox.getAllSkillIds(); assertNotNull(skillNames, "Skill names set should not be null"); assertTrue( @@ -236,7 +236,7 @@ void testGetAllSkillIdsWithMultipleSkills() { skillBox.registerSkill(skill2); skillBox.registerSkill(skill3); - var skillNames = skillBox.getAllSkillNames(); + var skillNames = skillBox.getAllSkillIds(); assertNotNull(skillNames, "Skill names set should not be null"); assertEquals(3, skillNames.size(), "Should have exactly three skill names"); diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRegistryTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRegistryTest.java index f04ab1cff2..c2a6e4bb2c 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRegistryTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRegistryTest.java @@ -19,7 +19,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; @@ -51,23 +50,41 @@ void testRegisterNewSkill() { } @Test - @DisplayName("Should re-registering the same instance be idempotent") - void testRegisterSameInstanceIsIdempotent() { - AgentSkill skill = createSkill("v1"); - registry.registerSkill("test", skill); - registry.registerSkill("test", skill); + @DisplayName("Should re-registering the same skillId replace the previous instance") + void testRegisterSameSkillIdReplaces() { + AgentSkill v1 = createSkill("v1"); + AgentSkill v2 = createSkill("v2"); + registry.registerSkill("test", v1); + registry.registerSkill("test", v2); // same id: version override, later wins assertTrue(registry.exists("test")); - assertEquals(skill, registry.getSkill("test")); + assertEquals(v2, registry.getSkill("test")); } @Test - @DisplayName("Should reject a different instance under an already-used name") - void testRegisterDuplicateNameDifferentInstanceRejected() { - registry.registerSkill("test", createSkill("v1")); - assertThrows( - IllegalArgumentException.class, - () -> registry.registerSkill("test", createSkill("v2"))); - assertTrue(registry.exists("test")); + @DisplayName("Should same-named skills from different sources coexist") + void testSameNameDifferentSourceCoexist() { + AgentSkill internal = + AgentSkill.builder() + .name("alpha") + .description("d") + .skillContent("c") + .source("internal") + .build(); + AgentSkill market = + AgentSkill.builder() + .name("alpha") + .description("d") + .skillContent("c") + .source("market") + .build(); + registry.registerSkill(internal.getSkillId(), internal); + registry.registerSkill(market.getSkillId(), market); + + assertEquals(2, registry.getSkillIds().size()); + assertEquals(internal, registry.getSkill("alpha__s__internal")); + assertEquals(market, registry.getSkill("alpha__s__market")); + // A bare name is itself the source-less id form: it never resolves a sourced copy. + assertNull(registry.getSkill("alpha")); } @Test @@ -76,10 +93,10 @@ void testGetSkillIds() { registry.registerSkill("test1", createSkill("test1")); registry.registerSkill("test2", createSkill("test2")); - var skillNames = registry.getSkillNames(); - assertEquals(2, skillNames.size()); - assertTrue(skillNames.contains("test1")); - assertTrue(skillNames.contains("test2")); + var skillIds = registry.getSkillIds(); + assertEquals(2, skillIds.size()); + assertTrue(skillIds.contains("test1")); + assertTrue(skillIds.contains("test2")); } @Test diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java index 406abfba51..335056a761 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java @@ -379,7 +379,7 @@ private List applySkillFilter( * subsequent steps (lazy resources, marketplace stage) can act on it. */ private LinkedHashMap mergeRepositories(RuntimeContext ctx) { - LinkedHashMap merged = new LinkedHashMap<>(); + LinkedHashMap merged = new LinkedHashMap<>(); // keyed by skillId for (AgentSkillRepository repo : repositories) { List skills; try { @@ -401,7 +401,7 @@ private LinkedHashMap mergeRepositories(RuntimeContext ctx) { if (skill == null || skill.getName() == null) { continue; } - merged.put(skill.getName(), new RepoBound(skill, repo)); + merged.put(skill.getSkillId(), new RepoBound(skill, repo)); } } return merged; diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillCatalog.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillCatalog.java index 5d12761d59..1dc4f20d46 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillCatalog.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/SkillCatalog.java @@ -24,8 +24,10 @@ /** * Immutable snapshot of the skills visible to a single {@code onSystemPrompt} pass. * - *

Keyed by skill name (the AgentSkill's business identity), insertion-order - * preserved so the prompt renders skills in compose order. + *

Keyed by skillId (the skill name when no source is set, or the combined {@code + * name__s__source} form), insertion-order preserved so the prompt renders skills in compose + * order. Same-named skills from different repositories coexist as separate entries; a + * same-id re-registration keeps the later entry (version override). */ public final class SkillCatalog { @@ -45,12 +47,17 @@ public static SkillCatalog of(List orderedEntries) { if (e == null) { continue; } - map.put(e.skill().getName(), e); + map.put(e.skill().getSkillId(), e); } return new SkillCatalog(map); } - /** Lookup by {@code skillId} (name_source). Returns {@code null} when absent. */ + /** + * Lookup by the exact skillId (a bare name is the source-less form and never resolves a + * sourced copy). + * + * @return the matching entry, or {@code null} when absent + */ public HarnessSkillEntry get(String skillId) { return skillId == null ? null : entries.get(skillId); } 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 2186634e1a..1c09de0870 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 @@ -169,9 +169,7 @@ public Mono callAsync(ToolCallParam param) { } SkillCatalog catalog = catalogFor(param); - // Addressing normalizes through the name part: the catalog is keyed by - // name, and a combined skillId carries the same name. - HarnessSkillEntry entry = catalog.get(AgentSkill.parseSkillName(skillId)); + HarnessSkillEntry entry = catalog.get(skillId); if (entry == null) { return Mono.just(ToolResultBlock.error(skillNotFound(skillId))); } @@ -270,7 +268,7 @@ private ToolResultBlock loadOne(HarnessSkillEntry entry, String path) { private String formatSkillMarkdown(HarnessSkillEntry entry) { AgentSkill skill = entry.skill(); StringBuilder sb = new StringBuilder(); - sb.append("Successfully loaded skill: ").append(skill.getName()).append("\n\n"); + sb.append("Successfully loaded skill: ").append(skill.getSkillId()).append("\n\n"); sb.append("Name: ").append(skill.getName()).append("\n"); sb.append("Description: ").append(skill.getDescription()).append("\n"); sb.append("Source: ").append(skill.getSource()).append("\n"); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java index f2d099f6cb..6798018076 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java @@ -338,7 +338,7 @@ void disableDynamicSkills_keepsWorkspaceLazyResourcesLoadable() throws Exception .input( Map.of( "skillId", - "lazy-resource", + "lazy-resource__s__workspace-namespaced", "path", "references/guide.md")) .build()) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java index ca30274be5..29d45c74dc 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillLoadToolFrontmatterViewTest.java @@ -56,7 +56,7 @@ void includesFrontmatter() { RuntimeContext ctx = RuntimeContext.empty(); ctx.put(SkillCatalog.class, catalog); - String text = runTool(tool, ctx, Map.of("skillId", skill.getName(), "path", "SKILL.md")); + String text = runTool(tool, ctx, Map.of("skillId", skill.getSkillId(), "path", "SKILL.md")); assertTrue( text.contains("name: alpha"), @@ -91,7 +91,7 @@ void resourceFenceConsistent() { ctx.put(SkillCatalog.class, catalog); String text = - runTool(tool, ctx, Map.of("skillId", skill.getName(), "path", "scripts/run.sh")); + runTool(tool, ctx, Map.of("skillId", skill.getSkillId(), "path", "scripts/run.sh")); assertTrue(text.contains("scripts/run.sh"), "resource path should appear in header"); assertTrue(text.contains("echo hi"), "resource body should be present"); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java index 6ab476b95b..175b7bab0f 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java @@ -69,7 +69,7 @@ void preservesInsertionOrder() { HarnessSkillEntry b = HarnessSkillEntry.of(skill("beta", "src"), null); HarnessSkillEntry c = HarnessSkillEntry.of(skill("gamma", "src"), null); SkillCatalog cat = SkillCatalog.of(List.of(a, b, c)); - assertEquals(List.of("alpha", "beta", "gamma"), cat.ids()); + assertEquals(List.of("alpha__s__src", "beta__s__src", "gamma__s__src"), cat.ids()); } @Test @@ -78,7 +78,7 @@ void laterEntryWithSameIdOverwrites() { HarnessSkillEntry aDup = HarnessSkillEntry.of(skill("alpha", "src"), null); SkillCatalog cat = SkillCatalog.of(List.of(a, aDup)); assertEquals(1, cat.size()); - assertSame(aDup.skill(), cat.get("alpha").skill()); + assertSame(aDup.skill(), cat.get("alpha__s__src").skill()); } } @@ -250,9 +250,9 @@ void returnsSkillMarkdownForSkillMdPath() { skillWith("alpha", "Alpha description.", "# Body line\n", null), null); r.install(SkillCatalog.of(List.of(e)), ctx, null); - ToolResultBlock res = invoke(r, ctx, "alpha", "SKILL.md"); + ToolResultBlock res = invoke(r, ctx, "alpha__s__workspace", "SKILL.md"); String text = textOf(res); - assertTrue(text.contains("Successfully loaded skill: alpha")); + assertTrue(text.contains("Successfully loaded skill: alpha__s__workspace")); assertTrue(text.contains("# Body line")); } @@ -267,7 +267,7 @@ void inMemoryResourceHitsBeforeLazyFallback() { HarnessSkillEntry.of(skillWith("alpha", "Alpha desc.", "# body", mem), lazy); r.install(SkillCatalog.of(List.of(e)), ctx, null); - String text = textOf(invoke(r, ctx, "alpha", "scripts/run.py")); + String text = textOf(invoke(r, ctx, "alpha__s__workspace", "scripts/run.py")); assertTrue(text.contains("in-memory-body")); assertFalse(text.contains("lazy-body")); } @@ -281,7 +281,7 @@ void lazyFallbackUsedWhenInMemoryMisses() { HarnessSkillEntry.of(skillWith("alpha", "Alpha desc.", "# body", null), lazy); r.install(SkillCatalog.of(List.of(e)), ctx, null); - String text = textOf(invoke(r, ctx, "alpha", "references/guide.md")); + String text = textOf(invoke(r, ctx, "alpha__s__workspace", "references/guide.md")); assertTrue(text.contains("from-fs")); } @@ -295,7 +295,7 @@ void notFoundEnumeratesBothSources() { HarnessSkillEntry.of(skillWith("alpha", "Alpha desc.", "# body", mem), lazy); r.install(SkillCatalog.of(List.of(e)), ctx, null); - String err = errorOf(invoke(r, ctx, "alpha", "does-not-exist")); + String err = errorOf(invoke(r, ctx, "alpha__s__workspace", "does-not-exist")); assertTrue(err.contains("Resource not found")); assertTrue(err.contains("SKILL.md")); assertTrue(err.contains("references/a.md")); @@ -375,16 +375,20 @@ void catalogsRemainIsolatedByRuntimeContextWithoutReRegistering() { HarnessSkillEntry second = HarnessSkillEntry.of(skill("second", "src"), null); r.install(SkillCatalog.of(List.of(second)), secondCtx, tk); - assertEquals(List.of("first"), r.currentCatalog(firstCtx).ids()); - assertEquals(List.of("second"), r.currentCatalog(secondCtx).ids()); + assertEquals(List.of("first__s__src"), r.currentCatalog(firstCtx).ids()); + assertEquals(List.of("second__s__src"), r.currentCatalog(secondCtx).ids()); assertTrue( - textOf(invoke(r, firstCtx, "first", "SKILL.md")) + textOf(invoke(r, firstCtx, "first__s__src", "SKILL.md")) .contains("Successfully loaded skill")); - assertTrue(errorOf(invoke(r, firstCtx, "second", "SKILL.md")).contains("not found")); assertTrue( - textOf(invoke(r, secondCtx, "second", "SKILL.md")) + errorOf(invoke(r, firstCtx, "second__s__src", "SKILL.md")) + .contains("not found")); + assertTrue( + textOf(invoke(r, secondCtx, "second__s__src", "SKILL.md")) .contains("Successfully loaded skill")); - assertTrue(errorOf(invoke(r, secondCtx, "first", "SKILL.md")).contains("not found")); + assertTrue( + errorOf(invoke(r, secondCtx, "first__s__src", "SKILL.md")) + .contains("not found")); // Both contexts use the same stateless registered tool instance. assertSame(r.loadTool(), tk.getTool(SkillLoadTool.TOOL_NAME)); @@ -409,7 +413,7 @@ void concurrentSessionsCannotLoadEachOthersSkills() throws Exception { Future first = executor.submit( () -> - textOf(invoke(r, firstCtx, "first", "SKILL.md")) + textOf(invoke(r, firstCtx, "first__s__src", "SKILL.md")) .contains("Successfully loaded skill") && errorOf( invoke( @@ -421,7 +425,7 @@ && errorOf( Future second = executor.submit( () -> - textOf(invoke(r, secondCtx, "second", "SKILL.md")) + textOf(invoke(r, secondCtx, "second__s__src", "SKILL.md")) .contains("Successfully loaded skill") && errorOf( invoke( @@ -446,12 +450,12 @@ void deprecatedRuntimeApiRemainsFunctionalWithoutLeakingIntoScopedCalls() { r.install(SkillCatalog.of(List.of(legacy)), tk); - assertEquals(List.of("legacy"), r.currentCatalog().ids()); + assertEquals(List.of("legacy__s__src"), r.currentCatalog().ids()); assertTrue( - textOf(invoke(r, null, "legacy", "SKILL.md")) + textOf(invoke(r, null, "legacy__s__src", "SKILL.md")) .contains("Successfully loaded skill")); assertTrue( - errorOf(invoke(r, RuntimeContext.empty(), "legacy", "SKILL.md")) + errorOf(invoke(r, RuntimeContext.empty(), "legacy__s__src", "SKILL.md")) .contains("not found")); } @@ -461,7 +465,7 @@ void deprecatedLoadToolConstructorRemainsContextFreeOnly() { AtomicReference ref = new AtomicReference<>(SkillCatalog.of(List.of(legacy))); SkillLoadTool tool = new SkillLoadTool(ref); - Map input = Map.of("skillId", "legacy", "path", "SKILL.md"); + Map input = Map.of("skillId", "legacy__s__src", "path", "SKILL.md"); ToolResultBlock contextFree = tool.callAsync(ToolCallParam.builder().input(input).build()).block(); From 5135442cc1cf16cac5c29c248cc23e928fb7af6c Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Thu, 15 Oct 2026 09:34:34 +0800 Subject: [PATCH 09/13] merge validatestructuredoutput conflict --- .../src/main/java/io/agentscope/core/ReActAgent.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index a4a6b9205d..bd5e5949a5 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -274,6 +274,8 @@ public class ReActAgent extends AgentBase implements AutoCloseable { * caller-controlled ToolRequestConfig. */ private final CapabilityResolver capabilityResolver; + + /** * Whether native structured-output responses are validated against the requested JSON Schema. * Enabled by default; {@link Builder#validateStructuredOutput(boolean)} can restore the * legacy trust-the-provider behavior. From af3334c2b9f70b19cf4913de598c1006fa9e77a8 Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Thu, 15 Oct 2026 12:36:06 +0800 Subject: [PATCH 10/13] test(core): align merged skillId assertions with dual-form __s__ format main's bare-name SkillFilter tests asserted the old composite skillId format (name_source, default source "custom") and failed after the merge. Update the literals to the dual-form scheme (name when no source, name__s__source when sourced) and give the disable-by-name test an explicit source so it still exercises hiding a composite-id skill. Test intent is unchanged: filters match the skill name. --- .../core/skill/AgentSkillPromptProviderTest.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java index 6ebd0720dc..e668acf493 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java @@ -365,16 +365,18 @@ void testBareNameFilterMatchesSkillWithCompositeId() { assertTrue( prompt.contains( - "host-forensics-client_filesystem-agentscope_skills")); + "host-forensics-client__s__filesystem-agentscope_skills" + + "")); assertFalse( - prompt.contains("other-skill_filesystem-agentscope_skills")); + prompt.contains( + "other-skill__s__filesystem-agentscope_skills")); } @Test @DisplayName("Should hide skill disabled by name") void testFilterDisableByNameHidesSkill() { - AgentSkill visible = new AgentSkill("visible", "visible", "# Content", null); - AgentSkill hidden = new AgentSkill("hidden", "hidden", "# Content", null); + AgentSkill visible = new AgentSkill("visible", "visible", "# Content", null, "demo"); + AgentSkill hidden = new AgentSkill("hidden", "hidden", "# Content", null, "demo"); skillRegistry.registerSkill(visible.getSkillId(), visible); skillRegistry.registerSkill(hidden.getSkillId(), hidden); @@ -382,8 +384,8 @@ void testFilterDisableByNameHidesSkill() { provider.getSkillSystemPrompt( SkillFilter.all().overlay(SkillFilter.disable("hidden"))); - assertTrue(prompt.contains("visible_custom")); - assertFalse(prompt.contains("hidden_custom")); + assertTrue(prompt.contains("visible__s__demo")); + assertFalse(prompt.contains("hidden__s__demo")); } @Test From 125364ac862fa5fda08dd51bcf466cc386398f74 Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Fri, 16 Oct 2026 11:37:10 +0800 Subject: [PATCH 11/13] feat(harness): add capabilityResolver with pre-staging grant filtering Resolve the grant surface at the harness call/stream entry, before the sandbox acquire, so marketplace skill staging materialises granted-only files; the inner delegate keeps the surface without re-resolving and an inherited surface is never overwritten in place. Also fix the stale catalog-key sentence and document the activateOnSkill grant rule in capability-grant.md (zh/en). --- .../harness/agent/HarnessAgent.java | 133 ++++++-- .../HarnessAgentCapabilityResolverTest.java | 316 ++++++++++++++++++ .../docs/building-blocks/capability-grant.md | 13 +- .../docs/building-blocks/capability-grant.md | 6 +- 4 files changed, 433 insertions(+), 35 deletions(-) create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentCapabilityResolverTest.java diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java index d066b0d55a..0edce81ca8 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java @@ -20,6 +20,8 @@ import io.agentscope.core.agent.Agent; import io.agentscope.core.agent.AgentBase; import io.agentscope.core.agent.AgentRun; +import io.agentscope.core.agent.CapabilityResolver; +import io.agentscope.core.agent.CapabilitySurface; import io.agentscope.core.agent.Event; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.agent.StreamOptions; @@ -156,7 +158,6 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; @@ -198,6 +199,10 @@ public class HarnessAgent implements Agent, AutoCloseable { private final SandboxContext defaultSandboxContext; private final CompactionMiddleware compactionHook; private final SandboxLifecycleMiddleware sandboxLifecycleMw; + + /** Per-user grant resolver; {@code null} = no trimming at the harness layer. */ + private final CapabilityResolver capabilityResolver; + private final List skillRepositories; private final PlanModeManager planModeManager; // Skill self-learning — null unless enableSkillManageTool / enableSkillCurator. @@ -231,6 +236,7 @@ private HarnessAgent( SandboxContext defaultSandboxContext, CompactionMiddleware compactionHook, SandboxLifecycleMiddleware sandboxLifecycleMw, + CapabilityResolver capabilityResolver, List skillRepositories, PlanModeManager planModeManager, SkillPromoter skillPromoter, @@ -250,6 +256,7 @@ private HarnessAgent( this.defaultSandboxContext = defaultSandboxContext; this.compactionHook = compactionHook; this.sandboxLifecycleMw = sandboxLifecycleMw; + this.capabilityResolver = capabilityResolver; this.skillRepositories = skillRepositories != null ? List.copyOf(skillRepositories) : List.of(); this.planModeManager = planModeManager; @@ -760,7 +767,7 @@ private synchronized void ensureGateway() { @Deprecated(since = "2.2.0") @Override public Mono call(List msgs) { - return wrappedCall(msgs, RuntimeContext.empty(), () -> delegate.call(msgs)); + return wrappedCall(msgs, RuntimeContext.empty(), eff -> delegate.call(msgs, eff)); } /** @@ -770,7 +777,7 @@ public Mono call(List msgs) { @Override public Mono call(List msgs, Class structuredModel) { return wrappedCall( - msgs, RuntimeContext.empty(), () -> delegate.call(msgs, structuredModel)); + msgs, RuntimeContext.empty(), eff -> delegate.call(msgs, structuredModel, eff)); } /** @@ -779,7 +786,7 @@ public Mono call(List msgs, Class structuredModel) { @Deprecated(since = "2.2.0") @Override public Mono call(List msgs, JsonNode schema) { - return wrappedCall(msgs, RuntimeContext.empty(), () -> delegate.call(msgs, schema)); + return wrappedCall(msgs, RuntimeContext.empty(), eff -> delegate.call(msgs, schema, eff)); } public Mono call(Msg msg, RuntimeContext ctx) { @@ -800,19 +807,19 @@ public Mono call(String text, RuntimeContext ctx) { public Mono call(List msgs, RuntimeContext ctx) { RuntimeContext effective = ensureSessionDefaults(ctx != null ? ctx : RuntimeContext.empty()); - return wrappedCall(msgs, effective, () -> delegate.call(msgs, effective)); + return wrappedCall(msgs, effective, eff -> delegate.call(msgs, eff)); } public Mono call(List msgs, Class structuredModel, RuntimeContext ctx) { RuntimeContext effective = ensureSessionDefaults(ctx != null ? ctx : RuntimeContext.empty()); - return wrappedCall(msgs, effective, () -> delegate.call(msgs, structuredModel, effective)); + return wrappedCall(msgs, effective, eff -> delegate.call(msgs, structuredModel, eff)); } public Mono call(List msgs, JsonNode schema, RuntimeContext ctx) { RuntimeContext effective = ensureSessionDefaults(ctx != null ? ctx : RuntimeContext.empty()); - return wrappedCall(msgs, effective, () -> delegate.call(msgs, schema, effective)); + return wrappedCall(msgs, effective, eff -> delegate.call(msgs, schema, eff)); } /** @@ -822,7 +829,7 @@ public Mono call(List msgs, JsonNode schema, RuntimeContext ctx) { @Deprecated(since = "2.0.0", forRemoval = true) @Override public Flux stream(List msgs, StreamOptions options) { - return wrappedStream(RuntimeContext.empty(), () -> delegate.stream(msgs, options)); + return wrappedStream(RuntimeContext.empty(), eff -> delegate.stream(msgs, options, eff)); } /** @@ -833,7 +840,8 @@ public Flux stream(List msgs, StreamOptions options) { @Override public Flux stream(List msgs, StreamOptions options, Class structuredModel) { return wrappedStream( - RuntimeContext.empty(), () -> delegate.stream(msgs, options, structuredModel)); + RuntimeContext.empty(), + eff -> delegate.stream(msgs, options, structuredModel, eff)); } /** @@ -843,7 +851,8 @@ public Flux stream(List msgs, StreamOptions options, Class struct @Deprecated(since = "2.0.0", forRemoval = true) @Override public Flux stream(List msgs, StreamOptions options, JsonNode schema) { - return wrappedStream(RuntimeContext.empty(), () -> delegate.stream(msgs, options, schema)); + return wrappedStream( + RuntimeContext.empty(), eff -> delegate.stream(msgs, options, schema, eff)); } /** @@ -869,7 +878,7 @@ public Flux stream(List msgs, RuntimeContext ctx) { public Flux stream(List msgs, StreamOptions options, RuntimeContext ctx) { RuntimeContext effective = ensureSessionDefaults(ctx != null ? ctx : RuntimeContext.empty()); - return wrappedStream(effective, () -> delegate.stream(msgs, options, effective)); + return wrappedStream(effective, eff -> delegate.stream(msgs, options, eff)); } /** @@ -881,7 +890,7 @@ public Flux stream( RuntimeContext effective = ensureSessionDefaults(ctx != null ? ctx : RuntimeContext.empty()); return wrappedStream( - effective, () -> delegate.stream(msgs, options, structuredModel, effective)); + effective, eff -> delegate.stream(msgs, options, structuredModel, eff)); } /** @@ -892,7 +901,7 @@ public Flux stream( List msgs, StreamOptions options, JsonNode schema, RuntimeContext ctx) { RuntimeContext effective = ensureSessionDefaults(ctx != null ? ctx : RuntimeContext.empty()); - return wrappedStream(effective, () -> delegate.stream(msgs, options, schema, effective)); + return wrappedStream(effective, eff -> delegate.stream(msgs, options, schema, eff)); } /** @@ -986,7 +995,7 @@ public Flux streamEvents(String text, RuntimeContext ctx) { public Flux streamEvents(List msgs, RuntimeContext ctx) { RuntimeContext effective = ensureSessionDefaults(ctx != null ? ctx : RuntimeContext.empty()); - return wrappedStreamEvents(effective, () -> delegate.streamEvents(msgs, effective)); + return wrappedStreamEvents(effective, eff -> delegate.streamEvents(msgs, eff)); } @Override @@ -1006,16 +1015,17 @@ public Toolkit getToolkit() { // ==================== Call/stream wrappers ==================== private Mono wrappedCall( - List msgs, RuntimeContext effective, Supplier> inner) { + List msgs, RuntimeContext effective, Function> inner) { Mono base = Mono.using( () -> { + RuntimeContext eff = prepareCapabilitySurface(effective); if (sandboxLifecycleMw != null) { - sandboxLifecycleMw.acquireForCall(effective); + sandboxLifecycleMw.acquireForCall(eff); } - return effective; + return eff; }, - eff -> inner.get(), + eff -> inner.apply(eff), eff -> { if (sandboxLifecycleMw != null) { sandboxLifecycleMw.releaseForCall(eff); @@ -1035,18 +1045,20 @@ private Mono wrappedCall( /** * @deprecated since 2.0.0, for removal alongside the {@link #stream(List, StreamOptions)} - * family. Replaced by {@link #wrappedStreamEvents(RuntimeContext, Supplier)}. + * family. Replaced by {@link #wrappedStreamEvents(RuntimeContext, Function)}. */ @Deprecated(since = "2.0.0", forRemoval = true) - private Flux wrappedStream(RuntimeContext effective, Supplier> inner) { + private Flux wrappedStream( + RuntimeContext effective, Function> inner) { return Flux.using( () -> { + RuntimeContext eff = prepareCapabilitySurface(effective); if (sandboxLifecycleMw != null) { - sandboxLifecycleMw.acquireForCall(effective); + sandboxLifecycleMw.acquireForCall(eff); } - return effective; + return eff; }, - eff -> inner.get(), + eff -> inner.apply(eff), eff -> { if (sandboxLifecycleMw != null) { sandboxLifecycleMw.releaseForCall(eff); @@ -1055,15 +1067,16 @@ private Flux wrappedStream(RuntimeContext effective, Supplier } private Flux wrappedStreamEvents( - RuntimeContext effective, Supplier> inner) { + RuntimeContext effective, Function> inner) { return Flux.using( () -> { + RuntimeContext eff = prepareCapabilitySurface(effective); if (sandboxLifecycleMw != null) { - sandboxLifecycleMw.acquireForCall(effective); + sandboxLifecycleMw.acquireForCall(eff); } - return effective; + return eff; }, - eff -> inner.get(), + eff -> inner.apply(eff), eff -> { if (sandboxLifecycleMw != null) { sandboxLifecycleMw.releaseForCall(eff); @@ -1071,6 +1084,47 @@ private Flux wrappedStreamEvents( }); } + /** + * Resolves the capability surface for this call, mirroring the core funnel rules: with no + * resolver any inherited surface is kept as-is (sub-agents reuse the parent's without + * re-resolving); a configured resolver resolves fresh per call, and when the rc already + * carries a surface (this harness running as a sub-agent) the result goes into a derived + * copy so the parent chain is never written in place. + * + *

Runs in the sandbox-acquire resource phase deliberately BEFORE {@code acquireForCall}: + * the pre-start callback stages marketplace skill files into {@code .skills-cache} for + * sandbox projection, so the grant set must already be on the rc — otherwise ungranted + * skill files would be materialised and readable from the sandbox shell. The inner delegate + * is built without a resolver and therefore keeps this surface by reference. + */ + private RuntimeContext prepareCapabilitySurface(RuntimeContext effective) { + if (capabilityResolver == null) { + return effective; + } + CapabilitySurface resolved; + try { + resolved = capabilityResolver.resolve(effective, this); + } catch (RuntimeException e) { + // Same policy as the core funnel: broken authorization infrastructure is not + // "no grants" — abort the call instead of silently trimming nothing. + log.error( + "CapabilityResolver failed; aborting the call (userId={}, agentId={})", + effective.getUserId(), + getName(), + e); + throw e; + } + CapabilitySurface surface = resolved != null ? resolved : CapabilitySurface.ALLOW_ALL; + CapabilitySurface present = effective.get(CapabilitySurface.class); + if (present == surface) { + return effective; + } + RuntimeContext target = + present == null ? effective : RuntimeContext.builder().from(effective).build(); + target.put(CapabilitySurface.class, surface); + return target; + } + /** * Fills in a default {@code sessionId} when the caller didn't provide one, and injects the * default sandbox context. The agent's persistence backend is bound at builder time via @@ -1155,9 +1209,14 @@ private Mono forceCompactAndRetry(List msgs, RuntimeContext effective) if (opt.isPresent()) { state.contextMutable().clear(); state.contextMutable().addAll(opt.get()); + // Re-prepare: the retry must carry the grant surface too (the + // wrapped resource phase is not re-entered on this path). return delegate.call( msgs, - effective != null ? effective : RuntimeContext.empty()); + prepareCapabilitySurface( + effective != null + ? effective + : RuntimeContext.empty())); } return Mono.error( new RuntimeException( @@ -1363,6 +1422,7 @@ public Builder contextPolicy(ContextPolicy policy) { TaskRepository taskRepository; Object externalSubagentTool; Function modelResolver; + CapabilityResolver capabilityResolver; final List additionalContextFiles = new ArrayList<>(); int maxContextTokens = 8000; boolean useLegacyXmlWorkspaceContext = false; @@ -2184,6 +2244,22 @@ public Builder modelResolver(Function resolver) { return this; } + /** + * Registers a capability resolver computing the per-user grant set at call entry. The + * harness resolves before the sandbox acquire/prestage phase, so marketplace skill + * staging materialises granted-only files; the inner delegate carries the resolved + * {@link CapabilitySurface} without re-resolving, and harness sub-agents reuse it + * (inherited surface). + * + *

Only an all-null surface skips trimming; any explicit dimension makes the surface + * a complete grant enumeration (unset dimensions contribute no grants). A throwing + * resolver aborts the call — authorization infrastructure failure is not "no grants". + */ + public Builder capabilityResolver(CapabilityResolver resolver) { + this.capabilityResolver = resolver; + return this; + } + /** * Adds a custom context file (relative to workspace) loaded into the system prompt * alongside AGENTS.md, MEMORY.md, and KNOWLEDGE.md. @@ -3149,6 +3225,7 @@ && isLocalSession(effectiveSession)) { defaultSandboxContext, compactionHook, sandboxLifecycleMw, + capabilityResolver, orderedSkillRepos, planModeManager, pendingSkillPromoter, diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentCapabilityResolverTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentCapabilityResolverTest.java new file mode 100644 index 0000000000..e1fe544fa2 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentCapabilityResolverTest.java @@ -0,0 +1,316 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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; +import static org.mockito.Mockito.when; + +import io.agentscope.core.agent.CapabilitySurface; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.skill.repository.AgentSkillRepository; +import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.harness.agent.example.support.InMemorySandboxFilesystemSpec; +import io.agentscope.harness.agent.filesystem.local.LocalFilesystem; +import io.agentscope.harness.agent.session.WorkspaceSessionLogStore; +import io.agentscope.harness.agent.testing.HarnessQuiescence; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import reactor.core.publisher.Flux; + +/** + * Grant-layer coverage of the harness {@code capabilityResolver(...)} pass-through: the + * resolver runs once per call at the harness entry, the resolved surface trims the + * model-visible tools, a pre-existing (inherited) surface is never overwritten in place, and + * marketplace skill staging already sees the resolved surface — closing the prestage timing + * hole where ungranted skill files were materialised into {@code .skills-cache} before the + * surface reached the rc. + * + * @author shanhongyu + */ +@HarnessQuiescence +class HarnessAgentCapabilityResolverTest { + + @TempDir Path workspace; + + /** Captures the tool schemas the model was offered per reasoning round. */ + private static final class RecordingModel implements Model { + private final List> toolBatches = new CopyOnWriteArrayList<>(); + private final String reply; + + RecordingModel(String reply) { + this.reply = reply; + } + + @Override + public String getModelName() { + return "recording-model"; + } + + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + toolBatches.add(List.copyOf(tools)); + ChatResponse chunk = + new ChatResponse( + "stub-id", + List.of(TextBlock.builder().text(reply).build()), + null, + Map.of(), + "stop"); + return Flux.just(chunk); + } + } + + private static AgentTool mockTool(String name) { + AgentTool tool = mock(AgentTool.class); + when(tool.getName()).thenReturn(name); + when(tool.getDescription()).thenReturn("mock tool " + name); + when(tool.getParameters()).thenReturn(Map.of("type", "object", "properties", Map.of())); + return tool; + } + + private static Msg userText(String text) { + return Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private HarnessAgent.Builder baseBuilder(RecordingModel model) { + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(mockTool("secret_probe")); + return HarnessAgent.builder() + .name("grant-harness") + .model(model) + .workspace(workspace) + .toolkit(toolkit) + .abstractFilesystem(new LocalFilesystem(workspace)) + .disableMemoryTools() + .disableMemoryHooks(); + } + + private static boolean offered(RecordingModel model, String toolName) { + return model.toolBatches.stream() + .flatMap(List::stream) + .anyMatch(t -> toolName.equals(t.getName())); + } + + @Test + void resolverRunsOnceAndTrimsModelVisibleTools() { + RecordingModel model = new RecordingModel("ok"); + AtomicInteger resolutions = new AtomicInteger(); + HarnessAgent agent = + baseBuilder(model) + .capabilityResolver( + (ctx, a) -> { + resolutions.incrementAndGet(); + // Tools dimension explicitly empty: zero named-tool grants. + return new CapabilitySurface(null, null, Set.of()); + }) + .build(); + + agent.call( + List.of(userText("hi")), + RuntimeContext.builder().userId("u1").sessionId("s1").build()) + .block(); + + assertEquals(1, resolutions.get(), "resolver must run exactly once per call"); + assertTrue(!offered(model, "secret_probe"), "denied tool must not reach the model"); + } + + @Test + void resolverNullResultMeansNoTrimming() { + RecordingModel model = new RecordingModel("ok"); + HarnessAgent agent = baseBuilder(model).capabilityResolver((ctx, a) -> null).build(); + + agent.call( + List.of(userText("hi")), + RuntimeContext.builder().userId("u1").sessionId("s1").build()) + .block(); + + assertTrue(offered(model, "secret_probe"), "null surface = no trimming"); + } + + @Test + void throwingResolverAbortsTheCall() { + RecordingModel model = new RecordingModel("ok"); + HarnessAgent agent = + baseBuilder(model) + .capabilityResolver( + (ctx, a) -> { + throw new IllegalStateException("authz down"); + }) + .build(); + + assertThrows( + IllegalStateException.class, + () -> + agent.call( + List.of(userText("hi")), + RuntimeContext.builder() + .userId("u1") + .sessionId("s1") + .build()) + .block()); + } + + @Test + void inheritedSurfaceIsUsedButNeverOverwrittenInPlace() { + RecordingModel model = new RecordingModel("ok"); + // Pre-seeded surface (as a parent agent would leave on the rc): denies the tool. + CapabilitySurface inherited = new CapabilitySurface(null, null, Set.of()); + // The harness's own resolver grants everything. + HarnessAgent agent = baseBuilder(model).capabilityResolver((ctx, a) -> null).build(); + RuntimeContext rc = RuntimeContext.builder().userId("u1").sessionId("s1").build(); + rc.put(CapabilitySurface.class, inherited); + + agent.call(List.of(userText("hi")), rc).block(); + + assertTrue( + offered(model, "secret_probe"), + "the harness resolver's surface must be the effective one for this call"); + assertSame( + inherited, + rc.get(CapabilitySurface.class), + "a derived copy must be used; the caller's rc is never written in place"); + } + + @Test + void marketplacePrestageAlreadySeesTheResolvedSurface() throws Exception { + Path ws = workspace; + Files.createDirectories(ws); + AgentSkill granted = + new AgentSkill( + "alpha", + "granted skill", + "# alpha", + Map.of("run.sh", "echo alpha"), + "market"); + AgentSkill denied = + new AgentSkill( + "beta", "denied skill", "# beta", Map.of("run.sh", "echo beta"), "market"); + + HarnessAgent agent = + HarnessAgent.builder() + .name("staging-harness") + .model(new RecordingModel("ok")) + .workspace(ws.toAbsolutePath().normalize().toString()) + .filesystem(new InMemorySandboxFilesystemSpec()) + .sessionLogStore( + new WorkspaceSessionLogStore( + new LocalFilesystem(ws.resolve("journal")))) + .skillRepositories( + List.of(new StubRepo(List.of(granted, denied), "market"))) + .disableMemoryTools() + .disableMemoryHooks() + .capabilityResolver( + (ctx, a) -> new CapabilitySurface(Set.of("alpha"), null, null)) + .build(); + + agent.call( + List.of(userText("hi")), + RuntimeContext.builder().userId("u1").sessionId("s1").build()) + .block(); + + Path cache = ws.resolve(".skills-cache").resolve("u1").resolve("market"); + assertTrue( + Files.exists(cache.resolve("alpha").resolve("run.sh")), + "granted skill resources must be staged for sandbox projection"); + assertTrue( + Files.notExists(cache.resolve("beta")), + "ungranted skill files must NOT be materialised into .skills-cache"); + } + + /** Minimal repository stub serving a fixed skill list from one source. */ + private static final class StubRepo implements AgentSkillRepository { + private final List skills; + private final String source; + + StubRepo(List skills, String source) { + this.skills = skills; + this.source = source; + } + + @Override + public AgentSkill getSkill(String name) { + return skills.stream().filter(s -> s.getName().equals(name)).findFirst().orElse(null); + } + + @Override + public List getAllSkillNames() { + return skills.stream().map(AgentSkill::getName).toList(); + } + + @Override + public List getAllSkills() { + return skills; + } + + @Override + public boolean save(List skills, boolean force) { + return false; + } + + @Override + public boolean delete(String skillName) { + return false; + } + + @Override + public boolean skillExists(String skillName) { + return skills.stream().anyMatch(s -> s.getName().equals(skillName)); + } + + @Override + public AgentSkillRepositoryInfo getRepositoryInfo() { + return new AgentSkillRepositoryInfo(source, "", false); + } + + @Override + public String getSource() { + return source; + } + + @Override + public void setWriteable(boolean writeable) {} + + @Override + public boolean isWriteable() { + return false; + } + } +} diff --git a/docs/v2/en/docs/building-blocks/capability-grant.md b/docs/v2/en/docs/building-blocks/capability-grant.md index b2c8f19d33..2168c44e48 100644 --- a/docs/v2/en/docs/building-blocks/capability-grant.md +++ b/docs/v2/en/docs/building-blocks/capability-grant.md @@ -49,6 +49,9 @@ The `CapabilitySurface` is an immutable grant enumeration. The framework resolve `call()` (server-side — a `null` return means no trimming) and carries it on the `RuntimeContext` through the whole call chain (frozen per request, never persisted); sub-agents without their own resolver reuse the parent's surface instance (zero re-computation). +`HarnessAgent.builder().capabilityResolver(...)` is supported as well: resolution happens +before the sandbox acquire and skill pre-staging, so the staged set, the prompt catalog and +every gate observe the same surface. **Caching**: the resolver runs at every call entry (exactly once per call). If its backend is an expensive lookup (DB / RPC), consider caching it yourself to match your business needs @@ -89,7 +92,9 @@ accessors (`allowedSkills()` etc.) still preserve the distinction. Positive authorization is hierarchical; matching **any set dimension** grants: - Granting a **skill** implicitly grants its manual, scripts and associated tools (the skill - tool group `skillId + "_skill_tools"`, where a source-less skillId is simply the skill name); + tool group `skillId + "_skill_tools"`, where a source-less skillId is simply the skill name), + **and automatically enables custom tool groups bound to it via `activateOnSkill`** (the call + entry merges bound groups into the grant set); - Granting a **ToolGroup** implicitly grants every tool in it; - Granting a **tool** by name grants exactly that tool (grouping is ignored, and it does not amplify to sibling tools in the same group). @@ -97,9 +102,9 @@ Positive authorization is hierarchical; matching **any set dimension** grants: Skills are granted and addressed **by skillId**: normally just the skill name (the industry-standard unique identity); the combined `name + "__s__" + source` form only when same-named skills come from different repositories (entitlement lists use the bare name; the -combined form for the rare precise case). Catalogs key by skill name and enforce -per-agent-catalog uniqueness (same name + same instance is idempotent, a different -instance is rejected). +combined form for the rare precise case). Catalogs key by skillId: same-named skills from +different repositories coexist under their combined form, a same-id re-registration replaces +the previous instance (version override), and lookups match the exact skillId. Only whitelists are provided; there is no deny (reverse-ban) capability. Default is deny — anything not reachable from the enumeration does not exist. diff --git a/docs/v2/zh/docs/building-blocks/capability-grant.md b/docs/v2/zh/docs/building-blocks/capability-grant.md index e2c99a072e..deb5a6fa90 100644 --- a/docs/v2/zh/docs/building-blocks/capability-grant.md +++ b/docs/v2/zh/docs/building-blocks/capability-grant.md @@ -40,7 +40,7 @@ ReActAgent agent = ReActAgent.builder() .build(); ``` -`CapabilitySurface` 是不可变的授权全集。框架在每次 `call()` 入口(服务端)解析一次——resolver 返回 `null` 等同不裁剪——随 `RuntimeContext` 走完整条调用链(请求内冻结、不持久化);未自配 resolver 的子 agent 直接复用父授权面(零重算)。 +`CapabilitySurface` 是不可变的授权全集。框架在每次 `call()` 入口(服务端)解析一次——resolver 返回 `null` 等同不裁剪——随 `RuntimeContext` 走完整条调用链(请求内冻结、不持久化);未自配 resolver 的子 agent 直接复用父授权面(零重算)。`HarnessAgent.builder().capabilityResolver(...)` 同样支持:解析发生在沙箱 acquire 与 skill 暂存之前,暂存清单、提示词目录与各道门看到同一份授权面。 **缓存建议**:resolver 在每次调用入口都会执行(调用内只此一次)。若其后端是 DB / RPC 等高成本查询,可按业务实际需要自行加缓存(如按 `userId` + TTL),并接受相应的权限变更生效延迟;框架不代管缓存。 @@ -73,11 +73,11 @@ new CapabilitySurface(Set.of(), Set.of(), Set.of()); 正向授权层次包含,命中**任一已设置维度**即放行: -- 授权一个 **skill** 即隐含授权其手册、脚本与关联工具(技能组 `skillId + "_skill_tools"`,无 source 时 skillId 即 skill 名); +- 授权一个 **skill** 即隐含授权其手册、脚本与关联工具(技能组 `skillId + "_skill_tools"`,无 source 时 skillId 即 skill 名),**并自动放行经 `activateOnSkill` 绑定到该 skill 的自定义工具组**(call 入口把绑定组并入授权面); - 授权一个 **ToolGroup** 即隐含授权组内全部工具; - 逐名授权一个**工具**即授权该工具本身(不问分组,也不放大到组内其他工具)。 -skill 以 **skillId** 授权与寻址:skillId 通常就是 skill 名(行业标准唯一定位),仅在同名 skill 来自不同仓库时取 `name + "__s__" + source` 组合形态精确到具体仓库(业务授权清单可以直接使用 skill 名,极端情况下使用组合形态)。目录以 skill 名为键并强制单个 agent 目录内唯一(同名同实例幂等、同名异实例拒绝)。 +skill 以 **skillId** 授权与寻址:skillId 通常就是 skill 名(行业标准唯一定位),仅在同名 skill 来自不同仓库时取 `name + "__s__" + source` 组合形态精确到具体仓库(业务授权清单可以直接使用 skill 名,极端情况下使用组合形态)。目录以 skillId 为键:同名 skill 来自不同仓库时以组合形态共存,同 id 重复注册覆盖旧实例(版本更新),寻址按 skillId 精确匹配。 只提供白名单,不提供 deny(反向禁用);默认拒绝——不在授权清单内的能力即「不存在」。 From eef172e0c3beb503b951bea9f0c909111762db12 Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Fri, 23 Oct 2026 13:15:55 +0800 Subject: [PATCH 12/13] test: deflake BotLoopGuard active-peer eviction under sweep race activePeersSurviveEvictionOfIdleOnes asserted evictIdlePeers() == 1, but a >=100ms overshoot of the 900ms sleep let allow("active-peer") fire the piggybacked sweep first (one full window+cooldown interval had elapsed), evicting idle-peer before the explicit call and flipping the count to 0. Restage so the sweep is the deterministic eviction path: idle-peer sits past the threshold before active-peer's first event, so that allow() always sweeps it and the explicit call finds nothing. Overshoots only push the idle peer further past the threshold. Verified 5 consecutive runs green. --- .../channel/common/BotLoopGuardTest.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-channel/agentscope-extensions-channel-common/src/test/java/io/agentscope/extensions/channel/common/BotLoopGuardTest.java b/agentscope-extensions/agentscope-extensions-channel/agentscope-extensions-channel-common/src/test/java/io/agentscope/extensions/channel/common/BotLoopGuardTest.java index e6118456b5..5a2b7b0e30 100644 --- a/agentscope-extensions/agentscope-extensions-channel/agentscope-extensions-channel-common/src/test/java/io/agentscope/extensions/channel/common/BotLoopGuardTest.java +++ b/agentscope-extensions/agentscope-extensions-channel/agentscope-extensions-channel-common/src/test/java/io/agentscope/extensions/channel/common/BotLoopGuardTest.java @@ -80,15 +80,17 @@ void idlePeersAreEvictedAfterWindowPlusCooldown() throws InterruptedException { @Test void activePeersSurviveEvictionOfIdleOnes() throws InterruptedException { - // The idle peer sits far past the threshold (a sleep overshoot only helps) and the - // active peer far inside it, so a multi-hundred-ms runner stall cannot flip either - // side of the check. + // The idle peer crosses the threshold before the active peer's first event, so that + // call's piggybacked sweep (which always fires: more than one interval has passed) + // deterministically evicts it — an explicit evictIdlePeers() racing the sweep is what + // flaked before, when a sleep overshoot let the sweep fire between the two calls. + // Sleep overshoots only push the idle peer further past the threshold here. BotLoopGuard guard = new BotLoopGuard(5, 500L, 500L); // idle threshold: 1000ms assertTrue(guard.allow("idle-peer")); - Thread.sleep(900); - assertTrue(guard.allow("active-peer")); - Thread.sleep(150); // idle-peer idle ~1050ms; active-peer idle ~150ms - assertEquals(1, guard.evictIdlePeers()); + Thread.sleep(1100); // idle-peer far past the threshold before the sweep can fire + assertTrue(guard.allow("active-peer")); // first call past the interval sweeps idle-peer + assertEquals(1, guard.trackedPeers()); // the active peer survived the eviction + assertEquals(0, guard.evictIdlePeers()); // nothing left to evict assertTrue(guard.allow("active-peer")); assertEquals(1, guard.trackedPeers()); } From 65d0ab754a3af7d04ea6e4b24dd4b4c77148005f Mon Sep 17 00:00:00 2001 From: xzxiaoshan <365384722@qq.com> Date: Fri, 30 Oct 2026 16:27:28 +0800 Subject: [PATCH 13/13] test(harness): isolate inbox scheduling test from post-call memory consolidation The notificationReachesModelRequestUnderRealScheduling test asserts on MockModel.getLastMessages() right after call().block() returns. The default memory hooks schedule a background consolidation model call after the reply completes; on a fast Linux runner that call can win the race and become the recorded "last" request, failing the assertion with the consolidation prompt. Disable memory hooks in this test so the only model request is the reasoning call under test. Fixes the ubuntu-latest failure in PR CI run 41024248628. --- .../harness/agent/middleware/InboxMiddlewareTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/InboxMiddlewareTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/InboxMiddlewareTest.java index 5db0379f71..fe2ecc700c 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/InboxMiddlewareTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/InboxMiddlewareTest.java @@ -279,6 +279,10 @@ void notificationReachesModelRequestUnderRealScheduling(@TempDir java.nio.file.P HarnessAgent.builder() .name("inbox-agent") .model(model) + // A post-call memory-consolidation model call races getLastMessages() + // after call() completes; disable it so the only model request is the + // reasoning call this test asserts on. + .disableMemoryHooks() .workspace(wsDir) .abstractFilesystem(new LocalFilesystem(wsDir)) .stateStore(stateStore)