Skip to content

feat(core): per-user capability surface with grant trimming for tools, groups and skills - #3500

Open
xzxiaoshan wants to merge 19 commits into
agentscope-ai:mainfrom
xzxiaoshan:feat/capability-surface
Open

xzxiaoshan wants to merge 19 commits into
agentscope-ai:mainfrom
xzxiaoshan:feat/capability-surface

Conversation

@xzxiaoshan

@xzxiaoshan xzxiaoshan commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Resolves #3298 #1963 #3443

Summary

Multi-tenant agents currently expose the same tool/skill inventory to every user. This PR introduces a server-side capability surface: an immutable, per-call grant enumeration resolved from the caller identity and enforced at both what the model sees (schema/prompt trimming) and what it can execute (hard gates) — without mutating the shared toolkit.

/**
 * Example resolver: computes the granted skill / tool capability list
 * dynamically, based on the current logged-in user's role.
 */
public class RoleBasedCapabilityResolver implements CapabilityResolver {

    @Override
    public CapabilitySurface resolve(RuntimeContext ctx, Agent agent) {
        // Read the caller's userId from the framework's ctx (RuntimeContext)
        String userId = ctx.getUserId();

        // The project team loads the skill/tool capability list for the user's
        // role based on userId — e.g. from its own permission system / RBAC
        // service (fixed values below stand in for the lookup result)
        // Grants grants = permissionService.findGrantsByUserId(userId);

        // Wrap the list as CapabilitySurface(allowedSkills, allowedToolGroups, allowedTools)
        return new CapabilitySurface(
                Set.of("pdf_export_custom"),  // allowed skillId
                Set.of("search"),             // allowed ToolGroup
                Set.of("web_read"));          // allowed individual tool name
    }
}

// ===== Assembling the agent: new the resolver separately, then pass it as an object =====
CapabilityResolver resolver = new RoleBasedCapabilityResolver();

ReActAgent agent = ReActAgent.builder()
        .capabilityResolver(resolver)
        // ... other configuration (model, tools, skills, etc.)
        .build();

// resolver returning null → unrestricted (same as not configured); throwing → the call aborts

How it works

The grant layer is a funnel with a single decision source (CapabilitySurface query methods); gates never re-derive authorization logic.

  1. Declaration — CapabilityResolver (functional interface) computes pure whitelists from RuntimeContext (userId), optionally per agent. Resolved once at call() entry and carried on the rc for the whole chain; sub-agents inherit the parent surface without re-resolving (derived rc — no parent pollution). Concurrent calls with different users never cross surfaces.
  2. Tri-state surface — three dimensions (skills / tool groups / per-name tools). All-null = unrestricted; any dimension set (even to an empty set) turns the surface into a complete enumeration; default deny.
  3. Read points (model-visible):
    • Toolkit.getToolSchemas(groups, requestConfig, surface) — grant-filtered compose in a single pass; trimmed backend tools are treated as non-existent; external (request-config) tools stay caller-controlled; the reset_equipped_tools enum and description are rebuilt from activatable groups only, so cut-off group names and descriptions do not leak.
    • DynamicSkillMiddleware filters visible skills fail-closed (empty set on missing surface or filter error); the filtered list drives signature, box cache, prompt and loading alike.
  4. Hard gates (execution, orthogonal to activation checks):
    • tools: ToolExecutor.requireToolGranted (backend only)
    • skill loading: SkillToolFactory convergence point (allowsSkill(AgentSkill) — full decision incl. system bypass; the id-based overload remains the grant-set-only fallback)
    • group activation: resetEquippedToolsImpl
  5. System capabilities — isSystem() marking chain (@Tool annotation → ToolBase / ToolGroup / SkillToolGroup / AgentSkill builders, with copy/toBuilder retention) bypasses grant trimming; framework meta tools are marked; every system=true registration leaves an INFO audit trail.
  6. Missing surface = unrestricted — direct executions that never entered an agent call() are trusted assembler code (the agent funnel always writes a surface); the same stance applies at read points and gates.

Security properties

  • All model-facing denials are byte-identical to not-found wording (tools, skills, groups) — the model channel cannot distinguish "exists but ungranted" from "missing", and cannot relay "no permission" to the end user. The truthful cause is logged with userId/sessionId/agentId at each gate.
  • The tool grant gate runs before the activation gate, so ungranted probes never reach activation wording; the activation message only speaks for granted-but-inactive tools (the designed self-healing path).

API changes

  • Toolkit.getToolSchemas(Collection, ToolRequestConfig) is now package-private — it had no external consumers and is the natural entry for unfiltered model-visible synthesis; per-call compose now goes through the 3-arg overload where the surface decision is explicit in the signature (absent → no trimming). Visibility narrowing of a published API — flagged for review.
  • Legacy 0/1-arg schema getters stay public for management/enumeration planes.

Known residual exposure

  • Management/observation planes (aistio adapter, example REST controllers) intentionally export the full inventory — registration-time, not per-user.
  • The static skillBox() path (or dynamicSkillsEnabled(false)) has no per-call skill visibility filtering: configuring a resolver there logs a build-time ERROR and disables it for the whole agent — all top-level calls of such an agent are entirely untrimmed (tools and groups included, not just the skill prompt). This is a deliberate fail-open trade-off to keep existing single-process builds working; strict deployments should fail fast themselves (wrap the builder and assert the combination after build()). When the agent runs as a sub-agent under an inherited surface, loading and execution stay gated — only the prompt still enumerates the full catalog. Multi-tenant setups should use skillRepositories(...).
  • The per-call generate_response structured-output tool executes outside the executor (system-equivalent response mechanism).

⚠️ Breaking Changes

Toolkit.getToolSchemas(Collection<String>, ToolRequestConfig) is now package-private.

