Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 57 additions & 18 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
import io.agentscope.core.permission.PermissionBehavior;
import io.agentscope.core.permission.PermissionContextState;
import io.agentscope.core.permission.PermissionEngine;
import io.agentscope.core.permission.PermissionEscalation;
import io.agentscope.core.permission.PermissionMode;
import io.agentscope.core.permission.PermissionRule;
import io.agentscope.core.rag.GenericRAGHook;
Expand Down Expand Up @@ -136,6 +137,7 @@
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -2832,14 +2834,14 @@ Flux<AgentEvent> actingStream(
.flatMapMany(
gate -> {
List<ToolUseBlock> pending = gate.pendingAsk();
Set<String> autoDenied = gate.autoDeniedIds();
Map<String, String> autoDenied = gate.autoDenied();

// Mark ToolUseBlock.state in context for every gated tool. ALLOWED
// calls run immediately; ASKING calls cause the agent to pause and
// return; DENIED calls get DENIED ToolResultBlocks written below.
Map<String, ToolCallState> stateUpdates = new HashMap<>();
for (ToolUseBlock tc : toolCalls) {
if (autoDenied.contains(tc.getId())) {
if (autoDenied.containsKey(tc.getId())) {
// DENIED tools don't need a state change — they'll get a
// DENIED ToolResultBlock and won't reappear in pending.
continue;
Expand Down Expand Up @@ -2893,13 +2895,15 @@ Flux<AgentEvent> actingStream(
* them to context so the conversation reflects the rejection (and resume doesn't see them
* as pending).
*/
private void writeAutoDeniedResults(List<ToolUseBlock> toolCalls, Set<String> deniedIds) {
private void writeAutoDeniedResults(
List<ToolUseBlock> toolCalls, Map<String, String> deniedMessages) {
for (ToolUseBlock tc : toolCalls) {
if (!deniedIds.contains(tc.getId())) {
String message = deniedMessages.get(tc.getId());
if (message == null) {
continue;
}
ToolResultBlock denied =
ToolResultBlock.text("Permission denied by rules")
ToolResultBlock.text(message)
.withIdAndName(tc.getId(), tc.getName())
.withState(ToolResultState.DENIED);
Msg deniedMsg = ToolResultMessageBuilder.buildToolResultMsg(denied, tc, getName());
Expand All @@ -2915,16 +2919,17 @@ private void writeAutoDeniedResults(List<ToolUseBlock> toolCalls, Set<String> de
*/
private Flux<AgentEvent> runToolBatch(
List<ToolUseBlock> toolCalls,
Set<String> deniedIds,
Map<String, String> deniedMessages,
String replyId,
AtomicReference<List<Map.Entry<ToolUseBlock, ToolResultBlock>>> resultHolder) {

List<Map.Entry<ToolUseBlock, ToolResultBlock>> deniedEntries = new ArrayList<>();
List<ToolUseBlock> approved = new ArrayList<>();
for (ToolUseBlock tc : toolCalls) {
if (deniedIds.contains(tc.getId())) {
String deniedMessage = deniedMessages.get(tc.getId());
if (deniedMessage != null) {
ToolResultBlock denied =
ToolResultBlock.text("Permission denied by rules")
ToolResultBlock.text(deniedMessage)
.withIdAndName(tc.getId(), tc.getName())
.withState(ToolResultState.DENIED);
deniedEntries.add(Map.entry(tc, denied));
Expand All @@ -2945,7 +2950,8 @@ private Flux<AgentEvent> runToolBatch(
replyId,
use.getId(),
use.getName(),
"Permission denied by rules"),
deniedMessages.getOrDefault(
use.getId(), "Permission denied")),
new ToolResultEndEvent(
replyId,
use.getId(),
Expand Down Expand Up @@ -3135,7 +3141,12 @@ private Flux<AgentEvent> runToolBatch(
* @param autoDeniedIds ids of tool calls whose decision was {@code DENY}; the agent loop
* synthesises denied results for them without invoking the tool.
*/
private record PermissionGate(List<ToolUseBlock> pendingAsk, Set<String> autoDeniedIds) {}
/**
* Calls gated to ASK, plus a map of auto-denied call id -> denial message (the engine's or
* the tool self-check's own text; never a generic placeholder when one exists).
*/
private record PermissionGate(
List<ToolUseBlock> pendingAsk, Map<String, String> autoDenied) {}

/**
* Run every tool call through the permission gate.
Expand All @@ -3151,7 +3162,7 @@ private record PermissionGate(List<ToolUseBlock> pendingAsk, Set<String> autoDen
*/
private Mono<PermissionGate> evaluatePermissions(List<ToolUseBlock> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Mono.just(new PermissionGate(List.of(), Set.of()));
return Mono.just(new PermissionGate(List.of(), Map.of()));
}
boolean useEngine = !state.getPermissionContext().isTrivial();
return Flux.fromIterable(toolCalls)
Expand All @@ -3160,10 +3171,15 @@ private Mono<PermissionGate> evaluatePermissions(List<ToolUseBlock> toolCalls) {
.map(
verdicts -> {
List<ToolUseBlock> pending = new ArrayList<>();
Set<String> denied = new HashSet<>();
Map<String, String> denied = new LinkedHashMap<>();
for (PermissionVerdict v : verdicts) {
switch (v.behavior()) {
case DENY -> denied.add(v.use().getId());
case DENY ->
denied.put(
v.use().getId(),
v.message() != null
? v.message()
: "Permission denied");
case ASK -> pending.add(v.use());
case ALLOW, PASSTHROUGH -> {
// auto-approved; falls through to execution
Expand All @@ -3184,7 +3200,10 @@ private Mono<PermissionVerdict> evaluateOne(ToolUseBlock use, boolean useEngine)
return Mono.just(new PermissionVerdict(use, PermissionBehavior.ALLOW));
}
Map<String, Object> input = use.getInput() == null ? Map.of() : use.getInput();
if (useEngine) {
// Calls carrying escalation arguments always engage the engine — enabling the
// escalation feature must not flip the evaluation path of calls that did not opt in
// (an otherwise-trivial context keeps its lightweight pre-2.0 path).
if (useEngine || PermissionEscalation.hasEscalationArgs(input)) {
return permissionEngine
.checkPermission(tb, input)
.map(
Expand All @@ -3193,7 +3212,8 @@ private Mono<PermissionVerdict> evaluateOne(ToolUseBlock use, boolean useEngine)
use,
decision == null
? PermissionBehavior.ASK
: decision.getBehavior()));
: decision.getBehavior(),
decision == null ? null : decision.getMessage()));
}
return tb.checkPermissions(input, state.getPermissionContext())
.map(
Expand All @@ -3205,15 +3225,34 @@ private Mono<PermissionVerdict> evaluateOne(ToolUseBlock use, boolean useEngine)
// gates execution; PASSTHROUGH and ALLOW both run, DENY is
// honoured.
return switch (decision.getBehavior()) {
case ASK -> new PermissionVerdict(use, PermissionBehavior.ASK);
case ASK ->
new PermissionVerdict(
use,
PermissionBehavior.ASK,
decision.getMessage());
case DENY ->
new PermissionVerdict(use, PermissionBehavior.DENY);
new PermissionVerdict(
use,
PermissionBehavior.DENY,
decision.getMessage());
default -> new PermissionVerdict(use, PermissionBehavior.ALLOW);
};
});
}

private record PermissionVerdict(ToolUseBlock use, PermissionBehavior behavior) {}
/**
* One tool call's permission verdict. {@code message} carries the engine's (or the tool
* self-check's) decision text so DENIED results can tell the model WHY it was denied —
* a generic "denied" gives the model nothing to correct against.
*/
private record PermissionVerdict(
ToolUseBlock use, PermissionBehavior behavior, String message) {

/** Convenience for ALLOW/PASSTHROUGH verdicts, which never enter the denied map. */
PermissionVerdict(ToolUseBlock use, PermissionBehavior behavior) {
this(use, behavior, null);
}
}

private List<ToolUseBlock> getSuspendedToolCalls(
List<Map.Entry<ToolUseBlock, ToolResultBlock>> results) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ public class Msg implements State {
* Metadata key for carrying a {@code List<ConfirmResult>} when resuming a Permission HITL
* pause. The receiving {@code ReActAgent.call(msgs)} extracts and applies these results to
* the ASKING tool calls in context.
*
* <p>Idempotency contract: when no tool call is currently ASKING (e.g. a transport retried
* an already-applied confirmation), the payload is processed as a normal turn and silently
* ignored — "no error" therefore does NOT mean "the confirmation took effect". A result
* referencing an id that is not among the ASKING calls, by contrast, is rejected outright.
*/
public static final String METADATA_CONFIRM_RESULTS = "agentscope_confirm_results";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,22 @@
* that tool. The engine evaluates {@code denyRules} first, then {@code askRules}, then tool
* self-check, then {@code allowRules}; see the {@code PermissionEngine} javadoc for full ordering.
*/
@JsonPropertyOrder({"mode", "working_directories", "allow_rules", "deny_rules", "ask_rules"})
@JsonPropertyOrder({
"mode",
"working_directories",
"allow_rules",
"deny_rules",
"ask_rules",
"escalation_enabled"
})
public final class PermissionContextState {

private final PermissionMode mode;
private final Map<String, AdditionalWorkingDirectory> workingDirectories;
private final Map<String, List<PermissionRule>> allowRules;
private final Map<String, List<PermissionRule>> denyRules;
private final Map<String, List<PermissionRule>> askRules;
private final boolean escalationEnabled;

private PermissionContextState(Builder builder) {
this.mode = builder.mode == null ? PermissionMode.DEFAULT : builder.mode;
Expand All @@ -49,6 +57,7 @@ private PermissionContextState(Builder builder) {
this.allowRules = freeze(builder.allowRules);
this.denyRules = freeze(builder.denyRules);
this.askRules = freeze(builder.askRules);
this.escalationEnabled = builder.escalationEnabled;
}

@JsonCreator
Expand All @@ -58,7 +67,8 @@ static PermissionContextState fromJson(
Map<String, AdditionalWorkingDirectory> workingDirectories,
@JsonProperty("allow_rules") Map<String, List<PermissionRule>> allowRules,
@JsonProperty("deny_rules") Map<String, List<PermissionRule>> denyRules,
@JsonProperty("ask_rules") Map<String, List<PermissionRule>> askRules) {
@JsonProperty("ask_rules") Map<String, List<PermissionRule>> askRules,
@JsonProperty("escalation_enabled") Boolean escalationEnabled) {
Builder b = builder();
if (mode != null) {
b.mode(mode);
Expand All @@ -69,6 +79,9 @@ static PermissionContextState fromJson(
copyInto(allowRules, b::addAllowRule);
copyInto(denyRules, b::addDenyRule);
copyInto(askRules, b::addAskRule);
if (escalationEnabled != null) {
b.escalationEnabled(escalationEnabled);
}
return b.build();
}

Expand Down Expand Up @@ -112,6 +125,19 @@ public boolean isTrivial() {
&& askRules.isEmpty();
}

/**
* Whether model-requested permission escalation is enabled on this agent. When {@code true},
* tools may declare optional {@code sandbox_permissions} + {@code justification} arguments;
* a valid, strictly-wider request is routed through the user-confirmation flow before
* anything executes. The flag deliberately does NOT affect {@link #isTrivial()}: enabling
* escalation leaves calls without escalation arguments on exactly their previous evaluation
* path — only calls that actually carry the arguments engage the permission engine.
*/
@JsonProperty("escalation_enabled")
public boolean isEscalationEnabled() {
return escalationEnabled;
}

@JsonProperty("working_directories")
public Map<String, AdditionalWorkingDirectory> getWorkingDirectories() {
return workingDirectories;
Expand Down Expand Up @@ -150,7 +176,23 @@ public PermissionContextState withMode(PermissionMode newMode) {
if (newMode == this.mode) {
return this;
}
Builder b = builder().mode(newMode);
Builder b = builder().mode(newMode).escalationEnabled(escalationEnabled);
workingDirectories.forEach(b::addWorkingDirectory);
copyInto(allowRules, b::addAllowRule);
copyInto(denyRules, b::addDenyRule);
copyInto(askRules, b::addAskRule);
return b.build();
}

/**
* Returns a copy of this context with the escalation flag replaced and every mode, working
* directory, and rule preserved (returns {@code this} when the flag is unchanged).
*/
public PermissionContextState withEscalationEnabled(boolean newEscalationEnabled) {
if (newEscalationEnabled == this.escalationEnabled) {
return this;
}
Builder b = builder().mode(mode).escalationEnabled(newEscalationEnabled);
workingDirectories.forEach(b::addWorkingDirectory);
copyInto(allowRules, b::addAllowRule);
copyInto(denyRules, b::addDenyRule);
Expand All @@ -167,6 +209,7 @@ public boolean equals(Object o) {
return false;
}
return mode == other.mode
&& escalationEnabled == other.escalationEnabled
&& Objects.equals(workingDirectories, other.workingDirectories)
&& Objects.equals(allowRules, other.allowRules)
&& Objects.equals(denyRules, other.denyRules)
Expand All @@ -175,7 +218,8 @@ public boolean equals(Object o) {

@Override
public int hashCode() {
return Objects.hash(mode, workingDirectories, allowRules, denyRules, askRules);
return Objects.hash(
mode, workingDirectories, allowRules, denyRules, askRules, escalationEnabled);
}

@Override
Expand All @@ -190,6 +234,8 @@ public String toString() {
+ denyRules
+ ", askRules="
+ askRules
+ ", escalationEnabled="
+ escalationEnabled
+ '}';
}

Expand All @@ -200,6 +246,7 @@ private interface RuleAdder {

public static final class Builder {
private PermissionMode mode = PermissionMode.DEFAULT;
private boolean escalationEnabled = false;
private final Map<String, AdditionalWorkingDirectory> workingDirectories =
new LinkedHashMap<>();
private final Map<String, List<PermissionRule>> allowRules = new LinkedHashMap<>();
Expand All @@ -213,6 +260,12 @@ public Builder mode(PermissionMode mode) {
return this;
}

/** Enables model-requested permission escalation on the built context. */
public Builder escalationEnabled(boolean escalationEnabled) {
this.escalationEnabled = escalationEnabled;
return this;
}

public Builder addWorkingDirectory(String key, AdditionalWorkingDirectory directory) {
Objects.requireNonNull(key, "key must not be null");
Objects.requireNonNull(directory, "directory must not be null");
Expand Down
Loading
Loading