agentscope-ai / agentscope-ai/agentscope-java

[Bug]:setPermissionMode 读取未回源的进程内 stateCache 并整体写回,多副本下会静默覆盖更新的 AgentState

Aperta
#2,535 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub
area/core/agent area/core/memory area/harness bug
Lingua principale
Java
Stelle
5.6k
Fork
1.3k
Merge medio
4g 12h
PR unite (30g)
77

Descrizione

**版本:** 2.0.0(在发布包上验证;`main` 分支同一份代码)
**模块:** `agentscope-core`,经 `agentscope-harness` 使用
**环境:** 多副本部署(Kubernetes,>1 pod),通过 `.stateStore(...)` 配置 `MysqlAgentStateStore`

## 概述

`ReActAgent#setPermissionMode(...)` 从进程内的 `stateCache` 读取会话的 `AgentState`,**不从已配置的 `AgentStateStore` 回源**,随后又通过 `saveAgentState(...)` 把该实例整体写回存储。

在多副本部署下,这会把**陈旧**状态持久化,静默截断对话历史;也会把一次失败调用遗留的、本不该落盘的半截状态扶正进存储,造成该会话永久不可用。

## 机制

```java
// ReActAgent#setPermissionMode(String, String, PermissionMode)
public void setPermissionMode(String userId, String sessionId, PermissionMode mode) {
Objects.requireNonNull(mode, "mode must not be null");
String sid = (sessionId == null || sessionId.isBlank()) ? defaultSessionId : sessionId;
String slot = slotKey(userId, sid);
AgentState s = getAgentState(userId, sid); // (1) 读缓存,不回源
s.setPermissionContext(s.getPermissionContext().withMode(mode));
permissionEngineCache.put(slot, new PermissionEngine(s.getPermissionContext()));
saveAgentState(userId, sid); // (2) 把【整个】 AgentState 写回
}

// (1) ReActAgent#getAgentState(String, String)
public AgentState getAgentState(String userId, String sessionId) {
String slot = slotKey(userId, sessionId);
return stateCache.computeIfAbsent(slot, k -> loadOrCreateAgentStateForSlot(...));
// ^^^^^^^^^^^^^^^ 命中即返回缓存实例,从不回源
}

// (2) ReActAgent#saveAgentState(String, String)
public void saveAgentState(String userId, String sessionId) {
if (stateStore == null) return;
AgentState s = stateCache.get(slotKey(userId, sessionId));
if (s != null) stateStore.save(userId, sessionId, "agent_state", s);
}
```

`stateCache` 是 agent 实例上的普通 `ConcurrentHashMap`。在 `agentscope-core` 与 `agentscope-harness` 中,对它**没有任何** `remove` / `clear` / 失效 / TTL,`activateSlotForContext` 放进去的条目会驻留至 JVM 结束。

### 故障 A —— 跨副本陈旧覆盖(丢失历史)

请求按 pod 分散路由,且会话跨多次调用时:

```
调用1 -> pod A:activateSlotForContext 回源加载,stateCache.put(slot, s_A)
调用结束,存储为 [u1, a1];pod A 仍持有 s_A == [u1, a1]
调用2 -> pod B:存储推进到 [u1, a1, u2, a2]
调用3 -> pod A:setPermissionMode -> getAgentState -> 缓存命中 -> s_A == [u1, a1](陈旧)
saveAgentState -> 存储被覆盖为 [u1, a1]
u2 / a2 丢失;无异常,无日志
```

### 故障 B —— 把未落盘的半截状态扶正(毒丸会话)

`saveStateToSession` 只在成功路径执行,因此中途失败的调用**不会**被持久化,这是正确的。但它改动的那个 `AgentState` 实例,正是 `stateCache` 里的那一个——`activateSlotForContext` 执行 `stateCache.put(slot, loaded)` 并把同一引用交给 `CallExecution`。没有任何机制清除它。

若该会话稍后的调用先执行了 `setPermissionMode`,这份半截状态就会被写入存储,例如末尾的 assistant 消息带着 `tool_use` 却没有配对的 `tool_result`。此后每次调用都会回源读到它,`doCallInner` 抛出:

```
java.lang.IllegalStateException: Pending tool calls exist without results.
Enable enablePendingToolRecovery or provide tool results. Pending IDs: [...]
```

该会话从此不可用。我们在生产环境遇到了这个问题:落盘行的最后一条 assistant 消息带有 4 个 `"state":"allowed"` 的 `tool_use` 且无任何 `tool_result`,同时 `shutdown_interrupted: false`(排除了停机状态保存器)。

`enablePendingToolRecovery(true)` 确实能从故障 B 中恢复,但那是治标——不一致的状态仍然进入了存储。

## 为什么这不符合预期

v2.0.0-RC3 的 release note 写道:

> **Distributed state always fresh** — when a state store is configured, agent state and permissions are reloaded from the store at the start of every call, preventing stale cache reads when sessions drift across machines.

`activateSlotForContext` 确实遵守了这一点,而 `setPermissionMode` 绕开了它,并且:

- `setPermissionMode(RuntimeContext, PermissionMode)` 这个重载签名看起来就是每请求 API;
- 它自身的 javadoc 说明了模式变更的语义,却完全没有提到「读取可能陈旧的缓存」以及「重写整个 `AgentState`」;
- 相关警告位于下一层的 `getAgentState` javadoc 中("returns the locally cached instance ... suitable for admin APIs and tests")。

一个配置了 `AgentStateStore`、并读过 RC3 那条说明的使用者,有理由认为权限切换同样是与存储一致的。

## 修复建议(任一即可)

1. **配置了 store 时先回源再修改。** 让 `setPermissionMode` 走与 `activateSlotForContext` 相同的权威回源路径并替换缓存条目,而不是 `computeIfAbsent`。
2. **缩小写入范围。** 只持久化 `permission_context` 而非整个 `AgentState`,使该调用无法破坏 `context`。(需要 `AgentStateStore` 提供局部更新能力。)
3. **文档 + 防护。** 至少在 `setPermissionMode` 上注明:它读取进程内状态并重写整个 `AgentState`,在多副本部署下不应按请求调用。可选地,当同一 slot 有调用正在进行时打印告警。

方案 1 最贴近 RC3 的意图,但需要处理一个边界:并发的 in-flight 调用仍持有旧实例的引用。

如果维护者认可其中某个方向,我很乐意提交 PR。

## 变通方案

不要在请求路径上调用 `setPermissionMode`,改为在构建期设置一次:

```java
HarnessAgent.builder()
.permissionContext(PermissionContextState.builder().mode(PermissionMode.BYPASS).build())
...
```

`loadOrCreateAgentStateForSlot` 会把它应用到新建会话并落盘,`activateSlotForContext` 每次调用都会依据存储中的 context 重建 `PermissionEngine`,因此新老会话都能得到预期的模式。

## 关联

`stateCache` 的无界驻留问题另见 #_____(提交 issue ② 后回填编号)。

Guida per i contributori

Apri la guida per i contributori

Valutazione

Questa issue non è ancora stata valutata.

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.