agentscope-ai / agentscope-ai/agentscope-java

[Bug]:streamEvents() 在绑定 RuntimeContext 之前就装配了 onAgent 中间件链 —— 导致长期记忆与会话落盘写入匿名 _default 命名空间

未關閉
#1,681 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視
area/core/agent area/harness bug
主要語言
Java
星號
5.6k
分支
1.3k
平均合併
4 天 12 小時
30 天內合併 PR
77

描述

---
标题: streamEvents() 在绑定 RuntimeContext 之前就装配了 onAgent 中间件链 —— 导致长期记忆与会话落盘写入匿名 _default 命名空间

受影响版本: 2.0.0-RC1(当前 main 同样存在)

涉及组件: agentscope-core(ReActAgent、AgentBase、MiddlewareChain)、agentscope-harness(MemoryFlushMiddleware、MemoryMaintenanceMiddleware、Re
moteFilesystemSpec)

---
概述

通过流式 API ReActAgent.streamEvents(List, RuntimeContext) 调用 agent 时,onAgent 中间件链是即时装配并执行的,发生在每次调用的
RuntimeContext 被绑定到 agent 之前。任何在 onAgent 中读取 agent.getRuntimeContext() 的中间件,此刻拿到的都是 null,于是回退成
RuntimeContext.empty()。

对记忆相关中间件而言,这会让 userId 解析成匿名兜底值 _default、sessionId 解析成 "default",结果是长期记忆 flush、消息 offload、MEMORY.md
合并全部被持久化到错误的命名空间(users/_default/...),而不是调用方真正的用户命名空间。非流式的 call(...) 路径不受影响。

根因(执行顺序)

ReActAgent.streamEvents(msgs, ctx) 只是把 context 暂存为 pending,随即委托下去:

// ReActAgent.java:558-561
public Flux streamEvents(List msgs, RuntimeContext context) {
this.pendingRuntimeContext = context; // 仅暂存,尚未绑定
return streamEvents(msgs);
}

streamEvents(msgs) 立刻装配并 apply 整条链:

// ReActAgent.java:533-534
return MiddlewareChain.build(middlewares, this, MiddlewareBase::onAgent, core)
.apply(new AgentInput(msgs == null ? List.of() : msgs));

MiddlewareChain.build(...).apply(...) 会在 装配时同步 执行每个 onAgent 方法体(它只是普通的嵌套函数组合,没有 defer):

// MiddlewareChain.java:54-58
Function> chain = core;
for (int i = middlewares.size() - 1; i >= 0; i--) {
MiddlewareBase mw = middlewares.get(i);
Function> next = chain;
chain = input -> method.apply(mw, agent, input, next); // .apply() 调用时 onAgent 即执行
}

而 pending context 要等到稍后、在被订阅的 core 内部(call() → beforeAgentExecution)才真正绑定:

// ReActAgent.java:356-363
protected void beforeAgentExecution(List msgs) {
RuntimeContext ctx = this.pendingRuntimeContext; // 在这里才消费
this.pendingRuntimeContext = null;
if (ctx == null) ctx = RuntimeContext.empty();
bindRuntimeContextToHooks(ctx); // 设置 currentRuntimeContext
...
}

// AgentBase.java:533-535
public RuntimeContext getRuntimeContext() { return currentRuntimeContext.get(); }

因此 onAgent 执行的那一刻,currentRuntimeContext 仍是 null。记忆中间件抓到的是空 context:

// MemoryFlushMiddleware.java:61-64(MemoryMaintenanceMiddleware.java:91-94 完全一致)
final RuntimeContext rc =
agent instanceof AgentBase ab && ab.getRuntimeContext() != null
? ab.getRuntimeContext() : RuntimeContext.empty(); // 流式路径下 -> empty
return next.apply(input).doOnComplete(() -> doFlush(agent, rc).subscribe());

继续往下,就得到匿名命名空间 + "default" 会话 id:

// MemoryFlushMiddleware.java:94
String sessionId = rc != null && rc.getSessionId() != null ? rc.getSessionId() : "default";
// RemoteFilesystemSpec.java:78 -> anonymousUserId = "_default"
// RemoteFilesystemSpec.java:259
String effectiveUid = (uid != null && !uid.isBlank()) ? uid : anonymousUserId; // null -> "_default"

为什么 call() 不受影响

在 AgentBase.call(...) 中,beforeAgentExecution(msgs) 运行在 Mono.using 的 resource 步骤里,也就是在 doCall 装配/执行之前。等到 onAgent 读取
getRuntimeContext() 时,context 已经绑定好了,记忆会正确落到调用方的 userId 下:

// AgentBase.java:250-267(call 路径)
return Mono.using(this::acquireExecution,
resource -> { beforeAgentExecution(msgs); return ...doCall... }, // 装配前先绑定
this::releaseExecution, true);

复现步骤

1. 构建一个带远程文件系统 + 用户隔离的 HarnessAgent:
HarnessAgent.builder()
.name("assistant").model(model)
.filesystem(new RemoteFilesystemSpec(new RedisStore(jedis))
.isolationScope(IsolationScope.USER))
.session(distributedSession) // 任意非 WorkspaceSession
.build();
2. 用带有身份的 context 走流式调用:
RuntimeContext ctx = RuntimeContext.builder().userId("alice").sessionId("s1").build();
agent.streamEvents(List.of(new UserMessage("记住我喜欢喝美式")), ctx).blockLast();
3. 检查存储后端。长期记忆 / 会话 offload / 合并都被写到了 agents//users/_default/... 以及 .../sessions/default.*,而不是 users/alice/...。
- 对照:同样的调用换成 agent.call(msgs, ctx).block(),则正确写入 users/alice/...。

预期 vs 实际

- 预期: 流式与非流式调用解析到相同的 RuntimeContext;streamEvents(msgs, ctx) 的记忆/会话应落到 users/alice/...。
- 实际: 流式调用把记忆/会话写到了匿名的 users/_default/...(会话 id 还成了 "default")。

影响

- 流式路径下长期记忆的多用户隔离失效: 所有用户的流式记忆都汇入同一个共享 _default
桶,导致用户永远召回不到自己的事实,且不同用户的记忆相互串扰。
- 隐蔽性强: 工具执行和会话状态持久化看起来都正常(它们用的是已绑定的 context / build 期的 SessionKey),所以不翻存储后端很难发现。
- 影响所有在 onAgent 中即时读取 getRuntimeContext() 的中间件,不仅限于记忆中间件。

修复建议

让流式路径在 apply onAgent 链之前先绑定 RuntimeContext,与 call() 路径(beforeAgentExecution 先行)保持一致。可选方案:
1. 在 ReActAgent.streamEvents(msgs) 中,于链 apply 前后绑定/解绑 pendingRuntimeContext,而不是依赖之后在 core 内部才执行的
beforeAgentExecution;或
2. 把链的装配延迟(defer),使 onAgent 在 beforeAgentExecution 之后执行;或
3. 让 MemoryFlushMiddleware / MemoryMaintenanceMiddleware 在执行/完成阶段再解析 context,而非在装配阶段捕获 ——
但需注意届时绑定必须仍然有效,因此方案 1/2 更优。

建议补一个回归测试:断言 streamEvents(msgs, ctx) 与 call(msgs, ctx) 写入同一命名空间。

貢獻指南

開啟貢獻指南

評估

這個 Issue 還沒有評估資料。

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。