agentscope-ai / agentscope-ai/agentscope-java

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

Abierto
#2,535 0 comentarios 0 reacciones 0 asignados Ver en GitHub
area/core/agent area/core/memory area/harness bug
Lenguaje dominante
Java
Estrellas
5.6k
Forks
1.3k
Merge medio
4 d 12 h
PR fusionados (30 d)
77

Descripción

**版本:** 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 ② 后回填编号)。

Guía de contribución

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.