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
78 changes: 62 additions & 16 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -365,22 +365,24 @@ private static AgentState loadOrCreateAgentStateForSlot(
return fresh;
}
try {
return stateStore
.get(userId, sessionId, "agent_state", AgentState.class)
.orElseGet(
() -> {
AgentState legacy =
LegacyStateLoader.loadFromLegacySession(
stateStore, userId, sessionId);
if (legacy != null
&& (!legacy.getContext().isEmpty()
|| !legacy.getToolContext()
.getActivatedGroups()
.isEmpty())) {
return legacy;
}
return fresh;
});
AgentState loaded =
stateStore
.get(userId, sessionId, "agent_state", AgentState.class)
.orElseGet(
() -> {
AgentState legacy =
LegacyStateLoader.loadFromLegacySession(
stateStore, userId, sessionId);
if (legacy != null
&& (!legacy.getContext().isEmpty()
|| !legacy.getToolContext()
.getActivatedGroups()
.isEmpty())) {
return legacy;
}
return fresh;
});
return mergeInitialPermissionContext(loaded, permCtx);
} catch (Exception e) {
log.warn(
"Failed to load AgentState for slot (userId={}, sessionId={}): {}",
Expand All @@ -404,6 +406,50 @@ private static AgentState freshState(
return asb.build();
}

/**
* Repair a stale persisted session whose {@link PermissionContextState} is {@linkplain
* PermissionContextState#isTrivial() trivial} by applying the builder's initial (non-trivial)
* {@code permCtx} on top of it.
*
* <p>Background (D-08): a non-trivial permission context is required for the full {@link
* PermissionEngine} to engage; otherwise {@code evaluatePermissions} falls back to the
* lightweight path that only honours each tool's own {@code checkPermissions} self-check —
* which returns {@code passthrough} for file/shell tools, so {@code write_file} runs unchecked
* and {@link io.agentscope.core.event.RequireUserConfirmEvent} never fires. Sessions persisted
* before the builder seeded a non-trivial context carry a trivial one forever, since the load
* path returns the persisted state verbatim. Merging the initial context here upgrades those
* stale sessions in place, preserving their conversation/tool state while restoring HITL
* gating. This only fires for trivial persisted contexts (mode DEFAULT, no rules), so a session
* whose mode was deliberately changed via {@link #setPermissionMode} — which makes the context
* non-trivial by flipping the mode away from DEFAULT — is left untouched.
*
* @param loaded the state loaded from the store (or legacy fallback)
* @param initialPermCtx the builder's initial permission context (may be {@code null})
* @return {@code loaded} unchanged when it already has a non-trivial context or no initial
* context was supplied; otherwise a copy of {@code loaded} with the initial context applied
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[recommended] PR contains two unrelated fixes (AG-UI messageId + ReActAgent permission context merge) but only describes the first. Consider splitting into two PRs for easier review, bisect, and revert. Also, the mergeInitialPermissionContext method manually copies 10+ fields from AgentState — if AgentState gains new fields in the future, this method will silently drop them. Consider adding a Builder.from(AgentState) factory method to prevent this maintenance hazard.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[recommended] PR contains two unrelated fixes (AG-UI messageId + ReActAgent permission context merge) but only describes the first. Consider splitting into two PRs for easier review, bisect, and revert. Also, the mergeInitialPermissionContext method manually copies 10+ fields from AgentState — if AgentState gains new fields in the future, this method will silently drop them. Consider adding a Builder.from(AgentState) factory method to prevent this maintenance hazard.

private static AgentState mergeInitialPermissionContext(
AgentState loaded, PermissionContextState initialPermCtx) {
if (loaded == null
|| initialPermCtx == null
|| !loaded.getPermissionContext().isTrivial()) {
return loaded;
}
return AgentState.builder()
.sessionId(loaded.getSessionId())
.userId(loaded.getUserId())
.summary(loaded.getSummary())
.context(loaded.getContext())
.replyId(loaded.getReplyId())
.curIter(loaded.getCurIter())
.shutdownInterrupted(loaded.isShutdownInterrupted())
.permissionContext(initialPermCtx)
.toolContext(loaded.getToolContext())
.tasksContext(loaded.getTasksContext())
.planModeContext(loaded.getPlanModeContext())
.build();
}

/**
* Persist the current {@link AgentState} via the configured {@link AgentStateStore}, or {@code
* Mono.empty()} when no AgentStateStore was provided. Synchronises toolkit activeGroups into the state
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/*
* 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.agentscope.core.ReActAgent;
import io.agentscope.core.event.AgentEvent;
import io.agentscope.core.event.RequestStopEvent;
import io.agentscope.core.event.RequireUserConfirmEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.GenerateReason;
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.model.ChatModelBase;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.permission.PermissionBehavior;
import io.agentscope.core.permission.PermissionContextState;
import io.agentscope.core.permission.PermissionDecision;
import io.agentscope.core.permission.PermissionMode;
import io.agentscope.core.permission.PermissionRule;
import io.agentscope.core.state.AgentState;
import io.agentscope.core.state.InMemoryAgentStateStore;
import io.agentscope.core.tool.ToolBase;
import io.agentscope.core.tool.ToolCallParam;
import io.agentscope.core.tool.Toolkit;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

/**
* Acceptance test for defect D-08 live gating gap: a {@link ReActAgent} built with a non-trivial
* initial {@link PermissionContextState} must still gate a passthrough tool ({@code write_file}-like)
* on a <em>stale persisted session</em> whose stored permission context is trivial.
*
* <p>The companion unit test {@code PermissionContextFactoryTest} (admin-service) only drives the
* engine in isolation and so passes regardless of whether the live agent actually loads the
* non-trivial context for a stale session. This test closes that gap by exercising the real
* {@link ReActAgent} slot-activation + permission-engine load path against a pre-seeded trivial
* session state.
*/
class ReActAgentStalePermissionContextTest {

/**
* Real-world file-write tool stand-in: {@code checkPermissions} returns {@code passthrough}
* (exactly like the harness {@code FilesystemTool} for non-readOnly tools), so under a trivial
* context the lightweight path allows it unchecked; under a non-trivial context with no allow
* rule it falls through to {@code defaultDecisionAsk} → ASK → {@link RequireUserConfirmEvent}.
*/
private static final class PassthroughWriteTool extends ToolBase {
PassthroughWriteTool() {
super(
"write_file",
"write a file",
schemaFor(),
false, // not readOnly
true,
false,
null,
false,
false);
}

private static Map<String, Object> schemaFor() {
Map<String, Object> schema = new HashMap<>();
schema.put("type", "object");
Map<String, Object> props = new HashMap<>();
Map<String, Object> path = new HashMap<>();
path.put("type", "string");
props.put("path", path);
Map<String, Object> content = new HashMap<>();
content.put("type", "string");
props.put("content", content);
schema.put("properties", props);
return schema;
}

@Override
public Mono<PermissionDecision> checkPermissions(
Map<String, Object> toolInput, PermissionContextState context) {
return Mono.just(PermissionDecision.passthrough("passthrough: " + getName()));
}

@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.just(ToolResultBlock.text("written"));
}
}

/**
* A non-trivial DEFAULT context equivalent to what {@code PermissionContextFactory.build(DEFAULT)}
* produces on the admin-service side: read-only built-ins ALLOWed (which is what makes the
* context non-trivial), write_file has no allow rule so the engine's {@code defaultDecisionAsk}
* fires.
*/
private static PermissionContextState defaultContext() {
PermissionContextState.Builder b =
PermissionContextState.builder().mode(PermissionMode.DEFAULT);
b.addAllowRule(
"read_file",
new PermissionRule("read_file", null, PermissionBehavior.ALLOW, "test"));
return b.build();
}

private static final class ScriptedModel extends ChatModelBase {
private final List<Supplier<Flux<ChatResponse>>> scripts;
private final AtomicInteger idx = new AtomicInteger(0);

ScriptedModel(List<Supplier<Flux<ChatResponse>>> scripts) {
this.scripts = scripts;
}

@Override
public String getModelName() {
return "scripted";
}

@Override
protected Flux<ChatResponse> doStream(
List<Msg> messages, List<ToolSchema> tools, GenerateOptions options) {
int i = idx.getAndIncrement();
if (i >= scripts.size()) {
return Flux.just(textResponse(""));
}
return scripts.get(i).get();
}
}

private static ChatResponse textResponse(String text) {
return ChatResponse.builder()
.content(List.<ContentBlock>of(TextBlock.builder().text(text).build()))
.build();
}

private static ChatResponse writeToolResponse(String toolId) {
Map<String, Object> input = new HashMap<>();
input.put("path", "stale.txt");
input.put("content", "hi");
return ChatResponse.builder()
.content(
List.<ContentBlock>of(
ToolUseBlock.builder()
.id(toolId)
.name("write_file")
.input(input)
.build()))
.build();
}

private static Toolkit toolkitWith(ToolBase... tools) {
Toolkit tk = new Toolkit();
for (ToolBase t : tools) {
tk.registerAgentTool(t);
}
return tk;
}

private static int indexOf(List<AgentEvent> events, Class<?> type) {
for (int i = 0; i < events.size(); i++) {
if (type.isInstance(events.get(i))) {
return i;
}
}
return -1;
}

@Test
void staleTrivialSession_isUpgradedToInitialContext_andGatesWriteFile() {
InMemoryAgentStateStore store = new InMemoryAgentStateStore();
// Pre-seed a STALE session with a TRIVIAL permission context — this is the live failure
// scenario: a session persisted before the agent seeded a non-trivial context.
String userId = "u1";
String sessionId = "stale-session";
AgentState stale =
AgentState.builder().sessionId(sessionId).userId(userId).build(); // trivial ctx
store.save(userId, sessionId, "agent_state", stale);

ReActAgent agent =
ReActAgent.builder()
.name("asst")
.defaultSessionId(sessionId)
.stateStore(store)
.permissionContext(defaultContext())
.toolkit(toolkitWith(new PassthroughWriteTool()))
.model(
new ScriptedModel(
List.of(() -> Flux.just(writeToolResponse("tc1")))))
.build();

Msg result =
agent.call(
List.of(),
RuntimeContext.builder()
.userId(userId)
.sessionId(sessionId)
.build())
.block();
assertNotNull(result);
// Without the merge fix the result would be a normal completion (write_file ran). With the
// fix the agent pauses with PERMISSION_ASKING.
assertTrue(
result.getGenerateReason() == GenerateReason.PERMISSION_ASKING,
"expected PERMISSION_ASKING after stale-session upgrade, got "
+ result.getGenerateReason());
}

@Test
void staleTrivialSession_emitsRequireUserConfirmEvent() {
InMemoryAgentStateStore store = new InMemoryAgentStateStore();
String userId = "u1";
String sessionId = "stale-session-2";
store.save(
userId,
sessionId,
"agent_state",
AgentState.builder().sessionId(sessionId).userId(userId).build());

ReActAgent agent =
ReActAgent.builder()
.name("asst")
.defaultSessionId(sessionId)
.stateStore(store)
.permissionContext(defaultContext())
.toolkit(toolkitWith(new PassthroughWriteTool()))
.model(
new ScriptedModel(
List.of(() -> Flux.just(writeToolResponse("tc1")))))
.build();

List<AgentEvent> events =
agent.streamEvents(
List.of(),
RuntimeContext.builder()
.userId(userId)
.sessionId(sessionId)
.build())
.collectList()
.block();
assertNotNull(events);

int iReq = indexOf(events, RequireUserConfirmEvent.class);
int iStop = indexOf(events, RequestStopEvent.class);
assertTrue(iReq >= 0, "RequireUserConfirmEvent must be emitted on stale session");
assertTrue(iStop > iReq, "RequestStopEvent must follow RequireUserConfirmEvent");
RequireUserConfirmEvent req = (RequireUserConfirmEvent) events.get(iReq);
assertTrue(
req.getToolCalls().stream().anyMatch(tc -> "write_file".equals(tc.getName())),
"RequireUserConfirmEvent must name write_file");
RequestStopEvent stop = (RequestStopEvent) events.get(iStop);
assertTrue(
stop.getGenerateReason() == GenerateReason.PERMISSION_ASKING,
"RequestStopEvent must carry PERMISSION_ASKING");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,15 @@ private List<AguiEvent> convertEvent(Event event, EventConversionState state) {
if (config.isEnableReasoning()) {
String thinking = thinkingBlock.getThinking();
if (thinking != null && !thinking.isEmpty()) {
String messageId = msg.getId();
// Reasoning is a distinct AG-UI message (role "reasoning") from the
// text answer (role "assistant"); they are associated only by runId
// and order, never by messageId. Reusing msg.getId() for both makes
// clients group them into a single message bubble (e.g. the answer
// gets folded into the reasoning/thought panel). Use a distinct id so
// the reasoning message renders independently and stays collapsible.
// Suffix (not a fresh UUID) so multiple ThinkingBlocks in one Msg
// dedupe into a single reasoning message, mirroring the text message.
String messageId = msg.getId() + "-reasoning";

// Start reasoning message if not started
if (!state.hasStartedReasoningMessage(messageId)) {
Expand Down
Loading
Loading