Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8fe0e83
feat(core): add per-user capability surface with grant trimming for t…
xzxiaoshan Oct 11, 2026
b14182e
merge: main into feat/capability-surface
xzxiaoshan Oct 11, 2026
4b9fad8
docs: register capability-grant pages in v2 navigation (en+zh)
xzxiaoshan Oct 11, 2026
be2e990
fix(test): agui adapter test uses the 3-arg schema compose
xzxiaoshan Oct 11, 2026
6889215
docs(core): clarify system tool-group marker is group-level only
xzxiaoshan Oct 12, 2026
fd31e75
refactor(core,harness): identify skills by name across the grant layer
xzxiaoshan Oct 12, 2026
3e4164c
feat(core): materialize activateOnSkill-bound groups into the grant s…
xzxiaoshan Oct 12, 2026
62e5ed3
refactor(skill): dual-form skillId — bare name or name__s__source
xzxiaoshan Oct 13, 2026
e4a7a2b
feat(skill): key catalogs by skillId so same-named skills coexist
xzxiaoshan Oct 13, 2026
8529159
Merge branch 'main' into feat/capability-surface
xzxiaoshan Oct 13, 2026
80cc746
Merge branch 'main' into feat/capability-surface
xzxiaoshan Oct 15, 2026
5135442
merge validatestructuredoutput conflict
xzxiaoshan Oct 15, 2026
a804a20
Merge branch 'main' into feat/capability-surface
xzxiaoshan Oct 15, 2026
af3334c
test(core): align merged skillId assertions with dual-form __s__ format
xzxiaoshan Oct 15, 2026
c09c9a1
Merge remote-tracking branch 'origin/feat/capability-surface' into fe…
xzxiaoshan Oct 15, 2026
125364a
feat(harness): add capabilityResolver with pre-staging grant filtering
xzxiaoshan Oct 16, 2026
7ba90b8
Merge branch 'main' into feat/capability-surface
xzxiaoshan Oct 16, 2026
733612e
merge: main into feat/capability-surface
xzxiaoshan Oct 23, 2026
eef172e
test: deflake BotLoopGuard active-peer eviction under sweep race
xzxiaoshan Oct 23, 2026
6c7e68d
merge: main into feat/capability-surface
xzxiaoshan Oct 26, 2026
f95c2f8
Merge commit 'a543f993cce6e22fb37c31ae14443cf9104dbeae' into feat/cap…
xzxiaoshan Oct 27, 2026
b4ff701
Merge commit '124fed684b142998db063ed3c6baa61c1d9446fe' into feat/cap…
xzxiaoshan Oct 30, 2026
65d0ab7
test(harness): isolate inbox scheduling test from post-call memory co…
xzxiaoshan Oct 30, 2026
021b59d
Merge commit '6ee804457' into feat/capability-surface
xzxiaoshan Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 139 additions & 3 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -968,11 +979,94 @@ protected Object beforeAgentExecution(List<Msg> 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.
*
* <p>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<String> grantedSkills = surface.allowedSkills();
if (grantedSkills == null || grantedSkills.isEmpty()) {
return surface;
}
Set<String> 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<String> existing = surface.allowedToolGroups();
if (existing != null && existing.containsAll(boundGroups)) {
return surface;
}
Set<String> 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
Expand Down Expand Up @@ -1604,7 +1698,10 @@ private Mono<Msg> doStructuredCall(List<Msg> msgs, Class<?> targetClass, JsonNod
List<String> 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
Expand Down Expand Up @@ -2876,7 +2973,8 @@ private Mono<Msg> reasoning(int iter, boolean ignoreMaxIters) {
List<ToolSchema> 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) {
Expand Down Expand Up @@ -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 <files-root>} listing + instructions). Off by default —
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -6173,7 +6275,7 @@ public Builder enableTaskList() {
*
* <p>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
Expand Down Expand Up @@ -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.
*
* <p>Only an all-null surface skips trimming; any explicit dimension makes the surface
* a complete grant enumeration (unset dimensions contribute no grants).
*
* <p>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:
Expand Down Expand Up @@ -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());

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
Loading
Loading