The 2-arg overload composes schemas without the capability grant layer. Keeping it public would
invite external callers into untrimmed model-facing synthesis — an avoidable authorization gap —
so it is intentionally narrowed; model-facing schema synthesis now goes through the 3-arg
overload where the surface decision is explicit in the signature.

Migration guide:

  • Model-facing synthesis: switch to getToolSchemas(activeGroups, requestConfig, surface),
    reading the surface from the RuntimeContext: rc.get(CapabilitySurface.class) (pass null
    on trusted direct paths with no agent context — equivalent to the old behavior).
  • Management/enumeration read-only uses: switch to getToolSchemas() or
    getToolSchemas(activeGroups) — behavior unchanged (these remain public).
  • Framework-internal callers (ReActAgent, the AG-UI adapter) are already migrated.

…ools, groups and skills

Introduce a server-side CapabilityResolver that resolves an immutable, per-call
CapabilitySurface from the caller identity. The surface trims what the model
sees (tool schemas, skill visibility, reset_equipped_tools enum/description)
and is enforced by hard execution gates for tools, skill loading and group
activation. System-marked capabilities bypass trimming; all model-facing
denials are byte-identical to not-found wording; a missing surface means a
trusted direct path (unrestricted).
- Builder.copy() now carries capabilityResolver (main's copy()-based build
  dropped it, silently disabling the grant layer)
- reset_equipped_tools keeps the RuntimeContext param while adopting
  ToolResultBlock.success from agentscope-ai#2751
- externalToolSchema and rewriteResetToolSchema carry the new deferLoading
  schema field
- grant tests observe the call-scoped rc (main isolates the caller rc) and
  assert the suspended external result
The 2-arg getToolSchemas is package-private since the grant layer landed;
pass a null surface (un-trimmed compose) for the EXTERNAL_ONLY assertion.
Unify the skill identifier vocabulary to skillName (the business identity,
unique per agent catalog) end to end; the composite name_source skillId is
now an internal storage detail:

- Grant surface: allowedSkills holds skill names; the skill tool-group
  naming convention derives from skillName ("_skill_tools"), so granting a
  skill reliably releases its tools without any id<->name translation
- SkillRegistry keys by name and enforces per-catalog uniqueness
  (re-registering the same instance is idempotent; a different instance
  under a used name is rejected)
- Loading: load_skill_through_path now takes a "skillName" parameter in
  both core and harness; prompts emit <skill-name>; not-found and denial
  wordings updated to match
- AgentSkill.getName() documents the industry-standard unique identity;
  getSkillId() is deprecated for removal
- Harness skill chain (catalog keying, load tool, prompt builder, usage
  middleware) aligned to the same vocabulary, closing the grant/addressing
  mismatch surfaced in review
- Synced docs/v2 pages (zh/en) and tests; full mvn test green
…urface

A SkillToolGroup bound to a granted skill (activateOnSkill) is now added to
allowedToolGroups once at call entry, so granting a skill implicitly grants
its custom-bound tool groups alongside the naming-convention group.

The expansion is a mechanical whitelist union performed while the entry
holds both the surface and the toolkit: the surface stays pure data, every
gate keeps consuming only the query methods, and groups bound to ungranted
skills stay denied. It is idempotent — the same instance is returned when
nothing is added, so sub-agents reusing an inherited surface keep reference
equality across toolkits with identical bindings.
skillId equals the skill name normally, extended to name__s__source only
when a source differentiates same-named skills. Grant entries accept both
forms (lazy failure on unknown sources); model-facing vocabulary back to
skillId; the "custom" source default is removed.
Registry, dynamic middleware and harness catalogs key by skillId: same-named
skills from different repositories coexist, the same skillId re-registration
replaces (version override), and lookups are exact (a bare name is the
source-less id form). SkillFilter matches the exact skillId; upload subdirs
and load echoes use skillId.
# Conflicts:
#	agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
main's bare-name SkillFilter tests asserted the old composite skillId
format (name_source, default source "custom") and failed after the
merge. Update the literals to the dual-form scheme (name when no
source, name__s__source when sourced) and give the disable-by-name
test an explicit source so it still exercises hiding a composite-id
skill. Test intent is unchanged: filters match the skill name.
Resolve the grant surface at the harness call/stream entry, before the
sandbox acquire, so marketplace skill staging materialises granted-only
files; the inner delegate keeps the surface without re-resolving and an
inherited surface is never overwritten in place. Also fix the stale
catalog-key sentence and document the activateOnSkill grant rule in
capability-grant.md (zh/en).
Conflict resolution in HarnessAgent#forceCompactAndRetry: keep main's
overflow-retry rework (agentscope-ai#3661 — persist compacted state via
updateAgentState, retry with retryInput to avoid duplicate input) and
re-apply the capability-surface re-prepare on the retry call so the
grant surface survives the overflow path.

Adapt HarnessSkillMiddlewareGrantTest to main's agentscope-ai#3691 change: the
skills block now rides a tail runtime material (harness:skills) instead
of the system prompt, so grant-visibility assertions move there.
@mintlify

mintlify Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Preview approval required

This pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit eef172e.

Approve preview

activePeersSurviveEvictionOfIdleOnes asserted evictIdlePeers() == 1, but
a >=100ms overshoot of the 900ms sleep let allow("active-peer") fire the
piggybacked sweep first (one full window+cooldown interval had elapsed),
evicting idle-peer before the explicit call and flipping the count to 0.

Restage so the sweep is the deterministic eviction path: idle-peer sits
past the threshold before active-peer's first event, so that allow()
always sweeps it and the explicit call finds nothing. Overshoots only
push the idle peer further past the threshold. Verified 5 consecutive
runs green.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant