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 525d4a4266..af3faf3a99 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -18,6 +18,8 @@ import com.fasterxml.jackson.databind.JsonNode; 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.ExecutionIdentity; import io.agentscope.core.agent.RunControl; @@ -129,6 +131,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; @@ -265,6 +268,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; + /** * Whether native structured-output responses are validated against the requested JSON Schema. * Enabled by default; {@link Builder#validateStructuredOutput(boolean)} can restore the @@ -417,6 +427,7 @@ private ReActAgent(Builder builder, Toolkit agentToolkit) { this.modelExecutionConfig = builder.modelExecutionConfig; this.toolExecutionConfig = builder.toolExecutionConfig; this.generateOptions = builder.generateOptions; + this.capabilityResolver = builder.capabilityResolver; this.validateStructuredOutput = builder.validateStructuredOutput; this.toolExecutionContext = builder.toolExecutionContext; this.enablePendingToolRecovery = builder.enablePendingToolRecovery; @@ -968,11 +979,94 @@ 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. + CapabilitySurface surface; + 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; + } + surface = resolved != null ? resolved : CapabilitySurface.ALLOW_ALL; + } else { + CapabilitySurface inherited = ctx.get(CapabilitySurface.class); + // 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). + ctx = RuntimeContext.builder().from(ctx).build(); + scope.rc = ctx; + } + ctx.put(CapabilitySurface.class, surface); + } + scope.systemMsg = null; scope.interruption = control.interruption(); 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 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; + } + 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 @@ -1604,7 +1698,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 @@ -2876,7 +2973,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) { @@ -5913,6 +6011,9 @@ public Builder enableTaskRequirements(boolean enabled) { 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 — @@ -5949,6 +6050,7 @@ public synchronized Builder copy() { copy.modelExecutionConfig = this.modelExecutionConfig; copy.toolExecutionConfig = this.toolExecutionConfig; copy.generateOptions = this.generateOptions; + copy.capabilityResolver = this.capabilityResolver; copy.validateStructuredOutput = this.validateStructuredOutput; copy.modelRequestPreparer = this.modelRequestPreparer; copy.actionObserver = this.actionObserver; @@ -6173,7 +6275,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 @@ -6535,6 +6637,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: @@ -6756,6 +6874,24 @@ private ReActAgent buildInstance() { List.copyOf(skillRepositories), skillCodeExecutionEnabled)); } + // 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..a3b864537e --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/CapabilitySurface.java @@ -0,0 +1,193 @@ +/* + * 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 name is whitelisted. Default is deny: anything + * not reachable from the enumeration does not exist. + * + * @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) + * @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)}). + * + *

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

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 matching grant entry + */ + public boolean allowsSkill(AgentSkill skill) { + if (skill == null) { + return false; // registry miss: fail closed + } + if (skill.isSystem() || isUnrestricted()) { + return true; + } + if (allowedSkills == null) { + return false; + } + return allowedSkills.contains(skill.getName()) + || allowedSkills.contains(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; + } + // Reachability: some per-name granted tool belongs to this group. + return allowedTools != null + && allowedTools.stream().anyMatch(t -> groupsOf.apply(t).contains(name)); + } + + /** + * 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 + */ + 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 7d24875f1d..787579ac8b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkill.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkill.java @@ -67,6 +67,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; @@ -80,11 +90,17 @@ 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. * *

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) @@ -94,7 +110,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); } /** @@ -107,7 +123,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( @@ -128,7 +144,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( @@ -149,7 +165,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, skillContent is null or empty, or @@ -161,12 +177,46 @@ 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 ({@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 + */ + private AgentSkill( + Map metadata, + String skillContent, + Map resources, + String source, + Path originDir, + boolean system) { String name = getRequiredMetadataString(metadata, "name"); String description = getRequiredMetadataString(metadata, "description"); SkillUtil.requireValidSkillName(name); 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); @@ -175,13 +225,19 @@ public 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; } /** * 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() { @@ -228,9 +284,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; @@ -265,19 +321,30 @@ public Set getResourcePaths() { } /** - * Gets a unique identifier for this skill. + * 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). * - *

The ID is composed of name and source: "name_source". Because the standard naming rule - * forbids underscores in names, the ID splits deterministically at the first underscore, so - * distinct {@code (name, source)} pairs always produce distinct IDs. {@code SkillRegistry} - * additionally warns if a same-ID registration ever overwrites a different {@code (name, - * source)} pair — with the current charset this is unreachable and serves purely as a - * tripwire against future rule relaxations. - * - * @return Unique skill identifier (never null) + * @return the skill identifier (never null) */ 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); } /** @@ -291,6 +358,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. * @@ -356,6 +434,7 @@ public static class Builder { private Map resources; private String source; private Path originDir; + private boolean system = false; /** * Creates an empty builder. @@ -376,6 +455,7 @@ private Builder(AgentSkill baseSkill) { this.resources = new HashMap<>(baseSkill.resources); this.source = baseSkill.source; this.originDir = baseSkill.originDir; + this.system = baseSkill.system; } /** @@ -514,6 +594,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. * @@ -521,7 +614,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/AgentSkillPromptProvider.java b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java index b54d1ecc83..9ffc8f1d5d 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 @@ -59,15 +59,15 @@ public class AgentSkillPromptProvider { - 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(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) @@ -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") """; 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 13eb4db27c..5c7c5cf77d 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; @@ -46,8 +47,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 @@ -240,6 +242,30 @@ private SkillFilter resolveFilter(Agent agent, RuntimeContext rc) { return baseline.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 SkillFilter resolveBaseline(Agent agent) { SkillFilter fromAgent = agent != null ? agent.getSkillFilter() : null; return fromAgent != null ? fromAgent : SkillFilter.all(); @@ -249,7 +275,10 @@ private SkillBox reloadSkills(Agent agent, 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 { @@ -271,29 +300,37 @@ private SkillBox reloadSkills(Agent agent, 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; } + // 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 b06c21ca57..b6247c4902 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 @@ -19,6 +19,7 @@ import io.agentscope.core.skill.util.SkillUtil; 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; @@ -114,8 +115,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 skillId : source.getAllSkillIds()) { + skillRegistry.registerSkill(skillId, source.getSkill(skillId)); } skillPromptProvider = source.skillPromptProvider.copyFor(skillRegistry); skillToolFactory = new SkillToolFactory(skillRegistry, toolkit); @@ -247,8 +248,9 @@ public void registerSkill(AgentSkill skill) { } /** - * Gets all skill IDs. - * @return All skill IDs + * 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 getAllSkillIds() { return skillRegistry.getSkillIds(); @@ -273,49 +275,53 @@ 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()); } /** - * Gets a skill by ID (latest version). + * Gets a skill by its exact skillId (a bare name is the source-less form). * - * @param skillId The skill ID + * @param skillName The skillId * @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); } /** @@ -546,7 +552,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.getName()); if (toolkit.getToolGroup(skillToolGroup) == null) { toolkit.createToolGroup(skillToolGroup, skillToolGroup, false); } @@ -635,7 +641,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) { 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 97a6aab26a..cd616d7ef9 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 @@ -16,6 +16,7 @@ package io.agentscope.core.skill; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; @@ -23,13 +24,16 @@ import org.slf4j.LoggerFactory; /** - * Pure storage layer for {@link AgentSkill}s, keyed by skill id. + * Pure storage layer for {@link AgentSkill}s, keyed by skillId. + * + *

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}. - * - *

This is a pure storage layer: parameters are assumed non-null and validation is performed at - * the {@code Toolkit} layer. */ class SkillRegistry { private static final Logger logger = LoggerFactory.getLogger(SkillRegistry.class); @@ -39,13 +43,14 @@ class SkillRegistry { // ==================== Registration ==================== /** - * Registers a skill, replacing any existing skill with the same id. Re-registering the same - * {@code (name, source)} pair (a content update) is the normal path. With the standard name - * charset (no underscores) distinct pairs cannot share an id, so the collision warning below - * is unreachable for valid skills and acts only as a tripwire against future rule relaxations - * or caller-supplied mismatched ids. + * Registers a skill under its skillId. Same-named skills from different sources + * coexist; re-registering the same skillId replaces the previous instance. Because + * neither the name nor the source may contain {@link + * AgentSkill#SKILL_ID_SEPARATOR}, distinct {@code (name, source)} pairs cannot share + * an id, so the collision warning below is unreachable for valid skills and acts only + * as a tripwire against future rule relaxations or caller-supplied mismatched ids. * - * @param skillId The unique skill identifier (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) */ void registerSkill(String skillId, AgentSkill skill) { @@ -78,48 +83,50 @@ void registerSkill(String skillId, AgentSkill skill) { static boolean isAmbiguousIdCollision(AgentSkill existing, AgentSkill incoming) { return existing != null && (!existing.getName().equals(incoming.getName()) - || !existing.getSource().equals(incoming.getSource())); + || !Objects.equals(existing.getSource(), incoming.getSource())); } // ==================== Query Operations ==================== /** - * Gets a skill by ID. + * Gets a skill by its exact skillId. * - * @param skillId The skill ID (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 skillId) { - return skills.get(skillId); + return skillId == null ? null : skills.get(skillId); } /** - * Gets all skill IDs. + * Gets all registered skillIds (each unique in this catalog). * - * @return Set of skill IDs (never null, may be empty) + * @return Set of skillIds (never null, may be empty) */ Set getSkillIds() { return new TreeSet<>(skills.keySet()); } /** - * Checks if a skill exists. + * Checks if a skill with this exact skillId exists. * - * @param skillId The skill ID (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 skillId) { - return skills.containsKey(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 skillId The skill ID (must not be null) + * @param skillId the skillId (a bare name is the source-less form) */ void removeSkill(String skillId) { - skills.remove(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 a8936aec7d..8fe2f83011 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,12 +15,14 @@ */ 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.skill.util.SkillFileSystemHelper; 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.nio.file.Path; @@ -44,7 +46,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 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" @@ -127,6 +129,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; @@ -206,9 +214,15 @@ 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 List availableSkillIds = new ArrayList<>(skillRegistry.getSkillIds()); return Map.of( @@ -220,7 +234,7 @@ public Map getParameters() { "type", "string", "description", - "The unique identifier of the" + " skill.", + "The unique identifier of the skill.", "enum", availableSkillIds), "path", @@ -255,7 +269,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()); @@ -273,15 +292,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 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 + * 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)) { @@ -317,7 +343,7 @@ String loadSkillResourceImpl(String skillId, String path, ToolContextState tcs) /** * Build response for SKILL.md content. * - * @param skillId The skill ID + * @param skillId The skill identifier (echoed as supplied) * @param skill The skill instance * @return Formatted skill markdown response */ @@ -326,7 +352,10 @@ private String buildSkillMarkdownResponse(String skillId, AgentSkill skill) { 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()); @@ -337,7 +366,7 @@ private String buildSkillMarkdownResponse(String skillId, AgentSkill skill) { /** * Build response for regular resource content. * - * @param skillId The skill ID + * @param skillId The skill identifier (echoed as supplied) * @param path The resource path * @param resourceContent The resource content * @return Formatted resource response @@ -356,7 +385,7 @@ 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 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 @@ -390,27 +419,47 @@ private String buildResourceNotFoundMessage( return message.toString(); } + /** + * 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. * - * @param skillId The unique identifier 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 skillId) { - if (!skillRegistry.exists(skillId)) { - throw new IllegalArgumentException( - String.format("Skill not found: '%s'. Please check the skill ID.", skillId)); - } - // Get skill + // Exact skillId lookup; a bare name is itself the source-less id form. AgentSkill skill = skillRegistry.getSkill(skillId); if (skill == null) { - throw new IllegalStateException( - String.format( - "Failed to load skill '%s' after validation. This is an internal" - + " error.", - skillId)); + throw new IllegalArgumentException( + String.format("Skill not found: '%s'. Please check the skill ID.", skillId)); } return skill; } @@ -429,7 +478,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( @@ -439,17 +488,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 = skillId + "_skill_tools"; + 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/skill/util/SkillUtil.java b/agentscope-core/src/main/java/io/agentscope/core/skill/util/SkillUtil.java index 0a39c68320..f12e567906 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 @@ -55,7 +55,6 @@ public class SkillUtil { private static final String SKILL_FILE_NAME = "SKILL.md"; - private static final String DEFAULT_SOURCE = "custom"; /** * Maximum length of a skill name, following the Agent Skills ecosystem standard. @@ -64,10 +63,12 @@ public class SkillUtil { /** * Pattern a skill name must match: starts with a lowercase letter or digit, then lowercase - * letters, digits and dashes only. Underscores are excluded because they collide with the - * {@code name_source} skill-id separator. + * letters, digits, dashes and underscores. Underscores are safe despite the skill-id + * separator because the separator is the reserved {@link + * io.agentscope.core.skill.AgentSkill#SKILL_ID_SEPARATOR "__s__"} substring, which + * {@code AgentSkill} construction rejects inside both the name and the source. */ - public static final Pattern VALID_SKILL_NAME_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]*$"); + public static final Pattern VALID_SKILL_NAME_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9_-]*$"); /** * Private constructor to prevent instantiation. @@ -131,7 +132,7 @@ public static Optional resolveContainedPath(Path baseDir, String relativeS * 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) @@ -139,7 +140,7 @@ public static Optional resolveContainedPath(Path baseDir, String relativeS * @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); } /** @@ -150,7 +151,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 */ @@ -191,7 +192,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); } /** @@ -206,7 +207,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); } /** @@ -223,7 +224,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); } /** @@ -237,7 +238,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); } /** @@ -247,7 +248,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 @@ -263,7 +264,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 @@ -286,7 +287,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 @@ -302,7 +303,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 @@ -329,7 +330,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 @@ -345,7 +346,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/main/java/io/agentscope/core/tool/AgentTool.java b/agentscope-core/src/main/java/io/agentscope/core/tool/AgentTool.java index 48f4e1f574..3e312ca179 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 @@ -145,6 +145,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 manages its own timeout, bypassing the ordinary tool execution timeout. * Parent cancellation still propagates to the tool. 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 9c25028a17..41cc7546d2 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.success(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); @@ -155,6 +189,22 @@ private String resetEquippedToolsImpl(List toActivate, ToolContextState throw new IllegalArgumentException( "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 f6066a8d97..78431a1a4d 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 @@ -146,7 +146,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..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 @@ -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 skillName + + * "_skill_tools"}. + * + * @param skillName the skill name (the skill's business identity) + * @return the derived tool-group name + */ + public static String skillToolsGroupName(String skillName) { + return skillName + "_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 : ""; @@ -82,15 +94,16 @@ public String getDescription() { @Override public ToolGroup copy() { - SkillToolGroup copy = - SkillToolGroup.skillBuilder() - .name(getName()) - .description(rawDescription) - .active(isActive()) - .activateOnSkill(activateOnSkill) - .tools(getTools()) - .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(); } /** @@ -110,6 +123,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 +180,21 @@ public Builder tools(Set tools) { return this; } + /** + * 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's enumeration/activation 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 81f34c01f1..0f9a71e90e 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 @@ -153,6 +153,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 4f588f29e1..2161a698de 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 @@ -70,6 +70,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; @@ -90,7 +93,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); } @@ -124,6 +128,7 @@ protected ToolBase( mcpName, externalTool, stateInjected, + false, false); } @@ -138,7 +143,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.title = title; @@ -150,6 +156,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"); } @@ -184,6 +191,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 @@ -332,6 +350,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; @@ -391,6 +410,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 75ab46a7bd..11dd909f5d 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; @@ -201,6 +202,41 @@ private Mono executeUnobserved( () -> executeCore(param, requestConfig, executionCallback)); } + /** + * 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. * @@ -228,11 +264,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..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,6 +47,13 @@ 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. Group-level only: + * exempts the group's enumeration and activation; member tools are governed by their own + * {@code system} markers. + */ + private final boolean system; + /** * Protected constructor for subclass use. * @@ -62,15 +69,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 +155,19 @@ public ToolGroupScope getScope() { return scope; } + /** + * Whether this group is a system capability exempt from grant-layer (per-user capability) + * 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 for enumeration and + * activation; {@code false} by default + */ + public boolean isSystem() { + return system; + } + /** * Gets a defensive copy of the tools in this group. * @@ -167,6 +217,7 @@ public ToolGroup copy() { .active(active) .scope(scope) .tools(tools) + .system(system) .build(); } @@ -187,6 +238,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 +296,21 @@ public Builder tools(Set tools) { return this; } + /** + * Marks the group as a system capability exempt from grant-layer (per-user capability) + * 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's enumeration/activation 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 364fc7d58a..7e1b3d293b 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)); } @@ -567,6 +586,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 11fcb49215..aa061dba45 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; @@ -35,6 +36,7 @@ import java.util.Objects; 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; @@ -274,6 +276,13 @@ private void registerAgentTool( } }); + // 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, @@ -410,7 +419,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 */ @@ -435,49 +444,208 @@ 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, config, 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()) + .deferLoading(tool.getDeferLoading()) + .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). 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 backend meta tool's enum and description rewritten + */ + private List rewriteMetaToolSchema( + 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(); + } - List schemas = new ArrayList<>(); - if (!hideBackend) { - for (ToolSchema schema : schemaProvider.getToolSchemas(activeGroups)) { - if (!requestConfig.overrides(schema.getName())) { - schemas.add(schema); + /** 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()) + .deferLoading(schema.getDeferLoading()) + .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)); + } + + /** + * 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()) - .deferLoading(tool.getDeferLoading()) - .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 schemas; + 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 value; } /** @@ -580,20 +748,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);
      * }
@@ -637,10 +804,14 @@ private ExecutionConfig resolveToolExecutionConfig(ExecutionConfig perCallConfig * 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( @@ -979,6 +1150,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 ==================== /** @@ -1029,6 +1211,13 @@ public void updateToolPresetParameters( * callbacks are retained. Shared user tools must support concurrent calls. Use * {@link ToolRequestConfig} for per-call visibility instead of copying the toolkit. * + *

Per-call tool-surface variation is instead expressed via {@link ToolRequestConfig} + * composition ({@link #getTool(String, ToolRequestConfig)} / {@link + * #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. + * * @return an isolated toolkit sharing the registered tool instances */ public Toolkit copy() { 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..28ababef6e --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilityGrantIntegrationTest.java @@ -0,0 +1,1305 @@ +/* + * 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.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; +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.SkillToolGroup; +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 skillName {@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 on the call-scoped rc") + void entrySeedsAllowAllWithoutResolverOrNullReturn() { + RecordingMiddleware recorder1 = new RecordingMiddleware(); + ReActAgent plain = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .middleware(recorder1) + .build(); + plain.call(userMsg("hi"), rc()).block(Duration.ofSeconds(10)); + assertSame(CapabilitySurface.ALLOW_ALL, recorder1.seen.get()); + + RecordingMiddleware recorder2 = new RecordingMiddleware(); + ReActAgent nullResolver = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .capabilityResolver((ctx, a) -> null) + .middleware(recorder2) + .build(); + nullResolver.call(userMsg("hi"), rc()).block(Duration.ofSeconds(10)); + assertSame(CapabilitySurface.ALLOW_ALL, recorder2.seen.get()); + } + + @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()); + RecordingMiddleware recorder = new RecordingMiddleware(); + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(new MockModel("done")) + .capabilityResolver((ctx, a) -> granted) + .middleware(recorder) + .build(); + agent.call(userMsg("hi"), rc()).block(Duration.ofSeconds(10)); + assertSame(granted, recorder.seen.get()); + } + + @Test + @DisplayName("skillBox + capabilityResolver → resolver disabled, behavior equals unconfigured") + void staticSkillBoxGuardDisablesResolver() { + Toolkit toolkit = new Toolkit(); + SkillBox skillBox = new SkillBox(toolkit); + skillBox.registerSkill(skill("boxed")); + RecordingMiddleware recorder = new RecordingMiddleware(); + 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())) + .middleware(recorder) + .build(); + RuntimeContext ctx = rc(); + agent.call(userMsg("hi"), ctx).block(Duration.ofSeconds(10)); + // Guard fired: resolver disabled, ALLOW_ALL fallback (prompt catalog stays full). + // Observed on the call-scoped rc — the caller's rc stays isolated. + assertSame(CapabilitySurface.ALLOW_ALL, recorder.seen.get()); + } + + @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(); + for (Future f : futures) { + f.get(30, TimeUnit.SECONDS); + } + + // The middleware saw the matching surface mid-call (the caller rcs stay isolated). + assertTrue(seenByUser.get("u1").allowsToolGroup("g1")); + assertFalse(seenByUser.get("u1").allowsToolGroup("g2")); + assertTrue(seenByUser.get("u2").allowsToolGroup("g2")); + assertFalse(seenByUser.get("u2").allowsToolGroup("g1")); + } 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()); + } + + @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() + .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)))); + + String prompt = + mw.onSystemPrompt( + null, + rc(new CapabilitySurface(Set.of("alpha"), 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("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)))); + + // 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, + rc(new CapabilitySurface(Set.of("alpha__s__market"), null, null)), + "base") + .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 = + 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"))))); + 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() { + DynamicSkillMiddleware mw = + new DynamicSkillMiddleware(List.of(new StubRepo(List.of(skill("alpha"))))); + 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"))))); + + 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"), 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")); + assertFalse(box1.getAllSkillIds().contains("beta")); + assertTrue(box2.getAllSkillIds().contains("beta")); + assertFalse(box2.getAllSkillIds().contains("alpha")); + } + + // ==================== 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(); + + assertTrue(result.isSuspended(), "external tool reaches the suspended short-circuit"); + } + + 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"))))); + mw.registerSkillTools(toolkit); + 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", ctx); + assertEquals( + "Error: Skill not found: 'beta'. Please check the skill ID.", + firstText(denied), + "deny wording must be byte-identical to the not-found wording"); + + ToolResultBlock allowed = loadViaToolkit(toolkit, "alpha", ctx); + assertTrue(firstText(allowed).contains("Successfully loaded skill: alpha")); + } + + @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)))); + mw.registerSkillTools(toolkit); + RuntimeContext ctx = rc(new CapabilitySurface(Set.of(), null, null)); + mw.onSystemPrompt(null, ctx, "base").block(); + + ToolResultBlock result = loadViaToolkit(toolkit, "gamma", ctx); + assertTrue(firstText(result).contains("Successfully loaded skill: gamma")); + } + + @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"), null, null)); + ToolResultBlock denied = + callDirect(loadTool, Map.of("skillId", "secret", "path", "SKILL.md"), ctx); + assertEquals( + "Error: Skill not found: 'secret'. Please check the skill ID.", firstText(denied)); + + ToolResultBlock allowed = + callDirect(loadTool, Map.of("skillId", "alpha", "path", "SKILL.md"), ctx); + assertTrue(firstText(allowed).contains("Successfully loaded skill: alpha")); + } + + @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", "path", "SKILL.md"), rc()); + assertTrue( + 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 skillName, RuntimeContext ctx) { + return toolkit.callTool( + param( + "load_skill_through_path", + Map.of("skillId", skillName, "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); + } + + // ==================== 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( + 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..43676d6a66 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/CapabilitySurfaceTest.java @@ -0,0 +1,350 @@ +/* + * 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" → 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"), 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.getName())); + + // Null guard: a registry miss is never granted, not even on ALLOW_ALL. + AgentSkill registryMiss = null; + 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() { + 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 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()); + // 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"))); + } + + // ==================== 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/AgentSkillPromptProviderTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/AgentSkillPromptProviderTest.java index 43c2829175..c38cee2850 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); @@ -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 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 7995343a77..5565e9386b 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; @@ -38,15 +39,7 @@ void testRejectsNonStandardNames() { // Arrange / Act / Assert: every name below violates the standard naming rule and must // fail construction with the same exception the contract already uses for missing fields. for (String invalid : - new String[] { - "技能A", - "Uppercase", - "with_underscore", - "dot.name", - "/tmp/evil", - "..", - "with space" - }) { + new String[] {"技能A", "Uppercase", "dot.name", "/tmp/evil", "..", "with space"}) { assertThrows( IllegalArgumentException.class, () -> new AgentSkill(invalid, "desc", "content", null), @@ -68,7 +61,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()); @@ -120,7 +114,29 @@ 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()); + 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 @@ -131,9 +147,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 @@ -191,13 +213,14 @@ void testConstructorValidatesContent() { } @Test - @DisplayName("Should skill id uniqueness") - void testSkillIdUniqueness() { - 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()); + @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(skill1.getName(), skill2.getName()); + assertEquals("source1", skill1.getSource()); + assertEquals("source2", skill2.getSource()); } @Test 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 875a2ebc93..a7dc106eb8 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 @@ -24,9 +24,11 @@ import io.agentscope.core.ReActAgent; import io.agentscope.core.agent.Agent; +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; @@ -112,10 +114,20 @@ private DynamicSkillMiddleware middleware(List skills) { return new DynamicSkillMiddleware(List.of(new StubRepo(skills))); } + /** + * 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(agent, rc, "base").block(); @@ -127,7 +139,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(agent, rc, "base").block(); @@ -141,7 +153,7 @@ void reusedRuntimeContextClearsStaleBox() { AgentSkill skill = new AgentSkill("my-skill", "My Skill", "# Content", null); StubRepo repo = new StubRepo(List.of(skill)); DynamicSkillMiddleware mw = new DynamicSkillMiddleware(List.of(repo)); - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); mw.onSystemPrompt(agent, rc, "base").block(); assertNotNull(rc.get(SkillBox.class)); @@ -163,8 +175,8 @@ void alternatingContentKeepsMaterializedResourcesIsolated() throws Exception { "shared", "Shared", "instructions", java.util.Map.of("data.txt", "B")); StubRepo repo = new StubRepo(List.of(a)); DynamicSkillMiddleware mw = new DynamicSkillMiddleware(List.of(repo), true); - RuntimeContext rcA = RuntimeContext.empty(); - RuntimeContext rcB = RuntimeContext.empty(); + RuntimeContext rcA = grantRc(); + RuntimeContext rcB = grantRc(); mw.onSystemPrompt(agent, rcA, "").block(); SkillBox boxA = rcA.get(SkillBox.class); String id = boxA.getAllSkillIds().iterator().next(); @@ -212,14 +224,14 @@ protected List filterVisible( futures.add( pool.submit( () -> { - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); mw.onSystemPrompt(agent, rc, "").block(); SkillBox box = rc.get(SkillBox.class); assertEquals( "complete resource", java.nio.file.Files.readString( box.getUploadDir() - .resolve(skill.getSkillId()) + .resolve(skill.getName()) .resolve("data.txt"))); return box; })); @@ -244,13 +256,13 @@ void evictionDoesNotRewriteResourcesOfAnActiveBox() throws Exception { java.util.Map.of("data.txt", "original")); StubRepo repo = new StubRepo(List.of(skill)); DynamicSkillMiddleware mw = new DynamicSkillMiddleware(List.of(repo)); - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); mw.onSystemPrompt(agent, 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(agent, RuntimeContext.empty(), "").block(); + mw.onSystemPrompt(agent, grantRc(), "").block(); } // An executing skill may have modified its workspace; rebuilding must leave it alone. java.nio.file.Files.writeString(activeFile, "in use"); @@ -264,17 +276,17 @@ void evictionDoesNotRewriteResourcesOfAnActiveBox() throws Exception { java.nio.file.Files.readString( rc.get(SkillBox.class) .getUploadDir() - .resolve(skill.getSkillId()) + .resolve(skill.getName()) .resolve("data.txt"))); } @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)) @@ -434,11 +446,11 @@ void runtimeOverlayComposesOverAgentBaseline() { org.mockito.Mockito.when(agent.getSkillFilter()) .thenReturn(SkillFilter.only("alpha", "beta")); - String baseline = mw.onSystemPrompt(agent, RuntimeContext.empty(), "").block(); + String baseline = mw.onSystemPrompt(agent, grantRc(), "").block(); assertTrue(baseline.contains("alpha"), "the agent baseline must show alpha"); assertTrue(baseline.contains("beta"), "the agent baseline must show beta"); - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); rc.put(SkillFilter.class, SkillFilter.disable("alpha")); String overlayed = mw.onSystemPrompt(agent, rc, "").block(); assertFalse(overlayed.contains("alpha"), "the runtime overlay must disable alpha"); @@ -460,8 +472,8 @@ void baselineResolvesFromEachInvokingAgent() { org.mockito.Mockito.when(gammaOnly.getSkillFilter()).thenReturn(SkillFilter.only("gamma")); org.mockito.Mockito.when(gammaOnly.getSkillWorkDir()).thenReturn(workDir); - String forAlpha = mw.onSystemPrompt(alphaOnly, RuntimeContext.empty(), "").block(); - String forGamma = mw.onSystemPrompt(gammaOnly, RuntimeContext.empty(), "").block(); + String forAlpha = mw.onSystemPrompt(alphaOnly, grantRc(), "").block(); + String forGamma = mw.onSystemPrompt(gammaOnly, grantRc(), "").block(); assertTrue(forAlpha.contains("alpha"), "agent A's baseline must apply to its pass"); assertFalse(forAlpha.contains("gamma"), "agent A's baseline must exclude gamma"); @@ -476,7 +488,7 @@ void baselineDefaultsToAllSkillsWhenAgentExposesNone() { DynamicSkillMiddleware mw = new DynamicSkillMiddleware(List.of(new StubRepo(List.of(alpha, gamma)))); - String prompt = mw.onSystemPrompt(agent, RuntimeContext.empty(), "").block(); + String prompt = mw.onSystemPrompt(agent, grantRc(), "").block(); assertTrue(prompt.contains("alpha"), "no agent baseline must default to all-allowed"); assertTrue(prompt.contains("gamma"), "no agent baseline must default to all-allowed"); @@ -491,7 +503,7 @@ void boxMaterialisesUnderSystemTempWhenAgentProvidesNoWorkDir() throws Exception DynamicSkillMiddleware mw = new DynamicSkillMiddleware(List.of(new StubRepo(List.of(skill)))); - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); mw.onSystemPrompt(bare, rc, "").block(); SkillBox box = rc.get(SkillBox.class); @@ -508,10 +520,11 @@ void boxMaterialisesUnderSystemTempWhenAgentProvidesNoWorkDir() throws Exception * the pre-refactor implementation (which copied the whole resource map per key). The refactor * to per-key {@code AgentSkill#getResource(String)} access must keep the hashed bytes — and * therefore this constant — bit-for-bit identical. {@code originDir} stays empty because - * {@code Path#toString()} is platform-dependent. + * {@code Path#toString()} is platform-dependent. Captured with sourceless 4-arg-constructor + * skills (a {@code null} source hashes as zero bytes). */ private static final String GOLDEN_SIGNATURE = - "12cf4fa8d7ce1d654ed2e5625b41b5e990bf5658db1c2deb7548413ffea1b7e3"; + "31a1141d42de16b3d2ac538c2181d886404c68fc85a054eb1eba015b1eaad9a1"; @Test void signatureValueUnchangedAfterPerKeyResourceAccess() throws Exception { @@ -554,7 +567,7 @@ void runsReloadOnBoundedElasticWhenCacheMisses() { }); DynamicSkillMiddleware mw = new DynamicSkillMiddleware(List.of(repo)); - StepVerifier.create(mw.onSystemPrompt(null, RuntimeContext.empty(), "base")) + StepVerifier.create(mw.onSystemPrompt(null, grantRc(), "base")) .assertNext(prompt -> assertTrue(prompt.contains("my-skill"))) .verifyComplete(); @@ -570,7 +583,7 @@ void keepsCacheHitPathBehaviorUnchanged() { AgentSkillRepository repo = org.mockito.Mockito.mock(AgentSkillRepository.class); org.mockito.Mockito.when(repo.getAllSkills()).thenReturn(List.of(skill)); DynamicSkillMiddleware mw = new DynamicSkillMiddleware(List.of(repo)); - RuntimeContext rc = RuntimeContext.empty(); + RuntimeContext rc = grantRc(); String first = mw.onSystemPrompt(null, rc, "base").block(); SkillBox firstBox = rc.get(SkillBox.class); diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxFileLockTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxFileLockTest.java index adbd599046..e6a8aac5f4 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxFileLockTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillBoxFileLockTest.java @@ -70,7 +70,7 @@ void concurrentUploadsToSamePathSerializeWithoutInterleaving() throws Exception int payloadSize = 64 * 1024; String payloadA = "A".repeat(payloadSize); String payloadB = "B".repeat(payloadSize); - Path target = workDir.resolve("skills").resolve("concurrent_custom").resolve("data.txt"); + Path target = workDir.resolve("skills").resolve("concurrent").resolve("data.txt"); SkillBox boxA = boxUploading("concurrent", payloadA); SkillBox boxB = boxUploading("concurrent", payloadB); 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 5e18296a6d..5a4c070174 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 @@ -75,7 +75,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()); @@ -94,11 +94,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 @@ -109,7 +109,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 @@ -126,7 +126,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"); @@ -168,7 +168,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"); } @@ -186,7 +186,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.getAllSkillIds(); - 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.getAllSkillIds(); - 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"); } } @@ -301,7 +302,7 @@ void testMaterializesValidResourcesUnderSkillIdAndBlocksEscapingOnes() throws IO // Valid resources land under skills// with their content intact; the // escaping resource is the only thing left out of the tree. - Path skillDir = tempDir.resolve("skills").resolve("safe_custom"); + Path skillDir = tempDir.resolve("skills").resolve("safe"); assertEquals("fine", Files.readString(skillDir.resolve("ok.txt"))); assertEquals( "echo hi", Files.readString(skillDir.resolve("scripts").resolve("run.sh"))); 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 3dc218b357..e1fa8b5738 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 @@ -143,10 +159,10 @@ void testLoadSkillResourceToolParameters() { assertTrue(required.contains("skillId")); 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("skillId"); + assertNotNull(skillNameParam.get("enum")); @SuppressWarnings("unchecked") Map pathParam = (Map) properties.get("path"); @@ -160,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("skillId", "test-skill", "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-001") @@ -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); @@ -184,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 = @@ -193,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("skillId", skillName, "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-002") @@ -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); @@ -289,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("skillId", "test-skill", "path", "config.json"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-006") @@ -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); @@ -313,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 = @@ -322,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("skillId", skillName, "path", "data.txt"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-007") @@ -333,7 +369,7 @@ void testActivateSkillAndToolGroupWhenLoadingResource() { ToolCallParam.builder() .toolUseBlock(toolUseBlock) .input(input) - .runtimeContext(runtimeContext) + .runtimeContext(withGrant(runtimeContext)) .build(); tool.callAsync(param).block(TIMEOUT); @@ -350,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("skillId", skillName, "path", "SKILL.md"); ToolCallParam param = ToolCallParam.builder() .toolUseBlock( @@ -381,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(); @@ -395,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( @@ -408,14 +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("skillId", 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()) @@ -436,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(); @@ -449,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; })); } @@ -476,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("skillId", "test-skill", "path", "non_existent.txt"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-008") @@ -485,7 +521,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); @@ -504,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("skillId", "test-skill"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-009") @@ -512,7 +552,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); @@ -527,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("skillId", "test-skill", "path", "list"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-010") @@ -535,7 +579,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); @@ -552,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("skillId", "empty-skill", "path", "invalid"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-011") @@ -560,7 +608,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 +636,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); @@ -597,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("skillId", "test-skill", "path", " "); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-013") @@ -605,7 +661,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); @@ -620,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("skillId", skillName, "path", "SKILL.md"); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-015") @@ -632,7 +692,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); @@ -640,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("skillId", skillName, "path", "."); ToolUseBlock toolUseBlock = ToolUseBlock.builder() .id("test-call-016") @@ -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/SkillRegistryTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRegistryTest.java index 59bc52cfb2..d1caa6f92b 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 @@ -50,35 +50,60 @@ 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 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(v2, registry.getSkill("test")); + } - assertTrue(registry.exists("test_custom")); - assertEquals(skill2, registry.getSkill("test_custom")); + @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 - @DisplayName("Should keep ids unambiguous by rejecting underscore names at construction") - void testUnderscoreNamesAreRejectedSoIdsSplitDeterministically() { - // Names cannot contain the id separator "_", so "name_source" always splits at the - // first underscore and distinct (name, source) pairs cannot share an id. - assertThrows(IllegalArgumentException.class, () -> createSkill("a_b")); - assertThrows(IllegalArgumentException.class, () -> createSkill("b-c_d")); + @DisplayName("Should keep ids unambiguous via the reserved skillId separator") + void testSeparatorIsReservedSoIdsSplitDeterministically() { + // Names and sources may contain underscores but never the reserved "__s__" + // separator, so a qualified id always splits at it and distinct (name, source) + // pairs cannot share an id. + assertThrows(IllegalArgumentException.class, () -> createSkill("a__s__b")); AgentSkill a = new AgentSkill("a", "desc", "content", null, "b-c"); - assertEquals("a_b-c", a.getSkillId()); + assertEquals("a__s__b-c", a.getSkillId()); } @Test @@ -105,33 +130,33 @@ void testMismatchedIdOverwriteIsFlaggedButNormalReregistrationIsNot() { @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")); + assertTrue(skillIds.contains("test1")); + assertTrue(skillIds.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 36579658e3..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 @@ -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 @@ -89,12 +96,12 @@ 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 = - 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(); @@ -103,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("skillId", skill.getName(), "path", "SKILL.md"); first.getToolkit() .getTool("load_skill_through_path") .callAsync( @@ -124,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) @@ -155,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"); @@ -167,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("skillId", skillName); loadParams.put("path", "SKILL.md"); ToolCallParam callParam = @@ -241,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"; @@ -308,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 @@ -341,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("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 2d9c769f2b..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 @@ -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; @@ -68,14 +70,19 @@ 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("skillId", skill.getName(), "path", "scripts/run.sh"); ToolUseBlock useBlock = ToolUseBlock.builder() .id("fallback-1") .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); @@ -109,14 +116,19 @@ 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("skillId", skill.getName(), "path", "scripts/run.sh"); ToolUseBlock useBlock = ToolUseBlock.builder() .id("precedence-1") .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); @@ -149,15 +161,19 @@ void rejectsPathTraversal(@TempDir Path tempDir) throws IOException { box.registerSkillLoadTool(); AgentTool tool = toolkit.getTool("load_skill_through_path"); - Map input = - Map.of("skillId", skill.getSkillId(), "path", "../forbidden.txt"); + Map input = Map.of("skillId", skill.getName(), "path", "../forbidden.txt"); ToolUseBlock useBlock = ToolUseBlock.builder() .id("traversal-1") .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 +181,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/skill/SkillUtilTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillUtilTest.java index 46ee32c11e..d01e2aa170 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 @@ -20,6 +20,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.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -48,21 +49,22 @@ class SkillNameValidation { @Test @DisplayName("Should validate names against the standard naming rule") void testValidatesNamesAgainstTheStandardRule() { - // Accept: lowercase letters, digits, dashes; digit start; max length. + // Accept: lowercase letters, digits, dashes, underscores; digit start; max length. assertTrue(SkillUtil.isValidSkillName("a")); assertTrue(SkillUtil.isValidSkillName("data-analysis")); + assertTrue(SkillUtil.isValidSkillName("with_underscore")); assertTrue(SkillUtil.isValidSkillName("0start")); assertTrue(SkillUtil.isValidSkillName("a".repeat(SkillUtil.MAX_SKILL_NAME_LENGTH))); - // Reject: everything else, including underscore/dot (skill-id separator and path - // shapes), non-ASCII, uppercase, and overlong names. + // Reject: everything else, including dot/slash (path shapes), non-ASCII, + // uppercase, and overlong names. Underscores are valid; the reserved "__s__" + // separator is rejected by AgentSkill construction, not by this pattern. for (String invalid : new String[] { null, "", "Uppercase", "技能", - "with_underscore", "dot.name", "with space", "colon:name", @@ -78,7 +80,7 @@ void testValidatesNamesAgainstTheStandardRule() { @Test @DisplayName("Should throw with a descriptive message for invalid names") void testRequireValidSkillNameThrowsWithMessage() { - assertEquals("^[a-z0-9][a-z0-9-]*$", SkillUtil.VALID_SKILL_NAME_PATTERN.pattern()); + assertEquals("^[a-z0-9][a-z0-9_-]*$", SkillUtil.VALID_SKILL_NAME_PATTERN.pattern()); assertEquals(64, SkillUtil.MAX_SKILL_NAME_LENGTH); IllegalArgumentException e = @@ -86,7 +88,7 @@ void testRequireValidSkillNameThrowsWithMessage() { IllegalArgumentException.class, () -> SkillUtil.requireValidSkillName("Bad Name")); assertTrue(e.getMessage().contains("Bad Name")); - assertTrue(e.getMessage().contains("^[a-z0-9][a-z0-9-]*$")); + assertTrue(e.getMessage().contains("^[a-z0-9][a-z0-9_-]*$")); assertDoesNotThrow(() -> SkillUtil.requireValidSkillName("good-name")); } } @@ -114,7 +116,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()); } @@ -132,7 +134,7 @@ void testCreateFromMarkdownWithCustomSource() { assertEquals("github-skill", skill.getName()); assertEquals("github", skill.getSource()); - assertEquals("github-skill_github", skill.getSkillId()); + assertEquals("github-skill__s__github", skill.getSkillId()); } @Test @@ -153,7 +155,7 @@ void testCreateFromMarkdownWithNullSource() { AgentSkill skill = SkillUtil.createFrom(skillMd, null, null); - assertEquals("custom", skill.getSource()); + assertNull(skill.getSource()); } @Test 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 b6acd58973..7d3946f507 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; @@ -278,11 +280,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); @@ -304,11 +306,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 4e7e5b4dc4..68d38d7eb4 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; @@ -97,9 +98,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) { @@ -610,15 +615,19 @@ 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); assertEquals(ToolResultState.ERROR, result.getState()); 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..d4b9a359b9 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/SystemCapabilityMarkerTest.java @@ -0,0 +1,333 @@ +/* + * 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()); + + // Explicit manual wiring registers the shared runtime load tool as a system tool. + new DynamicSkillMiddleware(List.of(new EmptyRepo())).registerSkillTools(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_skill_tools", SkillToolGroup.skillToolsGroupName("report_gen")); + + 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 5be3206dcc..9de2a09654 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; @@ -55,6 +57,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(); @@ -83,7 +95,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"); @@ -137,7 +150,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"); @@ -185,7 +198,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"); @@ -221,7 +234,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"); @@ -263,7 +276,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"); @@ -312,7 +326,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"); @@ -369,7 +383,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"); @@ -449,7 +463,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"); @@ -506,6 +521,7 @@ public ToolResultBlock testOverride( ToolCallParam.builder() .toolUseBlock(toolCall) .input(explicitInput) + .runtimeContext(grantRc()) .build()) .block(TIMEOUT); @@ -540,7 +556,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"); @@ -599,7 +619,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"); @@ -675,7 +699,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 106db67820..1d506b419f 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 @@ -28,6 +28,7 @@ import io.agentscope.core.model.ToolSchema; import io.agentscope.core.observation.ActionObservation; import io.agentscope.core.observation.ActionObserver; +import io.agentscope.core.tool.test.GrantTestUtils; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -153,7 +154,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); @@ -188,7 +189,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); @@ -288,7 +289,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 cf54632c7f..e36bfeccb5 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 @@ -36,6 +36,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; @@ -503,13 +504,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), @@ -540,7 +551,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"); } @@ -565,7 +581,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)); } @@ -599,7 +620,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), @@ -608,7 +634,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), @@ -616,7 +647,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), @@ -624,7 +660,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), @@ -669,7 +710,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"); @@ -678,7 +724,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); @@ -745,7 +796,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"); @@ -789,7 +845,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 @@ -826,7 +887,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 @@ -841,7 +907,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"); } @@ -1103,7 +1174,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( @@ -1197,7 +1273,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"); @@ -1226,7 +1307,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"); @@ -1291,7 +1377,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/search/ToolSearchTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/search/ToolSearchTest.java index e36e558be4..bfc6ad294b 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/search/ToolSearchTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/search/ToolSearchTest.java @@ -99,7 +99,7 @@ private static List expose( ctx, new ModelCallInput( ctx.getAgentState().getContext(), - toolkit.getToolSchemas(List.of(), ctx.getToolRequestConfig()), + toolkit.getToolSchemas(List.of(), ctx.getToolRequestConfig(), null), options, model), input -> { @@ -314,7 +314,7 @@ void inactiveRequestsWithoutSearchHistoryAreNotRefreshed() { ctx, new ModelCallInput( inputMessages, - toolkit.getToolSchemas(List.of(), ctx.getToolRequestConfig()), + toolkit.getToolSchemas(List.of(), ctx.getToolRequestConfig(), null), null, model), input -> { @@ -406,7 +406,8 @@ void nativeHistoryDropsRemovedToolsAndRefreshesOverriddenSchemas() { ctx, new ModelCallInput( ctx.getAgentState().getContext(), - toolkit.getToolSchemas(List.of(), ctx.getToolRequestConfig()), + toolkit.getToolSchemas( + List.of(), ctx.getToolRequestConfig(), null), null, model), input -> { 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/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 981da7a2b3..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,19 +80,17 @@ void idlePeersAreEvictedAfterWindowPlusCooldown() throws InterruptedException { @Test void activePeersSurviveEvictionOfIdleOnes() throws InterruptedException { - // allow() piggybacks a sweep on the same windowMillis + cooldownMillis threshold that - // idle eviction uses, so a stall between the two allows can let that sweep evict the - // idle peer before the explicit evictIdlePeers() call. Each phase keeps a 400ms margin - // against a multi-hundred-ms runner stall: the first allow sits 600ms inside the 1000ms - // sweep threshold, the active peer is 600ms inside its idle threshold at both eviction - // points, and the explicit eviction only relies on Thread.sleep never returning early - // (1200ms slept vs the 1000ms threshold). - BotLoopGuard guard = new BotLoopGuard(5, 500L, 500L); // sweep/idle threshold: 1000ms + // 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(600); - assertTrue(guard.allow("active-peer")); - Thread.sleep(600); // idle-peer idle ~1200ms; active-peer idle ~600ms - 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()); } 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..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 @@ -248,7 +248,7 @@ public Mono onEvent(T event) { + targetSkillName + "] | " + provider.getProviderName() - + " — did not call load_skill_through_path(skillId='" + + " — did not call load_skill_through_path(skillName='" + 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 ac0948900d..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 @@ -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")) 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 ca8f984316..8c51a07296 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")); 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 f400bc286e..f970b77ced 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; @@ -160,7 +162,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; @@ -212,6 +213,10 @@ public Set activePoints() { 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. @@ -245,6 +250,7 @@ private HarnessAgent( SandboxContext defaultSandboxContext, CompactionMiddleware compactionHook, SandboxLifecycleMiddleware sandboxLifecycleMw, + CapabilityResolver capabilityResolver, List skillRepositories, PlanModeManager planModeManager, SkillPromoter skillPromoter, @@ -264,6 +270,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; @@ -774,7 +781,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)); } /** @@ -784,7 +791,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)); } /** @@ -793,7 +800,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) { @@ -814,19 +821,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)); } /** @@ -836,7 +843,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)); } /** @@ -847,7 +854,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)); } /** @@ -857,7 +865,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)); } /** @@ -883,7 +892,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)); } /** @@ -895,7 +904,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)); } /** @@ -906,7 +915,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)); } /** @@ -1000,7 +1009,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 @@ -1020,16 +1029,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); @@ -1049,18 +1059,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); @@ -1069,15 +1081,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); @@ -1085,6 +1098,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 @@ -1180,6 +1234,8 @@ private Mono forceCompactAndRetry(List msgs, RuntimeContext effective) + " result")); } List compacted = opt.get(); + // Re-prepare: the retry must carry the grant surface too (the + // wrapped resource phase is not re-entered on this path). return Mono.fromRunnable( () -> delegate.updateAgentState( @@ -1191,7 +1247,13 @@ private Mono forceCompactAndRetry(List msgs, RuntimeContext effective) .addAll(compacted); })) .subscribeOn(Schedulers.boundedElastic()) - .then(Mono.defer(() -> delegate.call(retryInput, ctx))); + .then( + Mono.defer( + () -> + delegate.call( + retryInput, + prepareCapabilitySurface( + ctx)))); }); } @@ -1407,6 +1469,7 @@ public Builder contextPolicy(ContextPolicy policy) { TaskRepository taskRepository; Object externalSubagentTool; Function modelResolver; + CapabilityResolver capabilityResolver; final List additionalContextFiles = new ArrayList<>(); int maxContextTokens = 8000; @@ -2277,6 +2340,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. @@ -3300,6 +3379,7 @@ && isLocalSession(effectiveSession)) { defaultSandboxContext, compactionHook, sandboxLifecycleMw, + capabilityResolver, orderedSkillRepos, planModeManager, pendingSkillPromoter, 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 34ff4bed50..96b1f0551b 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; @@ -266,7 +267,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)); } @@ -288,7 +290,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); registerSkillsMaterial(ctx, ""); @@ -370,6 +373,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()) { @@ -391,7 +415,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 { @@ -413,7 +437,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/middleware/SkillUsageMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/SkillUsageMiddleware.java index 56b43cdcde..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 @@ -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. */ 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..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 {@code skillId} (the AgentSkill's {@code name + "_" + source}), 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 { @@ -50,7 +52,12 @@ public static SkillCatalog of(List orderedEntries) { 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 c504799865..33b9464918 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; @@ -164,11 +165,10 @@ public Mono callAsync(ToolCallParam param) { SkillCatalog catalog = catalogFor(param); HarnessSkillEntry entry = catalog.get(skillId); if (entry == null) { - return Mono.just( - ToolResultBlock.error( - "Skill not found: '" - + skillId - + "'. Check the available skills list.")); + return Mono.just(ToolResultBlock.error(skillNotFound(skillId))); + } + if (!isSkillGranted(param, entry)) { + return Mono.just(ToolResultBlock.error(skillNotFound(skillId))); } return Mono.just(loadOne(entry, path)); @@ -178,6 +178,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): skillId='{}', userId='{}', sessionId='{}'", + entry.skill().getName(), + param.getRuntimeContext().getUserId(), + param.getRuntimeContext().getSessionId()); + return false; + } + + /** + * @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 skillId) { + return "Skill not found: '" + skillId + "'. Check the available skills list."; + } + private SkillCatalog catalogFor(ToolCallParam param) { if (param.getRuntimeContext() != null) { return SkillRuntime.catalogFor(param.getRuntimeContext()); @@ -264,7 +300,7 @@ private String formatResource(HarnessSkillEntry entry, String path, String conte String fence = contentFence(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()) { @@ -285,7 +321,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<>(); @@ -303,7 +339,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/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/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..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_workspace-namespaced", + "lazy-resource__s__workspace-namespaced", "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 7998c7d26b..c291f98753 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 @@ -94,10 +94,10 @@ void usesCallContextWhenSharedActiveContextPointsToAnotherSession() throws IOExc assertEquals(ContextItem.Placement.RUNTIME, skills.placement()); 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"))); } @Test 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..c66555e18a --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddlewareGrantTest.java @@ -0,0 +1,284 @@ +/* + * 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.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; +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 io.agentscope.harness.agent.context.WorkspaceContextMaterials; +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()); + } + + /** + * The rendered skills block as registered by {@code onSystemPrompt}: a tail runtime material + * (keyed {@code harness:skills}), not part of the system prompt. + */ + private static String skillsMaterial(RuntimeContext ctx) { + WorkspaceContextMaterials materials = ctx.get(WorkspaceContextMaterials.class); + assertNotNull(materials); + return materials.items().stream() + .filter(item -> "harness:skills".equals(item.sourceId())) + .findFirst() + .orElseThrow() + .content(); + } + + /** 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: skills material 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(); + + assertEquals("base", prompt, "system prompt stays untouched"); + String block = skillsMaterial(ctx); + assertTrue(block.contains("alpha"), "granted skill kept in the skills material"); + assertFalse(block.contains("beta"), "un-granted skill cut from the skills material"); + 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)); + + mw.onSystemPrompt(null, ctx, "base").block(); + + String block = skillsMaterial(ctx); + assertTrue(block.contains("gamma"), "system skill kept"); + assertFalse(block.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); + + mw.onSystemPrompt(null, ctx, "base").block(); + + String block = skillsMaterial(ctx); + assertTrue(block.contains("alpha") && block.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("skillId", 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/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) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/LazyFileSystemSkillLoadingTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/LazyFileSystemSkillLoadingTest.java index f92f975bbc..a3702306c6 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/LazyFileSystemSkillLoadingTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/LazyFileSystemSkillLoadingTest.java @@ -86,13 +86,13 @@ public Flux stream( .id("load-" + round) .name(SkillLoadTool.TOOL_NAME) .content( - "{\"skillId\":\"alpha_audit\",\"path\":\"" + "{\"skillId\":\"alpha__s__audit\",\"path\":\"" + path + "\"}") .input( Map.of( "skillId", - "alpha_audit", + "alpha__s__audit", "path", path)) .build())) @@ -143,7 +143,7 @@ void missingResourcesListDiskFilesAndRejectTraversal() throws IOException { "./")) { assertFalse(load(ctx, path).contains("OUTSIDE_SENTINEL"), path); } - assertNull(middleware.runtime().currentCatalog(ctx).get("alpha_audit").filesRoot()); + assertNull(middleware.runtime().currentCatalog(ctx).get("alpha__s__audit").filesRoot()); } @Test @@ -160,7 +160,7 @@ void binaryAccessorPreservesLiteralBase64Prefix() throws IOException { middleware .runtime() .currentCatalog(ctx) - .get("alpha_audit") + .get("alpha__s__audit") .lazyResources() .readBinary("literal.txt") .orElseThrow()); @@ -224,7 +224,7 @@ private static String toolText(List messages) { } private static String load(RuntimeContext ctx, String path) { - Map input = Map.of("skillId", "alpha_audit", "path", path); + Map input = Map.of("skillId", "alpha__s__audit", "path", path); ToolUseBlock use = ToolUseBlock.builder() .id("resource") 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 295b6d0b2f..8fb9e90930 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 @@ -73,7 +73,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__s__src", "beta__s__src", "gamma__s__src"), cat.ids()); } @Test @@ -82,7 +82,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__s__src").skill()); } } @@ -164,7 +164,7 @@ void emptyCatalogReturnsEmptyString() { void rendersSkillIdAndOmitsFilesRootWhenAbsent() { 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__s__wkspace")); // No actual ... element rendered. // (The header text mentions "" descriptively without a closing tag.) assertFalse(out.contains("")); @@ -195,7 +195,7 @@ void mixedCatalogKeepsInlineSkillWithoutPromisingItAResourceRoot() { String out = new SkillPromptBuilder().render(SkillCatalog.of(List.of(inline, scripted))); - assertTrue(out.contains("inline_workspace")); + assertTrue(out.contains("inline__s__workspace")); assertTrue(out.contains("/workspace/skills/scripted")); assertFalse(out.contains("/workspace/skills/inline")); assertEquals(1, out.split("", -1).length - 1); @@ -248,8 +248,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__s__src")); + assertFalse(out.contains("hidden__s__src")); } @Test @@ -262,7 +262,7 @@ void filterRejectingAllReturnsEmpty() { } @Test - void filterWithBareNameMatchesCompositeId() { + void filterBySkillNameMatchesEntry() { HarnessSkillEntry visible = HarnessSkillEntry.of( skill("host-forensics-client", "filesystem-agentscope_skills"), null); @@ -276,9 +276,10 @@ void filterWithBareNameMatchesCompositeId() { String out = new SkillPromptBuilder().render(cat, only); assertTrue( out.contains( - "host-forensics-client_filesystem-agentscope_skills")); + "host-forensics-client__s__filesystem-agentscope_skills")); assertFalse( - out.contains("other-skill_filesystem-agentscope_skills")); + out.contains( + "other-skill__s__filesystem-agentscope_skills")); } @Test @@ -301,7 +302,7 @@ void defaultRendersSlimViewWithinByteBudget() { // Slim view: name/description/skill-id/files-root only, no frontmatter extras. assertTrue(out.contains("data-analysis")); assertTrue(out.contains("Analyze CSV datasets")); - assertTrue(out.contains("data-analysis_workspace")); + assertTrue(out.contains("data-analysis__s__workspace")); assertFalse(out.contains("")); assertFalse(out.contains("")); assertFalse(out.contains("")); @@ -349,9 +350,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__s__workspace", "SKILL.md"); String text = textOf(res); - assertTrue(text.contains("Successfully loaded skill: alpha_workspace")); + assertTrue(text.contains("Successfully loaded skill: alpha__s__workspace")); assertTrue(text.contains("# Body line")); assertFalse(text.contains("Files root:")); } @@ -367,7 +368,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__s__workspace", "scripts/run.py")); assertTrue(text.contains("in-memory-body")); assertFalse(text.contains("lazy-body")); } @@ -381,7 +382,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__s__workspace", "references/guide.md")); assertTrue(text.contains("from-fs")); } @@ -395,7 +396,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__s__workspace", "does-not-exist")); assertTrue(err.contains("Resource not found")); assertTrue(err.contains("SKILL.md")); assertTrue(err.contains("references/a.md")); @@ -429,11 +430,11 @@ void missingParametersReturnsError() { } 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("skillId", skillName, "path", path)) .build(); return r.loadTool().callAsync(p).block(); } @@ -475,18 +476,20 @@ 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__s__src"), r.currentCatalog(firstCtx).ids()); + assertEquals(List.of("second__s__src"), r.currentCatalog(secondCtx).ids()); assertTrue( - textOf(invoke(r, firstCtx, "first_src", "SKILL.md")) + textOf(invoke(r, firstCtx, "first__s__src", "SKILL.md")) .contains("Successfully loaded skill")); assertTrue( - errorOf(invoke(r, firstCtx, "second_src", "SKILL.md")).contains("not found")); + errorOf(invoke(r, firstCtx, "second__s__src", "SKILL.md")) + .contains("not found")); assertTrue( - textOf(invoke(r, secondCtx, "second_src", "SKILL.md")) + textOf(invoke(r, secondCtx, "second__s__src", "SKILL.md")) .contains("Successfully loaded skill")); assertTrue( - errorOf(invoke(r, secondCtx, "first_src", "SKILL.md")).contains("not found")); + 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)); @@ -511,25 +514,25 @@ void concurrentSessionsCannotLoadEachOthersSkills() throws Exception { Future first = executor.submit( () -> - textOf(invoke(r, firstCtx, "first_src", "SKILL.md")) + textOf(invoke(r, firstCtx, "first__s__src", "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__s__src", "SKILL.md")) .contains("Successfully loaded skill") && errorOf( invoke( r, secondCtx, - "first_src", + "first", "SKILL.md")) .contains("not found")); @@ -548,12 +551,12 @@ void deprecatedRuntimeApiRemainsFunctionalWithoutLeakingIntoScopedCalls() { r.install(SkillCatalog.of(List.of(legacy)), tk); - assertEquals(List.of("legacy_src"), r.currentCatalog().ids()); + assertEquals(List.of("legacy__s__src"), r.currentCatalog().ids()); assertTrue( - textOf(invoke(r, null, "legacy_src", "SKILL.md")) + textOf(invoke(r, null, "legacy__s__src", "SKILL.md")) .contains("Successfully loaded skill")); assertTrue( - errorOf(invoke(r, RuntimeContext.empty(), "legacy_src", "SKILL.md")) + errorOf(invoke(r, RuntimeContext.empty(), "legacy__s__src", "SKILL.md")) .contains("not found")); } @@ -563,7 +566,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("skillId", "legacy__s__src", "path", "SKILL.md"); ToolResultBlock contextFree = tool.callAsync(ToolCallParam.builder().input(input).build()).block(); @@ -580,12 +583,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("skillId", skillName, "path", path)) .build()) .block(); } diff --git a/docs/docs.json b/docs/docs.json index 18bc6904bf..3d0e6262a4 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", @@ -514,6 +515,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", 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..2168c44e48 --- /dev/null +++ b/docs/v2/en/docs/building-blocks/capability-grant.md @@ -0,0 +1,154 @@ +--- +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. 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: + +| 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 (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 +}; + +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). +`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 +(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"`, 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). + +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 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. + +## 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. + +**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. + +## 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 `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 7b4f6bad32..a138540910 100644 --- a/docs/v2/en/docs/building-blocks/tool.md +++ b/docs/v2/en/docs/building-blocks/tool.md @@ -501,7 +501,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. | +| `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: @@ -557,7 +557,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; @@ -570,7 +570,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 f5944a21a5..2558596f73 100644 --- a/docs/v2/en/docs/harness/skill.md +++ b/docs/v2/en/docs/harness/skill.md @@ -285,7 +285,7 @@ On each call, harness builds an `` block listing every skill c code-reviewer Use when the user asks for code review, style feedback, or PR audits. - code-reviewer_workspace-namespaced + code-reviewer__s__workspace-namespaced /workspace/skills/code-reviewer ... @@ -338,7 +338,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 new file mode 100644 index 0000000000..deb5a6fa90 --- /dev/null +++ b/docs/v2/zh/docs/building-blocks/capability-grant.md @@ -0,0 +1,102 @@ +--- +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` 加载、不可执行。Harness 的技能链路(`HarnessSkillMiddleware` 的提示词与技能目录、harness 版 `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(通常即 skill 名);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 直接复用父授权面(零重算)。`HarnessAgent.builder().capabilityResolver(...)` 同样支持:解析发生在沙箱 acquire 与 skill 暂存之前,暂存清单、提示词目录与各道门看到同一份授权面。 + +**缓存建议**: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"`,无 source 时 skillId 即 skill 名),**并自动放行经 `activateOnSkill` 绑定到该 skill 的自定义工具组**(call 入口把绑定组并入授权面); +- 授权一个 **ToolGroup** 即隐含授权组内全部工具; +- 逐名授权一个**工具**即授权该工具本身(不问分组,也不放大到组内其他工具)。 + +skill 以 **skillId** 授权与寻址:skillId 通常就是 skill 名(行业标准唯一定位),仅在同名 skill 来自不同仓库时取 `name + "__s__" + source` 组合形态精确到具体仓库(业务授权清单可以直接使用 skill 名,极端情况下使用组合形态)。目录以 skillId 为键:同名 skill 来自不同仓库时以组合形态共存,同 id 重复注册覆盖旧实例(版本更新),寻址按 skillId 精确匹配。 + +只提供白名单,不提供 deny(反向禁用);默认拒绝——不在授权清单内的能力即「不存在」。 + +## 系统能力标记(`system`) + +一般而言,标记 `system` 的能力是**保证 agent 正常运行的基本核心能力**(框架元工具即属此类);该标记不是业务形态的裁剪控制手段,不应拿它充当业务能力的开关。 + +框架自身的元工具(`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 行为 + +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 同样全量)。 diff --git a/docs/v2/zh/docs/building-blocks/tool.md b/docs/v2/zh/docs/building-blocks/tool.md index 464cf0ed5f..0f019c3da5 100644 --- a/docs/v2/zh/docs/building-blocks/tool.md +++ b/docs/v2/zh/docs/building-blocks/tool.md @@ -501,7 +501,7 @@ ReActAgent agent = | 参数 | 类型 | 说明 | | --- | --- | --- | -| `skillId` | `string`(枚举:已注册的 skill ID) | 要加载的 skill。 | +| `skillId` | `string`(枚举:已注册的 skillId,通常即 skill 名) | 要加载的 skill。 | | `path` | `string` | 传 `"SKILL.md"` 取该 skill 的 markdown 指令;或传 skill 声明过的精确资源路径,例如 `"references/guide.md"`、`"scripts/run.py"`。不要传 `"."`、`"./"`、目录或绝对路径。 | 调用示例: @@ -557,7 +557,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; @@ -570,7 +570,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 943cd76c48..26e5b9d4eb 100644 --- a/docs/v2/zh/docs/harness/skill.md +++ b/docs/v2/zh/docs/harness/skill.md @@ -285,7 +285,7 @@ agent 每次 call 时,harness 会构建一个 `` 块,列 code-reviewer 当用户需要代码评审、风格反馈或 PR 审核时使用。 - code-reviewer_workspace-namespaced + code-reviewer__s__workspace-namespaced /workspace/skills/code-reviewer ... @@ -338,7 +338,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